diff --git a/.dockerignore b/.dockerignore index bb384c21f6..75651c3d57 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ rustfmt.toml # Docker config (dockerfiles are sent separately by compose, not from context) etc/docker/ !etc/docker/nitro-enclave/ +!etc/docker/nitro-host/ # Devnet tooling not needed in container builds etc/scripts/devnet/grafana/ diff --git a/.github/workflows/action-tests.yml b/.github/workflows/action-tests.yml index 76708e9f1b..752c23f038 100644 --- a/.github/workflows/action-tests.yml +++ b/.github/workflows/action-tests.yml @@ -24,10 +24,10 @@ jobs: timeout-minutes: 30 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: ./.github/actions/setup diff --git a/.github/workflows/base-std-fork-tests.yml b/.github/workflows/base-std-fork-tests.yml new file mode 100644 index 0000000000..022a407f46 --- /dev/null +++ b/.github/workflows/base-std-fork-tests.yml @@ -0,0 +1,89 @@ +name: Base Std Fork Tests + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + BASE_ANVIL_REF: base-anvil-fork + BASE_STD_REF: main + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + SCCACHE_GHA_ENABLED: "true" + +permissions: + contents: read + +jobs: + fork-tests: + name: Base Std Fork Tests + runs-on: + group: BasePerfRunnerGroup + timeout-minutes: 120 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout Base + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + + - uses: ./.github/actions/setup + with: + free-disk: "true" + foundry: "true" + mold: "true" + native-deps: "true" + rust-cache-shared-key: "base-std-fork-tests" + + - name: Clone fork-test repositories + shell: bash + run: | + set -euo pipefail + + workdir="$RUNNER_TEMP/base-std-fork-tests" + rm -rf "$workdir" + mkdir -p "$workdir" + + git clone --depth 1 --single-branch --recurse-submodules --shallow-submodules \ + --branch "$BASE_STD_REF" \ + https://github.com/base/base-std.git "$workdir/base-std" + git clone --depth 1 --single-branch --branch "$BASE_ANVIL_REF" \ + https://github.com/base/base-anvil.git "$workdir/base-anvil" + + echo "BASE_STD_DIR=$workdir/base-std" >> "$GITHUB_ENV" + echo "BASE_ANVIL_DIR=$workdir/base-anvil" >> "$GITHUB_ENV" + + - name: Build patched base-anvil binaries + shell: bash + env: + CARGO_PROFILE_RELEASE_LTO: "false" + run: | + set -euo pipefail + + cd "$BASE_ANVIL_DIR" + rustup show active-toolchain + cargo \ + --config "patch.\"https://github.com/base/base.git\".base-common-precompiles.path=\"$GITHUB_WORKSPACE/crates/common/precompiles\"" \ + --config "patch.\"https://github.com/base/base.git\".base-common-chains.path=\"$GITHUB_WORKSPACE/crates/common/chains\"" \ + build --release --no-default-features --features anvil/cli -p anvil -p forge + + "$BASE_ANVIL_DIR/target/release/anvil" --version + "$BASE_ANVIL_DIR/target/release/forge" --version + + - name: Run base-std fork tests + shell: bash + run: | + set -euo pipefail + + cd "$BASE_STD_DIR" + ANVIL_BIN="$BASE_ANVIL_DIR/target/release/anvil" \ + FORGE_BIN="$BASE_ANVIL_DIR/target/release/forge" \ + ANVIL_LOG="$RUNNER_TEMP/base-std-anvil.log" \ + ./script/run-fork-tests.sh diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index fe3c4fec71..6681c04507 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -27,18 +27,18 @@ jobs: actions: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 # Cache based on Cargo files and crates source, not the whole repo # This allows iterating on benchmark workflow without rebuilding binaries - name: Cache binaries by source hash - uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # v3.4.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: cache-binaries with: path: ~/bin @@ -79,7 +79,7 @@ jobs: ls -la ~/bin/ - name: Upload binaries artifact - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries path: ~/bin/ @@ -95,17 +95,17 @@ jobs: actions: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: node-reth fetch-depth: 1 - name: Checkout benchmark repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ env.BENCHMARK_REPO }} ref: ${{ env.BENCHMARK_REF }} @@ -114,14 +114,14 @@ jobs: fetch-depth: 1 - name: Set up Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - name: Install Go dependencies working-directory: benchmark run: go mod download - name: Download binaries - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: binaries path: ${{ runner.temp }}/bin/ @@ -149,7 +149,7 @@ jobs: --load-test-bin ${{ runner.temp }}/bin/base-load-test - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "20" @@ -163,7 +163,7 @@ jobs: popd - name: Upload Output - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: benchmark-output @@ -171,7 +171,7 @@ jobs: retention-days: 7 - name: Upload Report - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: benchmark-report path: benchmark/report/dist/ diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 1ee0413718..b5e3e1ec00 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -16,13 +16,16 @@ on: env: REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/node-reth-dev RELEASE_BINS: | + base base-reth-node - basectl base-consensus + basectl permissions: contents: write packages: write + id-token: write + attestations: write jobs: # Build native binaries for release artifacts @@ -45,12 +48,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) if: matrix.target.os == 'linux' - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.tag }} fetch-depth: 1 @@ -105,13 +108,39 @@ jobs: fi done <<< "${RELEASE_BINS}" + - name: Sign archives + env: + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + if [[ -z "$GPG_SIGNING_KEY" || -z "$GPG_PASSPHRASE" ]]; then + echo "::error::GPG_SIGNING_KEY and GPG_PASSPHRASE secrets are required to sign release binaries" + exit 1 + fi + + GNUPGHOME="$(mktemp -d)" + export GNUPGHOME + trap 'rm -rf "$GNUPGHOME"' EXIT + + echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --batch --import + for archive in *.tar.gz; do + printf '%s' "$GPG_PASSPHRASE" | \ + gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign "$archive" + done + + - name: Attest build provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: "*.tar.gz" + - name: Upload artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binaries-${{ matrix.target.triple }} path: | *.tar.gz *.tar.gz.sha256 + *.tar.gz.asc if-no-files-found: error retention-days: 1 @@ -129,7 +158,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -139,7 +168,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.tag }} fetch-depth: 1 @@ -153,10 +182,10 @@ jobs: echo "version=${{ inputs.tag }}" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Login to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,7 +211,7 @@ jobs: touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* @@ -196,33 +225,33 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.tag }} fetch-depth: 0 fetch-tags: true - name: Download digests - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - name: Login to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Determine Docker tags id: tags @@ -275,7 +304,7 @@ jobs: - name: Download binary artifacts if: inputs.is_final - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/binaries pattern: binaries-* @@ -287,7 +316,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | TAG="${{ inputs.tag }}" - gh release upload "$TAG" ${{ runner.temp }}/binaries/*.tar.gz ${{ runner.temp }}/binaries/*.tar.gz.sha256 + gh release upload "$TAG" ${{ runner.temp }}/binaries/*.tar.gz ${{ runner.temp }}/binaries/*.tar.gz.sha256 ${{ runner.temp }}/binaries/*.tar.gz.asc - name: Summary run: | diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 030e3027ce..1afe4ce2fc 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -52,10 +52,10 @@ jobs: docker: ${{ steps.filter.outputs.docker }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 @@ -72,10 +72,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: ./.github/actions/setup @@ -93,10 +93,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: ./.github/actions/setup @@ -115,10 +115,10 @@ jobs: BASE_REF: ${{ inputs.base_ref }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: ${{ inputs.base_ref != '' && 0 || 1 }} - name: Fetch base branch @@ -145,10 +145,10 @@ jobs: BASE_REF: ${{ inputs.base_ref }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: ${{ inputs.base_ref != '' && 0 || 1 }} - name: Fetch base branch @@ -174,10 +174,10 @@ jobs: BASE_REF: ${{ inputs.base_ref }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: ${{ inputs.base_ref != '' && 0 || 1 }} - name: Fetch base branch @@ -219,10 +219,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: ./.github/actions/setup @@ -243,18 +243,18 @@ jobs: packages: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Login to GHCR if: inputs.allow_registry_login - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -268,10 +268,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: ./.github/actions/setup @@ -290,10 +290,10 @@ jobs: timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: ./.github/actions/setup @@ -309,7 +309,7 @@ jobs: - name: Clear prior JUnit report run: rm -f target/nextest/ci/test-results.xml - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Pre-pull docker images run: just devnet pull-images - name: Run devnet tests diff --git a/.github/workflows/ci-main-cache.yml b/.github/workflows/ci-main-cache.yml index 9ba544e371..b20884be9d 100644 --- a/.github/workflows/ci-main-cache.yml +++ b/.github/workflows/ci-main-cache.yml @@ -23,12 +23,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 1 + fetch-depth: 2 + - name: Detect SP1 ELF input changes + id: elf-inputs + run: python3 etc/scripts/ci/check-succinct-elf-inputs.py - uses: ./.github/actions/setup with: free-disk: "true" @@ -39,4 +42,11 @@ jobs: elf-cache: "true" rust-cache-shared-key: "stable" rust-cache-save: "true" - - run: just build::ci + - name: Build CI cache + if: steps.elf-inputs.outputs.needs_rebuild != 'true' + run: just build::ci + - name: Build CI cache with stubbed SP1 ELFs + if: steps.elf-inputs.outputs.needs_rebuild == 'true' + run: | + just build::contracts + BASE_SUCCINCT_ELF_STUB=1 cargo build --locked --workspace --all-targets --profile ci diff --git a/.github/workflows/ci-merge-queue.yml b/.github/workflows/ci-merge-queue.yml index b96a751257..c6ab3221b8 100644 --- a/.github/workflows/ci-merge-queue.yml +++ b/.github/workflows/ci-merge-queue.yml @@ -10,8 +10,9 @@ concurrency: permissions: checks: write contents: read + issues: write packages: read - pull-requests: read + pull-requests: write jobs: ci: diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 511f4ff54a..f003f26875 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -10,8 +10,9 @@ concurrency: permissions: checks: write contents: read + issues: write packages: read - pull-requests: read + pull-requests: write jobs: ci: diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index abbbf544e6..f6af2060a7 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -25,7 +25,7 @@ jobs: group: BaseRunnerGroup steps: - name: Harden the runner (Block unauthorized outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: block allowed-endpoints: > @@ -37,7 +37,7 @@ jobs: release-assets.githubusercontent.com:443 ${{ vars.LLM_GATEWAY_HOSTNAME }}:443 - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Run Claude Code diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index 2a87cd1b92..dea7d6bb9c 100644 --- a/.github/workflows/create-rc.yml +++ b/.github/workflows/create-rc.yml @@ -8,6 +8,8 @@ on: permissions: contents: write packages: write + id-token: write + attestations: write concurrency: group: release-${{ github.ref_name }} @@ -22,12 +24,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.ref_name }} fetch-depth: 0 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f419c0edc8..6ccecbfafc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -39,7 +39,7 @@ jobs: platform=${{ matrix.settings.arch }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 @@ -51,10 +51,10 @@ jobs: echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Login to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -79,7 +79,7 @@ jobs: touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* @@ -102,19 +102,19 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Login to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -136,31 +136,31 @@ jobs: - build-and-push steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - name: Download digests - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }}/digests pattern: digests-* merge-multiple: true - name: Log into the Container registry - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - name: Extract metadata for the Docker image id: meta diff --git a/.github/workflows/docs-specs-ci.yml b/.github/workflows/docs-specs-ci.yml deleted file mode 100644 index f48767c57e..0000000000 --- a/.github/workflows/docs-specs-ci.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Docs Specs CI - -on: - pull_request: - paths: - - "docs/specs/**" - push: - branches: [main] - paths: - - "docs/specs/**" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - docs-specs-build: - name: Docs Specs Build - runs-on: ubuntu-latest - steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 - with: - egress-policy: audit - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - with: - fetch-depth: 1 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version: "22" - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: "1.2" - - name: Install dependencies - working-directory: docs/specs - run: bun ci - - name: Build specs site - working-directory: docs/specs - run: bun run build diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml index d022514e61..8aaaef2f63 100644 --- a/.github/workflows/lychee.yml +++ b/.github/workflows/lychee.yml @@ -20,10 +20,10 @@ jobs: timeout-minutes: 30 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0 diff --git a/.github/workflows/no-std.yml b/.github/workflows/no-std.yml index c25d9550ad..c2c48a6e0f 100644 --- a/.github/workflows/no-std.yml +++ b/.github/workflows/no-std.yml @@ -23,11 +23,11 @@ jobs: timeout-minutes: 30 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 @@ -50,11 +50,11 @@ jobs: timeout-minutes: 30 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4447f9d625..6e52f27e4e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,6 +11,8 @@ on: permissions: contents: write packages: write + id-token: write + attestations: write concurrency: group: publish-release-${{ inputs.version }} @@ -24,12 +26,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true @@ -54,7 +56,7 @@ jobs: } - name: Checkout release branch - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: releases/v${{ inputs.version }} fetch-depth: 0 diff --git a/.github/workflows/sp1-elf-manifest.yml b/.github/workflows/sp1-elf-manifest.yml new file mode 100644 index 0000000000..3bcba45f98 --- /dev/null +++ b/.github/workflows/sp1-elf-manifest.yml @@ -0,0 +1,165 @@ +name: SP1 ELF Manifest + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + AUTOMATION_BRANCH: automation/update-sp1-elf-manifest + MANIFEST_PATH: crates/proof/succinct/elf/manifest.toml + TARGET_BRANCH: main + +permissions: + contents: write + pull-requests: write + +jobs: + update-manifest: + name: Update SP1 ELF Manifest + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 2 + ref: ${{ env.TARGET_BRANCH }} + + - name: Detect SP1 ELF input changes + id: elf-inputs + if: github.event_name != 'workflow_dispatch' + run: python3 etc/scripts/ci/check-succinct-elf-inputs.py + + - name: Report no SP1 ELF input changes + if: >- + github.event_name != 'workflow_dispatch' && + steps.elf-inputs.outputs.needs_rebuild != 'true' + run: echo "No SP1 ELF inputs changed; skipping manifest refresh." >> "$GITHUB_STEP_SUMMARY" + + - uses: ./.github/actions/setup + if: >- + github.event_name == 'workflow_dispatch' || + steps.elf-inputs.outputs.needs_rebuild == 'true' + with: + free-disk: "true" + sp1: "true" + + - name: Rebuild and update SP1 ELF manifest + id: elf-manifest + if: >- + github.event_name == 'workflow_dispatch' || + steps.elf-inputs.outputs.needs_rebuild == 'true' + shell: bash + run: | + set -euo pipefail + report_dir="${RUNNER_TEMP:-/tmp}/sp1-elf-manifest" + mkdir -p "$report_dir" + hash_file="$report_dir/hashes.txt" + diff_file="$report_dir/manifest.diff" + + just succinct build-elfs + just succinct write-manifest + python3 etc/scripts/local/check-elf-manifest.py \ + --print-hashes "$MANIFEST_PATH" crates/proof/succinct/elf \ + | tee "$hash_file" + git diff -- "$MANIFEST_PATH" | tee "$diff_file" + + if [ -s "$diff_file" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Report current SP1 ELF manifest + if: steps.elf-manifest.outputs.changed == 'false' + run: echo "SP1 ELF manifest already matches the fresh build." >> "$GITHUB_STEP_SUMMARY" + + - name: Open or update manifest refresh PR + if: steps.elf-manifest.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + report_dir="${RUNNER_TEMP:-/tmp}/sp1-elf-manifest" + hash_file="$report_dir/hashes.txt" + diff_file="$report_dir/manifest.diff" + body_file="$report_dir/pr-body.md" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$AUTOMATION_BRANCH" + git add "$MANIFEST_PATH" + git commit -m "fix(proof): refresh succinct elf manifest" + + remote_sha="$(git ls-remote --heads origin "$AUTOMATION_BRANCH" | awk '{print $1}')" + if [ -n "$remote_sha" ]; then + git push --force-with-lease="refs/heads/$AUTOMATION_BRANCH:$remote_sha" \ + origin "HEAD:refs/heads/$AUTOMATION_BRANCH" + else + git push origin "HEAD:refs/heads/$AUTOMATION_BRANCH" + fi + + { + echo "## Summary" + echo + echo "Refreshes \`$MANIFEST_PATH\` from a fresh SP1 ELF build after a merge to trunk (\`$TARGET_BRANCH\`)." + echo + echo "Generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}." + echo + echo "## Validation" + echo + echo "- \`just succinct build-elfs\`" + echo "- \`just succinct write-manifest\`" + echo "- \`python3 etc/scripts/local/check-elf-manifest.py --print-hashes $MANIFEST_PATH crates/proof/succinct/elf\`" + echo + echo "## Hashes" + echo + echo '```text' + cat "$hash_file" + echo '```' + echo + echo "## Diff" + echo + echo '```diff' + cat "$diff_file" + echo '```' + } > "$body_file" + + pr_number="$( + gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --head "$AUTOMATION_BRANCH" \ + --base "$TARGET_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number // ""' + )" + + if [ -n "$pr_number" ]; then + gh pr edit "$pr_number" \ + --repo "$GITHUB_REPOSITORY" \ + --title "fix(proof): refresh succinct elf manifest" \ + --body-file "$body_file" + pr_url="$(gh pr view "$pr_number" --repo "$GITHUB_REPOSITORY" --json url --jq '.url')" + else + pr_url="$( + gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base "$TARGET_BRANCH" \ + --head "$AUTOMATION_BRANCH" \ + --title "fix(proof): refresh succinct elf manifest" \ + --body-file "$body_file" + )" + fi + + echo "Opened or updated SP1 ELF manifest PR: $pr_url" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 951f15cbf9..ee49716e94 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit diff --git a/.github/workflows/start-release.yml b/.github/workflows/start-release.yml index d4ea3a0d2a..401ad59728 100644 --- a/.github/workflows/start-release.yml +++ b/.github/workflows/start-release.yml @@ -24,12 +24,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 fetch-tags: true diff --git a/.github/workflows/udeps-report.yml b/.github/workflows/udeps-report.yml index 3fbb83f290..f51b3246bb 100644 --- a/.github/workflows/udeps-report.yml +++ b/.github/workflows/udeps-report.yml @@ -22,12 +22,12 @@ jobs: timeout-minutes: 120 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 diff --git a/.github/workflows/vouch.yml b/.github/workflows/vouch.yml index 5a8a939390..4bd099bfe3 100644 --- a/.github/workflows/vouch.yml +++ b/.github/workflows/vouch.yml @@ -22,7 +22,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1 @@ -46,10 +46,10 @@ jobs: issues: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1 with: repo: ${{ github.repository }} diff --git a/.github/workflows/zepter.yml b/.github/workflows/zepter.yml index 0a38d7d47a..a8fb5c30e8 100644 --- a/.github/workflows/zepter.yml +++ b/.github/workflows/zepter.yml @@ -16,16 +16,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 - - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + - uses: taiki-e/install-action@3575e532701a5fc614b0c842e4119af4cc5fd16d # v2.62.60 with: - just-version: "1.43.1" + tool: just@1.43.1 - uses: taiki-e/install-action@3575e532701a5fc614b0c842e4119af4cc5fd16d # v2.62.60 with: tool: zepter diff --git a/.gitignore b/.gitignore index 022232d2f8..33511f7590 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,10 @@ target/ +.claude/ .idea/ .DS_Store .devnet/ .sisyphus/ eif*.bin -docs/specs/node_modules/ -docs/specs/dist/ -docs/specs/.vocs/ *.log .vscode/ *.env diff --git a/CHANGELOG.sf.md b/CHANGELOG.sf.md index 9a4eed3f11..1fc3070c5b 100644 --- a/CHANGELOG.sf.md +++ b/CHANGELOG.sf.md @@ -1,3 +1,12 @@ +## v1.0.1-fh + +* Bumped base to `v1.0.1` +* Fixes on flash blocks: fetch fresh state on every block to avoid mismatches that cause UNDOs + +## v1.0.0-fh + +* Bumped base to `v1.0.0` + ## v0.9.1-fh-1 * Fixed flash blocks to arrive in the right order and be 100% identical to the canonical blocks diff --git a/Cargo.lock b/Cargo.lock index 70d15f2af4..1f156cda37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,20 +146,20 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50ab0cd8afe573d1f7dc2353698a51b1f93aec362c8211e28cfd3948c6adba39" dependencies = [ - "alloy-consensus", - "alloy-contract", + "alloy-consensus 1.8.3", + "alloy-contract 1.8.3", "alloy-core", - "alloy-eips", - "alloy-network", - "alloy-node-bindings", - "alloy-provider", - "alloy-rpc-client", - "alloy-rpc-types", - "alloy-serde", - "alloy-signer", - "alloy-signer-local", - "alloy-transport", - "alloy-transport-http", + "alloy-eips 1.8.3", + "alloy-network 1.8.3", + "alloy-node-bindings 1.8.3", + "alloy-provider 1.8.3", + "alloy-rpc-client 1.8.3", + "alloy-rpc-types 1.8.3", + "alloy-serde 1.8.3", + "alloy-signer 1.8.3", + "alloy-signer-local 1.8.3", + "alloy-transport 1.8.3", + "alloy-transport-http 1.8.3", "alloy-trie", ] @@ -184,12 +184,39 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" dependencies = [ - "alloy-eips", + "alloy-eips 1.8.3", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 1.8.3", "alloy-trie", - "alloy-tx-macros", + "alloy-tx-macros 1.8.3", + "auto_impl", + "borsh", + "c-kzg", + "derive_more 2.1.1", + "either", + "k256", + "once_cell", + "rand 0.8.6", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-consensus" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83447eeb17816e172f1dfc0db1f9dc0b7c5d069bd1f7cecbecceb382bf931015" +dependencies = [ + "alloy-eips 2.0.5", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 2.0.5", + "alloy-trie", + "alloy-tx-macros 2.0.5", "arbitrary", "auto_impl", "borsh", @@ -212,11 +239,25 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 1.8.3", + "alloy-eips 1.8.3", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 1.8.3", + "serde", +] + +[[package]] +name = "alloy-consensus-any" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5406343e306856dc2be762700e98a16904de45dee14a07f233e742ce68daff2f" +dependencies = [ + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "arbitrary", "serde", ] @@ -227,16 +268,39 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ac9e0c34dc6bce643b182049cdfcca1b8ce7d9c260cbdd561f511873b7e26cd" dependencies = [ - "alloy-consensus", + "alloy-consensus 1.8.3", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network 1.8.3", + "alloy-network-primitives 1.8.3", + "alloy-primitives", + "alloy-provider 1.8.3", + "alloy-rpc-types-eth 1.8.3", + "alloy-sol-types", + "alloy-transport 1.8.3", + "futures", + "futures-util", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "alloy-contract" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8b60d71b92824e095b4003ff01fd2bc923017b7568997c5f459240e83499c" +dependencies = [ + "alloy-consensus 2.0.5", "alloy-dyn-abi", "alloy-json-abi", - "alloy-network", - "alloy-network-primitives", + "alloy-network 2.0.5", + "alloy-network-primitives 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", "alloy-sol-types", - "alloy-transport", + "alloy-transport 2.0.5", "futures", "futures-util", "serde_json", @@ -347,15 +411,38 @@ dependencies = [ "alloy-eip7928", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 1.8.3", + "auto_impl", + "borsh", + "c-kzg", + "derive_more 2.1.1", + "either", + "serde", + "serde_with", + "sha2 0.10.9", +] + +[[package]] +name = "alloy-eips" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dca4c89ace90684b4b77366d00631ed498c9af962079af2a5dbc593a0618a77" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 2.0.5", "arbitrary", "auto_impl", "borsh", "c-kzg", "derive_more 2.1.1", "either", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", + "ethereum_ssz", + "ethereum_ssz_derive", "serde", "serde_with", "sha2 0.10.9", @@ -363,23 +450,22 @@ dependencies = [ [[package]] name = "alloy-evm" -version = "0.27.3" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b991c370ce44e70a3a9e474087e3d65e42e66f967644ad729dc4cec09a21fd09" +checksum = "c1ceeea6dcbbcd4e546b27700763a6f6c3b3fee30054209884f521078b6fda4f" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-hardforks 0.4.7", "alloy-primitives", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-sol-types", "auto_impl", "derive_more 2.1.1", - "op-alloy", - "op-revm", "revm", "thiserror 2.0.18", + "tracing", ] [[package]] @@ -388,9 +474,22 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbf9480307b09d22876efb67d30cadd9013134c21f3a17ec9f93fd7536d38024" dependencies = [ - "alloy-eips", + "alloy-eips 1.8.3", + "alloy-primitives", + "alloy-serde 1.8.3", + "alloy-trie", + "serde", +] + +[[package]] +name = "alloy-genesis" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0e0fe9e6d1120ad7bb9254c3fc2b9bc80a8df42a033fb626be6559c13d5153" +dependencies = [ + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-serde", + "alloy-serde 2.0.5", "alloy-trie", "borsh", "serde", @@ -451,22 +550,63 @@ dependencies = [ "tracing", ] +[[package]] +name = "alloy-json-rpc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0a82e56b1843bce483942d54fcadea92e676f1bde162e93c7d3b621fabc4e1" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http 1.4.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "alloy-network" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7197a66d94c4de1591cdc16a9bcea5f8cccd0da81b865b49aef97b1b4016e0fa" dependencies = [ - "alloy-consensus", - "alloy-consensus-any", - "alloy-eips", - "alloy-json-rpc", - "alloy-network-primitives", + "alloy-consensus 1.8.3", + "alloy-consensus-any 1.8.3", + "alloy-eips 1.8.3", + "alloy-json-rpc 1.8.3", + "alloy-network-primitives 1.8.3", + "alloy-primitives", + "alloy-rpc-types-any 1.8.3", + "alloy-rpc-types-eth 1.8.3", + "alloy-serde 1.8.3", + "alloy-signer 1.8.3", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more 2.1.1", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-network" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7db7b095b0b1db1d18ce7e91dcd2e82007f2d52bfb8125e6b64633a74a06bc3" +dependencies = [ + "alloy-consensus 2.0.5", + "alloy-consensus-any 2.0.5", + "alloy-eips 2.0.5", + "alloy-json-rpc 2.0.5", + "alloy-network-primitives 2.0.5", "alloy-primitives", - "alloy-rpc-types-any", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-signer", + "alloy-rpc-types-any 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", + "alloy-signer 2.0.5", "alloy-sol-types", "async-trait", "auto_impl", @@ -483,10 +623,23 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 1.8.3", + "alloy-eips 1.8.3", "alloy-primitives", - "alloy-serde", + "alloy-serde 1.8.3", + "serde", +] + +[[package]] +name = "alloy-network-primitives" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd28d9bfd11729037d194f2b1d43db8642eb3f342032691f4ca96bb745479c3c" +dependencies = [ + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-primitives", + "alloy-serde 2.0.5", "serde", ] @@ -496,12 +649,34 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9b2fda91b56bb08907cd44c5068130360e027e46a8c17612d386869fa7940be" dependencies = [ - "alloy-genesis", + "alloy-genesis 1.8.3", + "alloy-hardforks 0.2.13", + "alloy-network 1.8.3", + "alloy-primitives", + "alloy-signer 1.8.3", + "alloy-signer-local 1.8.3", + "k256", + "libc", + "rand 0.8.6", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "alloy-node-bindings" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f2f7dac66147d165063c670dabca7b34807428130bef4583a7976523140f8d" +dependencies = [ + "alloy-genesis 2.0.5", "alloy-hardforks 0.2.13", - "alloy-network", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "k256", "libc", "rand 0.8.6", @@ -551,25 +726,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf6b18b929ef1d078b834c3631e9c925177f3b23ddc6fa08a722d13047205876" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", - "alloy-json-rpc", - "alloy-network", - "alloy-network-primitives", - "alloy-node-bindings", + "alloy-consensus 1.8.3", + "alloy-eips 1.8.3", + "alloy-json-rpc 1.8.3", + "alloy-network 1.8.3", + "alloy-network-primitives 1.8.3", + "alloy-node-bindings 1.8.3", + "alloy-primitives", + "alloy-rpc-client 1.8.3", + "alloy-rpc-types-anvil 1.8.3", + "alloy-rpc-types-eth 1.8.3", + "alloy-signer 1.8.3", + "alloy-sol-types", + "alloy-transport 1.8.3", + "alloy-transport-http 1.8.3", + "async-stream", + "async-trait", + "auto_impl", + "dashmap", + "either", + "futures", + "futures-utils-wasm", + "lru 0.16.4", + "parking_lot", + "pin-project", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-provider" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8955ab30418343de57b356de2ea60200f9fb8016a7ea3bc7f5c6176f01a8b1cf" +dependencies = [ + "alloy-chains", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-json-rpc 2.0.5", + "alloy-network 2.0.5", + "alloy-network-primitives 2.0.5", "alloy-primitives", "alloy-pubsub", - "alloy-rpc-client", - "alloy-rpc-types-anvil", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-debug", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-rpc-types-trace", "alloy-rpc-types-txpool", - "alloy-signer", + "alloy-signer 2.0.5", "alloy-sol-types", - "alloy-transport", - "alloy-transport-http", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "alloy-transport-ipc", "alloy-transport-ws", "async-stream", @@ -594,13 +808,13 @@ dependencies = [ [[package]] name = "alloy-pubsub" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad54073131e7292d4e03e1aa2287730f737280eb160d8b579fb31939f558c11" +checksum = "7cd85cfea1fa8ebd20d3475e961fe3a3624c0eb4659ea137715c0c83c8aeaff0" dependencies = [ - "alloy-json-rpc", + "alloy-json-rpc 2.0.5", "alloy-primitives", - "alloy-transport", + "alloy-transport 2.0.5", "auto_impl", "bimap", "futures", @@ -642,11 +856,34 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94fcc9604042ca80bd37aa5e232ea1cd851f337e31e2babbbb345bc0b1c30de3" dependencies = [ - "alloy-json-rpc", + "alloy-json-rpc 1.8.3", + "alloy-primitives", + "alloy-transport 1.8.3", + "alloy-transport-http 1.8.3", + "futures", + "pin-project", + "reqwest 0.13.3", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower 0.5.3", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-client" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24f461f091dc8f657e73b5dea18fd63d5c7049720cd252f1eade4a7ebed6a7e1" +dependencies = [ + "alloy-json-rpc 2.0.5", "alloy-primitives", "alloy-pubsub", - "alloy-transport", - "alloy-transport-http", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "alloy-transport-ipc", "alloy-transport-ws", "futures", @@ -667,23 +904,34 @@ name = "alloy-rpc-types" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4faad925d3a669ffc15f43b3deec7fbdf2adeb28a4d6f9cf4bc661698c0f8f4b" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth 1.8.3", + "alloy-serde 1.8.3", + "serde", +] + +[[package]] +name = "alloy-rpc-types" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052c031d1f7c5611997056bbcb8814e5cbf20f7efeee8c3de690555172038cf2" dependencies = [ "alloy-primitives", "alloy-rpc-types-debug", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-rpc-types-txpool", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "serde", ] [[package]] name = "alloy-rpc-types-admin" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b38080c2b01ad1bacbd3583cf7f6f800e5e0ffc11eaddaad7321225733a2d818" +checksum = "ef669b370940e7945a3a384cc4024038cd69ee658b71270d59c20b78dd8d20d4" dependencies = [ - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", "serde", "serde_json", @@ -696,8 +944,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47df51bedb3e6062cb9981187a51e86d0d64a4de66eb0855e9efe6574b044ddf" dependencies = [ "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 1.8.3", + "alloy-serde 1.8.3", + "serde", +] + +[[package]] +name = "alloy-rpc-types-anvil" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff111a54268dc0bbd3b17f98571a7e27cc661dc081ad2999d91888647eb2e11" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "serde", ] @@ -707,23 +967,38 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3823026d1ed239a40f12364fac50726c8daf1b6ab8077a97212c5123910429ed" dependencies = [ - "alloy-consensus-any", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-consensus-any 1.8.3", + "alloy-rpc-types-eth 1.8.3", + "alloy-serde 1.8.3", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6561ed4759c974d9c144500a59e3fb8c1d87327a12900d5ce455c0cae6dcb6" +dependencies = [ + "alloy-consensus-any 2.0.5", + "alloy-network-primitives 2.0.5", + "alloy-primitives", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", + "serde", + "serde_json", ] [[package]] name = "alloy-rpc-types-beacon" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f526dbd7bb039327cfd0ccf18c8a29ffd7402616b0c7a0239512bf8417d544c7" +checksum = "9a62f6ce2d95f59ed310bd90d5fd1566a29d1ec45cc219abbc5dcc807d31f136" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "derive_more 2.1.1", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", + "ethereum_ssz", + "ethereum_ssz_derive", "serde", "serde_json", "serde_with", @@ -734,11 +1009,12 @@ dependencies = [ [[package]] name = "alloy-rpc-types-debug" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2145138f3214928f08cd13da3cb51ef7482b5920d8ac5a02ecd4e38d1a8f6d1e" +checksum = "48b9ad6eee93dd35a9ec0a6c1c6b180892a900ee17a6ed6500921552dd71e846" dependencies = [ "alloy-primitives", + "alloy-rlp", "derive_more 2.1.1", "serde", "serde_with", @@ -746,20 +1022,20 @@ dependencies = [ [[package]] name = "alloy-rpc-types-engine" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb9b97b6e7965679ad22df297dda809b11cebc13405c1b537e5cffecc95834fa" +checksum = "7eba59e1c069f168a01982f42a57797736923b76aa854194df4930be17867e1c" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "arbitrary", "derive_more 2.1.1", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", - "jsonwebtoken 9.3.1", + "ethereum_ssz", + "ethereum_ssz_derive", + "jsonwebtoken", "rand 0.8.6", "serde", "strum", @@ -771,13 +1047,34 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" dependencies = [ - "alloy-consensus", - "alloy-consensus-any", - "alloy-eips", - "alloy-network-primitives", + "alloy-consensus 1.8.3", + "alloy-consensus-any 1.8.3", + "alloy-eips 1.8.3", + "alloy-network-primitives 1.8.3", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 1.8.3", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175a2a5b6017d7f61b5e4b800d21215fe8e94fe729d00828e13bb6d93dcf3492" +dependencies = [ + "alloy-consensus 2.0.5", + "alloy-consensus-any 2.0.5", + "alloy-eips 2.0.5", + "alloy-network-primitives 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "alloy-sol-types", "arbitrary", "itertools 0.14.0", @@ -789,28 +1086,28 @@ dependencies = [ [[package]] name = "alloy-rpc-types-mev" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eae9c65ff60dcc262247b6ebb5ad391ddf36d09029802c1768c5723e0cfa2f4" +checksum = "ed1004c1d68bfaee001712f83356f88031ab74a727b8560fb7fc738d1281ebe5" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "serde", "serde_json", ] [[package]] name = "alloy-rpc-types-trace" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a4d010f86cd4e01e5205ec273911e538e1738e76d8bafe9ecd245910ea5a3" +checksum = "514b4b1ce3354f65067b4fc7eb75358e0f2ec8be3340c96dea65d6894f9ca435" dependencies = [ "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "serde", "serde_json", "thiserror 2.0.18", @@ -818,13 +1115,13 @@ dependencies = [ [[package]] name = "alloy-rpc-types-txpool" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942d26a2ca8891b26de4a8529d21091e21c1093e27eb99698f1a86405c76b1ff" +checksum = "76e34a42ebb4a71ab0bfdebc6d2f3c7bf809f01edf154d08fed159d10d1ef1d4" dependencies = [ "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "serde", ] @@ -833,6 +1130,17 @@ name = "alloy-serde" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11ece63b89294b8614ab3f483560c08d016930f842bf36da56bf0b764a15c11e" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-serde" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc21a8772af7d78bba286726aa245bd2ff81cd9abe230afea2e91578996831c9" dependencies = [ "alloy-primitives", "arbitrary", @@ -855,16 +1163,31 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "alloy-signer" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffbce94c50dd9d4d1f83e044c5595bbd3ada981bd3057ce28b3a5470e77385d" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror 2.0.18", +] + [[package]] name = "alloy-signer-aws" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8194c416115dc27f03796c0075dee0731239e2d7fbce735a74894aa8f6a47d7d" dependencies = [ - "alloy-consensus", - "alloy-network", + "alloy-consensus 1.8.3", + "alloy-network 1.8.3", "alloy-primitives", - "alloy-signer", + "alloy-signer 1.8.3", "async-trait", "aws-config", "aws-sdk-kms", @@ -876,14 +1199,14 @@ dependencies = [ [[package]] name = "alloy-signer-gcp" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa71d57808c8ce3c41342a71245d67839b032d7e18072b50a8d262e28143c18" +checksum = "3bdfd6d63ce90e92e1c364eedea75213b4e8e616f18dec773ea793c523653299" dependencies = [ - "alloy-consensus", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-signer", + "alloy-signer 2.0.5", "async-trait", "gcloud-sdk", "k256", @@ -898,10 +1221,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f721f4bf2e4812e5505aaf5de16ef3065a8e26b9139ac885862d00b5a55a659a" dependencies = [ - "alloy-consensus", - "alloy-network", + "alloy-consensus 1.8.3", + "alloy-network 1.8.3", "alloy-primitives", - "alloy-signer", + "alloy-signer 1.8.3", + "async-trait", + "k256", + "rand 0.8.6", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-signer-local" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48366d2c42b8d95ef951101bafa28486590f21b7a1e68b6b2d069746557bbe3" +dependencies = [ + "alloy-consensus 2.0.5", + "alloy-network 2.0.5", + "alloy-primitives", + "alloy-signer 2.0.5", "async-trait", "coins-bip32", "coins-bip39", @@ -990,7 +1329,30 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" dependencies = [ - "alloy-json-rpc", + "alloy-json-rpc 1.8.3", + "auto_impl", + "base64 0.22.1", + "derive_more 2.1.1", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tower 0.5.3", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86052fdcec72d37ca4aa4b66254601e7453c45a6e1c70aa4561033d002fb80cc" +dependencies = [ + "alloy-json-rpc 2.0.5", "auto_impl", "base64 0.22.1", "derive_more 2.1.1", @@ -1013,15 +1375,31 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7" dependencies = [ - "alloy-json-rpc", + "alloy-json-rpc 1.8.3", + "alloy-transport 1.8.3", + "itertools 0.14.0", + "reqwest 0.13.3", + "serde_json", + "tower 0.5.3", + "tracing", + "url", +] + +[[package]] +name = "alloy-transport-http" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b273587487921274f4f5d0ef2c7ef36944dcbb75a4e2318e69eae822bd263f91" +dependencies = [ + "alloy-json-rpc 2.0.5", "alloy-rpc-types-engine", - "alloy-transport", + "alloy-transport 2.0.5", "http-body-util", "hyper 1.9.0", "hyper-tls", "hyper-util", "itertools 0.14.0", - "jsonwebtoken 9.3.1", + "jsonwebtoken", "opentelemetry 0.31.0", "opentelemetry-http", "reqwest 0.13.3", @@ -1034,13 +1412,13 @@ dependencies = [ [[package]] name = "alloy-transport-ipc" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1bd98c3870b8a44b79091dde5216a81d58ffbc1fd8ed61b776f9fee0f3bdf20" +checksum = "bfb89df168b24773ef603af14f2449c05a7d3f27d05d3eceaea6bf96cccae168" dependencies = [ - "alloy-json-rpc", + "alloy-json-rpc 2.0.5", "alloy-pubsub", - "alloy-transport", + "alloy-transport 2.0.5", "bytes", "futures", "interprocess", @@ -1054,12 +1432,12 @@ dependencies = [ [[package]] name = "alloy-transport-ws" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3ab7a72b180992881acc112628b7668337a19ce15293ee974600ea7b693691" +checksum = "33e32e0b47d3b3bf5770b7c132090c614b008d307c5e1544f1925f5b7e9e9af6" dependencies = [ "alloy-pubsub", - "alloy-transport", + "alloy-transport 2.0.5", "futures", "http 1.4.0", "rustls 0.23.40", @@ -1103,6 +1481,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "alloy-tx-macros" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a0035943b75fe1e249f52e688492d7a1b1826bc2d19b8e1d5d3c24a2ad8f50" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ambassador" version = "0.4.2" @@ -1256,6 +1646,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" + [[package]] name = "argon2" version = "0.5.3" @@ -1888,7 +2284,7 @@ dependencies = [ [[package]] name = "audit-archiver" -version = "0.9.1" +version = "1.0.1" dependencies = [ "anyhow", "audit-archiver-lib", @@ -1907,9 +2303,9 @@ dependencies = [ [[package]] name = "audit-archiver-lib" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "anyhow", "async-trait", @@ -1923,7 +2319,6 @@ dependencies = [ "jsonrpsee-types", "metrics", "moka", - "rdkafka", "serde", "serde_json", "testcontainers", @@ -2010,6 +2405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -2713,23 +3109,31 @@ dependencies = [ [[package]] name = "base" -version = "0.9.1" +version = "1.0.1" dependencies = [ + "alloy-chains", "base-cli-utils", "base-common-chains", + "base-consensus-cli", + "base-execution-chainspec", + "base-execution-cli", "clap", "eyre", "figment", + "reth-cli-runner", "serde", + "tokio", + "tokio-util", "tracing", + "url", ] [[package]] name = "base-access-lists" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-contract", + "alloy-consensus 2.0.5", + "alloy-contract 2.0.5", "alloy-eip7928", "alloy-primitives", "alloy-rlp", @@ -2744,20 +3148,21 @@ dependencies = [ [[package]] name = "base-action-harness" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-signer", - "alloy-signer-local", - "alloy-transport", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", + "alloy-sol-types", + "alloy-transport 2.0.5", "async-trait", "base-batcher-core", "base-batcher-encoder", @@ -2767,18 +3172,21 @@ dependencies = [ "base-common-consensus", "base-common-genesis", "base-common-network", + "base-common-precompiles", "base-common-rpc-types", "base-common-rpc-types-engine", "base-consensus-derive", "base-consensus-engine", "base-consensus-gossip", "base-consensus-node", + "base-consensus-rpc", "base-consensus-safedb", "base-execution-chainspec", "base-execution-evm", "base-execution-payload-builder", "base-execution-txpool", "base-node-core", + "base-precompile-storage", "base-protocol", "base-runtime", "base-test-utils", @@ -2791,6 +3199,7 @@ dependencies = [ "reth-payload-primitives", "reth-primitives-traits", "reth-provider", + "reth-revm", "reth-transaction-pool", "serde_json", "tempfile", @@ -2803,12 +3212,12 @@ dependencies = [ [[package]] name = "base-balance-monitor" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-network", - "alloy-node-bindings", + "alloy-network 2.0.5", + "alloy-node-bindings 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "tokio", "tokio-util", "tracing", @@ -2816,7 +3225,7 @@ dependencies = [ [[package]] name = "base-batcher-admin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-batcher-core", "derive_more 2.1.1", @@ -2827,10 +3236,10 @@ dependencies = [ [[package]] name = "base-batcher-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-signer-local", + "alloy-signer-local 2.0.5", "base-batcher-core", "base-batcher-encoder", "base-batcher-service", @@ -2846,11 +3255,11 @@ dependencies = [ [[package]] name = "base-batcher-core" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "async-trait", "auto_impl", "base-batcher-encoder", @@ -2871,10 +3280,10 @@ dependencies = [ [[package]] name = "base-batcher-encoder" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "base-common-consensus", "base-common-genesis", @@ -2891,14 +3300,14 @@ dependencies = [ [[package]] name = "base-batcher-service" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer-local 2.0.5", "async-trait", "base-batcher-admin", "base-batcher-core", @@ -2925,9 +3334,9 @@ dependencies = [ [[package]] name = "base-batcher-source" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "async-trait", "base-common-consensus", @@ -2942,9 +3351,9 @@ dependencies = [ [[package]] name = "base-blobs" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "base-protocol", "rstest", @@ -2953,7 +3362,7 @@ dependencies = [ [[package]] name = "base-builder-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "base-builder-core", @@ -2973,26 +3382,26 @@ dependencies = [ [[package]] name = "base-builder-core" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-contract", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-contract 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-json-rpc", - "alloy-network", + "alloy-json-rpc 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-beacon", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-signer", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", - "alloy-transport", - "alloy-transport-http", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "async-trait", "base-access-lists", "base-builder-core", @@ -3064,7 +3473,6 @@ dependencies = [ "reth-payload-builder-primitives", "reth-payload-primitives", "reth-payload-util", - "reth-primitives", "reth-primitives-traits", "reth-provider", "reth-revm", @@ -3086,7 +3494,6 @@ dependencies = [ "serde_json", "serde_with", "sha3 0.10.9", - "shellexpand", "tar", "tempfile", "testcontainers", @@ -3105,7 +3512,7 @@ dependencies = [ [[package]] name = "base-builder-metering" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "base-builder-core", @@ -3118,7 +3525,7 @@ dependencies = [ [[package]] name = "base-builder-publish" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-metrics", "base-ring-buffer", @@ -3138,7 +3545,7 @@ dependencies = [ [[package]] name = "base-bundle-extension" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-execution-txpool", "base-node-runner", @@ -3151,13 +3558,13 @@ dependencies = [ [[package]] name = "base-bundles" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-serde", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-serde 2.0.5", + "alloy-signer-local 2.0.5", "base-common-consensus", "base-common-flz", "base-common-rpc-types", @@ -3168,14 +3575,14 @@ dependencies = [ [[package]] name = "base-challenger" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer-local 2.0.5", "alloy-trie", "async-trait", "base-balance-monitor", @@ -3212,7 +3619,7 @@ dependencies = [ [[package]] name = "base-challenger-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-challenger", "base-cli-utils", @@ -3222,7 +3629,7 @@ dependencies = [ [[package]] name = "base-cli-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ "backtrace", "base-metrics", @@ -3245,11 +3652,11 @@ dependencies = [ [[package]] name = "base-common-chains" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-eips", - "alloy-genesis", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-hardforks 0.4.7", "alloy-primitives", "auto_impl", @@ -3261,17 +3668,17 @@ dependencies = [ [[package]] name = "base-common-consensus" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-network", + "alloy-network 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-signer", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", + "alloy-signer 2.0.5", "arbitrary", "bincode 2.0.1", "bytes", @@ -3279,7 +3686,6 @@ dependencies = [ "rand 0.9.4", "reth-codecs", "reth-db-api", - "reth-ethereum-primitives", "reth-firehose", "reth-primitives-traits", "reth-zstd-compressors", @@ -3293,10 +3699,10 @@ dependencies = [ [[package]] name = "base-common-evm" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", "alloy-hardforks 0.4.7", "alloy-primitives", @@ -3317,12 +3723,12 @@ dependencies = [ [[package]] name = "base-common-flashblocks" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "brotli", "bytes", "rstest", @@ -3333,7 +3739,7 @@ dependencies = [ [[package]] name = "base-common-flz" -version = "0.9.1" +version = "1.0.1" dependencies = [ "hex-literal 1.1.0", "rstest", @@ -3341,11 +3747,11 @@ dependencies = [ [[package]] name = "base-common-genesis" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-hardforks 0.4.7", "alloy-primitives", "alloy-sol-types", @@ -3362,44 +3768,51 @@ dependencies = [ [[package]] name = "base-common-network" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-transport", + "alloy-rpc-types-eth 2.0.5", + "alloy-transport 2.0.5", "async-trait", "base-common-consensus", "base-common-rpc-types", "base-common-rpc-types-engine", - "reth-rpc-convert", "rstest", ] [[package]] name = "base-common-precompiles" -version = "0.9.1" +version = "1.0.1" dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", "base-common-chains", + "base-precompile-macros", + "base-precompile-storage", + "criterion", + "k256", "revm", + "rstest", ] [[package]] name = "base-common-rpc-types" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-network", - "alloy-network-primitives", + "alloy-network 2.0.5", + "alloy-network-primitives 2.0.5", "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-signer", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", + "alloy-signer 2.0.5", "arbitrary", "base-common-consensus", "base-common-evm", @@ -3414,19 +3827,19 @@ dependencies = [ [[package]] name = "base-common-rpc-types-engine" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", - "alloy-serde", + "alloy-serde 2.0.5", "arbitrary", "arbtest", "base-common-consensus", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", + "ethereum_ssz", + "ethereum_ssz_derive", "reth-payload-primitives", "serde", "serde_json", @@ -3437,16 +3850,16 @@ dependencies = [ [[package]] name = "base-common-signer" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", - "alloy-node-bindings", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", + "alloy-node-bindings 2.0.5", "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-signer", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "async-trait", "jsonrpsee", "thiserror 2.0.18", @@ -3457,10 +3870,10 @@ dependencies = [ [[package]] name = "base-comp" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-sol-types", @@ -3482,7 +3895,7 @@ dependencies = [ [[package]] name = "base-consensus" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-cli-utils", "base-consensus-cli", @@ -3491,15 +3904,15 @@ dependencies = [ [[package]] name = "base-consensus-cli" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rpc-types-engine", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "base-cli-utils", "base-common-chains", "base-common-genesis", @@ -3515,7 +3928,7 @@ dependencies = [ "base-jwt", "clap", "dirs 6.0.0", - "discv5", + "discv5 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)", "eyre", "libp2p", "metrics", @@ -3527,17 +3940,18 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "url", ] [[package]] name = "base-consensus-derive" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", @@ -3549,6 +3963,7 @@ dependencies = [ "base-consensus-upgrades", "base-metrics", "base-protocol", + "criterion", "metrics", "proptest", "serde", @@ -3562,7 +3977,7 @@ dependencies = [ [[package]] name = "base-consensus-disc" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-rlp", "backon", @@ -3572,7 +3987,7 @@ dependencies = [ "base-consensus-peers", "base-metrics", "derive_more 2.1.1", - "discv5", + "discv5 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)", "libp2p", "metrics", "rand 0.9.4", @@ -3585,20 +4000,20 @@ dependencies = [ [[package]] name = "base-consensus-engine" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-json-rpc", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-json-rpc 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-pubsub", - "alloy-rpc-client", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-transport", - "alloy-transport-http", + "alloy-rpc-types-eth 2.0.5", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "alloy-transport-ws", "arbitrary", "async-trait", @@ -3628,11 +4043,11 @@ dependencies = [ [[package]] name = "base-consensus-gossip" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", @@ -3644,7 +4059,7 @@ dependencies = [ "base-consensus-peers", "base-metrics", "derive_more 2.1.1", - "discv5", + "discv5 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)", "futures", "ipnet", "libp2p", @@ -3667,21 +4082,21 @@ dependencies = [ [[package]] name = "base-consensus-node" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-signer", - "alloy-signer-local", - "alloy-transport", - "alloy-transport-http", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "anyhow", "arbitrary", "async-stream", @@ -3707,7 +4122,8 @@ dependencies = [ "base-protocol", "bytes", "derive_more 2.1.1", - "discv5", + "discv5 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)", + "ethereum_ssz", "futures", "http 1.4.0", "http-body 1.0.1", @@ -3719,6 +4135,7 @@ dependencies = [ "mockall", "rand 0.9.4", "redb", + "reqwest 0.13.3", "rstest", "serde", "strum", @@ -3735,7 +4152,7 @@ dependencies = [ [[package]] name = "base-consensus-peers" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -3744,7 +4161,7 @@ dependencies = [ "base-common-chains", "derive_more 2.1.1", "dirs 6.0.0", - "discv5", + "discv5 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)", "libp2p", "libp2p-identity", "multihash", @@ -3761,19 +4178,19 @@ dependencies = [ [[package]] name = "base-consensus-providers" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-beacon", "alloy-rpc-types-engine", - "alloy-serde", - "alloy-transport", - "alloy-transport-http", + "alloy-serde 2.0.5", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "async-trait", "base-common-consensus", "base-common-genesis", @@ -3798,9 +4215,9 @@ dependencies = [ [[package]] name = "base-consensus-rpc" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "async-trait", "backon", @@ -3824,9 +4241,9 @@ dependencies = [ [[package]] name = "base-consensus-safedb" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "async-trait", "base-protocol", @@ -3840,14 +4257,14 @@ dependencies = [ [[package]] name = "base-consensus-sources" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-rpc-client", - "alloy-signer", - "alloy-signer-local", - "alloy-transport", - "alloy-transport-http", + "alloy-rpc-client 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "base-common-rpc-types-engine", "derive_more 2.1.1", "notify", @@ -3862,9 +4279,9 @@ dependencies = [ [[package]] name = "base-consensus-upgrades" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "base-common-consensus", "base-common-evm", @@ -3874,16 +4291,16 @@ dependencies = [ [[package]] name = "base-engine-tree" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-eip7928", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "base-common-consensus", "base-common-evm", "base-common-flashblocks", @@ -3925,12 +4342,12 @@ dependencies = [ [[package]] name = "base-execution-chainspec" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-hardforks 0.4.7", "alloy-primitives", "base-common-chains", @@ -3947,9 +4364,9 @@ dependencies = [ [[package]] name = "base-execution-cli" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "backon", "base-bundle-extension", "base-cli-utils", @@ -3990,6 +4407,7 @@ dependencies = [ "reth-node-metrics", "reth-provider", "reth-rpc-server-types", + "reth-tasks", "reth-tracing", "rstest", "secp256k1 0.30.0", @@ -4002,11 +4420,11 @@ dependencies = [ [[package]] name = "base-execution-consensus" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-trie", "base-common-chains", @@ -4032,12 +4450,12 @@ dependencies = [ [[package]] name = "base-execution-evm" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", "base-common-chains", "base-common-consensus", @@ -4059,10 +4477,10 @@ dependencies = [ [[package]] name = "base-execution-exex" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "base-execution-chainspec", "base-execution-trie", "base-node-core", @@ -4085,9 +4503,9 @@ dependencies = [ [[package]] name = "base-execution-firehose" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-evm", "alloy-primitives", "base-common-consensus", @@ -4102,10 +4520,10 @@ dependencies = [ [[package]] name = "base-execution-payload-builder" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rlp", @@ -4132,6 +4550,7 @@ dependencies = [ "reth-revm", "reth-storage-api", "reth-transaction-pool", + "reth-trie-common", "revm", "serde", "sha2 0.10.9", @@ -4141,20 +4560,20 @@ dependencies = [ [[package]] name = "base-execution-rpc" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-json-rpc", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-json-rpc 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-rpc-client", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-debug", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-serde", - "alloy-transport", - "alloy-transport-http", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "async-trait", "base-common-chains", "base-common-consensus", @@ -4195,6 +4614,7 @@ dependencies = [ "reth-storage-api", "reth-tasks", "reth-transaction-pool", + "reth-trie-common", "revm", "serde", "thiserror 2.0.18", @@ -4204,11 +4624,11 @@ dependencies = [ [[package]] name = "base-execution-trie" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "auto_impl", "base-metrics", @@ -4250,13 +4670,13 @@ dependencies = [ [[package]] name = "base-execution-txpool" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "async-trait", "base-common-chains", "base-common-consensus", @@ -4292,17 +4712,17 @@ dependencies = [ [[package]] name = "base-firehose-flashblocks" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", - "alloy-network", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "base-common-chains", "base-common-consensus", "base-common-evm", @@ -4337,11 +4757,11 @@ dependencies = [ [[package]] name = "base-firehose-tests" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "base-common-consensus", "base-execution-chainspec", @@ -4362,18 +4782,18 @@ dependencies = [ [[package]] name = "base-flashblocks" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-network", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types", + "alloy-provider 2.0.5", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "arc-swap", "base-common-chains", "base-common-consensus", @@ -4395,13 +4815,14 @@ dependencies = [ "rayon", "reth-chainspec", "reth-evm", - "reth-primitives", + "reth-primitives-traits", "reth-provider", "reth-revm", "reth-rpc", "reth-rpc-convert", "reth-rpc-eth-api", "reth-rpc-eth-types", + "reth-tasks", "revm", "revm-database", "rstest", @@ -4417,20 +4838,20 @@ dependencies = [ [[package]] name = "base-flashblocks-node" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-contract", - "alloy-eips", - "alloy-genesis", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-contract 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", - "alloy-rpc-types", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-signer", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", "alloy-sol-macro", "alloy-sol-types", "base-common-consensus", @@ -4455,7 +4876,6 @@ dependencies = [ "reth-db", "reth-db-common", "reth-evm", - "reth-primitives", "reth-primitives-traits", "reth-provider", "reth-revm", @@ -4474,9 +4894,9 @@ dependencies = [ [[package]] name = "base-health" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-transport-http", + "alloy-transport-http 2.0.5", "async-trait", "axum 0.8.9", "bytes", @@ -4496,10 +4916,10 @@ dependencies = [ [[package]] name = "base-jwt" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rpc-types-engine", "backon", "base-common-network", @@ -4512,13 +4932,13 @@ dependencies = [ [[package]] name = "base-load-tester-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-network", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-rpc-types 2.0.5", + "alloy-signer-local 2.0.5", "base-cli-utils", "base-load-tests", "clap", @@ -4534,16 +4954,16 @@ dependencies = [ [[package]] name = "base-load-tests" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types", - "alloy-signer", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-rpc-types 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", "base-common-consensus", "base-common-flashblocks", @@ -4572,16 +4992,16 @@ dependencies = [ [[package]] name = "base-metering" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-rpc-client", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-sol-types", "arc-swap", "base-bundles", @@ -4614,7 +5034,6 @@ dependencies = [ "reth-trie-common", "revm", "revm-bytecode", - "revm-context-interface", "revm-database", "revm-inspectors", "serde", @@ -4627,7 +5046,7 @@ dependencies = [ [[package]] name = "base-metrics" -version = "0.9.1" +version = "1.0.1" dependencies = [ "ctor", "metrics", @@ -4637,16 +5056,16 @@ dependencies = [ [[package]] name = "base-node-core" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "base-common-chains", "base-common-consensus", "base-common-evm", @@ -4707,14 +5126,14 @@ dependencies = [ [[package]] name = "base-node-runner" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", - "alloy-genesis", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", - "alloy-rpc-types", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-engine", "base-common-consensus", "base-common-network", @@ -4733,10 +5152,12 @@ dependencies = [ "eyre", "futures", "jsonrpsee", + "reth-chainspec", "reth-db", "reth-engine-primitives", "reth-evm", "reth-exex", + "reth-firehose", "reth-ipc", "reth-node-api", "reth-node-builder", @@ -4758,14 +5179,38 @@ dependencies = [ "url", ] +[[package]] +name = "base-precompile-macros" +version = "1.0.1" +dependencies = [ + "alloy-primitives", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base-precompile-storage" +version = "1.0.1" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", + "base-precompile-macros", + "derive_more 2.1.1", + "proptest", + "revm", + "thiserror 2.0.18", +] + [[package]] name = "base-proof" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-trie", @@ -4798,9 +5243,9 @@ dependencies = [ [[package]] name = "base-proof-client" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-evm", "alloy-primitives", "base-common-consensus", @@ -4821,13 +5266,13 @@ dependencies = [ [[package]] name = "base-proof-contracts" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-contract", + "alloy-contract 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-sol-types", - "alloy-transport", + "alloy-transport 2.0.5", "async-trait", "futures", "rstest", @@ -4838,9 +5283,9 @@ dependencies = [ [[package]] name = "base-proof-driver" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rlp", @@ -4858,18 +5303,18 @@ dependencies = [ [[package]] name = "base-proof-executor" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", - "alloy-rpc-client", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-engine", - "alloy-transport", - "alloy-transport-http", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "alloy-trie", "base-common-chains", "base-common-consensus", @@ -4892,18 +5337,18 @@ dependencies = [ [[package]] name = "base-proof-host" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", - "alloy-rpc-types", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-beacon", - "alloy-transport", + "alloy-transport 2.0.5", "ark-ff 0.5.0", "async-trait", "base-common-consensus", @@ -4933,14 +5378,14 @@ dependencies = [ [[package]] name = "base-proof-mpt" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", - "alloy-rpc-types", - "alloy-transport-http", + "alloy-rpc-types 2.0.5", + "alloy-transport-http 2.0.5", "alloy-trie", "base-common-rpc-types-engine", "criterion", @@ -4954,7 +5399,7 @@ dependencies = [ [[package]] name = "base-proof-preimage" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "async-channel", @@ -4968,10 +5413,10 @@ dependencies = [ [[package]] name = "base-proof-primitives" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "async-trait", "base-common-genesis", @@ -4986,16 +5431,16 @@ dependencies = [ [[package]] name = "base-proof-rpc" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", - "alloy-network", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", - "alloy-rpc-types-eth", - "alloy-transport", - "alloy-transport-http", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-transport 2.0.5", + "alloy-transport-http 2.0.5", "async-trait", "backon", "base-common-genesis", @@ -5013,7 +5458,7 @@ dependencies = [ [[package]] name = "base-proof-succinct-build-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ "cargo_metadata 0.18.1", "sp1-build", @@ -5021,12 +5466,12 @@ dependencies = [ [[package]] name = "base-proof-succinct-client-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-sol-types", @@ -5036,6 +5481,7 @@ dependencies = [ "base-common-consensus", "base-common-evm", "base-common-genesis", + "base-common-precompiles", "base-consensus-derive", "base-proof", "base-proof-driver", @@ -5057,7 +5503,7 @@ dependencies = [ [[package]] name = "base-proof-succinct-elfs" -version = "0.9.1" +version = "1.0.1" dependencies = [ "serde", "sha2 0.10.9", @@ -5066,9 +5512,9 @@ dependencies = [ [[package]] name = "base-proof-succinct-ethereum-client-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-genesis", + "alloy-genesis 2.0.5", "anyhow", "async-trait", "base-common-genesis", @@ -5083,9 +5529,9 @@ dependencies = [ [[package]] name = "base-proof-succinct-ethereum-host-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "anyhow", "async-trait", @@ -5102,14 +5548,14 @@ dependencies = [ [[package]] name = "base-proof-succinct-host-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-contract", - "alloy-eips", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-contract 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", "alloy-sol-types", "anyhow", @@ -5156,7 +5602,7 @@ dependencies = [ [[package]] name = "base-proof-succinct-proof-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ "anyhow", "base-proof-succinct-elfs", @@ -5176,17 +5622,16 @@ dependencies = [ [[package]] name = "base-proof-succinct-prove" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-contract", - "alloy-eips", - "alloy-network", - "alloy-node-bindings", + "alloy-contract 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", - "alloy-transport-http", + "alloy-transport-http 2.0.5", "anyhow", "base-proof-preimage", "base-proof-succinct-build-utils", @@ -5212,16 +5657,15 @@ dependencies = [ [[package]] name = "base-proof-succinct-scripts" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", - "alloy-network", - "alloy-node-bindings", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", - "alloy-transport-http", + "alloy-transport-http 2.0.5", "anyhow", "base-proof-succinct-build-utils", "base-proof-succinct-client-utils", @@ -5245,18 +5689,18 @@ dependencies = [ [[package]] name = "base-proof-succinct-signer-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-signer", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", "alloy-signer-gcp", - "alloy-signer-local", - "alloy-transport-http", + "alloy-signer-local 2.0.5", + "alloy-transport-http 2.0.5", "anyhow", "base-proof-succinct-host-utils", "dotenv", @@ -5266,12 +5710,12 @@ dependencies = [ [[package]] name = "base-proof-succinct-validity" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", "anyhow", "base-proof-contracts", @@ -5305,10 +5749,9 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-attestation-prover" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-signer-local", "anyhow", "async-trait", "base-proof-tee-nitro-verifier", @@ -5324,13 +5767,13 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-enclave" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-chains", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "async-trait", "aws-nitro-enclaves-nsm-api", "base-common-chains", @@ -5357,10 +5800,10 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-host" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-signer", + "alloy-signer 2.0.5", "async-trait", "base-health", "base-proof-contracts", @@ -5381,7 +5824,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-verifier" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -5398,13 +5841,12 @@ dependencies = [ [[package]] name = "base-proof-tee-registrar" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-signer", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", "alloy-sol-types", "async-trait", "aws-sdk-ec2", @@ -5415,6 +5857,7 @@ dependencies = [ "base-proof-tee-nitro-attestation-prover", "base-proof-tee-nitro-verifier", "base-tx-manager", + "boundless-market", "futures", "hex", "hex-literal 1.1.0", @@ -5435,11 +5878,11 @@ dependencies = [ [[package]] name = "base-proof-tee-registrar-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-provider", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-signer-local 2.0.5", "aws-config", "aws-sdk-ec2", "aws-sdk-elasticloadbalancingv2", @@ -5449,6 +5892,7 @@ dependencies = [ "base-proof-tee-nitro-attestation-prover", "base-proof-tee-registrar", "base-tx-manager", + "boundless-market", "clap", "eyre", "hex", @@ -5464,9 +5908,11 @@ dependencies = [ [[package]] name = "base-proofs-extension" -version = "0.9.1" +version = "1.0.1" dependencies = [ + "base-common-consensus", "base-execution-exex", + "base-execution-payload-builder", "base-execution-rpc", "base-execution-trie", "base-node-core", @@ -5481,13 +5927,13 @@ dependencies = [ [[package]] name = "base-proposer" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", "async-trait", "base-balance-monitor", @@ -5519,7 +5965,7 @@ dependencies = [ [[package]] name = "base-proposer-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-cli-utils", "base-proposer", @@ -5530,16 +5976,16 @@ dependencies = [ [[package]] name = "base-protocol" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloc-no-stdlib", - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "ambassador", "arbitrary", "async-trait", @@ -5566,7 +6012,7 @@ dependencies = [ [[package]] name = "base-prover-nitro-enclave" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-proof-tee-nitro-enclave", "eyre", @@ -5575,7 +6021,7 @@ dependencies = [ [[package]] name = "base-prover-nitro-host" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "base-cli-utils", @@ -5592,7 +6038,7 @@ dependencies = [ [[package]] name = "base-prover-zk" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-cli-utils", "base-proof-succinct-host-utils", @@ -5620,14 +6066,14 @@ dependencies = [ [[package]] name = "base-reth-cli" -version = "0.9.1" +version = "1.0.1" dependencies = [ "reth-node-core", ] [[package]] name = "base-reth-node" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "base-cli-utils", @@ -5650,11 +6096,11 @@ dependencies = [ [[package]] name = "base-ring-buffer" -version = "0.9.1" +version = "1.0.1" [[package]] name = "base-runtime" -version = "0.9.1" +version = "1.0.1" dependencies = [ "futures", "rand 0.9.4", @@ -5664,9 +6110,50 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "base-snapshotter" +version = "1.0.1" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-sdk-s3", + "blake3", + "bollard", + "clap", + "futures", + "rayon", + "serde", + "serde_json", + "serial_test", + "tar", + "tempfile", + "testcontainers", + "testcontainers-modules", + "tokio", + "tracing", + "zstd", +] + +[[package]] +name = "base-snapshotter-bin" +version = "1.0.1" +dependencies = [ + "anyhow", + "aws-config", + "aws-credential-types", + "aws-sdk-s3", + "base-snapshotter", + "clap", + "rayon", + "tokio", + "tracing", + "tracing-subscriber 0.3.23", +] + [[package]] name = "base-snark-e2e" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-zk-service", "tokio", @@ -5676,15 +6163,15 @@ dependencies = [ [[package]] name = "base-test-utils" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-contract", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-contract 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-macro", "alloy-sol-types", "base-common-rpc-types", @@ -5694,7 +6181,7 @@ dependencies = [ [[package]] name = "base-tx-forwarding" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-execution-txpool", "base-node-runner", @@ -5704,20 +6191,20 @@ dependencies = [ [[package]] name = "base-tx-manager" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-json-rpc", - "alloy-network", - "alloy-node-bindings", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-json-rpc 2.0.5", + "alloy-network 2.0.5", + "alloy-node-bindings 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-signer", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "alloy-sol-types", - "alloy-transport", + "alloy-transport 2.0.5", "async-trait", "backon", "base-common-signer", @@ -5738,7 +6225,7 @@ dependencies = [ [[package]] name = "base-txpool-rpc" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", "base-node-runner", @@ -5755,10 +6242,10 @@ dependencies = [ [[package]] name = "base-txpool-tracing" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "base-common-consensus", @@ -5786,13 +6273,13 @@ dependencies = [ [[package]] name = "base-witness-diff" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", - "alloy-rpc-client", - "alloy-transport-http", + "alloy-rpc-client 2.0.5", + "alloy-transport-http 2.0.5", "alloy-trie", "base-common-network", "base-proof-mpt", @@ -5812,7 +6299,7 @@ checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" [[package]] name = "base-zk-client" -version = "0.9.1" +version = "1.0.1" dependencies = [ "async-trait", "prost 0.14.3", @@ -5828,7 +6315,7 @@ dependencies = [ [[package]] name = "base-zk-db" -version = "0.9.1" +version = "1.0.1" dependencies = [ "anyhow", "chrono", @@ -5844,7 +6331,7 @@ dependencies = [ [[package]] name = "base-zk-outbox" -version = "0.9.1" +version = "1.0.1" dependencies = [ "anyhow", "async-trait", @@ -5857,11 +6344,11 @@ dependencies = [ [[package]] name = "base-zk-service" -version = "0.9.1" +version = "1.0.1" dependencies = [ "alloy-primitives", - "alloy-provider", - "alloy-rpc-types", + "alloy-provider 2.0.5", + "alloy-rpc-types 2.0.5", "anyhow", "async-trait", "axum 0.8.9", @@ -5943,25 +6430,26 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "basectl" -version = "0.9.1" +version = "1.0.1" dependencies = [ "anyhow", "basectl-cli", "clap", "rustls 0.23.40", "tokio", + "url", ] [[package]] name = "basectl-cli" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-contract", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-contract 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", "alloy-sol-types", "anyhow", "arboard", @@ -5990,13 +6478,13 @@ dependencies = [ [[package]] name = "based" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-transport-http", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-transport-http 2.0.5", "anyhow", "async-trait", "cadence", @@ -6006,7 +6494,7 @@ dependencies = [ [[package]] name = "based-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "base-cli-utils", "based", @@ -6181,6 +6669,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + [[package]] name = "bitvec" version = "1.0.1" @@ -6203,17 +6697,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "blake2b_simd" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" -dependencies = [ - "arrayref", - "arrayvec", - "constant_time_eq", -] - [[package]] name = "blake3" version = "1.8.5" @@ -6271,19 +6754,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "bls12_381" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3c196a77437e7cc2fb515ce413a6401291578b5afc8ecb29a3c7ab957f05941" -dependencies = [ - "ff 0.12.1", - "group 0.12.1", - "pairing 0.22.0", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "blst" version = "0.3.16" @@ -7227,9 +7697,9 @@ dependencies = [ [[package]] name = "const-hex" -version = "1.19.0" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20d9a563d167a9cce0f94153382b33cb6eded6dfabff03c69ad65a28ea1514e0" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -8103,23 +8573,26 @@ dependencies = [ [[package]] name = "devnet" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-client", + "alloy-provider 2.0.5", + "alloy-rpc-client 2.0.5", "alloy-rpc-types-engine", - "alloy-signer", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", + "alloy-sol-types", "base-batcher-service", "base-builder-core", "base-bundle-extension", "base-common-genesis", "base-common-network", + "base-common-precompiles", "base-common-rpc-types", "base-consensus-disc", "base-consensus-gossip", @@ -8134,13 +8607,17 @@ dependencies = [ "base-flashblocks-node", "base-node-core", "base-node-runner", + "base-proof-rpc", "base-protocol", "base-runtime", "base-tx-forwarding", "base-txpool-rpc", "base-txpool-tracing", + "base-zk-client", + "clap", "eyre", "hex", + "indicatif 0.18.4", "jsonrpsee", "k256", "libp2p", @@ -8305,6 +8782,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "discv5" +version = "0.10.4" +source = "git+https://github.com/sigp/discv5?rev=7663c00#7663c00ee0837ea98547caaedede95d9d6736f4d" +dependencies = [ + "aes", + "aes-gcm", + "alloy-rlp", + "arrayvec", + "ctr", + "delay_map", + "enr", + "fnv", + "futures", + "hashlink 0.11.0", + "hex", + "hkdf", + "lazy_static", + "libp2p-identity", + "more-asserts", + "multiaddr", + "parking_lot", + "rand 0.8.6", + "smallvec", + "socket2 0.6.3", + "tokio", + "tracing", + "uint 0.10.0", + "zeroize", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -8552,9 +9060,9 @@ dependencies = [ "base16ct", "crypto-bigint", "digest 0.10.7", - "ff 0.13.1", + "ff", "generic-array 0.14.7", - "group 0.13.0", + "group", "hkdf", "pem-rfc7468", "pkcs8", @@ -8754,9 +9262,9 @@ dependencies = [ [[package]] name = "ethereum_hashing" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c853bd72c9e5787f8aafc3df2907c2ed03cff3150c3acd94e2e53a98ab70a8ab" +checksum = "5aa93f58bb1eb3d1e556e4f408ef1dac130bad01ac37db4e7ade45de40d1c86a" dependencies = [ "cpufeatures 0.2.17", "ring", @@ -8776,21 +9284,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "ethereum_ssz" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dcddb2554d19cde19b099fadddde576929d7a4d0c1cd3512d1fd95cf174375c" -dependencies = [ - "alloy-primitives", - "ethereum_serde_utils", - "itertools 0.13.0", - "serde", - "serde_derive", - "smallvec", - "typenum", -] - [[package]] name = "ethereum_ssz" version = "0.10.4" @@ -8806,18 +9299,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ethereum_ssz_derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a657b6b3b7e153637dc6bdc6566ad9279d9ee11a15b12cfb24a2e04360637e9f" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "ethereum_ssz_derive" version = "0.10.4" @@ -8967,17 +9448,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "bitvec", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "ff" version = "0.13.1" @@ -9063,12 +9533,13 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] name = "firehose-tracer" -version = "5.2.0" -source = "git+https://github.com/streamingfast/evm-firehose-tracer-rs.git?branch=offset-flashblocks#e36843d3d241846fa2baec14a97f27eb03696ba7" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45902d2ed9ce2b946d44f20703a94522d49db4c9119ea2f043f6f14a031e9c3b" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "base64-simd", @@ -9458,7 +9929,7 @@ dependencies = [ "chrono", "futures", "hyper 1.9.0", - "jsonwebtoken 10.4.0", + "jsonwebtoken", "once_cell", "prost 0.14.3", "prost-types 0.14.3", @@ -9730,25 +10201,13 @@ dependencies = [ "spinning_top", ] -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff 0.12.1", - "memuse", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "group" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", + "ff", "rand_core 0.6.4", "subtle", ] @@ -9808,29 +10267,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "halo2" -version = "0.1.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a23c779b38253fe1538102da44ad5bd5378495a61d2c4ee18d64eaa61ae5995" -dependencies = [ - "halo2_proofs", -] - -[[package]] -name = "halo2_proofs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e925780549adee8364c7f2b685c753f6f3df23bde520c67416e93bf615933760" -dependencies = [ - "blake2b_simd", - "ff 0.12.1", - "group 0.12.1", - "pasta_curves 0.4.1", - "rand_core 0.6.4", - "rayon", -] - [[package]] name = "hash-db" version = "0.15.2" @@ -10672,6 +11108,31 @@ dependencies = [ "tiff", ] +[[package]] +name = "imbl" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e525189e5f603908d0c6e0d402cb5de9c4b2c8866151fabc4ebd771ed2630a2e" +dependencies = [ + "archery", + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.9.5", + "rand_xoshiro", + "serde_core", + "version_check", + "wide", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -10785,9 +11246,9 @@ dependencies = [ [[package]] name = "ingress-rpc" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-provider", + "alloy-provider 2.0.5", "anyhow", "audit-archiver-lib", "base-bundles", @@ -10797,7 +11258,6 @@ dependencies = [ "dotenvy", "ingress-rpc-lib", "jsonrpsee", - "rdkafka", "serde", "tokio", "tracing", @@ -10805,19 +11265,19 @@ dependencies = [ [[package]] name = "ingress-rpc-lib" -version = "0.9.1" +version = "1.0.1" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-signer-local", + "alloy-provider 2.0.5", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer-local 2.0.5", "anyhow", "async-trait", "audit-archiver-lib", "axum 0.8.9", - "backon", "base-bundles", "base-common-consensus", "base-common-evm", @@ -10828,8 +11288,8 @@ dependencies = [ "jsonrpsee", "metrics", "moka", - "rdkafka", "reth-rpc-eth-types", + "reth-rpc-server-types", "serde", "serde_json", "tokio", @@ -11149,9 +11609,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -11331,27 +11791,13 @@ dependencies = [ "url", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "jsonwebtoken" version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "getrandom 0.2.17", "js-sys", @@ -11363,20 +11809,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "jubjub" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a575df5f985fe1cd5b2b05664ff6accfc46559032b954529fd225a2168d27b0f" -dependencies = [ - "bitvec", - "bls12_381", - "ff 0.12.1", - "group 0.12.1", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "k256" version = "0.13.4" @@ -11473,7 +11905,7 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8b4f55c3dedcfaa8668de1dfc8469e7a32d441c28edf225ed1f566fb32977d" dependencies = [ - "ff 0.13.1", + "ff", "hex", "rkyv", "serde", @@ -12004,7 +12436,6 @@ dependencies = [ "libc", "libz-sys", "lz4-sys", - "tikv-jemalloc-sys", "zstd-sys", ] @@ -12085,9 +12516,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "loom" @@ -12398,42 +12829,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mempool-rebroadcaster" -version = "0.9.1" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types", - "alloy-rpc-types-eth", - "alloy-trie", - "serde", - "serde_json", - "tokio", - "tracing", -] - -[[package]] -name = "mempool-rebroadcaster-bin" -version = "0.9.1" -dependencies = [ - "base-cli-utils", - "clap", - "dotenvy", - "mempool-rebroadcaster", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "memuse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" - [[package]] name = "merlin" version = "3.0.0" @@ -13322,7 +13717,7 @@ checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "flate2", "memchr", - "ruzstd", + "ruzstd 0.7.3", ] [[package]] @@ -13331,7 +13726,12 @@ version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ + "crc32fast", + "flate2", + "hashbrown 0.15.5", + "indexmap 2.14.0", "memchr", + "ruzstd 0.8.3", ] [[package]] @@ -13365,122 +13765,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "op-alloy" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9b8fee21003dd4f076563de9b9d26f8c97840157ef78593cd7f262c5ca99848" -dependencies = [ - "op-alloy-consensus", - "op-alloy-network", - "op-alloy-provider", - "op-alloy-rpc-types", - "op-alloy-rpc-types-engine", -] - -[[package]] -name = "op-alloy-consensus" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736381a95471d23e267263cfcee9e1d96d30b9754a94a2819148f83379de8a86" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", - "alloy-primitives", - "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-serde", - "arbitrary", - "derive_more 2.1.1", - "serde", - "serde_with", - "thiserror 2.0.18", -] - -[[package]] -name = "op-alloy-network" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4034183dca6bff6632e7c24c92e75ff5f0eabb58144edb4d8241814851334d47" -dependencies = [ - "alloy-consensus", - "alloy-network", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth", - "alloy-signer", - "op-alloy-consensus", - "op-alloy-rpc-types", -] - -[[package]] -name = "op-alloy-provider" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6753d90efbaa8ea8bcb89c1737408ca85fa60d7adb875049d3f382c063666f86" -dependencies = [ - "alloy-network", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-engine", - "alloy-transport", - "async-trait", - "op-alloy-rpc-types-engine", -] - -[[package]] -name = "op-alloy-rpc-types" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd87c6b9e5b6eee8d6b76f41b04368dca0e9f38d83338e5b00e730c282098a4" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network-primitives", - "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", - "derive_more 2.1.1", - "op-alloy-consensus", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "op-alloy-rpc-types-engine" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727699310a18cdeed32da3928c709e2704043b6584ed416397d5da65694efc" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-rpc-types-engine", - "alloy-serde", - "derive_more 2.1.1", - "ethereum_ssz 0.9.1", - "ethereum_ssz_derive 0.9.1", - "op-alloy-consensus", - "serde", - "sha2 0.10.9", - "snap", - "thiserror 2.0.18", -] - -[[package]] -name = "op-revm" -version = "15.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c92b75162c2ed1661849fa51683b11254a5b661798360a2c24be918edafd40" -dependencies = [ - "auto_impl", - "revm", - "serde", -] - [[package]] name = "opaque-debug" version = "0.3.1" @@ -13756,9 +14040,9 @@ dependencies = [ [[package]] name = "p3-air" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d275c27bb81483d669709d7244ce333b51f9743af2474cdc09ba1509f5c290db" +checksum = "d3a5de20a2301bf2530de1ceb13768ec3a80f729fbf8b72f813e30bc54c5bce2" dependencies = [ "p3-field", "p3-matrix", @@ -13767,26 +14051,28 @@ dependencies = [ [[package]] name = "p3-baby-bear" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a083928c9055f2171e3cb0bb4767969e4955473e71ba61affe46d7a3c98a89" +checksum = "d69e6e9af4eaaaa60f7bb9f0e0f73ebcbaefe7e00974d97ad0fa542d6a4f0890" dependencies = [ + "cfg-if", "num-bigint 0.4.6", "p3-field", "p3-mds", "p3-poseidon2", "p3-symmetric", "rand 0.8.6", + "rustc_version 0.4.1", "serde", ] [[package]] name = "p3-bn254-fr" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9abf208fbfe540d6e2a6caaa2a9a345b1c8cb23ffdcdfcc6987244525d4fc821" +checksum = "2077757c7cb514202ccb5368f521f23f5709c720599e6545c683c66e0a52d2d8" dependencies = [ - "ff 0.13.1", + "ff", "num-bigint 0.4.6", "p3-field", "p3-poseidon2", @@ -13797,9 +14083,9 @@ dependencies = [ [[package]] name = "p3-challenger" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b725b453bbb35117a1abf0ddfd900b0676063d6e4231e0fa6bb0d76018d8ad" +checksum = "b6a908924d43e4cfb93fb41c8346cac211b70314385a9037e9241f5b7f3eaf77" dependencies = [ "p3-field", "p3-maybe-rayon", @@ -13811,9 +14097,9 @@ dependencies = [ [[package]] name = "p3-commit" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518695b56f450f9223bdd8994dda87916b97ebf1d1c03c956807e78522fdb333" +checksum = "50acacc7219fce6c01db938f82c1b21b5e7133990b7fff861f91534aeb569419" dependencies = [ "itertools 0.12.1", "p3-challenger", @@ -13825,9 +14111,9 @@ dependencies = [ [[package]] name = "p3-dft" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56a1f81101bff744b7ebba7f4497e917a2c6716d6e62736e4a56e555a2d98cb7" +checksum = "be6408b10a2c27eb13a7d5580c546c2179a8dc7dbc10a990657311891f9b41c0" dependencies = [ "p3-field", "p3-matrix", @@ -13838,9 +14124,9 @@ dependencies = [ [[package]] name = "p3-field" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36459d4acb03d08097d713f336c7393990bb489ab19920d4f68658c7a5c10968" +checksum = "3dc75969ca3ac847f43e632ab979d59ff7a68f9eac8dbf8edcbba47fc2e1d3aa" dependencies = [ "itertools 0.12.1", "num-bigint 0.4.6", @@ -13852,9 +14138,9 @@ dependencies = [ [[package]] name = "p3-fri" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2529a174a04189cfe705d756fb0e33d3c8fb06b167b521ddb877c78407f12a" +checksum = "5cbc4965ee488f3247867b7ec4bb005b8afa72cb0d461a4dcb1387ecab6426d5" dependencies = [ "itertools 0.12.1", "p3-challenger", @@ -13871,9 +14157,9 @@ dependencies = [ [[package]] name = "p3-interpolation" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662049877c802155cdb4863db59899469fc3565d22d9047e1bd22d6b71f28e5" +checksum = "08ad7e9f08c336d7ea39d12e11951188473542565323bac2a6535e536b58487d" dependencies = [ "p3-field", "p3-matrix", @@ -13882,9 +14168,9 @@ dependencies = [ [[package]] name = "p3-keccak-air" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "169c96f8f0aaa9042872fdb6bbae0477fd1363b87c23877dbb2ec7fb46f8fcfa" +checksum = "1f5bf177d56740078b5a5a842fa2393427796283dc8174b4a1a325c2d0b042de" dependencies = [ "p3-air", "p3-field", @@ -13896,24 +14182,26 @@ dependencies = [ [[package]] name = "p3-koala-bear" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1f52bcb6be38bdc8fa6b38b3434d4eedd511f361d4249fd798c6a5ef817b40" +checksum = "3a9683cd0ef68100df7c62490533047bcf19c04c4a0fa1efc9d7c1e03e31f6b3" dependencies = [ + "cfg-if", "num-bigint 0.4.6", "p3-field", "p3-mds", "p3-poseidon2", "p3-symmetric", "rand 0.8.6", + "rustc_version 0.4.1", "serde", ] [[package]] name = "p3-matrix" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5583e9cd136a4095a25c41a9edfdcce2dfae58ef01639317813bdbbd5b55c583" +checksum = "75c3f150ceb90e09539413bf481e618d05ee19210b4e467d2902eb82d2e15281" dependencies = [ "itertools 0.12.1", "p3-field", @@ -13926,18 +14214,18 @@ dependencies = [ [[package]] name = "p3-maybe-rayon" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e524d47a49fb4265611303339c4ef970d892817b006cc330dad18afb91e411b1" +checksum = "e0641952b42da45e1dfa2d4a2a3163e330f944ad9740942f35026c0a71a605f1" dependencies = [ "rayon", ] [[package]] name = "p3-mds" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f6cb8edcb276033d43769a3725570c340d2ed6f35c3cca4cddeee07718fa376" +checksum = "aa4a5f250e174dcfca5cbeac6ad75713924e7e7320e0a335e3c50b8b1f4fe8ec" dependencies = [ "itertools 0.12.1", "p3-dft", @@ -13950,9 +14238,9 @@ dependencies = [ [[package]] name = "p3-merkle-tree" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e8bc3c224fc70d22f9556393e1482b52539e11c7b82ac6933c436fd82738f4" +checksum = "d5703d9229d52a8c09970e4d722c3a8b4d37e688c306c3a1c03b872efcd204e6" dependencies = [ "itertools 0.12.1", "p3-commit", @@ -13967,9 +14255,9 @@ dependencies = [ [[package]] name = "p3-poseidon2" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a26197df2097b98ab7038d59a01e1fe1a0f545e7e04aa9436b2454b1836654f" +checksum = "522986377b2164c5f94f2dae88e0e0a3d169cc6239202ef4aeb4322d60feffd0" dependencies = [ "gcd", "p3-field", @@ -13981,9 +14269,9 @@ dependencies = [ [[package]] name = "p3-symmetric" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1d3b5202096bca57cde912fbbb9cbaedaf5ac7c42a924c7166b98709d64d21" +checksum = "9047ce85c086a9b3f118e10078f10636f7bfeed5da871a04da0b61400af8793a" dependencies = [ "itertools 0.12.1", "p3-field", @@ -13992,9 +14280,9 @@ dependencies = [ [[package]] name = "p3-uni-stark" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef1cdb8285a7adb78df991852d3b66d3b25cf6ffc34f528505d1aee49bdb968" +checksum = "fc3dfdeba14d8db621c4e52dd63973384ff35f353fd750154ff88397f4ea5adf" dependencies = [ "itertools 0.12.1", "p3-air", @@ -14011,9 +14299,9 @@ dependencies = [ [[package]] name = "p3-util" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f0388aa6d935ca3a17444086120f393f0b2f0816010b5ff95998c1c4095e3" +checksum = "cff962f8eaa5f36e0447cee7c241f6b4b475fadf3ee61f154327a26bb4e009ba" dependencies = [ "serde", ] @@ -14064,20 +14352,11 @@ dependencies = [ "bytes", "delegate", "futures", - "log", - "rand 0.8.6", - "thiserror 1.0.69", - "tokio", - "windows 0.59.0", -] - -[[package]] -name = "pairing" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135590d8bdba2b31346f9cd1fb2a912329f5135e832a4f422942eb6ead8b6b3b" -dependencies = [ - "group 0.12.1", + "log", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "windows 0.59.0", ] [[package]] @@ -14086,7 +14365,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" dependencies = [ - "group 0.13.0", + "group", ] [[package]] @@ -14095,7 +14374,6 @@ version = "3.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" dependencies = [ - "arbitrary", "arrayvec", "bitvec", "byte-slice-cast", @@ -14184,36 +14462,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "pasta_curves" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc65faf8e7313b4b1fbaa9f7ca917a0eed499a9663be71477f87993604341d8" -dependencies = [ - "blake2b_simd", - "ff 0.12.1", - "group 0.12.1", - "lazy_static", - "rand 0.8.6", - "static_assertions", - "subtle", -] - -[[package]] -name = "pasta_curves" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" -dependencies = [ - "blake2b_simd", - "ff 0.13.1", - "group 0.13.0", - "lazy_static", - "rand 0.8.6", - "static_assertions", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" @@ -15572,38 +15820,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "rdkafka" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7956f9ac12b5712e50372d9749a3102f4810a8d42481c5eae3748d36d585bcf" -dependencies = [ - "futures-channel", - "futures-util", - "libc", - "log", - "rdkafka-sys", - "serde", - "serde_derive", - "serde_json", - "slab", - "tokio", -] - -[[package]] -name = "rdkafka-sys" -version = "4.10.0+2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e234cf318915c1059d4921ef7f75616b5219b10b46e9f3a511a15eb4b56a3f77" -dependencies = [ - "libc", - "libz-sys", - "num_enum 0.7.6", - "openssl-sys", - "pkg-config", - "zstd-sys", -] - [[package]] name = "recvmsg" version = "1.0.0" @@ -15818,6 +16034,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2 0.4.14", @@ -15932,16 +16149,17 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reth-basic-payload-builder" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "futures-core", "futures-util", "metrics", "reth-chain-state", + "reth-execution-cache", "reth-metrics", "reth-payload-builder", "reth-payload-builder-primitives", @@ -15950,20 +16168,22 @@ dependencies = [ "reth-revm", "reth-storage-api", "reth-tasks", + "reth-trie-parallel", + "serde", "tokio", "tracing", ] [[package]] name = "reth-chain-state" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-signer", - "alloy-signer-local", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "derive_more 2.1.1", "metrics", "parking_lot", @@ -15988,14 +16208,14 @@ dependencies = [ [[package]] name = "reth-chainspec" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-trie", "auto_impl", @@ -16008,30 +16228,30 @@ dependencies = [ [[package]] name = "reth-cli" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-genesis", + "alloy-genesis 2.0.5", "clap", "eyre", "reth-cli-runner", "reth-db", "serde_json", - "shellexpand", ] [[package]] name = "reth-cli-commands" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "arbitrary", "backon", + "blake3", "clap", "comfy-table", "crossterm", @@ -16047,7 +16267,8 @@ dependencies = [ "proptest", "proptest-arbitrary-interop", "ratatui", - "reqwest 0.12.28", + "rayon", + "reqwest 0.13.3", "reth-chainspec", "reth-cli", "reth-cli-runner", @@ -16108,8 +16329,8 @@ dependencies = [ [[package]] name = "reth-cli-runner" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "reth-tasks", "tokio", @@ -16118,10 +16339,10 @@ dependencies = [ [[package]] name = "reth-cli-util" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "cfg-if", "eyre", @@ -16131,23 +16352,25 @@ dependencies = [ "secp256k1 0.30.0", "serde", "thiserror 2.0.18", + "tikv-jemalloc-sys", "tikv-jemallocator", ] [[package]] name = "reth-codecs" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce542a96bf888f31854803e80b3340bc233927743aa580838014e8a88fe0d66" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-trie", "arbitrary", "bytes", "modular-bitfield", - "op-alloy-consensus", + "parity-scale-codec", "reth-codecs-derive", "reth-zstd-compressors", "serde", @@ -16156,8 +16379,9 @@ dependencies = [ [[package]] name = "reth-codecs-derive" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634c90f1cc0f9887680ca785b0b21aa961070b9465917bf65afaec56a6d005bb" dependencies = [ "proc-macro2", "quote", @@ -16166,8 +16390,8 @@ dependencies = [ [[package]] name = "reth-config" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "eyre", "humantime-serde", @@ -16182,10 +16406,10 @@ dependencies = [ [[package]] name = "reth-consensus" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "auto_impl", "reth-execution-types", @@ -16195,11 +16419,12 @@ dependencies = [ [[package]] name = "reth-consensus-common" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-primitives", "reth-chainspec", "reth-consensus", "reth-primitives-traits", @@ -16207,21 +16432,21 @@ dependencies = [ [[package]] name = "reth-consensus-debug-client" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-json-rpc", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-json-rpc 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rpc-types-engine", - "alloy-transport", + "alloy-transport 2.0.5", "auto_impl", "derive_more 2.1.1", "eyre", "futures", - "reqwest 0.12.28", + "reqwest 0.13.3", "reth-node-api", "reth-primitives-traits", "reth-tracing", @@ -16233,15 +16458,17 @@ dependencies = [ [[package]] name = "reth-db" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "derive_more 2.1.1", "eyre", + "libc", "metrics", "page_size", "parking_lot", + "quanta", "reth-db-api", "reth-fs-util", "reth-libmdbx", @@ -16260,11 +16487,10 @@ dependencies = [ [[package]] name = "reth-db-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-genesis", + "alloy-consensus 2.0.5", "alloy-primitives", "arbitrary", "arrayvec", @@ -16272,8 +16498,6 @@ dependencies = [ "derive_more 2.1.1", "metrics", "modular-bitfield", - "op-alloy-consensus", - "parity-scale-codec", "proptest", "reth-codecs", "reth-db-models", @@ -16289,11 +16513,11 @@ dependencies = [ [[package]] name = "reth-db-common" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "boyer-moore-magiclen", "eyre", @@ -16319,10 +16543,10 @@ dependencies = [ [[package]] name = "reth-db-models" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "arbitrary", "bytes", @@ -16334,12 +16558,12 @@ dependencies = [ [[package]] name = "reth-discv4" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "alloy-rlp", - "discv5", + "discv5 0.10.4 (git+https://github.com/sigp/discv5?rev=7663c00)", "enr", "itertools 0.14.0", "parking_lot", @@ -16359,13 +16583,13 @@ dependencies = [ [[package]] name = "reth-discv5" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "alloy-rlp", "derive_more 2.1.1", - "discv5", + "discv5 0.10.4 (git+https://github.com/sigp/discv5?rev=7663c00)", "enr", "futures", "itertools 0.14.0", @@ -16383,8 +16607,8 @@ dependencies = [ [[package]] name = "reth-dns-discovery" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "dashmap", @@ -16407,11 +16631,11 @@ dependencies = [ [[package]] name = "reth-downloaders" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "async-compression", @@ -16442,19 +16666,19 @@ dependencies = [ [[package]] name = "reth-e2e-test-utils" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-network", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-provider", + "alloy-provider 2.0.5", "alloy-rlp", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", - "alloy-signer", - "alloy-signer-local", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "derive_more 2.1.1", "eyre", "futures-util", @@ -16478,7 +16702,6 @@ dependencies = [ "reth-payload-builder", "reth-payload-builder-primitives", "reth-payload-primitives", - "reth-primitives", "reth-primitives-traits", "reth-provider", "reth-rpc-api", @@ -16500,8 +16723,8 @@ dependencies = [ [[package]] name = "reth-ecies" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "aes", "alloy-primitives", @@ -16528,10 +16751,10 @@ dependencies = [ [[package]] name = "reth-engine-local" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "eyre", @@ -16551,11 +16774,11 @@ dependencies = [ [[package]] name = "reth-engine-primitives" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "auto_impl", @@ -16574,44 +16797,22 @@ dependencies = [ "tokio", ] -[[package]] -name = "reth-engine-service" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" -dependencies = [ - "futures", - "pin-project", - "reth-chainspec", - "reth-consensus", - "reth-engine-primitives", - "reth-engine-tree", - "reth-evm", - "reth-network-p2p", - "reth-node-types", - "reth-payload-builder", - "reth-provider", - "reth-prune", - "reth-stages-api", - "reth-tasks", - "reth-trie-db", -] - [[package]] name = "reth-engine-tree" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-eip7928", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", "crossbeam-channel", "derive_more 2.1.1", - "fixed-cache", "futures", + "indexmap 2.14.0", "metrics", "moka", "parking_lot", @@ -16624,6 +16825,7 @@ dependencies = [ "reth-errors", "reth-ethereum-primitives", "reth-evm", + "reth-execution-cache", "reth-execution-types", "reth-firehose", "reth-metrics", @@ -16648,7 +16850,6 @@ dependencies = [ "revm", "revm-primitives", "schnellru", - "smallvec", "thiserror 2.0.18", "tokio", "tracing", @@ -16656,10 +16857,10 @@ dependencies = [ [[package]] name = "reth-engine-util" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-rpc-types-engine", "eyre", "futures", @@ -16684,29 +16885,30 @@ dependencies = [ [[package]] name = "reth-era" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", - "ethereum_ssz 0.10.4", - "ethereum_ssz_derive 0.10.4", + "ethereum_ssz", + "ethereum_ssz_derive", + "sha2 0.10.9", "snap", "thiserror 2.0.18", ] [[package]] name = "reth-era-downloader" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "bytes", "eyre", "futures-util", - "reqwest 0.12.28", + "reqwest 0.13.3", "reth-era", "reth-fs-util", "sha2 0.10.9", @@ -16715,10 +16917,10 @@ dependencies = [ [[package]] name = "reth-era-utils" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "eyre", "futures-util", @@ -16737,8 +16939,8 @@ dependencies = [ [[package]] name = "reth-errors" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "reth-consensus", "reth-execution-errors", @@ -16748,8 +16950,8 @@ dependencies = [ [[package]] name = "reth-eth-wire" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-chains", "alloy-primitives", @@ -16777,12 +16979,13 @@ dependencies = [ [[package]] name = "reth-eth-wire-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-chains", - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eip7928", + "alloy-eips 2.0.5", "alloy-hardforks 0.4.7", "alloy-primitives", "alloy-rlp", @@ -16801,11 +17004,11 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "reth-chainspec", "reth-consensus", @@ -16817,26 +17020,24 @@ dependencies = [ [[package]] name = "reth-ethereum-engine-primitives" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-rlp", "alloy-rpc-types-engine", "reth-engine-primitives", "reth-ethereum-primitives", "reth-payload-primitives", "reth-primitives-traits", "serde", - "sha2 0.10.9", "thiserror 2.0.18", ] [[package]] name = "reth-ethereum-forks" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-eip2124", "alloy-hardforks 0.4.7", @@ -16849,11 +17050,11 @@ dependencies = [ [[package]] name = "reth-ethereum-payload-builder" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", @@ -16864,6 +17065,7 @@ dependencies = [ "reth-ethereum-primitives", "reth-evm", "reth-evm-ethereum", + "reth-execution-cache", "reth-payload-builder", "reth-payload-builder-primitives", "reth-payload-primitives", @@ -16878,28 +17080,22 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-serde", - "arbitrary", - "modular-bitfield", + "alloy-rpc-types-eth 2.0.5", "reth-codecs", "reth-primitives-traits", - "reth-zstd-compressors", "serde", - "serde_with", ] [[package]] name = "reth-etl" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "rayon", "reth-db-api", @@ -16908,11 +17104,11 @@ dependencies = [ [[package]] name = "reth-evm" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "auto_impl", @@ -16932,16 +17128,14 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rpc-types-engine", - "derive_more 2.1.1", - "parking_lot", "reth-chainspec", "reth-ethereum-forks", "reth-ethereum-primitives", @@ -16952,10 +17146,28 @@ dependencies = [ "revm", ] +[[package]] +name = "reth-execution-cache" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" +dependencies = [ + "alloy-primitives", + "fixed-cache", + "metrics", + "parking_lot", + "reth-errors", + "reth-metrics", + "reth-primitives-traits", + "reth-provider", + "reth-revm", + "reth-trie", + "tracing", +] + [[package]] name = "reth-execution-errors" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-evm", "alloy-primitives", @@ -16967,13 +17179,14 @@ dependencies = [ [[package]] name = "reth-execution-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", + "alloy-rlp", "derive_more 2.1.1", "reth-ethereum-primitives", "reth-primitives-traits", @@ -16985,11 +17198,11 @@ dependencies = [ [[package]] name = "reth-exex" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "eyre", "futures", @@ -17023,10 +17236,10 @@ dependencies = [ [[package]] name = "reth-exex-test-utils" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "eyre", "futures-util", "reth-chainspec", @@ -17055,10 +17268,10 @@ dependencies = [ [[package]] name = "reth-exex-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "reth-chain-state", "reth-execution-types", @@ -17069,13 +17282,13 @@ dependencies = [ [[package]] name = "reth-firehose" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "eyre", @@ -17096,12 +17309,12 @@ dependencies = [ [[package]] name = "reth-firehose-tests" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", "base64 0.22.1", @@ -17123,8 +17336,8 @@ dependencies = [ [[package]] name = "reth-fs-util" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "serde", "serde_json", @@ -17133,10 +17346,10 @@ dependencies = [ [[package]] name = "reth-invalid-block-hooks" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-debug", @@ -17161,8 +17374,8 @@ dependencies = [ [[package]] name = "reth-ipc" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "bytes", "futures", @@ -17181,11 +17394,12 @@ dependencies = [ [[package]] name = "reth-libmdbx" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "bitflags 2.11.1", "byteorder", + "crossbeam-queue", "dashmap", "derive_more 2.1.1", "parking_lot", @@ -17197,8 +17411,8 @@ dependencies = [ [[package]] name = "reth-mdbx-sys" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "bindgen 0.72.1", "cc", @@ -17206,20 +17420,21 @@ dependencies = [ [[package]] name = "reth-metrics" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "futures", "metrics", "metrics-derive", + "reth-primitives-traits", "tokio", "tokio-util", ] [[package]] name = "reth-net-banlist" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "ipnet", @@ -17227,12 +17442,12 @@ dependencies = [ [[package]] name = "reth-net-nat" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "futures-util", "if-addrs 0.14.0", - "reqwest 0.12.28", + "reqwest 0.13.3", "serde_with", "thiserror 2.0.18", "tokio", @@ -17241,17 +17456,17 @@ dependencies = [ [[package]] name = "reth-network" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "aquamarine", "auto_impl", "derive_more 2.1.1", - "discv5", + "discv5 0.10.4 (git+https://github.com/sigp/discv5?rev=7663c00)", "enr", "futures", "itertools 0.14.0", @@ -17289,6 +17504,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "smallvec", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -17298,13 +17514,13 @@ dependencies = [ [[package]] name = "reth-network-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "alloy-rpc-types-admin", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "auto_impl", "derive_more 2.1.1", "enr", @@ -17323,11 +17539,11 @@ dependencies = [ [[package]] name = "reth-network-p2p" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "auto_impl", "derive_more 2.1.1", @@ -17346,8 +17562,8 @@ dependencies = [ [[package]] name = "reth-network-peers" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -17361,8 +17577,8 @@ dependencies = [ [[package]] name = "reth-network-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-eip2124", "humantime-serde", @@ -17375,8 +17591,8 @@ dependencies = [ [[package]] name = "reth-nippy-jar" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "anyhow", "bincode 1.3.3", @@ -17392,8 +17608,8 @@ dependencies = [ [[package]] name = "reth-node-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-rpc-types-engine", "eyre", @@ -17416,14 +17632,14 @@ dependencies = [ [[package]] name = "reth-node-builder" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-provider", - "alloy-rpc-types", + "alloy-provider 2.0.5", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-engine", "aquamarine", "eyre", @@ -17444,7 +17660,6 @@ dependencies = [ "reth-downloaders", "reth-engine-local", "reth-engine-primitives", - "reth-engine-service", "reth-engine-tree", "reth-engine-util", "reth-evm", @@ -17486,11 +17701,11 @@ dependencies = [ [[package]] name = "reth-node-core" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "clap", @@ -17524,12 +17739,12 @@ dependencies = [ "reth-stages-types", "reth-storage-api", "reth-storage-errors", + "reth-tasks", "reth-tracing", "reth-tracing-otlp", "reth-transaction-pool", "secp256k1 0.30.0", "serde", - "shellexpand", "strum", "thiserror 2.0.18", "toml 0.9.12+spec-1.1.0", @@ -17541,13 +17756,13 @@ dependencies = [ [[package]] name = "reth-node-ethereum" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", - "alloy-network", + "alloy-eips 2.0.5", + "alloy-network 2.0.5", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "eyre", "reth-chainspec", "reth-engine-local", @@ -17579,10 +17794,10 @@ dependencies = [ [[package]] name = "reth-node-ethstats" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "chrono", "futures-util", @@ -17603,11 +17818,11 @@ dependencies = [ [[package]] name = "reth-node-events" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "derive_more 2.1.1", @@ -17627,8 +17842,8 @@ dependencies = [ [[package]] name = "reth-node-metrics" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "bytes", "eyre", @@ -17643,7 +17858,7 @@ dependencies = [ "metrics-util", "pprof_util", "procfs", - "reqwest 0.12.28", + "reqwest 0.13.3", "reth-fs-util", "reth-metrics", "reth-tasks", @@ -17656,8 +17871,8 @@ dependencies = [ [[package]] name = "reth-node-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "reth-chainspec", "reth-db-api", @@ -17668,20 +17883,23 @@ dependencies = [ [[package]] name = "reth-payload-builder" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", - "alloy-rpc-types", + "alloy-rpc-types 2.0.5", + "derive_more 2.1.1", "futures-util", "metrics", "reth-chain-state", "reth-ethereum-engine-primitives", + "reth-execution-cache", "reth-metrics", "reth-payload-builder-primitives", "reth-payload-primitives", "reth-primitives-traits", + "reth-trie-parallel", "tokio", "tokio-stream", "tracing", @@ -17689,8 +17907,8 @@ dependencies = [ [[package]] name = "reth-payload-builder-primitives" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "pin-project", "reth-payload-primitives", @@ -17701,16 +17919,16 @@ dependencies = [ [[package]] name = "reth-payload-primitives" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "auto_impl", "either", - "op-alloy-rpc-types-engine", "reth-chain-state", "reth-chainspec", "reth-errors", @@ -17718,72 +17936,54 @@ dependencies = [ "reth-primitives-traits", "reth-trie-common", "serde", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", ] [[package]] name = "reth-payload-util" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "reth-transaction-pool", ] [[package]] name = "reth-payload-validator" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-rpc-types-engine", "reth-primitives-traits", ] -[[package]] -name = "reth-primitives" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", - "alloy-primitives", - "alloy-rlp", - "c-kzg", - "once_cell", - "reth-codecs", - "reth-ethereum-forks", - "reth-ethereum-primitives", - "reth-primitives-traits", - "reth-static-file-types", -] - [[package]] name = "reth-primitives-traits" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee12e304adbacbb32248c9806ebafbe1e2811fbfefe53c5e5b710a8438b7ec0" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-trie", "arbitrary", - "auto_impl", "byteorder", "bytes", "dashmap", "derive_more 2.1.1", "modular-bitfield", "once_cell", - "op-alloy-consensus", "proptest", "proptest-arbitrary-interop", + "quanta", "rayon", "reth-codecs", "revm-bytecode", @@ -17791,17 +17991,17 @@ dependencies = [ "revm-state", "secp256k1 0.30.0", "serde", - "serde_with", "thiserror 2.0.18", ] [[package]] name = "reth-provider" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "eyre", @@ -17842,11 +18042,11 @@ dependencies = [ [[package]] name = "reth-prune" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "itertools 0.14.0", "metrics", @@ -17871,8 +18071,8 @@ dependencies = [ [[package]] name = "reth-prune-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "arbitrary", @@ -17887,10 +18087,12 @@ dependencies = [ [[package]] name = "reth-revm" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-debug", "reth-primitives-traits", "reth-storage-api", "reth-storage-errors", @@ -17900,31 +18102,30 @@ dependencies = [ [[package]] name = "reth-rpc" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-dyn-abi", - "alloy-eip7928", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-genesis", - "alloy-network", + "alloy-genesis 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-rpc-client", - "alloy-rpc-types", + "alloy-rpc-client 2.0.5", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-admin", "alloy-rpc-types-beacon", "alloy-rpc-types-debug", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-rpc-types-mev", "alloy-rpc-types-trace", "alloy-rpc-types-txpool", - "alloy-serde", - "alloy-signer", - "alloy-signer-local", + "alloy-serde 2.0.5", + "alloy-signer 2.0.5", + "alloy-signer-local 2.0.5", "async-trait", "derive_more 2.1.1", "dyn-clone", @@ -17960,6 +18161,7 @@ dependencies = [ "reth-rpc-server-types", "reth-storage-api", "reth-tasks", + "reth-tracing", "reth-transaction-pool", "reth-trie-common", "revm", @@ -17977,41 +18179,41 @@ dependencies = [ [[package]] name = "reth-rpc-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eip7928", - "alloy-eips", - "alloy-genesis", - "alloy-json-rpc", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", + "alloy-json-rpc 2.0.5", "alloy-primitives", - "alloy-rpc-types", + "alloy-rpc-types 2.0.5", "alloy-rpc-types-admin", - "alloy-rpc-types-anvil", + "alloy-rpc-types-anvil 2.0.5", "alloy-rpc-types-beacon", "alloy-rpc-types-debug", "alloy-rpc-types-engine", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-rpc-types-mev", "alloy-rpc-types-trace", "alloy-rpc-types-txpool", - "alloy-serde", + "alloy-serde 2.0.5", "jsonrpsee", "reth-chain-state", "reth-engine-primitives", "reth-network-peers", "reth-rpc-eth-api", "reth-trie-common", + "serde", "serde_json", ] [[package]] name = "reth-rpc-builder" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-network", - "alloy-provider", + "alloy-network 2.0.5", + "alloy-provider 2.0.5", "dyn-clone", "http 1.4.0", "jsonrpsee", @@ -18026,9 +18228,11 @@ dependencies = [ "reth-metrics", "reth-network-api", "reth-node-core", + "reth-payload-primitives", "reth-primitives-traits", "reth-rpc", "reth-rpc-api", + "reth-rpc-engine-api", "reth-rpc-eth-api", "reth-rpc-eth-types", "reth-rpc-layer", @@ -18048,32 +18252,32 @@ dependencies = [ [[package]] name = "reth-rpc-convert" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-evm", - "alloy-json-rpc", - "alloy-network", + "alloy-json-rpc 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-signer", + "alloy-rpc-types-eth 2.0.5", "auto_impl", "dyn-clone", "jsonrpsee-types", - "reth-ethereum-primitives", "reth-evm", "reth-primitives-traits", + "reth-rpc-traits", "thiserror 2.0.18", ] [[package]] name = "reth-rpc-engine-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", + "alloy-rlp", "alloy-rpc-types-engine", "async-trait", "jsonrpsee-core", @@ -18099,20 +18303,21 @@ dependencies = [ [[package]] name = "reth-rpc-eth-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-dyn-abi", - "alloy-eips", + "alloy-eip7928", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-json-rpc", - "alloy-network", + "alloy-json-rpc 2.0.5", + "alloy-network 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-rpc-types-mev", - "alloy-serde", + "alloy-serde 2.0.5", "async-trait", "auto_impl", "dyn-clone", @@ -18137,24 +18342,25 @@ dependencies = [ "reth-trie-common", "revm", "revm-inspectors", + "serde_json", "tokio", "tracing", ] [[package]] name = "reth-rpc-eth-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-evm", - "alloy-network", + "alloy-network 2.0.5", "alloy-primitives", - "alloy-rpc-client", - "alloy-rpc-types-eth", + "alloy-rpc-client 2.0.5", + "alloy-rpc-types-eth 2.0.5", "alloy-sol-types", - "alloy-transport", + "alloy-transport 2.0.5", "derive_more 2.1.1", "futures", "itertools 0.14.0", @@ -18162,7 +18368,7 @@ dependencies = [ "jsonrpsee-types", "metrics", "rand 0.9.4", - "reqwest 0.12.28", + "reqwest 0.13.3", "reth-chain-state", "reth-chainspec", "reth-errors", @@ -18191,8 +18397,8 @@ dependencies = [ [[package]] name = "reth-rpc-layer" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-rpc-types-engine", "http 1.4.0", @@ -18205,10 +18411,10 @@ dependencies = [ [[package]] name = "reth-rpc-server-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "jsonrpsee-core", @@ -18219,21 +18425,37 @@ dependencies = [ "strum", ] +[[package]] +name = "reth-rpc-traits" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "860fe223501a76ff14aa3bf164f739f31008c2a2905ac85708bfd88f042e6151" +dependencies = [ + "alloy-consensus 2.0.5", + "alloy-network 2.0.5", + "alloy-primitives", + "alloy-rpc-types-eth 2.0.5", + "alloy-signer 2.0.5", + "reth-primitives-traits", + "thiserror 2.0.18", +] + [[package]] name = "reth-stages" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", - "bincode 1.3.3", + "alloy-rlp", "eyre", "futures-util", "itertools 0.14.0", "num-traits", + "page_size", "rayon", - "reqwest 0.12.28", + "reqwest 0.13.3", "reth-chainspec", "reth-codecs", "reth-config", @@ -18249,6 +18471,7 @@ dependencies = [ "reth-execution-types", "reth-exex", "reth-fs-util", + "reth-libmdbx", "reth-network-p2p", "reth-primitives-traits", "reth-provider", @@ -18271,15 +18494,16 @@ dependencies = [ [[package]] name = "reth-stages-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "aquamarine", "auto_impl", "futures-util", "metrics", + "reth-codecs", "reth-consensus", "reth-errors", "reth-metrics", @@ -18298,8 +18522,8 @@ dependencies = [ [[package]] name = "reth-stages-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "arbitrary", @@ -18312,8 +18536,8 @@ dependencies = [ [[package]] name = "reth-static-file" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "parking_lot", @@ -18332,8 +18556,8 @@ dependencies = [ [[package]] name = "reth-static-file-types" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "clap", @@ -18347,11 +18571,11 @@ dependencies = [ [[package]] name = "reth-storage-api" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rpc-types-engine", "auto_impl", @@ -18371,13 +18595,14 @@ dependencies = [ [[package]] name = "reth-storage-errors" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "derive_more 2.1.1", + "reth-codecs", "reth-primitives-traits", "reth-prune-types", "reth-static-file-types", @@ -18388,17 +18613,20 @@ dependencies = [ [[package]] name = "reth-tasks" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "auto_impl", - "dyn-clone", + "crossbeam-utils", + "dashmap", "futures-util", + "libc", "metrics", + "parking_lot", "pin-project", "rayon", "reth-metrics", "thiserror 2.0.18", + "thread-priority", "tokio", "tracing", "tracing-futures", @@ -18406,12 +18634,12 @@ dependencies = [ [[package]] name = "reth-testing-utils" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-genesis", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", + "alloy-genesis 2.0.5", "alloy-primitives", "rand 0.8.6", "rand 0.9.4", @@ -18422,8 +18650,8 @@ dependencies = [ [[package]] name = "reth-tokio-util" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "tokio", "tokio-stream", @@ -18432,8 +18660,8 @@ dependencies = [ [[package]] name = "reth-tracing" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "clap", "eyre", @@ -18451,8 +18679,8 @@ dependencies = [ [[package]] name = "reth-tracing-otlp" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "clap", "eyre", @@ -18469,17 +18697,18 @@ dependencies = [ [[package]] name = "reth-transaction-pool" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "aquamarine", "auto_impl", "bitflags 2.11.1", "futures-util", + "imbl", "metrics", "parking_lot", "paste", @@ -18513,11 +18742,11 @@ dependencies = [ [[package]] name = "reth-trie" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", - "alloy-eips", + "alloy-consensus 2.0.5", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-trie", @@ -18539,14 +18768,14 @@ dependencies = [ [[package]] name = "reth-trie-common" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ - "alloy-consensus", + "alloy-consensus 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-serde", + "alloy-rpc-types-eth 2.0.5", + "alloy-serde 2.0.5", "alloy-trie", "arbitrary", "arrayvec", @@ -18566,8 +18795,8 @@ dependencies = [ [[package]] name = "reth-trie-db" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "metrics", @@ -18586,12 +18815,15 @@ dependencies = [ [[package]] name = "reth-trie-parallel" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ + "alloy-eip7928", + "alloy-evm", "alloy-primitives", "alloy-rlp", "crossbeam-channel", + "crossbeam-utils", "derive_more 2.1.1", "itertools 0.14.0", "metrics", @@ -18603,35 +18835,38 @@ dependencies = [ "reth-storage-errors", "reth-tasks", "reth-trie", - "reth-trie-common", "reth-trie-sparse", + "revm-state", "thiserror 2.0.18", "tracing", ] [[package]] name = "reth-trie-sparse" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "2.2.0" +source = "git+https://github.com/streamingfast/reth.git?branch=firehose%2F2.x#3230608344cf9a16dab512c32239f107ef86419e" dependencies = [ "alloy-primitives", "alloy-rlp", "alloy-trie", - "auto_impl", "metrics", "rayon", "reth-execution-errors", "reth-metrics", "reth-primitives-traits", "reth-trie-common", + "serde", + "serde_json", + "slotmap", "smallvec", "tracing", ] [[package]] name = "reth-zstd-compressors" -version = "1.11.4" -source = "git+https://github.com/streamingfast/reth.git?tag=v1.11.4-fh-2#eefa7afad557dc084e4a8b3829801f4ba5d5de22" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12fafa33d2f420a9d39249a3e0357b1928d09429f30758b85280409092873b2" dependencies = [ "zstd", ] @@ -18647,9 +18882,9 @@ dependencies = [ [[package]] name = "revm" -version = "34.0.0" +version = "38.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2aabdebaa535b3575231a88d72b642897ae8106cf6b0d12eafc6bfdf50abfc7" +checksum = "91202d39dbe8e8d10e9e8f2b76c30da68ecd1d25be69ba6d853ad0d03a3a398a" dependencies = [ "revm-bytecode", "revm-context", @@ -18666,9 +18901,9 @@ dependencies = [ [[package]] name = "revm-bytecode" -version = "8.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d1e5c1eaa44d39d537f668bc5c3409dc01e5c8be954da6c83370bbdf006457" +checksum = "bdbb3a3d735efa94c91f2ef6bf20a35f99a77bc78f3e25bd758336901bdf9661" dependencies = [ "bitvec", "phf 0.13.1", @@ -18678,9 +18913,9 @@ dependencies = [ [[package]] name = "revm-context" -version = "13.0.0" +version = "16.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "892ff3e6a566cf8d72ffb627fdced3becebbd9ba64089c25975b9b028af326a5" +checksum = "c5f68d928d8b228e0faeb1c6ed75c4fde7d124f1ddf9119b67e7a0ad4041237d" dependencies = [ "bitvec", "cfg-if", @@ -18695,9 +18930,9 @@ dependencies = [ [[package]] name = "revm-context-interface" -version = "14.0.0" +version = "17.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f61cc6d23678c4840af895b19f8acfbbd546142ec8028b6526c53cc1c16c98" +checksum = "1f3758e6167c4ba7a59a689c519a047edaefcd4c37d74f279b93ed87bc8aece4" dependencies = [ "alloy-eip2930", "alloy-eip7702", @@ -18711,11 +18946,11 @@ dependencies = [ [[package]] name = "revm-database" -version = "10.0.0" +version = "13.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529528d0b05fe646be86223032c3e77aa8b05caa2a35447d538c55965956a511" +checksum = "c281a1f11d3bcb8c0bba1199ed6bcb001d1aeb3d4fb366819e14f88723989a4e" dependencies = [ - "alloy-eips", + "alloy-eips 1.8.3", "revm-bytecode", "revm-database-interface", "revm-primitives", @@ -18725,9 +18960,9 @@ dependencies = [ [[package]] name = "revm-database-interface" -version = "9.0.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7bf93ac5b91347c057610c0d96e923db8c62807e03f036762d03e981feddc1d" +checksum = "d89efb9832a4e3742bb4ded5f7fe5bf905e8860e69427d4dfec153484fc6d304" dependencies = [ "auto_impl", "either", @@ -18739,9 +18974,9 @@ dependencies = [ [[package]] name = "revm-handler" -version = "15.0.0" +version = "18.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd0e43e815a85eded249df886c4badec869195e70cdd808a13cfca2794622d2" +checksum = "783e903d6922b7f5f9a940d1bb229530502d2924b1aed9d5ca5a94ebf065d460" dependencies = [ "auto_impl", "derive-where", @@ -18758,9 +18993,9 @@ dependencies = [ [[package]] name = "revm-inspector" -version = "15.0.0" +version = "19.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3ccad59db91ef93696536a0dbaf2f6f17cfe20d4d8843ae118edb7e97947ef" +checksum = "8216ad58422090d0daa9eb430e0a081f7ad07e7fd30681dee71f8420c99624e0" dependencies = [ "auto_impl", "either", @@ -18776,12 +19011,12 @@ dependencies = [ [[package]] name = "revm-inspectors" -version = "0.34.3" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e341d9777b1903a8428bc6f8fe7a8f80671d2249837c9749566e61328ef14125" +checksum = "5dea6997563b46432f9c1822275cab4c462aed8f4a189dc518478c31317afb99" dependencies = [ "alloy-primitives", - "alloy-rpc-types-eth", + "alloy-rpc-types-eth 2.0.5", "alloy-rpc-types-trace", "alloy-sol-types", "anstyle", @@ -18796,9 +19031,9 @@ dependencies = [ [[package]] name = "revm-interpreter" -version = "32.0.0" +version = "35.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11406408597bc249392d39295831c4b641b3a6f5c471a7c41104a7a1e3564c07" +checksum = "1ece9f41b69658c15d748288a4dbdfc06a63f3ce93d983af440de3f1631dce6a" dependencies = [ "revm-bytecode", "revm-context-interface", @@ -18809,9 +19044,9 @@ dependencies = [ [[package]] name = "revm-precompile" -version = "32.1.0" +version = "34.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ec11f45deec71e4945e1809736bb20d454285f9167ab53c5159dae1deb603f" +checksum = "a346a8cc6c8c39bd65306641c692191299c0a7b63d38810e39e8fe9b92378660" dependencies = [ "ark-bls12-381", "ark-bn254", @@ -18820,11 +19055,13 @@ dependencies = [ "ark-serialize 0.5.0", "arrayref", "aurora-engine-modexp", + "aws-lc-rs", "blst", "c-kzg", "cfg-if", "k256", "p256", + "revm-context-interface", "revm-primitives", "ripemd", "secp256k1 0.31.1", @@ -18834,9 +19071,9 @@ dependencies = [ [[package]] name = "revm-primitives" -version = "22.1.0" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfb5ce6cf18b118932bcdb7da05cd9c250f2cb9f64131396b55f3fe3537c35" +checksum = "0c99bda77d9661521ba0b4bc04558c6692074f01e65dd420fa3b893033d9b8a2" dependencies = [ "alloy-primitives", "num_enum 0.7.6", @@ -18846,9 +19083,9 @@ dependencies = [ [[package]] name = "revm-state" -version = "9.0.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311720d4f0f239b041375e7ddafdbd20032a33b7bae718562ea188e188ed9fd3" +checksum = "c32490ed687dba31c3c882beb8c20408bdd30ef96690d8f145b0ee9a87040bfe" dependencies = [ "alloy-eip7928", "bitflags 2.11.1", @@ -18877,7 +19114,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -19191,7 +19428,7 @@ dependencies = [ "bytemuck", "cfg-if", "digest 0.10.7", - "ff 0.13.1", + "ff", "hex", "hex-literal 0.4.1", "metal", @@ -19778,7 +20015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -19790,7 +20027,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -19817,7 +20054,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fad02996bfc73da3e301efe90b1837be9ed8f4a462b6ed410aa35d00381de89f" dependencies = [ - "twox-hash", + "twox-hash 1.6.3", +] + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash 2.1.2", ] [[package]] @@ -19996,7 +20242,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -20449,15 +20695,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs 6.0.0", -] - [[package]] name = "shlex" version = "1.3.0" @@ -20596,16 +20833,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slop-air" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaab798ba2ee1d2fffb3075fc303e4fd87b5b3062693a68f81adb8b508821bd" dependencies = [ "p3-air", ] [[package]] name = "slop-algebra" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e8abf7cfad18c0580576e8adc01f7fa27b1cb19432e451e82950c9a445a7cfc" dependencies = [ "itertools 0.14.0", "p3-field", @@ -20614,8 +20853,9 @@ dependencies = [ [[package]] name = "slop-alloc" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550acd655362b52fc00d00410f08fc5dea4d22e35eef091c09b6a37d9c79e014" dependencies = [ "serde", "slop-algebra", @@ -20624,8 +20864,9 @@ dependencies = [ [[package]] name = "slop-baby-bear" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3263d9487d6e632a747dd267d981e859e4f9fb97506dd8b547049116e0f49dc9" dependencies = [ "lazy_static", "p3-baby-bear", @@ -20638,8 +20879,9 @@ dependencies = [ [[package]] name = "slop-basefold" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "596da7b2b980055ec1074c61f7b7f243e57f4fef81ab04f717bcc99b3391191d" dependencies = [ "derive-where", "itertools 0.14.0", @@ -20660,8 +20902,9 @@ dependencies = [ [[package]] name = "slop-basefold-prover" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce348433feab7a98c2d18419c11320a63a9407a7bf360fcb1d12e98610def29d" dependencies = [ "derive-where", "itertools 0.14.0", @@ -20686,23 +20929,24 @@ dependencies = [ [[package]] name = "slop-bn254" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c327e0927fabf9c0ae9a7b0333027dc04431e5981ab80d7bbe994d6c4ce35fc1" dependencies = [ - "ff 0.13.1", + "ff", "p3-bn254-fr", "serde", "slop-algebra", "slop-challenger", "slop-poseidon2", "slop-symmetric", - "zkhash", ] [[package]] name = "slop-challenger" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c263e731bb694d4465eedae7ecd6faf1f277198e751f3c209a1c4186d80d1b6b" dependencies = [ "futures", "p3-challenger", @@ -20713,8 +20957,9 @@ dependencies = [ [[package]] name = "slop-commit" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109134f16ae1ea59d333cb28de896bfc8ee383fb575819b6a9f78ba72f46e8ad" dependencies = [ "p3-commit", "serde", @@ -20723,8 +20968,9 @@ dependencies = [ [[package]] name = "slop-dft" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bac9e643a07f2138cf5faaae46ae1a16bb359d5224aa3010bbf7b0b794dfd07" dependencies = [ "p3-dft", "serde", @@ -20736,16 +20982,18 @@ dependencies = [ [[package]] name = "slop-fri" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407ac1bf5c4b05a4da95c9951bf32307049f3157988e4b22f65915ff58f33173" dependencies = [ "p3-fri", ] [[package]] name = "slop-futures" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee61e1c5d07006a5221314cfe6f6f77d2168e117081fb13581f8596f7262ddd4" dependencies = [ "crossbeam", "futures", @@ -20758,8 +21006,9 @@ dependencies = [ [[package]] name = "slop-jagged" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d313a73ca824342c6468fd1c63a7e3cde89b7cf456d2f3408843100603eadab" dependencies = [ "derive-where", "futures", @@ -20791,16 +21040,18 @@ dependencies = [ [[package]] name = "slop-keccak-air" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07b7952549a449a95b4a2daa4e1861afbe94c5f22d3c0f36bf42406855f5912f" dependencies = [ "p3-keccak-air", ] [[package]] name = "slop-koala-bear" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a8cdc74f04d13e738b4628312b16dfec6a2e547bde697c8bdcf556884c6e91f" dependencies = [ "lazy_static", "p3-koala-bear", @@ -20813,27 +21064,29 @@ dependencies = [ [[package]] name = "slop-matrix" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57b20e8f35b9ebe60aa9bc0a2b76848bb43c8130f4df85491c5d09f49aa92c45" dependencies = [ "p3-matrix", ] [[package]] name = "slop-maybe-rayon" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef79faf36b964b0b8423179e29c9c6839fd16d3f9548785b69988a50c1ea617d" dependencies = [ "p3-maybe-rayon", ] [[package]] name = "slop-merkle-tree" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "160e61fa46d477af39d4d1b5737faaee77afe0b4106531ae22b9781686b2888e" dependencies = [ "derive-where", - "ff 0.13.1", "itertools 0.14.0", "p3-merkle-tree", "serde", @@ -20851,13 +21104,13 @@ dependencies = [ "slop-tensor", "slop-utils", "thiserror 1.0.69", - "zkhash", ] [[package]] name = "slop-multilinear" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671628cc4119c29685ff484d5eb993ba483cef7915d065c993c55842b4c2f3c9" dependencies = [ "derive-where", "futures", @@ -20876,24 +21129,27 @@ dependencies = [ [[package]] name = "slop-poseidon2" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aedfc3bf87cf2694bd108c039d663c346c7bb807491a7db6701eb9dff5c6e5d" dependencies = [ "p3-poseidon2", ] [[package]] name = "slop-primitives" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30e6e332c3cb103541bed9f5f477b769df1b5fb6076b5e2386569c94b1475dc" dependencies = [ "slop-algebra", ] [[package]] name = "slop-stacked" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4981970b3ea9620889b558cc859edac2ffaeabc0b444027674cf15dd11e8d433" dependencies = [ "derive-where", "futures", @@ -20914,8 +21170,9 @@ dependencies = [ [[package]] name = "slop-sumcheck" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a67f4c3d2f73c34032c13b85e22eba69ac8a677481dbbdb3f9942be12eb765" dependencies = [ "futures", "itertools 0.14.0", @@ -20931,16 +21188,18 @@ dependencies = [ [[package]] name = "slop-symmetric" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb1de854325a8c36a1bdfee5514e1d4c9f39290ae19eeaecb21ca6ee88d96d6" dependencies = [ "p3-symmetric", ] [[package]] name = "slop-tensor" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf7b2a0e76f1ad8ca43aac248965e4b7448101954661f877a6e6ac7f3813dad" dependencies = [ "arrayvec", "derive-where", @@ -20958,16 +21217,18 @@ dependencies = [ [[package]] name = "slop-uni-stark" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab33d6a10b1f4dc2edc26131bccd0955ccf5d76e7de6f15c50a9d0873f3f8603" dependencies = [ "p3-uni-stark", ] [[package]] name = "slop-utils" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a9ff3ba185c57a0f4f3bc64e124f5c536d6db416f5f4930c46b278aade270e6" dependencies = [ "p3-util", "tracing-forest", @@ -20976,8 +21237,9 @@ dependencies = [ [[package]] name = "slop-whir" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07626b0cb8878f3884fa0f68e06ba37746181aa1e7ce0d6e95e53013c5c924e1" dependencies = [ "derive-where", "futures", @@ -21003,6 +21265,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "small_btree" version = "0.1.0" @@ -21093,9 +21364,9 @@ dependencies = [ [[package]] name = "sp1-build" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7321136acefff985b0fb201b84359d609451bbd0a63203d4b601eaabe28da14f" +checksum = "082381d1779d12762a5fb4efa150c2ebdede79a3500eb0a93bf875f7cd64efa0" dependencies = [ "anyhow", "cargo_metadata 0.18.1", @@ -21107,8 +21378,8 @@ dependencies = [ [[package]] name = "sp1-cluster-artifact" -version = "2.1.4" -source = "git+https://github.com/succinctlabs/sp1-cluster?rev=843ce58d15da0609a98f6ae37579389f14f5af03#843ce58d15da0609a98f6ae37579389f14f5af03" +version = "2.3.2" +source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.3.2#0abbe390f86a9b58d0167e71b89b648b18a1e7a3" dependencies = [ "anyhow", "async-scoped", @@ -21133,8 +21404,8 @@ dependencies = [ [[package]] name = "sp1-cluster-common" -version = "2.1.4" -source = "git+https://github.com/succinctlabs/sp1-cluster?rev=843ce58d15da0609a98f6ae37579389f14f5af03#843ce58d15da0609a98f6ae37579389f14f5af03" +version = "2.3.2" +source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.3.2#0abbe390f86a9b58d0167e71b89b648b18a1e7a3" dependencies = [ "backoff", "chrono", @@ -21161,8 +21432,8 @@ dependencies = [ [[package]] name = "sp1-cluster-utils" -version = "2.1.4" -source = "git+https://github.com/succinctlabs/sp1-cluster?rev=843ce58d15da0609a98f6ae37579389f14f5af03#843ce58d15da0609a98f6ae37579389f14f5af03" +version = "2.3.2" +source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.3.2#0abbe390f86a9b58d0167e71b89b648b18a1e7a3" dependencies = [ "eyre", "sp1-cluster-artifact", @@ -21175,8 +21446,9 @@ dependencies = [ [[package]] name = "sp1-core-executor" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ead1f498f7763a3b27740d77db7ef07a737cf568a3c3c85efa72bb1a4858ea6a" dependencies = [ "bincode 1.3.3", "bytemuck", @@ -21193,6 +21465,7 @@ dependencies = [ "itertools 0.14.0", "memmap2", "num", + "object 0.37.3", "rrs-succinct", "rustc-demangle", "serde", @@ -21217,8 +21490,9 @@ dependencies = [ [[package]] name = "sp1-core-executor-runner" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d0e9425daa3f50fcfa9b434e6030b64b3dfacaed3d97fd47314e5ddd851603" dependencies = [ "base64 0.22.1", "bincode 1.3.3", @@ -21238,8 +21512,9 @@ dependencies = [ [[package]] name = "sp1-core-executor-runner-binary" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6671dac7abe0391d28669cbf6b08806c07feb757c0a5fc29bac8ef6882a642df" dependencies = [ "bincode 1.3.3", "crash-handler", @@ -21252,8 +21527,9 @@ dependencies = [ [[package]] name = "sp1-core-machine" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e479b3999aa550b5ef9cadaa950a3ccbfb16ff226c5598fe632a6ee6ea1749d" dependencies = [ "bincode 1.3.3", "cfg-if", @@ -21286,6 +21562,7 @@ dependencies = [ "sp1-jit", "sp1-primitives", "static_assertions", + "struct-reflection", "strum", "sysinfo 0.30.13", "tempfile", @@ -21299,8 +21576,9 @@ dependencies = [ [[package]] name = "sp1-curves" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4376b39a9d40040b826555984b013bd887c4505a354993a27988a6a394155fe7" dependencies = [ "cfg-if", "dashu", @@ -21319,8 +21597,9 @@ dependencies = [ [[package]] name = "sp1-derive" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2bf01a986b185b4497d7386ce3fc35eba3fbeb5169aaa7645b61053c34f449" dependencies = [ "proc-macro2", "quote", @@ -21329,8 +21608,9 @@ dependencies = [ [[package]] name = "sp1-hypercube" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecb3ffe4a61132c3277e00f85c532fbf38c3a9287ccf10a815339fe5be48af2b" dependencies = [ "arrayref", "deepsize2", @@ -21367,6 +21647,7 @@ dependencies = [ "slop-whir", "sp1-derive", "sp1-primitives", + "struct-reflection", "strum", "thiserror 1.0.69", "thousands", @@ -21376,8 +21657,9 @@ dependencies = [ [[package]] name = "sp1-jit" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "208902ad494f7a101e9c8420f493bc8c42fe4f1f6a3e21b7929d084f3c2e0dce" dependencies = [ "dynasmrt", "hashbrown 0.14.5", @@ -21385,15 +21667,16 @@ dependencies = [ "memfd", "memmap2", "serde", + "sp1-primitives", "tracing", "uuid", ] [[package]] name = "sp1-lib" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b96392c1b1c197beaa6b0806099a8d73643a09d5ac0874e26c9c5153a7fcb4c" +checksum = "d0d5f56efe1d2a980d0f46083863ef4fdf715ed70cc32668c9e5725af145b8d9" dependencies = [ "bincode 1.3.3", "serde", @@ -21402,8 +21685,9 @@ dependencies = [ [[package]] name = "sp1-primitives" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ad00052921b993af682403b378c8fe23c40382f9790f093c8fac0f30433c5e" dependencies = [ "bincode 1.3.3", "blake3", @@ -21425,8 +21709,9 @@ dependencies = [ [[package]] name = "sp1-prover" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf089b2fc3cacd5040e6eaeec7d96dc97bfd428421492a0be939f72d48a49851" dependencies = [ "anyhow", "bincode 1.3.3", @@ -21488,8 +21773,9 @@ dependencies = [ [[package]] name = "sp1-prover-types" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e29236cc1217ab04fdc548dbc83c817ecbf78c348879f5dbe65649680cd2ce89" dependencies = [ "anyhow", "async-scoped", @@ -21500,6 +21786,9 @@ dependencies = [ "mti", "prost 0.13.5", "serde", + "sp1-core-machine", + "sp1-hypercube", + "sp1-primitives", "tokio", "tonic 0.12.3", "tonic-build 0.12.3", @@ -21508,8 +21797,9 @@ dependencies = [ [[package]] name = "sp1-recursion-circuit" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe43147fe2a5604b1c14d10f44cb7d4304127275470f676b03593a2a00da4f2" dependencies = [ "bincode 1.3.3", "itertools 0.14.0", @@ -21547,8 +21837,9 @@ dependencies = [ [[package]] name = "sp1-recursion-compiler" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95f8488f5a4bd212f2498a8363d0f76378328aea5cbe69658c636016797c72ab" dependencies = [ "backtrace", "cfg-if", @@ -21567,8 +21858,9 @@ dependencies = [ [[package]] name = "sp1-recursion-executor" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef824f774b7ffac1c3c4f58fe145e4b503ba30ef885993947d4186a448748a7c" dependencies = [ "backtrace", "cfg-if", @@ -21590,8 +21882,9 @@ dependencies = [ [[package]] name = "sp1-recursion-gnark-ffi" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6961ae41bdca71e213d01aad4a7f664d0c95a760975a12816b2ac2d54e1e570" dependencies = [ "anyhow", "bincode 1.3.3", @@ -21614,8 +21907,9 @@ dependencies = [ [[package]] name = "sp1-recursion-machine" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d741527465de17de5c3ef4f9465e77117d2a15599398f97e8fb0cf8c3e57b2f4" dependencies = [ "itertools 0.14.0", "rand 0.8.6", @@ -21631,19 +21925,18 @@ dependencies = [ "sp1-recursion-executor", "strum", "tracing", - "zkhash", ] [[package]] name = "sp1-sdk" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349ca86c7a88456c9f0fa1c8869e0d35bb63d6c811dc9ad8337c90909ce532ec" +checksum = "f4c15071380f43c33b3dbe5650cd5acf54a8e0af4b80a833075a56309256a383" dependencies = [ "alloy-primitives", - "alloy-signer", + "alloy-signer 1.8.3", "alloy-signer-aws", - "alloy-signer-local", + "alloy-signer-local 1.8.3", "anyhow", "async-trait", "aws-config", @@ -21688,8 +21981,9 @@ dependencies = [ [[package]] name = "sp1-verifier" -version = "6.1.0" -source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04eea9c69efabee3fff3c24f2946963d04b62af7fdd7fc6da06b1d4853f8aca6" dependencies = [ "bincode 1.3.3", "blake3", @@ -21719,9 +22013,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f23e41cd36168cc2e51e5d3e35ff0c34b204d945769a65591a76286d04b51e43" dependencies = [ "cfg-if", - "ff 0.13.1", - "group 0.13.0", - "pairing 0.23.0", + "ff", + "group", + "pairing", "rand_core 0.6.4", "sp1-lib", "subtle", @@ -22045,6 +22339,26 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "struct-reflection" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701b671d1ad68e250e05718f95dae3014a17f4e69cbe51842531c30495ff3301" +dependencies = [ + "struct-reflection-derive", +] + +[[package]] +name = "struct-reflection-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ab74230a0592602e361bd63c645413fa8cbe4500d10274e849179e5c72548f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "structmeta" version = "0.3.0" @@ -22514,6 +22828,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" +[[package]] +name = "thread-priority" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2210811179577da3d54eb69ab0b50490ee40491a25d95b8c6011ba40771cb721" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.61.3", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -22771,6 +23099,7 @@ dependencies = [ "log", "native-tls", "rustls 0.23.40", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-native-tls", @@ -23309,9 +23638,9 @@ dependencies = [ [[package]] name = "tracing-logfmt" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1f47d22deb79c3f59fcf2a1f00f60cbdc05462bf17d1cd356c1fefa3f444bd" +checksum = "a250055a3518b5efba928a18ffac8d32d42ea607a9affff4532144cd5b2e378e" dependencies = [ "time", "tracing", @@ -23454,24 +23783,24 @@ dependencies = [ [[package]] name = "tree_hash" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee44f4cef85f88b4dea21c0b1f58320bdf35715cf56d840969487cff00613321" +checksum = "f7fd51aa83d2eb83b04570808430808b5d24fdbf479a4d5ac5dee4a2e2dd2be4" dependencies = [ "alloy-primitives", "ethereum_hashing", - "ethereum_ssz 0.9.1", + "ethereum_ssz", "smallvec", "typenum", ] [[package]] name = "tree_hash_derive" -version = "0.10.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bee2ea1551f90040ab0e34b6fb7f2fa3bad8acc925837ac654f2c78a13e3089" +checksum = "8840ad4d852e325d3afa7fde8a50b2412f89dce47d7eb291c0cc7f87cd040f38" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -23580,6 +23909,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "typed-arena" version = "2.0.2" @@ -23788,6 +24123,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" +[[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" @@ -24105,9 +24446,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -24118,9 +24459,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -24128,9 +24469,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -24138,9 +24479,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -24151,9 +24492,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -24234,9 +24575,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -24290,7 +24631,7 @@ dependencies = [ [[package]] name = "websocket-proxy" -version = "0.9.1" +version = "1.0.1" dependencies = [ "axum 0.8.9", "backoff", @@ -24311,7 +24652,7 @@ dependencies = [ [[package]] name = "websocket-proxy-bin" -version = "0.9.1" +version = "1.0.1" dependencies = [ "axum 0.8.9", "base-cli-utils", @@ -24484,16 +24825,38 @@ dependencies = [ "windows-targets 0.53.5", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -24527,6 +24890,19 @@ dependencies = [ "windows-targets 0.53.5", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -24540,6 +24916,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + [[package]] name = "windows-future" version = "0.3.2" @@ -24548,7 +24935,7 @@ checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", "windows-link 0.2.1", - "windows-threading", + "windows-threading 0.2.1", ] [[package]] @@ -24596,6 +24983,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -24644,6 +25041,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-strings" version = "0.5.1" @@ -24770,6 +25176,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -25419,33 +25834,6 @@ dependencies = [ "zopfli", ] -[[package]] -name = "zkhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4352d1081da6922701401cdd4cbf29a2723feb4cfabb5771f6fee8e9276da1c7" -dependencies = [ - "ark-ff 0.4.2", - "ark-std 0.4.0", - "bitvec", - "blake2", - "bls12_381", - "byteorder", - "cfg-if", - "group 0.12.1", - "group 0.13.0", - "halo2", - "hex", - "jubjub", - "lazy_static", - "pasta_curves 0.5.1", - "rand 0.8.6", - "serde", - "sha2 0.10.9", - "sha3 0.10.9", - "subtle", -] - [[package]] name = "zmij" version = "1.0.21" @@ -25488,7 +25876,6 @@ version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ - "bindgen 0.72.1", "cc", "pkg-config", ] @@ -25507,3 +25894,208 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[patch.unused]] +name = "slop-air" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-algebra" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-alloc" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-baby-bear" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-basefold" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-basefold-prover" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-bn254" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-challenger" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-commit" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-dft" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-fri" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-futures" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-jagged" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-koala-bear" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-matrix" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-maybe-rayon" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-merkle-tree" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-multilinear" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-poseidon2" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-primitives" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-stacked" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-sumcheck" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-symmetric" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-tensor" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-utils" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "slop-whir" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-core-executor" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-core-executor-runner" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-core-machine" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-derive" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-hypercube" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-jit" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-primitives" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-prover" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-prover-types" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-recursion-circuit" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-recursion-compiler" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-recursion-executor" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-recursion-gnark-ffi" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-recursion-machine" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" + +[[patch.unused]] +name = "sp1-verifier" +version = "6.1.0" +source = "git+https://github.com/succinctlabs/sp1.git?tag=v6.1.0#d454975ac7c1126097e36eceda9bce2cb9899da4" diff --git a/Cargo.toml b/Cargo.toml index b25cf7743e..98095e19e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.9.1" +version = "1.0.1" edition = "2024" rust-version = "1.93" license = "MIT" @@ -9,7 +9,7 @@ exclude = [".github/"] [workspace] resolver = "2" -exclude = ["bin/prover", "crates/proof/succinct/programs"] +exclude = ["bin/prover", "crates/proof/succinct/programs", "actions/fixtures"] members = [ "bin/*", "bin/prover/nitro-host", @@ -165,8 +165,10 @@ base-common-rpc-types = { path = "crates/common/rpc-types" } base-common-flashblocks = { path = "crates/common/flashblocks" } base-common-evm = { path = "crates/common/evm", default-features = false } base-common-flz = { path = "crates/common/flz", default-features = false } +base-precompile-macros = { path = "crates/common/precompile-macros" } base-common-genesis = { path = "crates/common/genesis", default-features = false } base-common-consensus = { path = "crates/common/consensus", default-features = false } +base-precompile-storage = { path = "crates/common/precompile-storage", default-features = false } base-common-precompiles = { path = "crates/common/precompiles", default-features = false } base-common-rpc-types-engine = { path = "crates/common/rpc-types-engine", default-features = false } @@ -221,8 +223,8 @@ basectl-cli = { path = "crates/infra/basectl" } audit-archiver-lib = { path = "crates/infra/audit" } base-load-tests = { path = "crates/infra/load-tests" } ingress-rpc-lib = { path = "crates/infra/ingress-rpc" } +base-snapshotter = { path = "crates/infra/snapshotter" } websocket-proxy = { path = "crates/infra/websocket-proxy" } -mempool-rebroadcaster = { path = "crates/infra/mempool-rebroadcaster" } # Proof base-proof-rpc = { path = "crates/proof/rpc" } @@ -285,122 +287,122 @@ base-metrics = { path = "crates/utilities/metrics", default-features = false } base-runtime = { path = "crates/utilities/runtime", default-features = false } # revm -revm = { version = "34.0.0", default-features = false } -revm-bytecode = { version = "8.0.0", default-features = false } -revm-database = { version = "10.0.0", default-features = false } -revm-inspectors = { version = "0.34.3", default-features = false } -revm-precompile = { version = "32.0.0", default-features = false } -revm-primitives = { version = "22.0.0", default-features = false } -revm-context-interface = { version = "14.0.0", default-features = false } +revm = { version = "38.0.0", default-features = false } +revm-bytecode = { version = "10.0.0", default-features = false } +revm-database = { version = "13.0.0", default-features = false } +revm-inspectors = { version = "0.39.1", default-features = false } +revm-precompile = { version = "34.0.0", default-features = false } +revm-primitives = { version = "23.0.0", default-features = false } +revm-context-interface = { version = "17.0.1", default-features = false } # alloy -alloy-signer = "1.8" -alloy-pubsub = "1.8" -alloy-network = "1.8" -alloy-provider = "1.8" -alloy-contract = "1.8" -alloy-json-rpc = "1.8" -alloy-eip7928 = "0.3.0" -alloy-transport = "1.8" -alloy-rpc-types = "1.8" -alloy-rpc-client = "1.8" -alloy-hardforks = "0.4.5" +alloy-signer = "2.0.4" +alloy-pubsub = "2.0.4" +alloy-network = "2.0.4" +alloy-eip7928 = "0.3.4" +alloy-provider = "2.0.4" +alloy-contract = "2.0.4" +alloy-json-rpc = "2.0.4" +alloy-transport = "2.0.4" +alloy-rpc-types = "2.0.4" +alloy-rpc-client = "2.0.4" +alloy-hardforks = "0.4.7" alloy-sol-macro = "1.5.6" -alloy-signer-gcp = "1.8" -alloy-signer-local = "1.8" -alloy-transport-ws = "1.8" -alloy-node-bindings = "1.8" -alloy-transport-http = "1.8" -alloy-rpc-types-beacon = "1.8" -alloy-eips = { version = "1.8", default-features = false } -alloy-serde = { version = "1.8", default-features = false } +alloy-signer-gcp = "2.0.4" +alloy-signer-local = "2.0.4" +alloy-transport-ws = "2.0.4" +alloy-node-bindings = "2.0.4" +alloy-transport-http = "2.0.4" +alloy-rpc-types-beacon = "2.0.4" alloy-rlp = { version = "0.3.13", default-features = false } alloy-trie = { version = "0.9.4", default-features = false } -alloy-evm = { version = "0.27.2", default-features = false } -alloy-genesis = { version = "1.8", default-features = false } -alloy-chains = { version = "0.2.5", default-features = false } -alloy-consensus = { version = "1.8", default-features = false } +alloy-eips = { version = "2.0.4", default-features = false } +alloy-evm = { version = "0.34.0", default-features = false } +alloy-serde = { version = "2.0.4", default-features = false } +alloy-chains = { version = "0.2.33", default-features = false } +alloy-genesis = { version = "2.0.4", default-features = false } +alloy-consensus = { version = "2.0.4", default-features = false } alloy-sol-types = { version = "1.5.6", default-features = false } alloy-primitives = { version = "1.5.6", default-features = false } -alloy-rpc-types-eth = { version = "1.8", default-features = false } -alloy-rpc-types-trace = { version = "1.8", default-features = false } -alloy-rpc-types-debug = { version = "1.8", default-features = false } -alloy-rpc-types-engine = { version = "1.8", default-features = false } -alloy-network-primitives = { version = "1.8", default-features = false } +alloy-rpc-types-eth = { version = "2.0.4", default-features = false } +alloy-rpc-types-trace = { version = "2.0.4", default-features = false } +alloy-rpc-types-debug = { version = "2.0.4", default-features = false } +alloy-rpc-types-engine = { version = "2.0.4", default-features = false } +alloy-network-primitives = { version = "2.0.4", default-features = false } # reth -reth-db = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-cli = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-ipc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-evm = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-revm = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-trie = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-exex = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-tasks = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-codecs = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-db-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-db-models = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-discv4 = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-discv5 = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-trie-db = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-network = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-net-nat = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-tracing = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-provider = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-cli-util = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-node-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-db-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-layer = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-node-core = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-consensus = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-chainspec = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-cli-runner = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-tokio-util = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-convert = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-network-p2p = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-storage-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-engine-tree = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-chain-state = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-trie-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-payload-util = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-node-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-node-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-cli-commands = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-tracing-otlp = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-testing-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-eth-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-trie-parallel = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-network-peers = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-engine-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-e2e-test-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-execution-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-exex-test-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-rpc-server-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-execution-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-payload-validator = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-primitives-traits = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-payload-builder-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4" } -reth-prune-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4", default-features = false } -reth-stages-types = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4", default-features = false } -reth-storage-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4", default-features = false } -reth-ethereum-forks = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4", default-features = false } -reth-consensus-common = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4", default-features = false } -reth-zstd-compressors = { git = "https://github.com/paradigmxyz/reth", tag = "v1.11.4", default-features = false } +reth-db = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-cli = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-ipc = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-evm = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-revm = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-trie = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-exex = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-tasks = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-codecs = { version = "0.3.1", default-features = false, features = ["alloy"] } +reth-db-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-db-models = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-discv4 = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-discv5 = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-trie-db = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-network = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-net-nat = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-tracing = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-provider = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-cli-util = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-db-common = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-layer = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-core = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-consensus = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-chainspec = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-cli-runner = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-tokio-util = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-convert = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-network-p2p = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-storage-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-engine-tree = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-chain-state = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-trie-common = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-util = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-metrics = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-cli-commands = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-evm-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-tracing-otlp = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-testing-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-eth-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-trie-parallel = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-network-peers = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-engine-api = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-e2e-test-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-execution-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-exex-test-utils = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-rpc-server-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-transaction-pool = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-execution-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-validator = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-primitives-traits = { version = "0.3.1", default-features = false } +reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-basic-payload-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-payload-builder-primitives = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0" } +reth-prune-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-stages-types = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-storage-errors = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-ethereum-forks = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-consensus-common = { git = "https://github.com/paradigmxyz/reth", tag = "v2.2.0", default-features = false } +reth-zstd-compressors = { version = "0.3.1", default-features = false } # tokio tokio = "1.48.0" @@ -456,16 +458,12 @@ risc0-ethereum-contracts = "3.0.1" risc0-zkvm = { version = "^3.0", default-features = false } # SP1 v6.1.0 (Hypercube) — used by crates/proof/succinct -sp1-sdk = { version = "=6.1.0" } -sp1-lib = { version = "=6.1.0" } -sp1-zkvm = { version = "=6.1.0" } -sp1-build = "=6.1.0" -sp1-prover-types = "=6.1.0" -# Pinned to a commit on top of v2.1.4 (includes fix for sp1_cluster_utils) -# TODO: Pin to version that merges sp1-cluster#80 (https://github.com/succinctlabs/sp1-cluster/pull/80) -sp1-cluster-artifact = { git = "https://github.com/succinctlabs/sp1-cluster", rev = "843ce58d15da0609a98f6ae37579389f14f5af03" } -sp1-cluster-common = { git = "https://github.com/succinctlabs/sp1-cluster", rev = "843ce58d15da0609a98f6ae37579389f14f5af03" } -sp1-cluster-utils = { git = "https://github.com/succinctlabs/sp1-cluster", rev = "843ce58d15da0609a98f6ae37579389f14f5af03" } +sp1-sdk = { version = "=6.2.1" } +sp1-build = "=6.2.1" +sp1-prover-types = "=6.2.1" +sp1-cluster-artifact = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.3.2" } +sp1-cluster-common = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.3.2" } +sp1-cluster-utils = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.3.2" } # crypto sha3 = "0.10" @@ -497,9 +495,9 @@ libp2p-identity = "0.2.12" bincode = "2" serde_bytes = "0.11" serde_with = "3.8.1" -ethereum_ssz = "0.9" +ethereum_ssz = "0.10" serde_yaml = "0.9.34" -ethereum_ssz_derive = "0.9" +ethereum_ssz_derive = "0.10" serde = { version = "1.0.228", default-features = false } serde_json = { version = "1.0.145", default-features = false } @@ -520,6 +518,13 @@ wiremock = { version = "0.6.2", default-features = false } testcontainers = { version = "0.25", default-features = false } testcontainers-modules = { version = "0.13", default-features = false } +# docker +bollard = "0.19.4" + +# compression +zstd = "0.13" +blake3 = "1.8" + # misc snap = "1" redb = "2" @@ -528,11 +533,13 @@ url = "2.5" libc = "0.2" ctor = "0.6" eyre = "0.6" +quote = "1" lru = "0.16" rand = "0.9" rkyv = "0.8" moka = "0.12" uuid = "1.21" +proc-macro2 = "1" dirs = "6.0.0" csv = "1.3.0" log = "0.4.22" @@ -564,7 +571,7 @@ shellexpand = "3.1" dirs-next = "2.0.0" num-format = "0.4.4" serde_cbor = "0.11.2" -lazy_static = "1.5.0" +lazy_static = { version = "1.5.0", features = ["spin_no_std"] } strum_macros = "0.28" ambassador = "0.4.2" miniz_oxide = "0.9.0" @@ -583,9 +590,9 @@ rand_08 = { package = "rand", version = "0.8" } strum = { version = "0.27", default-features = false } brotli = { version = "8.0.2", default-features = false } rocksdb = { version = "0.24", default-features = false } -rdkafka = { version = "0.39", default-features = false } thiserror = { version = "2.0", default-features = false } either = { version = "1.15.0", default-features = false } +syn = { version = "2", features = ["full", "extra-traits"] } kzg-rs = { version = "0.2.8", default-features = false } dotenvy = { version = "0.15.7", default-features = false } opentelemetry-stdout = { version = "0.31", default-features = false } @@ -618,10 +625,10 @@ ark-ff = { version = "0.5.0", default-features = false } unsigned-varint = { version = "0.8", default-features = false } ark-bls12-381 = { version = "0.5.0", default-features = false } -# Firehose tracer (defined by the streamingfast reth fork, release/reth-1.x branch). -reth-firehose = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-firehose-tests = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -firehose-tracer = "5.2.0" +# Firehose tracer (defined by the streamingfast reth fork, firehose/2.x branch). +reth-firehose = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-firehose-tests = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +firehose-tracer = "5.2.1" [patch.crates-io] # The crates.io SP1 v6.1.0 release is partially broken: several crates were never @@ -676,85 +683,72 @@ slop-merkle-tree = { git = "https://github.com/succinctlabs/sp1.git", tag = "v6. slop-maybe-rayon = { git = "https://github.com/succinctlabs/sp1.git", tag = "v6.1.0" } slop-basefold-prover = { git = "https://github.com/succinctlabs/sp1.git", tag = "v6.1.0" } -# Git override: pulls the in-flight cumulative-across-block tracer offsets -# (`flashblock_tx_index_offset`, `flashblock_cumulative_gas_offset`, -# `flashblock_log_block_index_offset`) applied in `complete_transaction`, -# `new_receipt_from_data`, and `on_log` so per-flashblock-iteration values -# produced by revm get rebased to canonical-across-block values. Pending an -# upstream crates.io release; revert this patch back to the version pin -# once the release is published. -firehose-tracer = { git = "https://github.com/streamingfast/evm-firehose-tracer-rs.git", branch = "offset-flashblocks" } - [patch."https://github.com/paradigmxyz/reth"] -reth-db = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-cli = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-ipc = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-evm = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-revm = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-trie = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-exex = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-tasks = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-errors = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-db-api = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-discv4 = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-discv5 = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-metrics = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-trie-db = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-api = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-network = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-net-nat = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-tracing = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-provider = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-cli-util = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-node-api = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-db-common = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-db-models = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-layer = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-node-core = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-consensus = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-chainspec = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-cli-runner = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-tokio-util = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-convert = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-builder = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-network-p2p = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-storage-api = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-eth-api = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-engine-tree = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-chain-state = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-trie-common = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-payload-util = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-node-builder = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-node-metrics = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-cli-commands = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-evm-ethereum = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-tracing-otlp = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-testing-utils = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-eth-types = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-trie-parallel = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-network-peers = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-node-ethereum = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-engine-api = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-e2e-test-utils = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-payload-builder = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-execution-types = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-exex-test-utils = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-rpc-server-types = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-transaction-pool = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-execution-errors = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-payload-validator = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-primitives-traits = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-engine-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-payload-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-ethereum-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-basic-payload-builder = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-payload-builder-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-prune-types = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-stages-types = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-storage-errors = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-ethereum-forks = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-consensus-common = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-codecs = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } -reth-zstd-compressors = { git = "https://github.com/streamingfast/reth.git", tag = "v1.11.4-fh-2" } +reth-db = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-cli = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-ipc = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-evm = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-revm = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-trie = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-exex = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-tasks = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-errors = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-db-api = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-discv4 = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-discv5 = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-metrics = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-trie-db = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-api = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-network = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-net-nat = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-tracing = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-provider = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-cli-util = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-node-api = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-db-common = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-db-models = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-layer = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-node-core = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-consensus = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-chainspec = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-cli-runner = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-tokio-util = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-convert = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-builder = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-network-p2p = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-storage-api = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-eth-api = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-engine-tree = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-chain-state = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-trie-common = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-payload-util = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-node-builder = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-node-metrics = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-cli-commands = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-evm-ethereum = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-tracing-otlp = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-testing-utils = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-eth-types = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-trie-parallel = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-network-peers = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-node-ethereum = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-engine-api = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-e2e-test-utils = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-payload-builder = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-execution-types = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-exex-test-utils = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-rpc-server-types = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-transaction-pool = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-execution-errors = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-payload-validator = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-engine-primitives = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-payload-primitives = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-ethereum-primitives = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-basic-payload-builder = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-payload-builder-primitives = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-prune-types = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-stages-types = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-storage-errors = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-ethereum-forks = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } +reth-consensus-common = { git = "https://github.com/streamingfast/reth.git", branch = "firehose/2.x" } diff --git a/Justfile b/Justfile index 33e9a1d793..2682447ebf 100644 --- a/Justfile +++ b/Justfile @@ -2,6 +2,8 @@ # The kernels require Xcode (Metal) on macOS but are only needed for linking # (cargo build), not for type-checking (cargo check/clippy). CI builds run # on Linux where CPU kernels compile without issue. + +[private] _skip_kernels := if os() == "macos" { "RISC0_SKIP_BUILD_KERNELS=1" } else { "" } set positional-arguments := true @@ -18,6 +20,8 @@ mod check 'etc/just/check.just' mod build 'etc/just/build.just' # SP1 / succinct ELF builds and proving helpers mod succinct 'etc/just/succinct.just' +# ZK prover gRPC request helpers +mod zk-prover 'etc/just/zk-prover.just' alias t := test alias f := fix @@ -26,19 +30,15 @@ alias c := clean alias h := hack alias wt := watch-test alias wc := watch-check -alias ldc := load-test-devnet-continuous +alias ldc := load-test-continuous # Default to display help menu default: @just --list -# Load test devnet in continuous mode (Ctrl-C to stop) -load-test-devnet-continuous: - just load-test devnet-continuous - -# Runs the specs docs locally -specs: - cd docs/specs && bun ci && bun dev +# Load test a network in continuous mode (Ctrl-C to stop) +load-test-continuous network='devnet': + just load-test continuous {{ network }} # One-time project setup: installs tooling and builds test contracts setup: @@ -131,11 +131,11 @@ test-affected base="main": install-nextest build::contracts build::elfs cargo nextest run --all-features "${pkg_args[@]}" # Runs tests with ci profile for minimal disk usage -test-ci: install-nextest build::contracts build::elfs +test-ci: install-nextest build::contracts cargo nextest run -P ci --locked --workspace --all-features --exclude devnet --cargo-profile ci # Runs tests only for affected crates with ci profile (for PRs) -test-affected-ci base="main": install-nextest build::contracts build::elfs +test-affected-ci base="main": install-nextest build::contracts #!/usr/bin/env bash set -euo pipefail pkg_args_output="$(python3 etc/scripts/local/affected-crates.py {{ base }} --exclude devnet --cargo-args)" @@ -163,12 +163,12 @@ hack: # Fixes any formatting issues format-fix: - {{_skip_kernels}} BASE_SUCCINCT_ELF_STUB=1 cargo fix --allow-dirty --allow-staged --workspace + {{ _skip_kernels }} BASE_SUCCINCT_ELF_STUB=1 cargo fix --allow-dirty --allow-staged --workspace cargo +nightly fmt --all # Fixes any clippy issues clippy-fix: - {{_skip_kernels}} BASE_SUCCINCT_ELF_STUB=1 cargo clippy --workspace --all-features --all-targets --fix --allow-dirty --allow-staged + {{ _skip_kernels }} BASE_SUCCINCT_ELF_STUB=1 cargo clippy --workspace --all-features --all-targets --fix --allow-dirty --allow-staged # Cleans the workspace clean: diff --git a/actions/harness/Cargo.toml b/actions/harness/Cargo.toml index c1c00fb2f5..12c372208c 100644 --- a/actions/harness/Cargo.toml +++ b/actions/harness/Cargo.toml @@ -20,6 +20,7 @@ reth-transaction-pool.workspace = true reth-primitives-traits.workspace = true reth-payload-primitives.workspace = true reth-basic-payload-builder.workspace = true +reth-revm.workspace = true reth-db = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } @@ -51,6 +52,7 @@ base-tx-manager.workspace = true base-batcher-core.workspace = true base-common-chains.workspace = true base-execution-evm.workspace = true +base-consensus-rpc.workspace = true base-consensus-node.workspace = true base-batcher-source.workspace = true base-batcher-encoder.workspace = true @@ -78,6 +80,16 @@ thiserror.workspace = true serde_json.workspace = true [dev-dependencies] +# alloy +alloy-sol-types = { workspace = true, features = ["std"] } + +# base base-blobs.workspace = true +base-common-precompiles.workspace = true +base-precompile-storage.workspace = true + +# tokio tokio = { workspace = true, features = ["rt", "macros"] } + +# tracing tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/actions/harness/src/common/account.rs b/actions/harness/src/common/account.rs new file mode 100644 index 0000000000..8325a0ebad --- /dev/null +++ b/actions/harness/src/common/account.rs @@ -0,0 +1,99 @@ +use alloy_consensus::SignableTransaction; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use base_common_consensus::BaseTxEnvelope; + +/// Hardcoded private key for the test account used across all action tests. +/// +/// The corresponding address is deterministic: derive it via +/// `PrivateKeySigner::from_bytes(&TEST_ACCOUNT_KEY).unwrap().address()`. +/// Tests that need to fund the account should include it in the genesis +/// allocation with a sufficient ETH balance. +pub const TEST_ACCOUNT_KEY: B256 = B256::new([0x01u8; 32]); + +/// The L2 address derived from [`TEST_ACCOUNT_KEY`]. +/// +/// Pre-computed so callers can reference it without constructing a signer. +// Address derived from the secp256k1 public key of [0x01; 32]. +pub const TEST_ACCOUNT_ADDRESS: Address = + alloy_primitives::address!("1a642f0E3c3aF545E7AcBD38b07251B3990914F1"); + +/// A test account with nonce tracking and signing capability. +/// +/// Wraps a [`PrivateKeySigner`] with an auto-incrementing nonce so callers +/// can build correctly-sequenced signed transactions without manual bookkeeping. +/// Shared via [`Arc`] so the sequencer and external test code stay in sync. +/// +/// [`Arc`]: std::sync::Arc +#[derive(Debug)] +pub struct TestAccount { + signer: PrivateKeySigner, + nonce: u64, +} + +impl TestAccount { + /// Create a new test account from a private key with nonce starting at 0. + pub fn new(key: B256) -> Self { + let signer = PrivateKeySigner::from_bytes(&key).expect("valid key"); + Self { signer, nonce: 0 } + } + + /// Return the address derived from this account's private key. + pub const fn address(&self) -> Address { + self.signer.address() + } + + /// Sign a pre-built EIP-1559 transaction without modifying the nonce. + /// + /// The caller is responsible for setting the correct nonce in the + /// transaction fields before calling this method. + pub fn sign_tx( + &mut self, + tx: alloy_consensus::TxEip1559, + ) -> Result { + let sig = self.signer.sign_hash_sync(&tx.signature_hash())?; + Ok(BaseTxEnvelope::Eip1559(tx.into_signed(sig))) + } + + /// Creates and signs a minimal EIP-1559 transfer, auto-incrementing the nonce. + pub fn create_eip1559_tx(&mut self, chain_id: u64) -> BaseTxEnvelope { + self.create_tx(chain_id, TxKind::Call(Address::ZERO), Bytes::new(), U256::from(1), 21_000) + } + + /// Creates and signs a custom EIP-1559 transaction, auto-incrementing the nonce. + /// + /// The caller provides the destination, calldata, value, and gas limit. + /// Chain-level fields (`chain_id`, `nonce`, fee caps) are filled in automatically. + pub fn create_tx( + &mut self, + chain_id: u64, + to: TxKind, + input: Bytes, + value: U256, + gas_limit: u64, + ) -> BaseTxEnvelope { + let tx = alloy_consensus::TxEip1559 { + chain_id, + nonce: self.nonce, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 1_000_000, + gas_limit, + to, + value, + input, + access_list: Default::default(), + }; + let sig = self + .signer + .sign_hash_sync(&tx.signature_hash()) + .expect("test account signing must not fail"); + self.nonce += 1; + BaseTxEnvelope::Eip1559(tx.into_signed(sig)) + } + + /// Return the current nonce. + pub const fn nonce(&self) -> u64 { + self.nonce + } +} diff --git a/actions/harness/src/common/block_hash_registry.rs b/actions/harness/src/common/block_hash_registry.rs new file mode 100644 index 0000000000..60167c0d0c --- /dev/null +++ b/actions/harness/src/common/block_hash_registry.rs @@ -0,0 +1,70 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use alloy_primitives::B256; + +/// Underlying map type for [`SharedBlockHashRegistry`]: block number -> (hash, optional state root). +pub type BlockHashInner = Arc)>>>; + +/// Shared L2 block hashes and state roots keyed by block number. +/// +/// `L2Sequencer` writes into this registry as blocks are built, and +/// `TestRollupNode` reads from the same registry when it applies derived +/// attributes so the resulting safe-head hash chain matches the sequencer's +/// sealed headers. The [`ActionEngineClient`] reads the stored state root for +/// post-derivation execution validation. +/// +/// The state root field is `Option`: it is `Some` only when the entry +/// was produced by real EVM execution (e.g. via [`L2Sequencer`] or +/// [`TestRollupNode::act_l2_unsafe_gossip_receive`]). Entries created with +/// [`TestRollupNode::register_block_hash`] store `None`, which causes the +/// executor to skip state-root validation for that block rather than panic +/// against a bogus sentinel value. +/// +/// [`ActionEngineClient`]: crate::ActionEngineClient +/// [`L2Sequencer`]: crate::L2Sequencer +/// [`TestRollupNode`]: crate::TestRollupNode +/// [`TestRollupNode::act_l2_unsafe_gossip_receive`]: crate::TestRollupNode::act_l2_unsafe_gossip_receive +/// [`TestRollupNode::register_block_hash`]: crate::TestRollupNode::register_block_hash +#[derive(Debug, Clone, Default)] +pub struct SharedBlockHashRegistry(BlockHashInner); + +impl SharedBlockHashRegistry { + /// Create an empty shared registry. + pub fn new() -> Self { + Self(Arc::new(Mutex::new(HashMap::new()))) + } + + /// Record the block hash and optional state root for an L2 block number. + /// + /// Pass `Some(state_root)` when the block was produced by real EVM + /// execution so that the engine client can validate it. + /// Pass `None` for synthetic blocks (e.g. via + /// [`TestRollupNode::register_block_hash`]); the executor will skip + /// state-root validation for those blocks. + /// + /// [`TestRollupNode::register_block_hash`]: crate::TestRollupNode::register_block_hash + pub fn insert(&self, number: u64, hash: B256, state_root: Option) { + self.0 + .lock() + .expect("block hash registry lock poisoned") + .insert(number, (hash, state_root)); + } + + /// Return the registered block hash for an L2 block number. + pub fn get(&self, number: u64) -> Option { + self.0.lock().expect("block hash registry lock poisoned").get(&number).map(|(h, _)| *h) + } + + /// Return the registered state root for an L2 block number, if any. + /// + /// Returns `None` when the block was not registered or was registered + /// without a state root (e.g. via [`TestRollupNode::register_block_hash`]). + /// + /// [`TestRollupNode::register_block_hash`]: crate::TestRollupNode::register_block_hash + pub fn get_state_root(&self, number: u64) -> Option { + self.0.lock().expect("block hash registry lock poisoned").get(&number).and_then(|(_, s)| *s) + } +} diff --git a/actions/harness/src/common/l2_source.rs b/actions/harness/src/common/l2_source.rs new file mode 100644 index 0000000000..6b7f024b26 --- /dev/null +++ b/actions/harness/src/common/l2_source.rs @@ -0,0 +1,61 @@ +use std::collections::VecDeque; + +use base_common_consensus::BaseBlock; + +use crate::L2BlockProvider; + +/// A pre-built queue of [`BaseBlock`]s for the batcher to drain. +/// +/// Tests push fully-formed blocks into the source, which the batcher +/// consumes one at a time via [`L2BlockProvider::next_block`]. +#[derive(Debug, Default)] +pub struct ActionL2Source { + blocks: VecDeque, +} + +impl ActionL2Source { + /// Create an empty source. + pub const fn new() -> Self { + Self { blocks: VecDeque::new() } + } + + /// Create a source containing the supplied blocks in iteration order. + pub fn from_blocks(blocks: impl IntoIterator) -> Self { + let mut source = Self::new(); + source.extend(blocks); + source + } + + /// Push a block to the back of the queue. + pub fn push(&mut self, block: BaseBlock) { + self.blocks.push_back(block); + } + + /// Return the number of blocks remaining. + pub fn remaining(&self) -> usize { + self.blocks.len() + } + + /// Return `true` if the source has been fully drained. + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +impl Extend for ActionL2Source { + fn extend>(&mut self, iter: T) { + self.blocks.extend(iter); + } +} + +impl FromIterator for ActionL2Source { + fn from_iter>(iter: T) -> Self { + Self::from_blocks(iter) + } +} + +impl L2BlockProvider for ActionL2Source { + fn next_block(&mut self) -> Option { + self.blocks.pop_front() + } +} diff --git a/actions/harness/src/common/mod.rs b/actions/harness/src/common/mod.rs new file mode 100644 index 0000000000..e1db899adc --- /dev/null +++ b/actions/harness/src/common/mod.rs @@ -0,0 +1,10 @@ +//! Common action-harness utilities shared by actors and tests. + +mod account; +pub use account::{TEST_ACCOUNT_ADDRESS, TEST_ACCOUNT_KEY, TestAccount}; + +mod block_hash_registry; +pub use block_hash_registry::{BlockHashInner, SharedBlockHashRegistry}; + +mod l2_source; +pub use l2_source::ActionL2Source; diff --git a/actions/harness/src/engine.rs b/actions/harness/src/engine.rs index 8c6a58409a..95e0b0cb75 100644 --- a/actions/harness/src/engine.rs +++ b/actions/harness/src/engine.rs @@ -21,7 +21,7 @@ use alloy_rpc_types_eth::{ }; use alloy_transport::{TransportError, TransportErrorKind, TransportResult}; use async_trait::async_trait; -use base_common_consensus::BasePrimitives; +use base_common_consensus::{BaseBlock, BasePrimitives, BaseReceipt}; use base_common_genesis::RollupConfig; use base_common_network::{Base, BaseEngineApi}; use base_common_rpc_types::Transaction as BaseTransaction; @@ -39,7 +39,7 @@ use base_execution_payload_builder::{ }; use base_execution_txpool::BasePooledTransaction; use base_node_core::BaseNode; -use base_protocol::{AttributesWithParent, BlockInfo, L2BlockInfo}; +use base_protocol::{AttributesWithParent, L2BlockInfo}; use base_test_utils::build_test_genesis; use reth_basic_payload_builder::{ BuildArguments, PayloadBuilder as RethPayloadBuilder, PayloadConfig, @@ -48,13 +48,14 @@ use reth_db::{DatabaseEnv, test_utils::TempDatabase}; use reth_db_common::init::init_genesis; use reth_execution_types::ExecutionOutcome; use reth_node_api::NodeTypesWithDBAdapter; -use reth_payload_primitives::{BuiltPayload, PayloadBuilderAttributes}; +use reth_payload_primitives::{BuiltPayload, PayloadAttributes}; use reth_primitives_traits::SealedHeader; use reth_provider::{ BlockWriter, HashedPostStateProvider, LatestStateProviderRef, ProviderFactory, StateProvider, StateProviderFactory, providers::BlockchainProvider, test_utils::create_test_provider_factory_with_node_types, }; +use reth_revm::{cached::CachedReads, cancelled::CancelOnDrop}; use reth_transaction_pool::noop::NoopTransactionPool; use crate::{SharedBlockHashRegistry, SharedL1Chain}; @@ -71,7 +72,7 @@ pub type TestBlockchainProvider = BlockchainProvider; /// Type alias for the noop pool used by the engine client. pub type TestPool = NoopTransactionPool; -/// A payload built in-process during sequencer mode, waiting to be fetched via `get_payload`. +/// A payload built in-process during sequencer mode, waiting to be sealed or inserted. #[derive(Debug, Clone)] pub struct PendingPayload { /// The built payload from the production `BasePayloadBuilder`. @@ -91,8 +92,12 @@ pub struct ActionEngineClientInner { chain_spec: Arc, canonical_head: L2BlockInfo, executed_headers: HashMap, + executed_infos: HashMap, + executed_receipts: HashMap>, /// Payloads built via FCU-with-attrs (sequencer mode), keyed by `PayloadId`. pending_payloads: HashMap, + /// Sealed payloads waiting for explicit insertion, keyed by block hash. + sealed_payloads: HashMap, payload_counter: u64, } @@ -111,11 +116,9 @@ pub struct ActionEngineClientInner { /// /// ## Sequencer mode /// -/// When `fork_choice_updated_vX` is called with `payload_attributes`, transactions -/// are executed via the production builder, a `PayloadId` is returned, and the resulting payload -/// is stored pending retrieval via `get_payload_vX`. A subsequent `new_payload` call -/// with the same block is a no-op (the EVM state was already advanced during the -/// build step), ensuring the builder is not applied twice. +/// When `fork_choice_updated_vX` is called with `payload_attributes`, a block is built via the +/// production builder and stored pending retrieval via `get_payload_vX`. The built block is only +/// committed to the database when it is explicitly inserted. /// /// [`L2Sequencer`]: crate::L2Sequencer #[derive(Clone, Debug)] @@ -190,6 +193,11 @@ impl ActionEngineClient { } else { genesis.config.extra_fields.insert("base".to_string(), base.into()); } + // Generated harness genesis specs use the funded test account as the activation admin. + genesis.config.extra_fields.insert( + "activationAdminAddress".to_string(), + serde_json::json!(crate::TEST_ACCOUNT_ADDRESS), + ); genesis } @@ -235,7 +243,10 @@ impl ActionEngineClient { chain_spec, canonical_head, executed_headers: HashMap::new(), + executed_infos: HashMap::new(), + executed_receipts: HashMap::new(), pending_payloads: HashMap::new(), + sealed_payloads: HashMap::new(), payload_counter: 0, })); Self { inner, rollup_config, block_registry, l1_chain } @@ -271,6 +282,12 @@ impl ActionEngineClient { .unwrap_or(alloy_primitives::U256::ZERO) } + /// Return receipts for an executed block number. + pub fn receipts_at(&self, block_number: u64) -> Option> { + let inner = self.inner.lock().expect("engine client lock"); + inner.executed_receipts.get(&block_number).cloned() + } + /// Check whether an account has non-empty code deployed. /// /// Returns `true` if the account exists and has code, `false` otherwise. @@ -284,10 +301,9 @@ impl ActionEngineClient { .is_some_and(|c: reth_primitives_traits::Bytecode| !c.is_empty()) } - /// Build a block from the given `BasePayloadAttributes` and commit it to the database, - /// returning the `BaseBuiltPayload`. - fn build_and_commit( - inner: &mut ActionEngineClientInner, + /// Build a block from the given `BasePayloadAttributes`, returning the `BaseBuiltPayload`. + fn build_payload( + inner: &ActionEngineClientInner, parent_hash: B256, attrs: BasePayloadAttributes, ) -> TransportResult> { @@ -320,15 +336,17 @@ impl ActionEngineClient { ))) })?; + let payload_id = builder_attrs.payload_id(&effective_parent_hash); let parent_sealed = SealedHeader::new(parent_header, effective_parent_hash); - let config = - PayloadConfig { parent_header: Arc::new(parent_sealed), attributes: builder_attrs }; - let args = BuildArguments { + let config = PayloadConfig::new(Arc::new(parent_sealed), builder_attrs, payload_id); + let args = BuildArguments::new( + CachedReads::default(), + None, + None, config, - cached_reads: Default::default(), - cancel: Default::default(), - best_payload: None, - }; + CancelOnDrop::default(), + None, + ); let pool = TestPool::new(); let payload_builder = BasePayloadBuilder::new( @@ -347,13 +365,35 @@ impl ActionEngineClient { )) })?; + Ok(built) + } + + /// Commit a built payload to the database and register its header/state root. + fn commit_built_payload( + inner: &mut ActionEngineClientInner, + registry: &SharedBlockHashRegistry, + rollup_config: &RollupConfig, + built: BaseBuiltPayload, + ) -> TransportResult<(B256, L2BlockInfo)> { + let block: BaseBlock = built.block().clone_block(); + let hdr = block.header.clone(); + let block_number = hdr.number(); + let block_hash = block.hash_slow(); + let state_root = hdr.state_root(); + + if let Some(existing) = inner.executed_infos.get(&block_number) + && existing.block_info.hash == block_hash + { + return Ok((block_hash, *existing)); + } + // Commit the block state to the database so subsequent blocks can build on it. if let Some(executed) = built.executed_block() { let execution_output = executed.execution_output; - let block_number = built.block().header().number(); + let receipts = execution_output.result.receipts.clone(); let execution_outcome = ExecutionOutcome { bundle: execution_output.state.clone(), - receipts: vec![execution_output.result.receipts.clone()], + receipts: vec![receipts.clone()], first_block: block_number, requests: vec![execution_output.result.requests.clone()], }; @@ -398,19 +438,39 @@ impl ActionEngineClient { "failed to rebuild blockchain provider: {e}" ))) })?; + + inner.executed_receipts.insert(block_number, receipts); } - Ok(built) + if let Some(expected_root) = registry.get_state_root(block_number) { + assert_eq!( + state_root, expected_root, + "state root mismatch at block {block_number}: computed={state_root}, expected={expected_root}", + ); + } + + registry.insert(block_number, block_hash, Some(state_root)); + + let l2_info = + L2BlockInfo::from_block_and_genesis(&block, &rollup_config.genesis).map_err(|e| { + TransportError::from(TransportErrorKind::custom_str(&format!( + "failed to derive L2 block info: {e}" + ))) + })?; + inner.executed_headers.insert(block_number, hdr); + inner.executed_infos.insert(block_number, l2_info); + Ok((block_hash, l2_info)) } /// Execute the transactions in a V1 payload against the production builder, returning the /// block hash. /// - /// If this block was already executed during a `build_payload_inner` call (sequencer mode), - /// execution is skipped and the pre-computed hash is returned directly. + /// If this block was already committed by the sequencer path, execution is skipped and the + /// stored hash is returned directly. fn execute_v1_inner( inner: &mut ActionEngineClientInner, registry: &SharedBlockHashRegistry, + rollup_config: &RollupConfig, payload: &ExecutionPayloadV1, ) -> TransportResult { // Skip re-execution if this block was already built. @@ -420,8 +480,8 @@ impl ActionEngineClient { // // In derivation mode (`TestRollupNode`) the payload is constructed with a zeroed // `block_hash` placeholder because the engine is expected to fill it in. When we see - // B256::ZERO we treat the block-number lookup alone as sufficient — the block was - // pre-built by the sequencer and its state is already committed to the DB. + // B256::ZERO we treat the block-number lookup alone as sufficient: the sequencer path + // already inserted the matching block and committed its state to the DB. if let Some(existing) = inner.executed_headers.get(&payload.block_number) { let existing_hash = existing.hash_slow(); if payload.block_hash == B256::ZERO || payload.block_hash == existing_hash { @@ -437,6 +497,7 @@ impl ActionEngineClient { suggested_fee_recipient: payload.fee_recipient, withdrawals: Some(vec![]), parent_beacon_block_root: None, + slot_number: None, }, transactions: Some(payload.transactions.clone()), no_tx_pool: Some(true), @@ -449,24 +510,8 @@ impl ActionEngineClient { min_base_fee: Some(0), }; - let built = Self::build_and_commit(inner, payload.parent_hash, attrs)?; - let block = built.block(); - let hdr = block.header(); - let state_root = hdr.state_root(); - let block_hash = block.hash(); - - if let Some(expected_root) = registry.get_state_root(payload.block_number) { - assert_eq!( - state_root, expected_root, - "state root mismatch at block {}: computed={}, expected={}", - payload.block_number, state_root, expected_root, - ); - } - - // Register the state root in the block registry. - registry.insert(payload.block_number, block_hash, Some(state_root)); - - inner.executed_headers.insert(payload.block_number, hdr.clone()); + let built = Self::build_payload(inner, payload.parent_hash, attrs)?; + let (block_hash, _) = Self::commit_built_payload(inner, registry, rollup_config, built)?; Ok(block_hash) } @@ -494,21 +539,13 @@ impl ActionEngineClient { return Ok(existing.hash_slow()); } - let built = Self::build_and_commit(&mut guard, parent_hash, attrs)?; - let block = built.block(); - let hdr = block.header(); - let state_root = hdr.state_root(); - let block_hash = block.hash(); - - if let Some(expected_root) = self.block_registry.get_state_root(block_number) { - assert_eq!( - state_root, expected_root, - "state root mismatch at block {block_number}: computed={state_root}, expected={expected_root}", - ); - } - - self.block_registry.insert(block_number, block_hash, Some(state_root)); - guard.executed_headers.insert(block_number, hdr.clone()); + let built = Self::build_payload(&guard, parent_hash, attrs)?; + let (block_hash, _) = Self::commit_built_payload( + &mut guard, + &self.block_registry, + &self.rollup_config, + built, + )?; Ok(block_hash) } @@ -518,25 +555,10 @@ impl ActionEngineClient { /// payload is stored in `pending_payloads` for later retrieval via `get_payload_vX`. fn build_payload_inner( inner: &mut ActionEngineClientInner, - registry: &SharedBlockHashRegistry, parent_hash: B256, attrs: &BasePayloadAttributes, ) -> TransportResult { - let built = Self::build_and_commit(inner, parent_hash, attrs.clone())?; - - let block = built.block(); - let hdr = block.header(); - let block_number = hdr.number(); - let state_root = hdr.state_root(); - let block_hash = block.hash(); - - // Register the state root so derivation can validate against it. - registry.insert(block_number, block_hash, Some(state_root)); - - // Store the full header cloned from the built block so that `hash_slow()` on the stored - // entry returns the actual block hash. Storing only a subset of fields would produce a - // different hash and break the skip-check in `execute_v1_inner`. - inner.executed_headers.insert(block_number, hdr.clone()); + let built = Self::build_payload(inner, parent_hash, attrs.clone())?; let id = PayloadId::new(inner.payload_counter.to_le_bytes()); inner.payload_counter += 1; @@ -716,19 +738,7 @@ impl EngineClient for ActionEngineClient { if n == guard.canonical_head.block_info.number { Some(guard.canonical_head) } else { - guard.executed_headers.get(&n).map(|h| { - let block_hash = h.hash_slow(); - L2BlockInfo { - block_info: BlockInfo { - hash: block_hash, - number: h.number, - parent_hash: h.parent_hash, - timestamp: h.timestamp, - }, - l1_origin: Default::default(), - seq_num: 0, - } - }) + guard.executed_infos.get(&n).copied() } } BlockNumberOrTag::Earliest => None, @@ -744,8 +754,12 @@ impl BaseEngineApi for ActionEngineClient { payload: ExecutionPayloadInputV2, ) -> TransportResult { let mut guard = self.inner.lock().expect("action engine inner lock poisoned"); - let block_hash = - Self::execute_v1_inner(&mut guard, &self.block_registry, &payload.execution_payload)?; + let block_hash = Self::execute_v1_inner( + &mut guard, + &self.block_registry, + &self.rollup_config, + &payload.execution_payload, + )?; Ok(Self::make_valid(block_hash)) } @@ -758,6 +772,7 @@ impl BaseEngineApi for ActionEngineClient { let block_hash = Self::execute_v1_inner( &mut guard, &self.block_registry, + &self.rollup_config, &payload.payload_inner.payload_inner, )?; Ok(Self::make_valid(block_hash)) @@ -772,6 +787,7 @@ impl BaseEngineApi for ActionEngineClient { let block_hash = Self::execute_v1_inner( &mut guard, &self.block_registry, + &self.rollup_config, &payload.payload_inner.payload_inner.payload_inner, )?; Ok(Self::make_valid(block_hash)) @@ -786,24 +802,15 @@ impl BaseEngineApi for ActionEngineClient { let mut guard = self.inner.lock().expect("action engine inner lock poisoned"); // Update canonical head if the block is in our executed headers. - if let Some(h) = guard.executed_headers.values().find(|h| h.hash_slow() == head).cloned() { - let block_hash = head; - guard.canonical_head = L2BlockInfo { - block_info: BlockInfo { - hash: block_hash, - number: h.number, - parent_hash: h.parent_hash, - timestamp: h.timestamp, - }, - l1_origin: Default::default(), - seq_num: 0, - }; + if let Some(h) = guard.executed_headers.values().find(|h| h.hash_slow() == head).cloned() + && let Some(info) = guard.executed_infos.get(&h.number) + { + guard.canonical_head = *info; } // Sequencer mode: build a block from the provided attributes. if let Some(ref attrs) = payload_attributes { - let payload_id = - Self::build_payload_inner(&mut guard, &self.block_registry, head, attrs)?; + let payload_id = Self::build_payload_inner(&mut guard, head, attrs)?; return Ok(ForkchoiceUpdated { payload_status: Self::make_valid(head), payload_id: Some(payload_id), @@ -908,13 +915,8 @@ impl SequencerEngineClient for ActionEngineClient { ) -> Result { let parent_hash = attributes.parent.block_info.hash; let mut guard = self.inner.lock().expect("action engine inner lock poisoned"); - Self::build_payload_inner( - &mut guard, - &self.block_registry, - parent_hash, - &attributes.attributes, - ) - .map_err(|e| NodeEngineClientError::RequestError(e.to_string())) + Self::build_payload_inner(&mut guard, parent_hash, &attributes.attributes) + .map_err(|e| NodeEngineClientError::RequestError(e.to_string())) } async fn get_sealed_payload( @@ -930,37 +932,40 @@ impl SequencerEngineClient for ActionEngineClient { let parent_beacon_block_root = block.header().parent_beacon_block_root(); let (payload, _sidecar) = BaseExecutionPayload::from_block_unchecked(block_hash, &block.clone_block()); + guard.sealed_payloads.insert(block_hash, pending); Ok(BaseExecutionPayloadEnvelope { parent_beacon_block_root, execution_payload: payload }) } async fn insert_unsafe_payload( &self, payload: BaseExecutionPayloadEnvelope, - ) -> Result<(), NodeEngineClientError> { + ) -> Result { // Extract the V1 payload for execution. let v1 = payload.execution_payload.as_v1(); let head_hash = v1.block_hash; let mut guard = self.inner.lock().expect("action engine inner lock poisoned"); - Self::execute_v1_inner(&mut guard, &self.block_registry, v1) - .map_err(|e| NodeEngineClientError::RequestError(e.to_string()))?; - - // Update canonical head. - if let Some(h) = - guard.executed_headers.values().find(|h| h.hash_slow() == head_hash).cloned() - { - guard.canonical_head = L2BlockInfo { - block_info: BlockInfo { - hash: head_hash, - number: h.number, - parent_hash: h.parent_hash, - timestamp: h.timestamp, - }, - l1_origin: Default::default(), - seq_num: 0, - }; - } - Ok(()) + let inserted_head = if let Some(pending) = guard.sealed_payloads.remove(&head_hash) { + Self::commit_built_payload( + &mut guard, + &self.block_registry, + &self.rollup_config, + pending.built, + ) + .map_err(|e| NodeEngineClientError::RequestError(e.to_string()))? + .1 + } else { + Self::execute_v1_inner(&mut guard, &self.block_registry, &self.rollup_config, v1) + .map_err(|e| NodeEngineClientError::RequestError(e.to_string()))?; + guard.executed_infos.get(&v1.block_number).copied().ok_or_else(|| { + NodeEngineClientError::ResponseError(format!( + "inserted block info not found for block {}", + v1.block_number, + )) + })? + }; + guard.canonical_head = inserted_head; + Ok(guard.canonical_head) } async fn get_unsafe_head(&self) -> Result { diff --git a/actions/harness/src/harness.rs b/actions/harness/src/harness.rs index f0740e7bf8..a5763d0932 100644 --- a/actions/harness/src/harness.rs +++ b/actions/harness/src/harness.rs @@ -8,7 +8,7 @@ use base_common_genesis::RollupConfig; use base_consensus_derive::{ DataAvailabilityProvider, EthereumDataSource, PipelineBuilder, StatefulAttributesBuilder, }; -use base_consensus_node::{GossipTransport, L1OriginSelector}; +use base_consensus_node::GossipTransport; use base_protocol::{BlockInfo, L1BlockInfoTx, L2BlockInfo}; use crate::{ @@ -273,31 +273,21 @@ impl ActionTestHarness { let genesis_head = self.l2_genesis(); - let l1_provider = ActionL1ChainProvider::new(l1_chain.clone()); let l2_provider = ActionL2ChainProvider::from_genesis(&self.rollup_config); - let attrs_builder = StatefulAttributesBuilder::new( - Arc::clone(&rollup_config), - Arc::clone(&l1_chain_config), - l2_provider.clone(), - l1_provider, - ); - - let origin_selector = L1OriginSelector::new(Arc::clone(&rollup_config), l1_chain.clone()); - let engine_client = Arc::new(ActionEngineClient::new( Arc::clone(&rollup_config), genesis_head, crate::SharedBlockHashRegistry::new(), - l1_chain, + l1_chain.clone(), )); L2Sequencer::new( genesis_head, - origin_selector, - attrs_builder, engine_client, rollup_config, + l1_chain_config, + l1_chain, l2_provider, ) } diff --git a/actions/harness/src/l2.rs b/actions/harness/src/l2.rs deleted file mode 100644 index 0802e0a382..0000000000 --- a/actions/harness/src/l2.rs +++ /dev/null @@ -1,701 +0,0 @@ -use std::{ - collections::{HashMap, VecDeque}, - sync::{Arc, Mutex}, -}; - -use alloy_consensus::SignableTransaction; -use alloy_eips::{BlockNumHash, eip2718::Encodable2718, eip7685::EMPTY_REQUESTS_HASH}; -use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; -use alloy_rpc_types_engine::{CancunPayloadFields, PraguePayloadFields}; -use alloy_signer::SignerSync; -use alloy_signer_local::PrivateKeySigner; -use base_common_consensus::{BaseBlock, BaseTxEnvelope}; -use base_common_genesis::RollupConfig; -use base_common_rpc_types_engine::{ - BaseExecutionPayload, BaseExecutionPayloadEnvelope, BaseExecutionPayloadSidecar, - NetworkPayloadEnvelope, PayloadHash, -}; -use base_consensus_derive::{AttributesBuilder, StatefulAttributesBuilder}; -use base_consensus_node::{ - Conductor, ConductorError, L1OriginSelector, OriginSelector, SequencerEngineClient, -}; -use base_protocol::{AttributesWithParent, BlockInfo, L2BlockInfo}; - -use crate::{ - ActionEngineClient, ActionL1ChainProvider, ActionL2ChainProvider, L2BlockProvider, - SharedL1Chain, SupervisedP2P, -}; - -/// Hardcoded private key for the test account used across all action tests. -/// -/// The corresponding address is deterministic: derive it via -/// `PrivateKeySigner::from_bytes(&TEST_ACCOUNT_KEY).unwrap().address()`. -/// Tests that need to fund the account should include it in the genesis -/// allocation with a sufficient ETH balance. -pub const TEST_ACCOUNT_KEY: B256 = B256::new([0x01u8; 32]); - -/// The L2 address derived from [`TEST_ACCOUNT_KEY`]. -/// -/// Pre-computed so callers can reference it without constructing a signer. -// Address derived from the secp256k1 public key of [0x01; 32]. -pub const TEST_ACCOUNT_ADDRESS: Address = - alloy_primitives::address!("1a642f0E3c3aF545E7AcBD38b07251B3990914F1"); - -/// A test account with nonce tracking and signing capability. -/// -/// Wraps a [`PrivateKeySigner`] with an auto-incrementing nonce so callers -/// can build correctly-sequenced signed transactions without manual bookkeeping. -/// Shared via [`Arc`] so the sequencer and external test code stay in sync. -#[derive(Debug)] -pub struct TestAccount { - signer: PrivateKeySigner, - nonce: u64, -} - -impl TestAccount { - /// Create a new test account from a private key with nonce starting at 0. - pub fn new(key: B256) -> Self { - let signer = PrivateKeySigner::from_bytes(&key).expect("valid key"); - Self { signer, nonce: 0 } - } - - /// Return the address derived from this account's private key. - pub const fn address(&self) -> Address { - self.signer.address() - } - - /// Sign a pre-built EIP-1559 transaction without modifying the nonce. - /// - /// The caller is responsible for setting the correct nonce in the - /// transaction fields before calling this method. - pub fn sign_tx( - &mut self, - tx: alloy_consensus::TxEip1559, - ) -> Result { - let sig = self.signer.sign_hash_sync(&tx.signature_hash())?; - Ok(BaseTxEnvelope::Eip1559(tx.into_signed(sig))) - } - - /// Creates and signs a minimal EIP-1559 transfer, auto-incrementing the nonce. - pub fn create_eip1559_tx(&mut self, chain_id: u64) -> BaseTxEnvelope { - self.create_tx(chain_id, TxKind::Call(Address::ZERO), Bytes::new(), U256::from(1), 21_000) - } - - /// Creates and signs a custom EIP-1559 transaction, auto-incrementing the nonce. - /// - /// The caller provides the destination, calldata, value, and gas limit. - /// Chain-level fields (`chain_id`, `nonce`, fee caps) are filled in automatically. - pub fn create_tx( - &mut self, - chain_id: u64, - to: TxKind, - input: Bytes, - value: U256, - gas_limit: u64, - ) -> BaseTxEnvelope { - let tx = alloy_consensus::TxEip1559 { - chain_id, - nonce: self.nonce, - max_fee_per_gas: 1_000_000_000, - max_priority_fee_per_gas: 1_000_000, - gas_limit, - to, - value, - input, - access_list: Default::default(), - }; - let sig = self - .signer - .sign_hash_sync(&tx.signature_hash()) - .expect("test account signing must not fail"); - self.nonce += 1; - BaseTxEnvelope::Eip1559(tx.into_signed(sig)) - } - - /// Return the current nonce. - pub const fn nonce(&self) -> u64 { - self.nonce - } -} - -/// Error type returned by [`L2Sequencer`]. -#[derive(Debug, thiserror::Error)] -pub enum L2SequencerError { - /// The L1 block required for the current epoch is missing from the chain. - #[error("L1 block {0} not found in shared chain")] - MissingL1Block(u64), - /// Failed to build the L1 info deposit transaction. - #[error("failed to build L1 info deposit: {0}")] - L1Info(#[from] base_protocol::BlockInfoError), - /// Transaction signing failed. - #[error("signing failed: {0}")] - Signing(#[from] alloy_signer::Error), - /// EVM execution failed. - #[error("EVM execution failed: {0}")] - Evm(String), - /// Origin selection failed. - #[error("origin selection failed: {0}")] - OriginSelection(String), - /// Attributes construction failed. - #[error("attributes construction failed: {0}")] - Attributes(String), - /// Engine client error. - #[error("engine client error: {0}")] - Engine(String), - /// Payload conversion error. - #[error("payload conversion error: {0}")] - PayloadConversion(String), - /// Conductor rejected the block (e.g. not leader, RPC error). - #[error("conductor error: {0}")] - Conductor(#[from] ConductorError), - /// This sequencer is not the conductor leader and cannot build blocks. - #[error("sequencer is not the conductor leader")] - NotLeader, -} - -/// A pre-built queue of [`BaseBlock`]s for the batcher to drain. -/// -/// Tests push fully-formed blocks into the source, which the batcher -/// consumes one at a time via [`L2BlockProvider::next_block`]. -#[derive(Debug, Default)] -pub struct ActionL2Source { - blocks: VecDeque, -} - -impl ActionL2Source { - /// Create an empty source. - pub const fn new() -> Self { - Self { blocks: VecDeque::new() } - } - - /// Create a source containing the supplied blocks in iteration order. - pub fn from_blocks(blocks: impl IntoIterator) -> Self { - let mut source = Self::new(); - source.extend(blocks); - source - } - - /// Push a block to the back of the queue. - pub fn push(&mut self, block: BaseBlock) { - self.blocks.push_back(block); - } - - /// Return the number of blocks remaining. - pub fn remaining(&self) -> usize { - self.blocks.len() - } - - /// Return `true` if the source has been fully drained. - pub fn is_empty(&self) -> bool { - self.blocks.is_empty() - } -} - -impl Extend for ActionL2Source { - fn extend>(&mut self, iter: T) { - self.blocks.extend(iter); - } -} - -impl FromIterator for ActionL2Source { - fn from_iter>(iter: T) -> Self { - Self::from_blocks(iter) - } -} - -impl L2BlockProvider for ActionL2Source { - fn next_block(&mut self) -> Option { - self.blocks.pop_front() - } -} - -/// Underlying map type for [`SharedBlockHashRegistry`]: block number -> (hash, optional state root). -pub type BlockHashInner = Arc)>>>; - -/// Shared L2 block hashes and state roots keyed by block number. -/// -/// `L2Sequencer` writes into this registry as blocks are built, and -/// `TestRollupNode` reads from the same registry when it applies derived -/// attributes so the resulting safe-head hash chain matches the sequencer's -/// sealed headers. The [`ActionEngineClient`] reads the stored state root for -/// post-derivation execution validation. -/// -/// The state root field is `Option`: it is `Some` only when the entry -/// was produced by real EVM execution (e.g. via [`L2Sequencer`] or -/// [`TestRollupNode::act_l2_unsafe_gossip_receive`]). Entries created with -/// [`TestRollupNode::register_block_hash`] store `None`, which causes the -/// executor to skip state-root validation for that block rather than panic -/// against a bogus sentinel value. -/// -/// [`TestRollupNode::act_l2_unsafe_gossip_receive`]: crate::TestRollupNode::act_l2_unsafe_gossip_receive -/// [`TestRollupNode::register_block_hash`]: crate::TestRollupNode::register_block_hash -#[derive(Debug, Clone, Default)] -pub struct SharedBlockHashRegistry(BlockHashInner); - -impl SharedBlockHashRegistry { - /// Create an empty shared registry. - pub fn new() -> Self { - Self(Arc::new(Mutex::new(HashMap::new()))) - } - - /// Record the block hash and optional state root for an L2 block number. - /// - /// Pass `Some(state_root)` when the block was produced by real EVM - /// execution so that the engine client can validate it. - /// Pass `None` for synthetic blocks (e.g. via - /// [`TestRollupNode::register_block_hash`]); the executor will skip - /// state-root validation for those blocks. - /// - /// [`TestRollupNode::register_block_hash`]: crate::TestRollupNode::register_block_hash - pub fn insert(&self, number: u64, hash: B256, state_root: Option) { - self.0 - .lock() - .expect("block hash registry lock poisoned") - .insert(number, (hash, state_root)); - } - - /// Return the registered block hash for an L2 block number. - pub fn get(&self, number: u64) -> Option { - self.0.lock().expect("block hash registry lock poisoned").get(&number).map(|(h, _)| *h) - } - - /// Return the registered state root for an L2 block number, if any. - /// - /// Returns `None` when the block was not registered or was registered - /// without a state root (e.g. via [`TestRollupNode::register_block_hash`]). - /// - /// [`TestRollupNode::register_block_hash`]: crate::TestRollupNode::register_block_hash - pub fn get_state_root(&self, number: u64) -> Option { - self.0.lock().expect("block hash registry lock poisoned").get(&number).and_then(|(_, s)| *s) - } -} - -/// Builds real [`BaseBlock`]s for use in action tests using production components. -/// -/// Uses: -/// - [`L1OriginSelector`] for epoch selection (same as the production sequencer) -/// - [`StatefulAttributesBuilder`] for L1-info deposit and attribute construction -/// - [`ActionEngineClient`] via [`SequencerEngineClient`] for block building -/// -/// Each block contains: -/// - A correct L1-info deposit transaction (type `0x7E`) as the first -/// transaction, built from the actual L1 block at the current epoch. -/// - A configurable number of signed EIP-1559 user transactions from the -/// test account ([`TEST_ACCOUNT_KEY`]). -/// -/// Epoch selection mirrors the real sequencer via [`L1OriginSelector`], -/// unless an L1 origin is pinned via [`pin_l1_origin`]. -/// -/// [`pin_l1_origin`]: L2Sequencer::pin_l1_origin -#[derive(Debug)] -pub struct L2Sequencer { - /// Production L1 origin selector. - origin_selector: L1OriginSelector, - /// Production attributes builder. - attributes_builder: StatefulAttributesBuilder, - /// Production engine client for block building. - engine_client: Arc, - /// Current unsafe L2 head. - head: L2BlockInfo, - /// Rollup configuration. - rollup_config: Arc, - /// Test account used for signing user transactions. - test_account: Arc>, - /// Shared registry of built L2 block hashes, keyed by block number. - block_hashes: SharedBlockHashRegistry, - /// Optional P2P handle for broadcasting unsafe blocks to a test transport. - supervised_p2p: Option, - /// Optional pinned L1 origin. When set, epoch selection is bypassed and - /// this block is used as the epoch for every subsequent L2 block built. - l1_origin_pin: Option, - /// Mutable L2 chain provider (for inserting new blocks/configs after each build). - l2_provider: ActionL2ChainProvider, - /// Optional conductor. When set, each build checks leadership via `leader()` and - /// commits the sealed payload via `commit_unsafe_payload()` before inserting. - conductor: Option>, - /// Optional signing key for gossip. When set, [`broadcast_unsafe_block`] computes the - /// real [`PayloadHash`] and signs with the production formula instead of using a zero - /// signature. - /// - /// [`broadcast_unsafe_block`]: L2Sequencer::broadcast_unsafe_block - unsafe_block_signer: Option, -} - -impl L2Sequencer { - /// Create a new sequencer using production components. - pub fn new( - head: L2BlockInfo, - origin_selector: L1OriginSelector, - attributes_builder: StatefulAttributesBuilder, - engine_client: Arc, - rollup_config: Arc, - l2_provider: ActionL2ChainProvider, - ) -> Self { - let test_account = Arc::new(Mutex::new(TestAccount::new(TEST_ACCOUNT_KEY))); - let block_hashes = engine_client.block_hash_registry(); - - Self { - origin_selector, - attributes_builder, - engine_client, - head, - rollup_config, - test_account, - block_hashes, - supervised_p2p: None, - l1_origin_pin: None, - l2_provider, - conductor: None, - unsafe_block_signer: None, - } - } - - /// Return the current unsafe L2 head. - pub const fn head(&self) -> L2BlockInfo { - self.head - } - - /// Return a shared handle to the sequencer's test account. - /// - /// External test code can use this to build signed transactions with - /// correct nonce tracking, independent of the sequencer. - pub fn test_account(&self) -> Arc> { - Arc::clone(&self.test_account) - } - - /// Return the sequencer's shared block-hash registry. - pub fn block_hash_registry(&self) -> SharedBlockHashRegistry { - self.block_hashes.clone() - } - - /// Return a clone of the sequencer's engine client. - /// - /// The derivation node can use this to share `executed_headers` with the - /// sequencer so blocks pre-built by the sequencer are recognised as - /// already-executed and not re-built from scratch during derivation. - pub fn engine_client(&self) -> Arc { - Arc::clone(&self.engine_client) - } - - /// Read a storage value from the latest committed state via the engine client. - /// - /// Accepts the slot as a `U256` for convenience. - /// Returns `U256::ZERO` if the account or slot does not exist. - pub fn storage_at( - &self, - address: alloy_primitives::Address, - slot: alloy_primitives::U256, - ) -> alloy_primitives::U256 { - self.engine_client.storage_at(address, slot) - } - - /// Check whether an account has non-empty code deployed via the engine client. - pub fn has_code(&self, address: alloy_primitives::Address) -> bool { - self.engine_client.has_code(address) - } - - /// Pin the L1 origin to the given block, bypassing automatic epoch advance. - /// - /// While pinned, every call to [`build_next_block_with_transactions`] uses `origin` - /// as the epoch regardless of timestamps. The sequencer number increments within - /// the same epoch until the pin is cleared. - /// - /// [`build_next_block_with_transactions`]: L2Sequencer::build_next_block_with_transactions - pub const fn pin_l1_origin(&mut self, origin: BlockInfo) { - self.l1_origin_pin = Some(origin); - } - - /// Clear the pinned L1 origin, restoring automatic epoch selection. - pub const fn clear_l1_origin_pin(&mut self) { - self.l1_origin_pin = None; - } - - /// Wire a [`SupervisedP2P`] handle to this sequencer. - /// - /// Once set, calling [`broadcast_unsafe_block`] delivers blocks to the - /// matching [`TestGossipTransport`] receiver. Use - /// [`ActionTestHarness::create_supervised_p2p`] to construct the pair and - /// wire it in a single step. - /// - /// [`broadcast_unsafe_block`]: L2Sequencer::broadcast_unsafe_block - /// [`ActionTestHarness::create_supervised_p2p`]: crate::ActionTestHarness::create_supervised_p2p - pub fn set_supervised_p2p(&mut self, p2p: SupervisedP2P) { - self.supervised_p2p = Some(p2p); - } - - /// Attach an unsafe block signing key to this sequencer. - /// - /// Once set, [`broadcast_unsafe_block`] computes the real [`PayloadHash`] - /// and signs it with the production formula: - /// `keccak256(domain || chain_id_padded || keccak256(SSZ(payload)))`. - /// - /// Wire the corresponding address to the receiving [`TestGossipTransport`] - /// via [`GossipTransport::set_block_signer`] and [`TestGossipTransport::set_chain_id`] - /// to activate end-to-end signature validation. - /// - /// [`broadcast_unsafe_block`]: L2Sequencer::broadcast_unsafe_block - /// [`GossipTransport::set_block_signer`]: base_consensus_node::GossipTransport::set_block_signer - /// [`TestGossipTransport::set_chain_id`]: crate::TestGossipTransport::set_chain_id - pub fn set_unsafe_block_signer(&mut self, key: PrivateKeySigner) { - self.unsafe_block_signer = Some(key); - } - - /// Return the address corresponding to the configured unsafe block signing key, if any. - pub fn unsafe_block_signer_address(&self) -> Option
{ - self.unsafe_block_signer.as_ref().map(|s| s.address()) - } - - /// Attach a conductor to this sequencer. - /// - /// Once set, every call to [`build_next_block_with_transactions`] first - /// checks leadership via [`Conductor::leader`] and, after sealing, - /// commits the payload via [`Conductor::commit_unsafe_payload`]. Build - /// attempts return [`L2SequencerError::NotLeader`] when leadership is - /// absent. - /// - /// Use [`TestConductorHandle::conductor`] to create a [`TestConductor`] - /// and pass it here. - /// - /// [`build_next_block_with_transactions`]: L2Sequencer::build_next_block_with_transactions - /// [`TestConductorHandle::conductor`]: crate::TestConductorHandle::conductor - /// [`TestConductor`]: crate::TestConductor - pub fn set_conductor(&mut self, conductor: Arc) { - self.conductor = Some(conductor); - } - - /// Broadcast `block` as a [`NetworkPayloadEnvelope`] to the wired - /// [`SupervisedP2P`] handle. - /// - /// A no-op when no handle has been set via [`set_supervised_p2p`]. - /// - /// When an unsafe block signing key is configured via - /// [`set_unsafe_block_signer`], the envelope is signed with the production - /// formula (`keccak256(domain || chain_id_padded || keccak256(SSZ(payload)))`). - /// Otherwise the envelope carries a zero signature, which passes through - /// transports that have no expected signer configured. - /// - /// [`set_supervised_p2p`]: L2Sequencer::set_supervised_p2p - /// [`set_unsafe_block_signer`]: L2Sequencer::set_unsafe_block_signer - pub fn broadcast_unsafe_block(&self, block: &BaseBlock) { - let Some(p2p) = &self.supervised_p2p else { return }; - let block_hash = block.header.hash_slow(); - let (execution_payload, _) = BaseExecutionPayload::from_block_unchecked(block_hash, block); - let parent_beacon_block_root = block.header.parent_beacon_block_root; - - let (signature, payload_hash) = self.unsafe_block_signer.as_ref().map_or_else( - || (Signature::new(U256::ZERO, U256::ZERO, false), PayloadHash(B256::ZERO)), - |signer| { - let envelope = BaseExecutionPayloadEnvelope { - execution_payload: execution_payload.clone(), - parent_beacon_block_root, - }; - let ph = envelope.payload_hash(); - let msg = ph.signature_message(self.rollup_config.l2_chain_id.id()); - let sig = signer.sign_hash_sync(&msg).expect("unsafe block signing must not fail"); - (sig, ph) - }, - ); - - p2p.send(NetworkPayloadEnvelope { - payload: execution_payload, - signature, - payload_hash, - parent_beacon_block_root, - }); - } - - /// Build the next L2 block containing no user transactions. - /// - /// Useful for simulating forced-empty blocks at the sequencer drift boundary. - /// - /// # Panics - /// - /// Panics if the block cannot be built (e.g. missing L1 block data). - pub async fn build_empty_block(&mut self) -> BaseBlock { - self.build_next_block_with_transactions(vec![]).await - } - - /// Build the next L2 block with a single transaction. - pub async fn build_next_block_with_single_transaction(&mut self) -> BaseBlock { - let tx = { - let mut account = self.test_account.lock().expect("test account lock poisoned"); - account.create_eip1559_tx(self.rollup_config.l2_chain_id.id()) - }; - self.build_next_block_with_transactions(vec![tx]).await - } - - /// Build `count` sequential L2 blocks with one user transaction each. - pub async fn build_next_blocks_with_single_transactions( - &mut self, - count: u64, - ) -> Vec { - let mut blocks = Vec::with_capacity(count as usize); - for _ in 0..count { - blocks.push(self.build_next_block_with_single_transaction().await); - } - blocks - } - - /// Build the next L2 block and advance the internal head. - /// - /// Returns a fully-formed [`BaseBlock`] containing the L1-info deposit and - /// any provided user transactions, built by the production engine. - /// - /// # Panics - /// - /// Panics if the block cannot be built (e.g. missing L1 block data or engine - /// execution failure). Use [`try_build_next_block_with_transactions`] if you need - /// to inspect the error. - /// - /// [`try_build_next_block_with_transactions`]: L2Sequencer::try_build_next_block_with_transactions - pub async fn build_next_block_with_transactions( - &mut self, - transactions: Vec, - ) -> BaseBlock { - self.try_build_next_block_with_transactions(transactions) - .await - .unwrap_or_else(|e| panic!("L2Sequencer::build_next_block failed: {e}")) - } - - /// Build the next L2 block, returning an error instead of panicking. - /// - /// Prefer [`build_next_block_with_transactions`] in test code; this method - /// exists for callers that need to inspect the failure reason. - /// - /// [`build_next_block_with_transactions`]: L2Sequencer::build_next_block_with_transactions - pub async fn try_build_next_block_with_transactions( - &mut self, - user_txs: Vec, - ) -> Result { - // 0. Conductor leadership check: refuse to build if this node is not the leader. - if let Some(conductor) = &self.conductor { - let is_leader = conductor.leader().await?; - if !is_leader { - return Err(L2SequencerError::NotLeader); - } - } - - // 1. Origin selection: use pinned origin if set, otherwise production L1OriginSelector. - let l1_origin = if let Some(pin) = self.l1_origin_pin { - pin - } else { - self.origin_selector - .next_l1_origin(self.head, false) - .await - .map_err(|e| L2SequencerError::OriginSelection(e.to_string()))? - }; - - // 2. Attribute construction via production StatefulAttributesBuilder. - let epoch = BlockNumHash { number: l1_origin.number, hash: l1_origin.hash }; - let mut attrs = self - .attributes_builder - .prepare_payload_attributes(self.head, epoch) - .await - .map_err(|e| L2SequencerError::Attributes(format!("{e}")))?; - - // 3. Inject user transactions (encoded as Bytes) after the deposit txs. - let encoded_user_txs: Vec = user_txs - .iter() - .map(|tx| { - let mut buf = Vec::new(); - tx.encode_2718(&mut buf); - Bytes::from(buf) - }) - .collect(); - if let Some(txs) = &mut attrs.transactions { - txs.extend(encoded_user_txs); - } - attrs.no_tx_pool = Some(true); - - // 4. Build via production engine client. - let attrs_with_parent = AttributesWithParent::new(attrs, self.head, None, false); - let payload_id = self - .engine_client - .start_build_block(attrs_with_parent.clone()) - .await - .map_err(|e| L2SequencerError::Engine(format!("start_build: {e}")))?; - - let envelope = self - .engine_client - .get_sealed_payload(payload_id, attrs_with_parent) - .await - .map_err(|e| L2SequencerError::Engine(format!("get_sealed: {e}")))?; - - // 5. Conductor commit: register the sealed payload before inserting. - // Map ConductorError::NotLeader to L2SequencerError::NotLeader so that - // callers get the same variant regardless of whether leadership was lost - // before the build started (pre-check) or between the check and commit - // (TOCTOU). - if let Some(conductor) = &self.conductor { - conductor.commit_unsafe_payload(&envelope).await.map_err(|e| match e { - ConductorError::NotLeader => L2SequencerError::NotLeader, - other => L2SequencerError::Conductor(other), - })?; - } - - // 6. Insert the block into the engine (updates canonical head). - self.engine_client - .insert_unsafe_payload(envelope.clone()) - .await - .map_err(|e| L2SequencerError::Engine(format!("insert: {e}")))?; - - // 7. Convert BaseExecutionPayload to BaseBlock. - // Use try_into_block_with_sidecar so PBBR and requests_hash are restored on the - // returned header. try_into_block() omits these fields, making hash_slow() return a - // different value than the sealed block hash. BatchEncoder::add_block tracks self.tip - // via block.header.hash_slow(), so missing sidecar fields cause block N+1's parent_hash - // (the canonical hash of block N) to not match self.tip, triggering - // ReorgError::ParentMismatch and resetting the encoder. - // - // V4 payloads (Isthmus+) require PraguePayloadFields with EMPTY_REQUESTS_HASH so that - // the reconstructed header's requests_hash = Some(EMPTY_REQUESTS_HASH) matches reth's - // canonical header. - let block_hash = envelope.execution_payload.as_v1().block_hash; - let pbbr = envelope.parent_beacon_block_root; - let sidecar = match &envelope.execution_payload { - BaseExecutionPayload::V4(_) => BaseExecutionPayloadSidecar::v4( - CancunPayloadFields { - parent_beacon_block_root: pbbr.unwrap_or_default(), - versioned_hashes: vec![], - }, - PraguePayloadFields::new(EMPTY_REQUESTS_HASH), - ), - _ => pbbr.map_or_else(BaseExecutionPayloadSidecar::default, |pbbr| { - BaseExecutionPayloadSidecar::v3(CancunPayloadFields { - parent_beacon_block_root: pbbr, - versioned_hashes: vec![], - }) - }), - }; - let block: BaseBlock = envelope - .execution_payload - .try_into_block_with_sidecar(&sidecar) - .map_err(|e| L2SequencerError::PayloadConversion(format!("{e}")))?; - - // 8. Compute seq_num and update head. - let seq_num = - if l1_origin.number == self.head.l1_origin.number { self.head.seq_num + 1 } else { 0 }; - let block_number = block.header.number; - let block_timestamp = block.header.timestamp; - - self.head = L2BlockInfo { - block_info: BlockInfo { - number: block_number, - timestamp: block_timestamp, - parent_hash: self.head.block_info.hash, - hash: block_hash, - }, - l1_origin: BlockNumHash { number: l1_origin.number, hash: l1_origin.hash }, - seq_num, - }; - - // 9. Update L2 provider state for next iteration. - self.l2_provider.insert_block(self.head); - // The system config is updated via the attributes builder's internal - // L2 chain provider when the epoch changes. For the sequencer's - // L2 provider copy, inherit the genesis config — the attributes - // builder reads the correct config from its own provider clone. - - Ok(block) - } -} diff --git a/actions/harness/src/lib.rs b/actions/harness/src/lib.rs index dd6a2bd7ae..cd1acda907 100644 --- a/actions/harness/src/lib.rs +++ b/actions/harness/src/lib.rs @@ -9,6 +9,12 @@ pub use action::{Action, L2BlockProvider}; mod conductor; pub use conductor::{ConductorState, TestConductor, TestConductorHandle}; +mod common; +pub use common::{ + ActionL2Source, BlockHashInner, SharedBlockHashRegistry, TEST_ACCOUNT_ADDRESS, + TEST_ACCOUNT_KEY, TestAccount, +}; + mod l1; pub use l1::{ ActionBlobProvider, ActionL1BlockFetcher, ActionL1ChainProvider, ActionL1FetcherError, L1Block, @@ -16,10 +22,11 @@ pub use l1::{ SharedL1Chain, UserDeposit, block_info_from, l1_block_to_rpc, }; -mod l2; -pub use l2::{ - ActionL2Source, BlockHashInner, L2Sequencer, L2SequencerError, SharedBlockHashRegistry, - TEST_ACCOUNT_ADDRESS, TEST_ACCOUNT_KEY, TestAccount, +mod sequencer; +pub use sequencer::{ + ActionConductor, ActionOriginSelector, ActionSequencerAttributesBuilder, + ActionSequencerEngineClient, ActionUnsafePayloadGossipClient, ExecutionPayloadConverter, + L2Sequencer, L2SequencerError, }; mod harness; diff --git a/actions/harness/src/sequencer/attributes.rs b/actions/harness/src/sequencer/attributes.rs new file mode 100644 index 0000000000..87a31220e5 --- /dev/null +++ b/actions/harness/src/sequencer/attributes.rs @@ -0,0 +1,60 @@ +use std::sync::{Arc, Mutex}; + +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::Bytes; +use async_trait::async_trait; +use base_common_consensus::BaseTxEnvelope; +use base_common_rpc_types_engine::BasePayloadAttributes; +use base_consensus_derive::{ + AttributesBuilder, PipelineError, PipelineResult, StatefulAttributesBuilder, +}; +use base_protocol::L2BlockInfo; + +use crate::{ActionL1ChainProvider, ActionL2ChainProvider}; + +/// Attributes builder adapter that injects one test-controlled transaction batch. +#[derive(Debug)] +pub struct ActionSequencerAttributesBuilder { + inner: StatefulAttributesBuilder, + user_txs: Arc>>>, +} + +impl ActionSequencerAttributesBuilder { + /// Create a new attributes adapter. + pub const fn new( + inner: StatefulAttributesBuilder, + user_txs: Arc>>>, + ) -> Self { + Self { inner, user_txs } + } +} + +#[async_trait] +impl AttributesBuilder for ActionSequencerAttributesBuilder { + async fn prepare_payload_attributes( + &mut self, + l2_parent: L2BlockInfo, + epoch: alloy_eips::BlockNumHash, + ) -> PipelineResult { + let mut attrs = self.inner.prepare_payload_attributes(l2_parent, epoch).await?; + let user_txs = self + .user_txs + .lock() + .expect("sequencer user tx queue lock poisoned") + .take() + .ok_or_else(|| PipelineError::NotEnoughData.temp())?; + let encoded_user_txs: Vec = user_txs + .into_iter() + .map(|tx| { + let mut buf = Vec::new(); + tx.encode_2718(&mut buf); + Bytes::from(buf) + }) + .collect(); + if !encoded_user_txs.is_empty() { + attrs.transactions.get_or_insert_with(Vec::new).extend(encoded_user_txs); + } + attrs.no_tx_pool = Some(true); + Ok(attrs) + } +} diff --git a/actions/harness/src/sequencer/conductor.rs b/actions/harness/src/sequencer/conductor.rs new file mode 100644 index 0000000000..a4aa5c3cff --- /dev/null +++ b/actions/harness/src/sequencer/conductor.rs @@ -0,0 +1,56 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; +use base_consensus_node::{Conductor, ConductorError}; + +/// Conductor adapter that allows the actor to own a cloneable conductor handle. +#[derive(Debug, Clone)] +pub struct ActionConductor { + inner: Arc>>>, +} + +impl ActionConductor { + /// Create a new conductor adapter. + pub fn new(inner: Arc>>>) -> Self { + Self { inner } + } +} + +#[async_trait] +impl Conductor for ActionConductor { + async fn leader(&self) -> Result { + let conductor = self.inner.lock().expect("conductor lock poisoned").clone(); + match conductor { + Some(conductor) => conductor.leader().await, + None => Ok(true), + } + } + + async fn active(&self) -> Result { + let conductor = self.inner.lock().expect("conductor lock poisoned").clone(); + match conductor { + Some(conductor) => conductor.active().await, + None => Ok(true), + } + } + + async fn commit_unsafe_payload( + &self, + payload: &BaseExecutionPayloadEnvelope, + ) -> Result<(), ConductorError> { + let conductor = self.inner.lock().expect("conductor lock poisoned").clone(); + match conductor { + Some(conductor) => conductor.commit_unsafe_payload(payload).await, + None => Ok(()), + } + } + + async fn override_leader(&self) -> Result<(), ConductorError> { + let conductor = self.inner.lock().expect("conductor lock poisoned").clone(); + match conductor { + Some(conductor) => conductor.override_leader().await, + None => Ok(()), + } + } +} diff --git a/actions/harness/src/sequencer/driver.rs b/actions/harness/src/sequencer/driver.rs new file mode 100644 index 0000000000..b45c8bbda3 --- /dev/null +++ b/actions/harness/src/sequencer/driver.rs @@ -0,0 +1,418 @@ +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use alloy_genesis::ChainConfig; +use alloy_primitives::{Address, B256, U256}; +use alloy_signer_local::PrivateKeySigner; +use base_common_consensus::{BaseBlock, BaseReceipt, BaseTxEnvelope}; +use base_common_genesis::RollupConfig; +use base_consensus_derive::StatefulAttributesBuilder; +use base_consensus_node::{ + Conductor, L1OriginSelector, NodeActor, PayloadBuilder, RecoveryModeGuard, SequencerActor, + SequencerActorError, SequencerAdminQuery, +}; +use base_consensus_rpc::SequencerAdminAPIError; +use base_protocol::{BlockInfo, L2BlockInfo}; +use tokio::{ + sync::{mpsc, oneshot}, + task::{JoinError, JoinHandle}, +}; +use tokio_util::sync::CancellationToken; + +use super::{ + ActionConductor, ActionOriginSelector, ActionSequencerAttributesBuilder, + ActionSequencerEngineClient, ActionUnsafePayloadGossipClient, ExecutionPayloadConverter, + L2SequencerError, +}; +use crate::{ + ActionEngineClient, ActionL1ChainProvider, ActionL2ChainProvider, SharedBlockHashRegistry, + SharedL1Chain, SupervisedP2P, TEST_ACCOUNT_KEY, TestAccount, +}; + +/// Builds real [`BaseBlock`]s for use in action tests using the production sequencer actor. +#[derive(Debug)] +pub struct L2Sequencer { + head: L2BlockInfo, + engine_client: Arc, + rollup_config: Arc, + l1_chain_config: Arc, + l1_chain: SharedL1Chain, + l2_provider: ActionL2ChainProvider, + test_account: Arc>, + block_hashes: SharedBlockHashRegistry, + supervised_p2p: Option, + l1_origin_pin: Arc>>, + conductor: Arc>>>, + unsafe_block_signer: Option, + user_txs: Arc>>>, + admin_api_tx: Option>, + inserted_rx: Option>, + cancellation_token: Option, + actor_task: Option>>, +} + +impl L2Sequencer { + /// Create a new sequencer using the production [`SequencerActor`]. + pub fn new( + head: L2BlockInfo, + engine_client: Arc, + rollup_config: Arc, + l1_chain_config: Arc, + l1_chain: SharedL1Chain, + l2_provider: ActionL2ChainProvider, + ) -> Self { + let test_account = Arc::new(Mutex::new(TestAccount::new(TEST_ACCOUNT_KEY))); + let block_hashes = engine_client.block_hash_registry(); + + Self { + head, + engine_client, + rollup_config, + l1_chain_config, + l1_chain, + l2_provider, + test_account, + block_hashes, + supervised_p2p: None, + l1_origin_pin: Arc::new(Mutex::new(None)), + conductor: Arc::new(Mutex::new(None)), + unsafe_block_signer: None, + user_txs: Arc::new(Mutex::new(None)), + admin_api_tx: None, + inserted_rx: None, + cancellation_token: None, + actor_task: None, + } + } + + /// Return the current unsafe L2 head. + pub const fn head(&self) -> L2BlockInfo { + self.head + } + + /// Return a shared handle to the sequencer's test account. + pub fn test_account(&self) -> Arc> { + Arc::clone(&self.test_account) + } + + /// Return the sequencer's shared block-hash registry. + pub fn block_hash_registry(&self) -> SharedBlockHashRegistry { + self.block_hashes.clone() + } + + /// Return a clone of the sequencer's engine client. + pub fn engine_client(&self) -> Arc { + Arc::clone(&self.engine_client) + } + + /// Read a storage value from the latest committed state via the engine client. + pub fn storage_at(&self, address: Address, slot: U256) -> U256 { + self.engine_client.storage_at(address, slot) + } + + /// Check whether an account has non-empty code deployed via the engine client. + pub fn has_code(&self, address: Address) -> bool { + self.engine_client.has_code(address) + } + + /// Return receipts for an executed block number. + pub fn receipts_at(&self, block_number: u64) -> Option> { + self.engine_client.receipts_at(block_number) + } + + /// Pin the L1 origin to the given block, bypassing automatic epoch advance. + pub fn pin_l1_origin(&mut self, origin: BlockInfo) { + *self.l1_origin_pin.lock().expect("L1 origin pin lock poisoned") = Some(origin); + } + + /// Clear the pinned L1 origin, restoring automatic epoch selection. + pub fn clear_l1_origin_pin(&mut self) { + *self.l1_origin_pin.lock().expect("L1 origin pin lock poisoned") = None; + } + + /// Wire a [`SupervisedP2P`] handle to this sequencer for explicit gossip injection. + pub fn set_supervised_p2p(&mut self, p2p: SupervisedP2P) { + self.supervised_p2p = Some(p2p); + } + + /// Attach an unsafe block signing key to this sequencer. + pub fn set_unsafe_block_signer(&mut self, key: PrivateKeySigner) { + self.unsafe_block_signer = Some(key); + } + + /// Return the address corresponding to the configured unsafe block signing key, if any. + pub fn unsafe_block_signer_address(&self) -> Option
{ + self.unsafe_block_signer.as_ref().map(|s| s.address()) + } + + /// Attach a conductor to this sequencer. + pub fn set_conductor(&mut self, conductor: Arc) { + *self.conductor.lock().expect("conductor lock poisoned") = Some(conductor); + } + + /// Broadcast `block` as a [`base_common_rpc_types_engine::NetworkPayloadEnvelope`] to the wired [`SupervisedP2P`] handle. + pub fn broadcast_unsafe_block(&self, block: &BaseBlock) { + let Some(p2p) = &self.supervised_p2p else { return }; + p2p.send(ExecutionPayloadConverter::network_envelope( + block, + self.unsafe_block_signer.as_ref(), + self.rollup_config.l2_chain_id.id(), + )); + } + + /// Build the next L2 block containing no user transactions. + pub async fn build_empty_block(&mut self) -> BaseBlock { + self.build_next_block_with_transactions(vec![]).await + } + + /// Build the next L2 block with a single transaction. + pub async fn build_next_block_with_single_transaction(&mut self) -> BaseBlock { + let tx = { + let mut account = self.test_account.lock().expect("test account lock poisoned"); + account.create_eip1559_tx(self.rollup_config.l2_chain_id.id()) + }; + self.build_next_block_with_transactions(vec![tx]).await + } + + /// Build `count` sequential L2 blocks with one user transaction each. + pub async fn build_next_blocks_with_single_transactions( + &mut self, + count: u64, + ) -> Vec { + let mut blocks = Vec::with_capacity(count as usize); + for _ in 0..count { + blocks.push(self.build_next_block_with_single_transaction().await); + } + blocks + } + + /// Build the next L2 block and advance the internal head. + pub async fn build_next_block_with_transactions( + &mut self, + transactions: Vec, + ) -> BaseBlock { + self.try_build_next_block_with_transactions(transactions) + .await + .unwrap_or_else(|e| panic!("L2Sequencer::build_next_block failed: {e}")) + } + + /// Build the next L2 block, returning an error instead of panicking. + pub async fn try_build_next_block_with_transactions( + &mut self, + user_txs: Vec, + ) -> Result { + if !self.conductor_leader().await? { + return Err(L2SequencerError::NotLeader); + } + + self.ensure_actor_started().await?; + self.queue_user_txs(user_txs)?; + if let Err(err) = self.start_sequencer().await { + self.clear_queued_user_txs(); + return Err(err); + } + + let (block, inserted_head) = match self.wait_for_inserted_block().await { + Ok(inserted) => inserted, + Err(err) => { + self.clear_queued_user_txs(); + let _ = self.stop_sequencer(self.head.block_info.hash).await; + return Err(err); + } + }; + + self.head = inserted_head; + self.l2_provider.insert_block(inserted_head); + self.l2_provider.insert_base_block(inserted_head.block_info.number, block.clone()); + self.stop_sequencer(inserted_head.block_info.hash).await?; + + Ok(block) + } + + /// Start the production actor task if it has not been started yet. + pub async fn ensure_actor_started(&mut self) -> Result<(), L2SequencerError> { + if let Some(actor_task) = &self.actor_task { + if actor_task.is_finished() { + let actor_task = self.actor_task.take().expect("actor task checked above"); + return Err(Self::actor_join_error(actor_task.await)); + } + return Ok(()); + } + + let attrs_builder = StatefulAttributesBuilder::new( + Arc::clone(&self.rollup_config), + Arc::clone(&self.l1_chain_config), + self.l2_provider.clone(), + ActionL1ChainProvider::new(self.l1_chain.clone()), + ); + let attrs_builder = + ActionSequencerAttributesBuilder::new(attrs_builder, Arc::clone(&self.user_txs)); + let origin_selector = + L1OriginSelector::new(Arc::clone(&self.rollup_config), self.l1_chain.clone()); + let origin_selector = + ActionOriginSelector::new(origin_selector, Arc::clone(&self.l1_origin_pin)); + + let (inserted_tx, inserted_rx) = mpsc::channel(8); + let engine_client = Arc::new(ActionSequencerEngineClient::new( + Arc::clone(&self.engine_client), + inserted_tx, + )); + let builder = PayloadBuilder { + attributes_builder: attrs_builder, + engine_client: Arc::clone(&engine_client), + origin_selector, + recovery_mode: RecoveryModeGuard::new(false), + rollup_config: Arc::clone(&self.rollup_config), + }; + + let (admin_api_tx, admin_api_rx) = mpsc::channel(8); + let cancellation_token = CancellationToken::new(); + let actor = SequencerActor { + admin_api_rx, + builder, + cancellation_token: cancellation_token.clone(), + conductor: Some(ActionConductor::new(Arc::clone(&self.conductor))), + engine_client, + is_active: false, + recovery_mode: RecoveryModeGuard::new(false), + rollup_config: self.actor_rollup_config(), + unsafe_payload_gossip_client: ActionUnsafePayloadGossipClient, + sealer: None, + pending_stop: None, + }; + + self.admin_api_tx = Some(admin_api_tx); + self.inserted_rx = Some(inserted_rx); + self.cancellation_token = Some(cancellation_token); + self.actor_task = Some(tokio::spawn(async move { actor.start(()).await })); + Ok(()) + } + + /// Return a rollup config suitable for the actor scheduler. + pub fn actor_rollup_config(&self) -> Arc { + // Action tests explicitly ask the actor for one block at a time. Keep + // the real config for attributes/origin selection and use a short + // cadence only for the actor scheduler's private copy, so tests with + // large L2 block times do not wait for wall-clock production slots. + if self.rollup_config.block_time != 1 { + let mut config = (*self.rollup_config).clone(); + config.block_time = 1; + Arc::new(config) + } else { + Arc::clone(&self.rollup_config) + } + } + + /// Return true when this sequencer can act as conductor leader. + pub async fn conductor_leader(&self) -> Result { + let conductor = self.conductor.lock().expect("conductor lock poisoned").clone(); + match conductor { + Some(conductor) => Ok(conductor.leader().await?), + None => Ok(true), + } + } + + /// Queue the next harness-controlled transaction batch for the actor. + pub fn queue_user_txs(&self, user_txs: Vec) -> Result<(), L2SequencerError> { + let mut queued = self.user_txs.lock().expect("sequencer user tx queue lock poisoned"); + if queued.is_some() { + return Err(L2SequencerError::Admin( + "sequencer already has a queued transaction batch".to_string(), + )); + } + *queued = Some(user_txs); + Ok(()) + } + + /// Clear any queued transaction batch. + pub fn clear_queued_user_txs(&self) { + *self.user_txs.lock().expect("sequencer user tx queue lock poisoned") = None; + } + + /// Ask the production actor to start sequencing from the current head. + pub async fn start_sequencer(&self) -> Result<(), L2SequencerError> { + let (tx, rx) = oneshot::channel(); + self.admin_api_tx()? + .send(SequencerAdminQuery::StartSequencer(self.head.block_info.hash, tx)) + .await + .map_err(|_| L2SequencerError::Admin("sequencer admin channel closed".to_string()))?; + match rx.await.map_err(|_| { + L2SequencerError::Admin("sequencer start response channel closed".to_string()) + })? { + Ok(()) => Ok(()), + Err(SequencerAdminAPIError::NotLeader) => Err(L2SequencerError::NotLeader), + Err(err) => Err(L2SequencerError::Admin(err.to_string())), + } + } + + /// Ask the production actor to stop sequencing after the requested block is inserted. + pub async fn stop_sequencer(&self, expected_head: B256) -> Result<(), L2SequencerError> { + let (tx, rx) = oneshot::channel(); + self.admin_api_tx()? + .send(SequencerAdminQuery::StopSequencer(tx)) + .await + .map_err(|_| L2SequencerError::Admin("sequencer admin channel closed".to_string()))?; + let stopped_head = rx + .await + .map_err(|_| { + L2SequencerError::Admin("sequencer stop response channel closed".to_string()) + })? + .map_err(|err| L2SequencerError::Admin(err.to_string()))?; + if stopped_head != expected_head { + return Err(L2SequencerError::Admin(format!( + "sequencer stopped at {stopped_head}, expected {expected_head}", + ))); + } + Ok(()) + } + + /// Wait for the actor to insert one block. + pub async fn wait_for_inserted_block( + &mut self, + ) -> Result<(BaseBlock, L2BlockInfo), L2SequencerError> { + let inserted_rx = self.inserted_rx.as_mut().ok_or_else(|| { + L2SequencerError::Admin("sequencer inserted-block channel not initialized".to_string()) + })?; + let sleep = tokio::time::sleep(Duration::from_secs(10)); + tokio::pin!(sleep); + + tokio::select! { + biased; + inserted = inserted_rx.recv() => { + inserted.ok_or(L2SequencerError::InsertChannelClosed) + } + _ = &mut sleep => Err(L2SequencerError::Timeout), + } + } + + /// Return the actor admin channel. + pub fn admin_api_tx(&self) -> Result<&mpsc::Sender, L2SequencerError> { + self.admin_api_tx.as_ref().ok_or_else(|| { + L2SequencerError::Admin("sequencer admin channel not initialized".to_string()) + }) + } + + /// Convert an actor task join result into [`L2SequencerError`]. + pub fn actor_join_error( + joined: Result, JoinError>, + ) -> L2SequencerError { + match joined { + Ok(Ok(())) => L2SequencerError::InsertChannelClosed, + Ok(Err(err)) => L2SequencerError::Actor(err.to_string()), + Err(err) => L2SequencerError::Actor(err.to_string()), + } + } +} + +impl Drop for L2Sequencer { + fn drop(&mut self) { + if let Some(cancellation_token) = &self.cancellation_token { + cancellation_token.cancel(); + } + if let Some(actor_task) = &self.actor_task { + actor_task.abort(); + } + } +} diff --git a/actions/harness/src/sequencer/engine_client.rs b/actions/harness/src/sequencer/engine_client.rs new file mode 100644 index 0000000000..422a5577e4 --- /dev/null +++ b/actions/harness/src/sequencer/engine_client.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use alloy_rpc_types_engine::PayloadId; +use async_trait::async_trait; +use base_common_consensus::BaseBlock; +use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; +use base_consensus_node::SequencerEngineClient; +use base_protocol::{AttributesWithParent, L2BlockInfo}; +use tokio::sync::mpsc; + +use super::ExecutionPayloadConverter; +use crate::ActionEngineClient; + +/// Sequencer engine client adapter that reports inserted blocks back to the harness driver. +#[derive(Debug, Clone)] +pub struct ActionSequencerEngineClient { + inner: Arc, + inserted_tx: mpsc::Sender<(BaseBlock, L2BlockInfo)>, +} + +impl ActionSequencerEngineClient { + /// Create a new engine client adapter. + pub const fn new( + inner: Arc, + inserted_tx: mpsc::Sender<(BaseBlock, L2BlockInfo)>, + ) -> Self { + Self { inner, inserted_tx } + } +} + +#[async_trait] +impl SequencerEngineClient for ActionSequencerEngineClient { + async fn reset_engine_forkchoice(&self) -> Result<(), base_consensus_node::EngineClientError> { + self.inner.reset_engine_forkchoice().await + } + + async fn start_build_block( + &self, + attributes: AttributesWithParent, + ) -> Result { + self.inner.start_build_block(attributes).await + } + + async fn get_sealed_payload( + &self, + payload_id: PayloadId, + attributes: AttributesWithParent, + ) -> Result { + self.inner.get_sealed_payload(payload_id, attributes).await + } + + async fn insert_unsafe_payload( + &self, + payload: BaseExecutionPayloadEnvelope, + ) -> Result { + let block = ExecutionPayloadConverter::block_from_envelope(&payload) + .map_err(|e| base_consensus_node::EngineClientError::ResponseError(e.to_string()))?; + let inserted_head = self.inner.insert_unsafe_payload(payload).await?; + let _ = self.inserted_tx.send((block, inserted_head)).await; + Ok(inserted_head) + } + + async fn get_unsafe_head(&self) -> Result { + self.inner.get_unsafe_head().await + } +} diff --git a/actions/harness/src/sequencer/error.rs b/actions/harness/src/sequencer/error.rs new file mode 100644 index 0000000000..82b09161a6 --- /dev/null +++ b/actions/harness/src/sequencer/error.rs @@ -0,0 +1,48 @@ +use base_consensus_node::ConductorError; + +/// Error type returned by [`crate::L2Sequencer`]. +#[derive(Debug, thiserror::Error)] +pub enum L2SequencerError { + /// The L1 block required for the current epoch is missing from the chain. + #[error("L1 block {0} not found in shared chain")] + MissingL1Block(u64), + /// Failed to build the L1 info deposit transaction. + #[error("failed to build L1 info deposit: {0}")] + L1Info(#[from] base_protocol::BlockInfoError), + /// Transaction signing failed. + #[error("signing failed: {0}")] + Signing(#[from] alloy_signer::Error), + /// EVM execution failed. + #[error("EVM execution failed: {0}")] + Evm(String), + /// Origin selection failed. + #[error("origin selection failed: {0}")] + OriginSelection(String), + /// Attributes construction failed. + #[error("attributes construction failed: {0}")] + Attributes(String), + /// Engine client error. + #[error("engine client error: {0}")] + Engine(String), + /// Payload conversion error. + #[error("payload conversion error: {0}")] + PayloadConversion(String), + /// Conductor rejected the block (e.g. not leader, RPC error). + #[error("conductor error: {0}")] + Conductor(#[from] ConductorError), + /// This sequencer is not the conductor leader and cannot build blocks. + #[error("sequencer is not the conductor leader")] + NotLeader, + /// The production sequencer actor failed. + #[error("sequencer actor error: {0}")] + Actor(String), + /// The production sequencer actor did not insert a block before the timeout. + #[error("sequencer actor timed out waiting for inserted block")] + Timeout, + /// The inserted-block notification channel closed before a block was produced. + #[error("sequencer actor exited before inserting a block")] + InsertChannelClosed, + /// The sequencer actor admin API failed. + #[error("sequencer actor admin error: {0}")] + Admin(String), +} diff --git a/actions/harness/src/sequencer/gossip.rs b/actions/harness/src/sequencer/gossip.rs new file mode 100644 index 0000000000..9ab37fd1e7 --- /dev/null +++ b/actions/harness/src/sequencer/gossip.rs @@ -0,0 +1,17 @@ +use async_trait::async_trait; +use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; +use base_consensus_node::{UnsafePayloadGossipClient, UnsafePayloadGossipClientError}; + +/// No-op gossip adapter used by the actor; tests still inject gossip explicitly. +#[derive(Debug, Clone, Default)] +pub struct ActionUnsafePayloadGossipClient; + +#[async_trait] +impl UnsafePayloadGossipClient for ActionUnsafePayloadGossipClient { + async fn schedule_execution_payload_gossip( + &self, + _payload: BaseExecutionPayloadEnvelope, + ) -> Result<(), UnsafePayloadGossipClientError> { + Ok(()) + } +} diff --git a/actions/harness/src/sequencer/mod.rs b/actions/harness/src/sequencer/mod.rs new file mode 100644 index 0000000000..cee40a4b10 --- /dev/null +++ b/actions/harness/src/sequencer/mod.rs @@ -0,0 +1,25 @@ +//! Sequencer harness adapters and driver types. + +mod attributes; +pub use attributes::ActionSequencerAttributesBuilder; + +mod conductor; +pub use conductor::ActionConductor; + +mod driver; +pub use driver::L2Sequencer; + +mod engine_client; +pub use engine_client::ActionSequencerEngineClient; + +mod error; +pub use error::L2SequencerError; + +mod gossip; +pub use gossip::ActionUnsafePayloadGossipClient; + +mod origin; +pub use origin::ActionOriginSelector; + +mod payload; +pub use payload::ExecutionPayloadConverter; diff --git a/actions/harness/src/sequencer/origin.rs b/actions/harness/src/sequencer/origin.rs new file mode 100644 index 0000000000..200dadd47a --- /dev/null +++ b/actions/harness/src/sequencer/origin.rs @@ -0,0 +1,38 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use base_consensus_node::{L1OriginSelector, OriginSelector}; +use base_protocol::{BlockInfo, L2BlockInfo}; + +use crate::SharedL1Chain; + +/// L1 origin selector adapter that supports test-controlled origin pinning. +#[derive(Debug)] +pub struct ActionOriginSelector { + inner: L1OriginSelector, + pin: Arc>>, +} + +impl ActionOriginSelector { + /// Create a new origin selector adapter. + pub const fn new( + inner: L1OriginSelector, + pin: Arc>>, + ) -> Self { + Self { inner, pin } + } +} + +#[async_trait] +impl OriginSelector for ActionOriginSelector { + async fn next_l1_origin( + &mut self, + unsafe_head: L2BlockInfo, + is_recovery_mode: bool, + ) -> Result { + if let Some(pin) = *self.pin.lock().expect("L1 origin pin lock poisoned") { + return Ok(pin); + } + self.inner.next_l1_origin(unsafe_head, is_recovery_mode).await + } +} diff --git a/actions/harness/src/sequencer/payload.rs b/actions/harness/src/sequencer/payload.rs new file mode 100644 index 0000000000..96d3d6bf6a --- /dev/null +++ b/actions/harness/src/sequencer/payload.rs @@ -0,0 +1,77 @@ +use alloy_eips::eip7685::EMPTY_REQUESTS_HASH; +use alloy_primitives::{B256, Signature, U256}; +use alloy_rpc_types_engine::{CancunPayloadFields, PraguePayloadFields}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use base_common_consensus::BaseBlock; +use base_common_rpc_types_engine::{ + BaseExecutionPayload, BaseExecutionPayloadEnvelope, BaseExecutionPayloadSidecar, + NetworkPayloadEnvelope, PayloadHash, +}; + +use super::L2SequencerError; + +/// Converts between execution payload envelopes and action-harness block/gossip types. +#[derive(Debug)] +pub struct ExecutionPayloadConverter; + +impl ExecutionPayloadConverter { + /// Convert a sealed execution payload envelope into a [`BaseBlock`]. + pub fn block_from_envelope( + envelope: &BaseExecutionPayloadEnvelope, + ) -> Result { + let pbbr = envelope.parent_beacon_block_root; + let sidecar = match &envelope.execution_payload { + BaseExecutionPayload::V4(_) => BaseExecutionPayloadSidecar::v4( + CancunPayloadFields { + parent_beacon_block_root: pbbr.unwrap_or_default(), + versioned_hashes: vec![], + }, + PraguePayloadFields::new(EMPTY_REQUESTS_HASH), + ), + _ => pbbr.map_or_else(BaseExecutionPayloadSidecar::default, |pbbr| { + BaseExecutionPayloadSidecar::v3(CancunPayloadFields { + parent_beacon_block_root: pbbr, + versioned_hashes: vec![], + }) + }), + }; + envelope + .execution_payload + .clone() + .try_into_block_with_sidecar(&sidecar) + .map_err(|e| L2SequencerError::PayloadConversion(format!("{e}"))) + } + + /// Convert a [`BaseBlock`] into a gossip network envelope, signing when a key is supplied. + pub fn network_envelope( + block: &BaseBlock, + signer: Option<&PrivateKeySigner>, + chain_id: u64, + ) -> NetworkPayloadEnvelope { + let block_hash = block.header.hash_slow(); + let (execution_payload, _) = BaseExecutionPayload::from_block_unchecked(block_hash, block); + let parent_beacon_block_root = block.header.parent_beacon_block_root; + + let (signature, payload_hash) = signer.map_or_else( + || (Signature::new(U256::ZERO, U256::ZERO, false), PayloadHash(B256::ZERO)), + |signer| { + let envelope = BaseExecutionPayloadEnvelope { + execution_payload: execution_payload.clone(), + parent_beacon_block_root, + }; + let ph = envelope.payload_hash(); + let msg = ph.signature_message(chain_id); + let sig = signer.sign_hash_sync(&msg).expect("unsafe block signing must not fail"); + (sig, ph) + }, + ); + + NetworkPayloadEnvelope { + payload: execution_payload, + signature, + payload_hash, + parent_beacon_block_root, + } + } +} diff --git a/actions/harness/src/test_rollup_config.rs b/actions/harness/src/test_rollup_config.rs index 197031aa39..811575bfcc 100644 --- a/actions/harness/src/test_rollup_config.rs +++ b/actions/harness/src/test_rollup_config.rs @@ -1,5 +1,5 @@ use alloy_primitives::Address; -use base_common_chains::Registry; +use base_common_chains::{ChainConfig, rollup_config}; use base_common_genesis::{HardForkConfig, RollupConfig}; use crate::BatcherConfig; @@ -11,9 +11,9 @@ pub struct TestRollupConfigBuilder { } impl TestRollupConfigBuilder { - /// Returns the Base mainnet [`RollupConfig`] from the chain registry. - pub fn mainnet() -> &'static RollupConfig { - Registry::rollup_config(8453).expect("Base mainnet config must exist in the registry") + /// Returns the Base mainnet [`RollupConfig`] from [`ChainConfig::MAINNET`]. + pub fn mainnet() -> RollupConfig { + rollup_config!(ChainConfig::MAINNET) } /// Starts from the Base mainnet config and applies the common harness overrides. @@ -22,9 +22,7 @@ impl TestRollupConfigBuilder { /// addresses, zeroing genesis for the in-memory L1 miner, and activating the /// Canyon-through-Fjord path from genesis. pub fn base_mainnet(batcher: &BatcherConfig) -> Self { - let mut config = Registry::rollup_config(8453) - .expect("Base mainnet config must exist in the registry") - .clone(); + let mut config = rollup_config!(ChainConfig::MAINNET); config.batch_inbox_address = batcher.inbox_address; config diff --git a/actions/harness/tests/azul/clz.rs b/actions/harness/tests/azul/clz.rs index 3ab89aef28..61783e3809 100644 --- a/actions/harness/tests/azul/clz.rs +++ b/actions/harness/tests/azul/clz.rs @@ -1,11 +1,8 @@ //! CLZ opcode activation test across the Base Azul boundary. use alloy_primitives::{Bytes, TxKind, U256, hex}; -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, - TEST_ACCOUNT_ADDRESS, TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; + +use crate::env::AzulTestEnv; /// CLZ probe-contract init code. /// @@ -42,86 +39,47 @@ const CLZ_EXPECTED_GAS_DELTA: u64 = 12; #[tokio::test] async fn azul_clz_op_code() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..Default::default() - }; - - // All forks through Jovian at genesis; Base Azul at ts=6 (block 3). - let base_azul_time = 6u64; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) - .through_isthmus() - .with_jovian_at(0) - .with_azul_at(base_azul_time) - .build(); - let chain_id = rollup_cfg.l2_chain_id.id(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let account = builder.test_account(); - let contract_addr = TEST_ACCOUNT_ADDRESS.create(0); + let mut env = AzulTestEnv::new(); + let contract_addr = env.first_contract_address(); // ── Block 1 (ts=2, pre-fork): deploy CLZ probe contract ────────── - let deploy_tx = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Create, - Bytes::from_static(&CLZ_INIT_CODE), - U256::ZERO, - 100_000, - ) - }; - let block1 = builder.build_next_block_with_transactions(vec![deploy_tx]).await; + let deploy_tx = + env.create_tx(TxKind::Create, Bytes::from_static(&CLZ_INIT_CODE), U256::ZERO, 100_000); + let block1 = env.sequencer.build_next_block_with_transactions(vec![deploy_tx]).await; // Verify the contract code was deployed. - assert!(builder.has_code(contract_addr), "deployed contract must have non-empty code"); + assert!(env.sequencer.has_code(contract_addr), "deployed contract must have non-empty code"); // ── Block 2 (ts=4, pre-fork): call CLZ(1) — must abort ────────── - let call_pre = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from_static(&CLZ_INPUT_ONE), - U256::ZERO, - 100_000, - ) - }; - let block2 = builder.build_next_block_with_transactions(vec![call_pre]).await; + let call_pre = env.create_tx( + TxKind::Call(contract_addr), + Bytes::from_static(&CLZ_INPUT_ONE), + U256::ZERO, + 100_000, + ); + let block2 = env.sequencer.build_next_block_with_transactions(vec![call_pre]).await; // Sentinel slot must remain zero — CLZ aborted before any SSTORE ran. assert_eq!( - builder.storage_at(contract_addr, CLZ_SENTINEL_SLOT), + env.sequencer.storage_at(contract_addr, CLZ_SENTINEL_SLOT), U256::ZERO, "sentinel must be zero: CLZ should abort as invalid opcode pre-fork" ); // ── Block 3 (ts=6, post-fork): call CLZ(1) — must succeed ─────── - let call_one = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from_static(&CLZ_INPUT_ONE), - U256::ZERO, - 100_000, - ) - }; - let block3 = builder.build_next_block_with_transactions(vec![call_one]).await; + let call_one = env.create_tx( + TxKind::Call(contract_addr), + Bytes::from_static(&CLZ_INPUT_ONE), + U256::ZERO, + 100_000, + ); + let block3 = env.sequencer.build_next_block_with_transactions(vec![call_one]).await; // Sentinel must now be 1 (CLZ completed), result slot must be 255. { - let sentinel = builder.storage_at(contract_addr, CLZ_SENTINEL_SLOT); - let result = builder.storage_at(contract_addr, CLZ_RESULT_SLOT); - let gas_delta = builder.storage_at(contract_addr, CLZ_GAS_DELTA_SLOT); + let sentinel = env.sequencer.storage_at(contract_addr, CLZ_SENTINEL_SLOT); + let result = env.sequencer.storage_at(contract_addr, CLZ_RESULT_SLOT); + let gas_delta = env.sequencer.storage_at(contract_addr, CLZ_GAS_DELTA_SLOT); assert_eq!(sentinel, U256::from(1), "sentinel must be 1 after successful CLZ"); assert_eq!(result, U256::from(255), "CLZ(1) must equal 255"); assert_eq!( @@ -132,22 +90,18 @@ async fn azul_clz_op_code() { } // ── Block 4 (ts=8, post-fork): call CLZ(0x8000…0) — result = 0 ── - let call_high = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from_static(&CLZ_INPUT_HIGH_BIT), - U256::ZERO, - 100_000, - ) - }; - let block4 = builder.build_next_block_with_transactions(vec![call_high]).await; + let call_high = env.create_tx( + TxKind::Call(contract_addr), + Bytes::from_static(&CLZ_INPUT_HIGH_BIT), + U256::ZERO, + 100_000, + ); + let block4 = env.sequencer.build_next_block_with_transactions(vec![call_high]).await; { - let sentinel = builder.storage_at(contract_addr, CLZ_SENTINEL_SLOT); - let result = builder.storage_at(contract_addr, CLZ_RESULT_SLOT); - let gas_delta = builder.storage_at(contract_addr, CLZ_GAS_DELTA_SLOT); + let sentinel = env.sequencer.storage_at(contract_addr, CLZ_SENTINEL_SLOT); + let result = env.sequencer.storage_at(contract_addr, CLZ_RESULT_SLOT); + let gas_delta = env.sequencer.storage_at(contract_addr, CLZ_GAS_DELTA_SLOT); assert_eq!(sentinel, U256::from(1), "sentinel must remain 1"); assert_eq!(result, U256::ZERO, "CLZ(0x8000…0) must equal 0"); assert_eq!( @@ -158,20 +112,5 @@ async fn azul_clz_op_code() { } // ── Batch and derive all 4 blocks ──────────────────────────────── - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - node.initialize().await; - - for (block, i) in [(block1, 1u64), (block2, 2), (block3, 3), (block4, 4)] { - batcher.push_block(block); - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - let derived = node.run_until_idle().await; - assert_eq!(derived, 1, "L1 block {i} should derive exactly one L2 block"); - } - - assert_eq!( - node.l2_safe().block_info.number, - 4, - "all 4 L2 blocks must derive through the Base V1 boundary" - ); + env.derive_blocks([(block1, 1), (block2, 2), (block3, 3), (block4, 4)], 4, "Base V1").await; } diff --git a/actions/harness/tests/azul/env.rs b/actions/harness/tests/azul/env.rs new file mode 100644 index 0000000000..66339aa148 --- /dev/null +++ b/actions/harness/tests/azul/env.rs @@ -0,0 +1,102 @@ +//! Shared test environment for Base Azul action tests. + +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, L2Sequencer, + SharedL1Chain, TEST_ACCOUNT_ADDRESS, TestRollupConfigBuilder, TestRollupNode, VerifierPipeline, +}; +use base_batcher_encoder::{DaType, EncoderConfig}; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; + +/// Test environment preconfigured to cross the Base Azul activation at L2 block 3. +pub(crate) struct AzulTestEnv { + /// Sequencer used to build probe deployment and call blocks. + pub(crate) sequencer: L2Sequencer, + harness: ActionTestHarness, + batcher_cfg: BatcherConfig, + node: TestRollupNode, + chain: SharedL1Chain, + chain_id: u64, +} + +impl AzulTestEnv { + /// Creates an environment with all forks through Jovian active at genesis + /// and Base Azul active at timestamp 6. + pub(crate) fn new() -> Self { + let batcher_cfg = BatcherConfig { + encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, + ..Default::default() + }; + + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) + .through_isthmus() + .with_jovian_at(0) + .with_azul_at(6) + .build(); + let chain_id = rollup_cfg.l2_chain_id.id(); + let harness = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + let l1_chain = SharedL1Chain::from_blocks(harness.l1.chain().to_vec()); + let mut sequencer = harness.create_l2_sequencer(l1_chain); + + let (node, chain) = harness.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(harness.l1.chain().to_vec()), + ); + + Self { sequencer, harness, batcher_cfg, node, chain, chain_id } + } + + /// Returns the address created by the first test-account deployment. + pub(crate) fn first_contract_address(&self) -> Address { + TEST_ACCOUNT_ADDRESS.create(0) + } + + /// Creates and signs a test-account transaction. + pub(crate) fn create_tx( + &self, + to: TxKind, + input: Bytes, + value: U256, + gas_limit: u64, + ) -> BaseTxEnvelope { + let account = self.sequencer.test_account(); + let mut account = account.lock().expect("test account lock"); + account.create_tx(self.chain_id, to, input, value, gas_limit) + } + + /// Batches the supplied L2 blocks, derives each one, and asserts the final safe head. + pub(crate) async fn derive_blocks( + &mut self, + blocks: [(BaseBlock, u64); N], + expected_safe_head: u64, + boundary: &str, + ) { + let mut batcher = Batcher::new( + ActionL2Source::new(), + &self.harness.rollup_config, + self.batcher_cfg.clone(), + ); + self.node.initialize().await; + + for (block, i) in blocks { + batcher.push_block(block); + batcher.advance(&mut self.harness.l1).await; + self.chain.push(self.harness.l1.tip().clone()); + let derived = self.node.run_until_idle().await; + assert_eq!(derived, 1, "L1 block {i} should derive exactly one L2 block"); + } + + assert_eq!( + self.node.l2_safe_number(), + expected_safe_head, + "all {expected_safe_head} L2 blocks must derive through the {boundary} boundary" + ); + } +} + +impl Default for AzulTestEnv { + fn default() -> Self { + Self::new() + } +} diff --git a/actions/harness/tests/azul/main.rs b/actions/harness/tests/azul/main.rs index e19d687cf4..6a5bd1c700 100644 --- a/actions/harness/tests/azul/main.rs +++ b/actions/harness/tests/azul/main.rs @@ -2,5 +2,6 @@ mod clz; mod derivation; +mod env; mod modexp; mod p256; diff --git a/actions/harness/tests/azul/modexp.rs b/actions/harness/tests/azul/modexp.rs index 59798d0286..492b00da90 100644 --- a/actions/harness/tests/azul/modexp.rs +++ b/actions/harness/tests/azul/modexp.rs @@ -1,11 +1,8 @@ //! MODEXP precompile tests across the Base Azul boundary. use alloy_primitives::{Bytes, TxKind, U256, hex}; -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, - TEST_ACCOUNT_ADDRESS, TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; + +use crate::env::AzulTestEnv; // ─── MODEXP probe contract ────────────────────────────────────────── // @@ -74,87 +71,48 @@ impl ModexpInput { /// Pre-fork the oversized call succeeds; post-fork it fails. #[tokio::test] async fn azul_modexp_upper_bound() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..Default::default() - }; - - // Base Azul activates at ts=6 (block 3). - let base_azul_time = 6u64; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) - .through_isthmus() - .with_jovian_at(0) - .with_azul_at(base_azul_time) - .build(); - let chain_id = rollup_cfg.l2_chain_id.id(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let account = builder.test_account(); - let contract_addr = TEST_ACCOUNT_ADDRESS.create(0); + let mut env = AzulTestEnv::new(); + let contract_addr = env.first_contract_address(); // ── Block 1 (ts=2, pre-fork): deploy MODEXP probe contract ────── - let deploy_tx = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Create, - Bytes::from_static(&MODEXP_INIT_CODE), - U256::ZERO, - 100_000, - ) - }; - let block1 = builder.build_next_block_with_transactions(vec![deploy_tx]).await; + let deploy_tx = + env.create_tx(TxKind::Create, Bytes::from_static(&MODEXP_INIT_CODE), U256::ZERO, 100_000); + let block1 = env.sequencer.build_next_block_with_transactions(vec![deploy_tx]).await; - assert!(builder.has_code(contract_addr), "deployed contract must have non-empty code"); + assert!(env.sequencer.has_code(contract_addr), "deployed contract must have non-empty code"); // Oversized input: base_len = 1025 (> 1024-byte EIP-7823 limit). let oversized_input = ModexpInput::build(&vec![0u8; 1025], &[], &[2]); // ── Block 2 (ts=4, pre-fork): call MODEXP with oversized input ─── - let call_pre = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from(oversized_input.clone()), - U256::ZERO, - 1_000_000, - ) - }; - let block2 = builder.build_next_block_with_transactions(vec![call_pre]).await; + let call_pre = env.create_tx( + TxKind::Call(contract_addr), + Bytes::from(oversized_input.clone()), + U256::ZERO, + 1_000_000, + ); + let block2 = env.sequencer.build_next_block_with_transactions(vec![call_pre]).await; // Pre-fork: oversized MODEXP succeeds. { - let sentinel = builder.storage_at(contract_addr, MODEXP_SENTINEL_SLOT); - let success = builder.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); + let sentinel = env.sequencer.storage_at(contract_addr, MODEXP_SENTINEL_SLOT); + let success = env.sequencer.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); assert_eq!(sentinel, U256::from(1), "sentinel must be 1: probe completed pre-fork"); assert_eq!(success, U256::from(1), "MODEXP with oversized input must succeed pre-fork"); } // ── Block 3 (ts=6, post-fork): call MODEXP with oversized input ── - let call_post = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from(oversized_input), - U256::ZERO, - 1_000_000, - ) - }; - let block3 = builder.build_next_block_with_transactions(vec![call_post]).await; + let call_post = env.create_tx( + TxKind::Call(contract_addr), + Bytes::from(oversized_input), + U256::ZERO, + 1_000_000, + ); + let block3 = env.sequencer.build_next_block_with_transactions(vec![call_post]).await; // Post-fork: oversized MODEXP must fail (EIP-7823). { - let success = builder.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); + let success = env.sequencer.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); assert_eq!( success, U256::ZERO, @@ -163,110 +121,52 @@ async fn azul_modexp_upper_bound() { } // ── Batch and derive ───────────────────────────────────────────── - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - node.initialize().await; - - for (block, i) in [(block1, 1u64), (block2, 2), (block3, 3)] { - batcher.push_block(block); - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - let derived = node.run_until_idle().await; - assert_eq!(derived, 1, "L1 block {i} should derive exactly one L2 block"); - } - - assert_eq!( - node.l2_safe().block_info.number, - 3, - "all 3 L2 blocks must derive through the Base Azul boundary" - ); + env.derive_blocks([(block1, 1), (block2, 2), (block3, 3)], 3, "Base Azul").await; } /// EIP-7883: MODEXP gas cost increases after Base Azul (min 200→500, general cost tripled). #[tokio::test] async fn azul_modexp_gas_cost_increase() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..Default::default() - }; - - // Base Azul activates at ts=6 (block 3). - let base_azul_time = 6u64; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) - .through_isthmus() - .with_jovian_at(0) - .with_azul_at(base_azul_time) - .build(); - let chain_id = rollup_cfg.l2_chain_id.id(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let account = builder.test_account(); - let contract_addr = TEST_ACCOUNT_ADDRESS.create(0); + let mut env = AzulTestEnv::new(); + let contract_addr = env.first_contract_address(); // ── Block 1 (ts=2, pre-fork): deploy MODEXP probe contract ────── - let deploy_tx = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Create, - Bytes::from_static(&MODEXP_INIT_CODE), - U256::ZERO, - 100_000, - ) - }; - let block1 = builder.build_next_block_with_transactions(vec![deploy_tx]).await; + let deploy_tx = + env.create_tx(TxKind::Create, Bytes::from_static(&MODEXP_INIT_CODE), U256::ZERO, 100_000); + let block1 = env.sequencer.build_next_block_with_transactions(vec![deploy_tx]).await; - assert!(builder.has_code(contract_addr), "deployed contract must have non-empty code"); + assert!(env.sequencer.has_code(contract_addr), "deployed contract must have non-empty code"); // Small valid input: 2^3 mod 5 (= 3). let small_input = ModexpInput::build(&[2], &[3], &[5]); // ── Block 2 (ts=4, pre-fork): call MODEXP ──────────────────────── - let call_pre = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from(small_input.clone()), - U256::ZERO, - 100_000, - ) - }; - let block2 = builder.build_next_block_with_transactions(vec![call_pre]).await; + let call_pre = env.create_tx( + TxKind::Call(contract_addr), + Bytes::from(small_input.clone()), + U256::ZERO, + 100_000, + ); + let block2 = env.sequencer.build_next_block_with_transactions(vec![call_pre]).await; let gas_delta_pre; { - let sentinel = builder.storage_at(contract_addr, MODEXP_SENTINEL_SLOT); - let success = builder.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); - gas_delta_pre = builder.storage_at(contract_addr, MODEXP_GAS_DELTA_SLOT); + let sentinel = env.sequencer.storage_at(contract_addr, MODEXP_SENTINEL_SLOT); + let success = env.sequencer.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); + gas_delta_pre = env.sequencer.storage_at(contract_addr, MODEXP_GAS_DELTA_SLOT); assert_eq!(sentinel, U256::from(1), "sentinel must be 1: probe completed pre-fork"); assert_eq!(success, U256::from(1), "MODEXP must succeed pre-fork"); } // ── Block 3 (ts=6, post-fork): call MODEXP with same input ─────── - let call_post = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - Bytes::from(small_input), - U256::ZERO, - 100_000, - ) - }; - let block3 = builder.build_next_block_with_transactions(vec![call_post]).await; + let call_post = + env.create_tx(TxKind::Call(contract_addr), Bytes::from(small_input), U256::ZERO, 100_000); + let block3 = env.sequencer.build_next_block_with_transactions(vec![call_post]).await; let gas_delta_post; { - let success = builder.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); - gas_delta_post = builder.storage_at(contract_addr, MODEXP_GAS_DELTA_SLOT); + let success = env.sequencer.storage_at(contract_addr, MODEXP_SUCCESS_SLOT); + gas_delta_post = env.sequencer.storage_at(contract_addr, MODEXP_GAS_DELTA_SLOT); assert_eq!(success, U256::from(1), "MODEXP must succeed post-fork"); } @@ -279,20 +179,5 @@ async fn azul_modexp_gas_cost_increase() { ); // ── Batch and derive ───────────────────────────────────────────── - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - node.initialize().await; - - for (block, i) in [(block1, 1u64), (block2, 2), (block3, 3)] { - batcher.push_block(block); - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - let derived = node.run_until_idle().await; - assert_eq!(derived, 1, "L1 block {i} should derive exactly one L2 block"); - } - - assert_eq!( - node.l2_safe().block_info.number, - 3, - "all 3 L2 blocks must derive through the Base V1 boundary" - ); + env.derive_blocks([(block1, 1), (block2, 2), (block3, 3)], 3, "Base V1").await; } diff --git a/actions/harness/tests/azul/p256.rs b/actions/harness/tests/azul/p256.rs index 7e5be4f622..21406d49c5 100644 --- a/actions/harness/tests/azul/p256.rs +++ b/actions/harness/tests/azul/p256.rs @@ -1,11 +1,8 @@ //! P256VERIFY precompile gas cost test across the Base Azul boundary. use alloy_primitives::{Bytes, TxKind, U256, hex}; -use base_action_harness::{ - ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, SharedL1Chain, - TEST_ACCOUNT_ADDRESS, TestRollupConfigBuilder, -}; -use base_batcher_encoder::{DaType, EncoderConfig}; + +use crate::env::AzulTestEnv; /// P256VERIFY probe-contract init code (12 bytes init + 34 bytes runtime). /// @@ -33,84 +30,42 @@ const P256_SENTINEL_SLOT: U256 = U256::from_limbs([2, 0, 0, 0]); /// P256VERIFY gas cost doubles after Base Azul (3,450 → 6,900). #[tokio::test] async fn azul_p256_verify_gas_cost_increase() { - let batcher_cfg = BatcherConfig { - encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, - ..Default::default() - }; - - // Base Azul activates at ts=6 (block 3). - let base_azul_time = 6u64; - let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) - .through_isthmus() - .with_jovian_at(0) - .with_azul_at(base_azul_time) - .build(); - let chain_id = rollup_cfg.l2_chain_id.id(); - let mut h = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); - - let l1_chain = SharedL1Chain::from_blocks(h.l1.chain().to_vec()); - let mut builder = h.create_l2_sequencer(l1_chain); - - let (mut node, chain) = h.create_test_rollup_node_from_sequencer( - &mut builder, - SharedL1Chain::from_blocks(h.l1.chain().to_vec()), - ); - - let account = builder.test_account(); - let contract_addr = TEST_ACCOUNT_ADDRESS.create(0); + let mut env = AzulTestEnv::new(); + let contract_addr = env.first_contract_address(); // ── Block 1 (ts=2, pre-fork): deploy P256VERIFY probe contract ─── - let deploy_tx = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Create, - Bytes::from_static(&P256_INIT_CODE), - U256::ZERO, - 100_000, - ) - }; - let block1 = builder.build_next_block_with_transactions(vec![deploy_tx]).await; - - assert!(builder.has_code(contract_addr), "deployed contract must have non-empty code"); + let deploy_tx = + env.create_tx(TxKind::Create, Bytes::from_static(&P256_INIT_CODE), U256::ZERO, 100_000); + let block1 = env.sequencer.build_next_block_with_transactions(vec![deploy_tx]).await; + + assert!(env.sequencer.has_code(contract_addr), "deployed contract must have non-empty code"); // Empty calldata — the precompile returns empty output (invalid sig) but // still charges its base gas fee, which is what we measure. let p256_input = Bytes::new(); // ── Block 2 (ts=4, pre-fork): call P256VERIFY ──────────────────── - let call_pre = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx( - chain_id, - TxKind::Call(contract_addr), - p256_input.clone(), - U256::ZERO, - 100_000, - ) - }; - let block2 = builder.build_next_block_with_transactions(vec![call_pre]).await; + let call_pre = + env.create_tx(TxKind::Call(contract_addr), p256_input.clone(), U256::ZERO, 100_000); + let block2 = env.sequencer.build_next_block_with_transactions(vec![call_pre]).await; let gas_delta_pre; { - let sentinel = builder.storage_at(contract_addr, P256_SENTINEL_SLOT); - let success = builder.storage_at(contract_addr, P256_SUCCESS_SLOT); - gas_delta_pre = builder.storage_at(contract_addr, P256_GAS_DELTA_SLOT); + let sentinel = env.sequencer.storage_at(contract_addr, P256_SENTINEL_SLOT); + let success = env.sequencer.storage_at(contract_addr, P256_SUCCESS_SLOT); + gas_delta_pre = env.sequencer.storage_at(contract_addr, P256_GAS_DELTA_SLOT); assert_eq!(sentinel, U256::from(1), "sentinel must be 1: probe completed pre-fork"); assert_eq!(success, U256::from(1), "P256VERIFY must succeed pre-fork"); } // ── Block 3 (ts=6, post-fork): call P256VERIFY with same input ─── - let call_post = { - let mut acct = account.lock().expect("test account lock"); - acct.create_tx(chain_id, TxKind::Call(contract_addr), p256_input, U256::ZERO, 100_000) - }; - let block3 = builder.build_next_block_with_transactions(vec![call_post]).await; + let call_post = env.create_tx(TxKind::Call(contract_addr), p256_input, U256::ZERO, 100_000); + let block3 = env.sequencer.build_next_block_with_transactions(vec![call_post]).await; let gas_delta_post; { - let success = builder.storage_at(contract_addr, P256_SUCCESS_SLOT); - gas_delta_post = builder.storage_at(contract_addr, P256_GAS_DELTA_SLOT); + let success = env.sequencer.storage_at(contract_addr, P256_SUCCESS_SLOT); + gas_delta_post = env.sequencer.storage_at(contract_addr, P256_GAS_DELTA_SLOT); assert_eq!(success, U256::from(1), "P256VERIFY must succeed post-fork"); } @@ -122,20 +77,5 @@ async fn azul_p256_verify_gas_cost_increase() { ); // ── Batch and derive ───────────────────────────────────────────── - let mut batcher = Batcher::new(ActionL2Source::new(), &h.rollup_config, batcher_cfg.clone()); - node.initialize().await; - - for (block, i) in [(block1, 1u64), (block2, 2), (block3, 3)] { - batcher.push_block(block); - batcher.advance(&mut h.l1).await; - chain.push(h.l1.tip().clone()); - let derived = node.run_until_idle().await; - assert_eq!(derived, 1, "L1 block {i} should derive exactly one L2 block"); - } - - assert_eq!( - node.l2_safe().block_info.number, - 3, - "all 3 L2 blocks must derive through the Base Azul boundary" - ); + env.derive_blocks([(block1, 1), (block2, 2), (block3, 3)], 3, "Base Azul").await; } diff --git a/actions/harness/tests/beryl/activation.rs b/actions/harness/tests/beryl/activation.rs new file mode 100644 index 0000000000..b35f064566 --- /dev/null +++ b/actions/harness/tests/beryl/activation.rs @@ -0,0 +1,140 @@ +//! Activation registry precompile action tests across the Base Beryl boundary. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::{SolCall, SolEvent}; +use base_common_precompiles::{ActivationFeature, ActivationRegistryStorage, IActivationRegistry}; + +use crate::env::BerylTestEnv; + +const GAS_LIMIT: u64 = 1_000_000; +const FEATURE: alloy_primitives::B256 = ActivationFeature::B20Security.id(); + +#[tokio::test] +async fn beryl_enables_activation_registry_admin_and_feature_lifecycle() { + let mut env = BerylTestEnv::new(); + let (probe, deploy_probe) = env.deploy_staticcall_probe_tx(ActivationRegistryStorage::ADDRESS); + + let admin_call = Bytes::from(IActivationRegistry::adminCall {}.abi_encode()); + let pre_beryl_admin = + env.call_staticcall_probe_tx(probe, admin_call.clone(), BerylTestEnv::B20_PROBE_GAS_LIMIT); + let block1 = + env.sequencer.build_next_block_with_transactions(vec![deploy_probe, pre_beryl_admin]).await; + + assert!(env.user_tx_succeeded(&block1, 0), "activation-registry probe must deploy"); + assert_ne!( + env.probe_return_word(probe), + word_from_address(BerylTestEnv::alice()), + "activation registry admin must not be returned before Beryl" + ); + + let beryl_boundary = env.sequencer.build_empty_block().await; + + let post_beryl_admin = + env.call_staticcall_probe_tx(probe, admin_call, BerylTestEnv::B20_PROBE_GAS_LIMIT); + let block2 = env.sequencer.build_next_block_with_transactions(vec![post_beryl_admin]).await; + + assert!(env.probe_call_succeeded(probe), "admin() staticcall must succeed after Beryl"); + assert_eq!( + env.probe_return_word(probe), + word_from_address(BerylTestEnv::alice()), + "admin() must return the harness activation admin" + ); + + let is_activated_call = + Bytes::from(IActivationRegistry::isActivatedCall { feature: FEATURE }.abi_encode()); + let inactive_probe = env.call_staticcall_probe_tx( + probe, + is_activated_call.clone(), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block3 = env.sequencer.build_next_block_with_transactions(vec![inactive_probe]).await; + + assert!(env.probe_call_succeeded(probe), "isActivated() staticcall must succeed"); + assert_eq!(env.probe_return_word(probe), U256::ZERO, "feature must start inactive"); + + let activate = env.activate_feature_tx(FEATURE); + let block4 = env.sequencer.build_next_block_with_transactions(vec![activate]).await; + + assert!(env.user_tx_succeeded(&block4, 0), "admin activate(feature) must succeed"); + assert_activation_log(&env, &block4, true); + + let activate_again = env.activate_feature_tx(FEATURE); + let block5 = env.sequencer.build_next_block_with_transactions(vec![activate_again]).await; + + assert!(!env.user_tx_succeeded(&block5, 0), "repeated activate(feature) must revert"); + + let active_probe = env.call_staticcall_probe_tx( + probe, + is_activated_call.clone(), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block6 = env.sequencer.build_next_block_with_transactions(vec![active_probe]).await; + + assert!(env.probe_call_succeeded(probe), "isActivated() staticcall must succeed"); + assert_eq!(env.probe_return_word(probe), U256::ONE, "feature must be active"); + + let deactivate = env.deactivate_feature_tx(FEATURE); + let block7 = env.sequencer.build_next_block_with_transactions(vec![deactivate]).await; + + assert!(env.user_tx_succeeded(&block7, 0), "admin deactivate(feature) must succeed"); + assert_activation_log(&env, &block7, false); + + let deactivate_again = env.deactivate_feature_tx(FEATURE); + let block8 = env.sequencer.build_next_block_with_transactions(vec![deactivate_again]).await; + + assert!(!env.user_tx_succeeded(&block8, 0), "repeated deactivate(feature) must revert"); + + let unauthorized = env.create_bob_tx( + TxKind::Call(ActivationRegistryStorage::ADDRESS), + Bytes::from(IActivationRegistry::activateCall { feature: FEATURE }.abi_encode()), + GAS_LIMIT, + ); + let block9 = env.sequencer.build_next_block_with_transactions(vec![unauthorized]).await; + + assert!(!env.user_tx_succeeded(&block9, 0), "non-admin activate(feature) must revert"); + + env.derive_blocks( + [ + (block1, 1), + (beryl_boundary, 2), + (block2, 3), + (block3, 4), + (block4, 5), + (block5, 6), + (block6, 7), + (block7, 8), + (block8, 9), + (block9, 10), + ], + 10, + ) + .await; +} + +fn assert_activation_log( + env: &BerylTestEnv, + block: &base_common_consensus::BaseBlock, + active: bool, +) { + let expected = if active { + IActivationRegistry::FeatureActivated { feature: FEATURE, caller: BerylTestEnv::alice() } + .encode_log_data() + } else { + IActivationRegistry::FeatureDeactivated { feature: FEATURE, caller: BerylTestEnv::alice() } + .encode_log_data() + }; + assert!( + env.user_tx_receipt(block, 0) + .logs() + .iter() + .any(|log| log.address == ActivationRegistryStorage::ADDRESS && log.data == expected), + "activation transition must emit the expected event" + ); +} + +fn word_from_address(address: Address) -> U256 { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(address.as_slice()); + U256::from_be_slice(&word) +} diff --git a/actions/harness/tests/beryl/b20.rs b/actions/harness/tests/beryl/b20.rs new file mode 100644 index 0000000000..44a47fd05a --- /dev/null +++ b/actions/harness/tests/beryl/b20.rs @@ -0,0 +1,991 @@ +//! B-20 precompile action tests across the Base Beryl boundary. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Address, B256, Bytes, FixedBytes, TxKind, U256, keccak256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use base_action_harness::TEST_ACCOUNT_KEY; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_precompiles::{B20TokenRole, IB20}; + +use crate::env::BerylTestEnv; + +const PERMIT_TYPE: &[u8] = + b"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"; +const DOMAIN_TYPE: &[u8] = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; +const DOMAIN_VERSION: &[u8] = b"1"; +const MEMO_TRANSFER: B256 = B256::repeat_byte(0x10); +const MEMO_TRANSFER_FROM: B256 = B256::repeat_byte(0x11); +const MEMO_MINT: B256 = B256::repeat_byte(0x12); +const MEMO_BURN: B256 = B256::repeat_byte(0x13); + +#[tokio::test] +async fn b20_transfers_update_balances_and_emit_events() { + let mut scenario = B20TokenScenario::new().await; + + let transfer_to_bob = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_TRANSFER), + ); + let block = scenario.build_block_with_transactions(vec![transfer_to_bob]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "Alice transfer transaction must succeed"); + scenario.assert_transfer_log( + &block, + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_TRANSFER, + ); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances( + BerylTestEnv::B20_INITIAL_SUPPLY - BerylTestEnv::B20_BOB_TRANSFER, + BerylTestEnv::B20_BOB_TRANSFER, + 0, + ); + + let transfer_to_carol = scenario.env.transfer_b20_from_bob_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(BerylTestEnv::B20_CAROL_TRANSFER), + ); + let block = scenario.build_block_with_transactions(vec![transfer_to_carol]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "Bob transfer transaction must succeed"); + scenario.assert_transfer_log( + &block, + BerylTestEnv::bob(), + BerylTestEnv::carol(), + BerylTestEnv::B20_CAROL_TRANSFER, + ); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances( + BerylTestEnv::B20_INITIAL_SUPPLY - BerylTestEnv::B20_BOB_TRANSFER, + BerylTestEnv::B20_BOB_TRANSFER - BerylTestEnv::B20_CAROL_TRANSFER, + BerylTestEnv::B20_CAROL_TRANSFER, + ); + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_transfer_reverts_when_sender_balance_is_insufficient() { + let mut scenario = B20TokenScenario::new().await; + + let transfer_to_bob = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_TRANSFER), + ); + let block = scenario.build_block_with_transactions(vec![transfer_to_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "Alice transfer transaction must succeed"); + + let overdraw_amount = BerylTestEnv::B20_BOB_TRANSFER + 1; + let overdraw = scenario.env.transfer_b20_from_bob_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(overdraw_amount), + ); + let block = scenario.build_block_with_transactions(vec![overdraw]).await; + + assert!(!scenario.env.user_tx_succeeded(&block, 0), "Bob overdraw transfer must revert"); + assert!( + !scenario.env.b20_transfer_log_emitted( + &block, + 0, + scenario.token, + BerylTestEnv::bob(), + BerylTestEnv::carol(), + U256::from(overdraw_amount), + ), + "failed overdraw transfer must not emit a Transfer event" + ); + scenario.assert_balances( + BerylTestEnv::B20_INITIAL_SUPPLY - BerylTestEnv::B20_BOB_TRANSFER, + BerylTestEnv::B20_BOB_TRANSFER, + 0, + ); + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_approval_and_transfer_from_update_allowance() { + let mut scenario = B20TokenScenario::new().await; + + let approve_bob = scenario.env.approve_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_ALLOWANCE), + ); + let block = scenario.build_block_with_transactions(vec![approve_bob]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "Alice approval transaction must succeed"); + scenario.assert_approval_log( + &block, + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_ALLOWANCE, + ); + scenario.assert_allowance( + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_ALLOWANCE, + ); + + let transfer_from_alice_to_carol = scenario.env.transfer_b20_from_alice_by_bob_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(BerylTestEnv::B20_TRANSFER_FROM_CAROL), + ); + let block = scenario.build_block_with_transactions(vec![transfer_from_alice_to_carol]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "Bob transferFrom transaction must succeed"); + scenario.assert_transfer_log( + &block, + BerylTestEnv::alice(), + BerylTestEnv::carol(), + BerylTestEnv::B20_TRANSFER_FROM_CAROL, + ); + scenario.assert_balances( + BerylTestEnv::B20_INITIAL_SUPPLY - BerylTestEnv::B20_TRANSFER_FROM_CAROL, + 0, + BerylTestEnv::B20_TRANSFER_FROM_CAROL, + ); + scenario.assert_allowance( + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_ALLOWANCE - BerylTestEnv::B20_TRANSFER_FROM_CAROL, + ); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_staticcall_abi_returns_storage_values() { + let mut scenario = B20TokenScenario::new().await; + + let transfer_to_bob = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_TRANSFER), + ); + let approve_bob = scenario.env.approve_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_ALLOWANCE), + ); + let transfer_from_alice_to_carol = scenario.env.transfer_b20_from_alice_by_bob_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(BerylTestEnv::B20_TRANSFER_FROM_CAROL), + ); + let block = scenario + .build_block_with_transactions(vec![ + transfer_to_bob, + approve_bob, + transfer_from_alice_to_carol, + ]) + .await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "Alice transfer transaction must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "Alice approval transaction must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 2), "Bob transferFrom transaction must succeed"); + + let probes = B20StaticcallProbes::deploy(&mut scenario).await; + let probe_calls = probes.call_txs(&scenario); + let _block = scenario.build_block_with_transactions(probe_calls).await; + + probes.assert_returns( + &scenario, + B20ProbeExpectations { + total_supply: BerylTestEnv::B20_INITIAL_SUPPLY, + alice_balance: BerylTestEnv::B20_INITIAL_SUPPLY + - BerylTestEnv::B20_BOB_TRANSFER + - BerylTestEnv::B20_TRANSFER_FROM_CAROL, + bob_balance: BerylTestEnv::B20_BOB_TRANSFER, + carol_balance: BerylTestEnv::B20_TRANSFER_FROM_CAROL, + allowance: BerylTestEnv::B20_BOB_ALLOWANCE - BerylTestEnv::B20_TRANSFER_FROM_CAROL, + decimals: BerylTestEnv::B20_DECIMALS, + }, + ); + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_transfer_reverts_while_token_feature_is_deactivated() { + let mut scenario = B20TokenScenario::new().await; + + let deactivate_b20 = scenario.env.deactivate_feature_tx(BerylTestEnv::b20_token_feature()); + let block = scenario.build_block_with_transactions(vec![deactivate_b20]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20_TOKEN deactivation must succeed"); + + let transfer_while_deactivated = + scenario.env.transfer_b20_tx(scenario.token, BerylTestEnv::bob(), U256::from(1)); + let block = scenario.build_block_with_transactions(vec![transfer_while_deactivated]).await; + + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "token transfer must revert when B20_TOKEN is deactivated" + ); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY, 0, 0); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + + let reactivate_b20 = scenario.env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let block = scenario.build_block_with_transactions(vec![reactivate_b20]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20_TOKEN re-activation must succeed"); + + let transfer_after_reactivate = + scenario.env.transfer_b20_tx(scenario.token, BerylTestEnv::bob(), U256::from(1)); + let block = scenario.build_block_with_transactions(vec![transfer_after_reactivate]).await; + + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "token transfer must succeed after B20_TOKEN is re-activated" + ); + scenario.assert_transfer_log(&block, BerylTestEnv::alice(), BerylTestEnv::bob(), 1); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY - 1, 1, 0); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_staticcall_abi_covers_all_read_methods() { + let mut scenario = B20TokenScenario::new().await; + + let approve_bob = scenario.env.approve_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_ALLOWANCE), + ); + let block = scenario.build_block_with_transactions(vec![approve_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "Alice approval transaction must succeed"); + + scenario + .assert_staticcall_cases(vec![ + StaticcallCase::output( + "name", + IB20::nameCall {}.abi_encode(), + IB20::nameCall::abi_encode_returns(&BerylTestEnv::B20_NAME.to_string()), + ), + StaticcallCase::output( + "symbol", + IB20::symbolCall {}.abi_encode(), + IB20::symbolCall::abi_encode_returns(&BerylTestEnv::B20_SYMBOL.to_string()), + ), + StaticcallCase::word( + "decimals", + IB20::decimalsCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_DECIMALS), + ), + StaticcallCase::word( + "totalSupply", + IB20::totalSupplyCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "balanceOf", + IB20::balanceOfCall { account: BerylTestEnv::alice() }.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "allowance", + IB20::allowanceCall { owner: BerylTestEnv::alice(), spender: BerylTestEnv::bob() } + .abi_encode(), + U256::from(BerylTestEnv::B20_BOB_ALLOWANCE), + ), + StaticcallCase::word( + "pausedFeatures", + IB20::pausedFeaturesCall {}.abi_encode(), + U256::from(32), + ) + .with_output(IB20::pausedFeaturesCall::abi_encode_returns( + &Vec::::new(), + )), + StaticcallCase::word( + "isPaused", + IB20::isPausedCall { feature: IB20::PausableFeature::TRANSFER }.abi_encode(), + U256::ZERO, + ), + StaticcallCase::word("supplyCap", IB20::supplyCapCall {}.abi_encode(), U256::MAX), + StaticcallCase::word( + "DOMAIN_SEPARATOR", + IB20::DOMAIN_SEPARATORCall {}.abi_encode(), + domain_separator_word( + scenario.env.chain_id(), + scenario.token, + BerylTestEnv::B20_NAME, + ), + ), + StaticcallCase::word( + "nonces", + IB20::noncesCall { owner: BerylTestEnv::alice() }.abi_encode(), + U256::ZERO, + ), + StaticcallCase::word( + "eip712Domain", + IB20::eip712DomainCall {}.abi_encode(), + eip712_domain_fields_word(), + ) + .with_output(IB20::eip712DomainCall::abi_encode_returns( + &eip712_domain_return( + scenario.env.chain_id(), + scenario.token, + BerylTestEnv::B20_NAME, + ), + )), + StaticcallCase::output( + "contractURI", + IB20::contractURICall {}.abi_encode(), + IB20::contractURICall::abi_encode_returns(&String::new()), + ), + ]) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_extended_mutations_update_state_and_emit_events() { + let mut scenario = B20TokenScenario::new().await; + let initial = BerylTestEnv::B20_INITIAL_SUPPLY; + let new_cap = U256::from(initial + 1_000); + let grant_roles = [ + B20TokenRole::Metadata, + B20TokenRole::Mint, + B20TokenRole::Burn, + B20TokenRole::Pause, + B20TokenRole::Unpause, + ] + .into_iter() + .map(|role| { + scenario.call_tx(IB20::grantRoleCall { role: role.id(), account: BerylTestEnv::alice() }) + }) + .collect(); + let block = scenario.build_block_with_transactions(grant_roles).await; + + for index in 0..5 { + assert!(scenario.env.user_tx_succeeded(&block, index), "role grant {index} must succeed"); + } + + let transfer_with_memo = scenario.call_tx(IB20::transferWithMemoCall { + to: BerylTestEnv::bob(), + amount: U256::from(10), + memo: MEMO_TRANSFER, + }); + let approve_bob = scenario + .call_tx(IB20::approveCall { spender: BerylTestEnv::bob(), amount: U256::from(50) }); + let transfer_from_with_memo = scenario.bob_call_tx(IB20::transferFromWithMemoCall { + from: BerylTestEnv::alice(), + to: BerylTestEnv::carol(), + amount: U256::from(5), + memo: MEMO_TRANSFER_FROM, + }); + let update_supply_cap = scenario.call_tx(IB20::updateSupplyCapCall { newSupplyCap: new_cap }); + let update_name = + scenario.call_tx(IB20::updateNameCall { newName: "Action B20 Updated".to_string() }); + let update_symbol = scenario.call_tx(IB20::updateSymbolCall { newSymbol: "AB20U".to_string() }); + let update_contract_uri = + scenario.call_tx(IB20::updateContractURICall { newURI: "ipfs://action".to_string() }); + let mint = + scenario.call_tx(IB20::mintCall { to: BerylTestEnv::alice(), amount: U256::from(20) }); + let mint_with_memo = scenario.call_tx(IB20::mintWithMemoCall { + to: BerylTestEnv::bob(), + amount: U256::from(30), + memo: MEMO_MINT, + }); + let burn = scenario.call_tx(IB20::burnCall { amount: U256::from(2) }); + let burn_with_memo = + scenario.call_tx(IB20::burnWithMemoCall { amount: U256::from(3), memo: MEMO_BURN }); + let pause = + scenario.call_tx(IB20::pauseCall { features: vec![IB20::PausableFeature::TRANSFER] }); + let unpause = + scenario.call_tx(IB20::unpauseCall { features: vec![IB20::PausableFeature::TRANSFER] }); + + let block = scenario + .build_block_with_transactions(vec![ + transfer_with_memo, + approve_bob, + transfer_from_with_memo, + update_supply_cap, + update_name, + update_symbol, + update_contract_uri, + mint, + mint_with_memo, + burn, + burn_with_memo, + pause, + unpause, + ]) + .await; + + for index in 0..13 { + assert!( + scenario.env.user_tx_succeeded(&block, index), + "B-20 mutation {index} must succeed" + ); + } + + scenario.assert_log( + &block, + 0, + IB20::Memo { caller: BerylTestEnv::alice(), memo: MEMO_TRANSFER }.encode_log_data(), + ); + scenario.assert_log( + &block, + 2, + IB20::Memo { caller: BerylTestEnv::bob(), memo: MEMO_TRANSFER_FROM }.encode_log_data(), + ); + scenario.assert_log( + &block, + 3, + IB20::SupplyCapUpdated { + updater: BerylTestEnv::alice(), + oldSupplyCap: U256::MAX, + newSupplyCap: new_cap, + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 4, + IB20::NameUpdated { + updater: BerylTestEnv::alice(), + newName: "Action B20 Updated".to_string(), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 5, + IB20::SymbolUpdated { updater: BerylTestEnv::alice(), newSymbol: "AB20U".to_string() } + .encode_log_data(), + ); + scenario.assert_log(&block, 6, IB20::ContractURIUpdated {}.encode_log_data()); + scenario.assert_log( + &block, + 8, + IB20::Memo { caller: BerylTestEnv::alice(), memo: MEMO_MINT }.encode_log_data(), + ); + scenario.assert_log( + &block, + 10, + IB20::Memo { caller: BerylTestEnv::alice(), memo: MEMO_BURN }.encode_log_data(), + ); + scenario.assert_log( + &block, + 11, + IB20::Paused { + updater: BerylTestEnv::alice(), + features: vec![IB20::PausableFeature::TRANSFER], + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 12, + IB20::Unpaused { + updater: BerylTestEnv::alice(), + features: vec![IB20::PausableFeature::TRANSFER], + } + .encode_log_data(), + ); + + scenario.assert_total_supply(initial + 20 + 30 - 2 - 3); + scenario.assert_allowance(BerylTestEnv::alice(), BerylTestEnv::bob(), 45); + + let zero_mint = + scenario.call_tx(IB20::mintCall { to: BerylTestEnv::alice(), amount: U256::ZERO }); + let zero_burn = scenario.call_tx(IB20::burnCall { amount: U256::ZERO }); + let block = scenario.build_block_with_transactions(vec![zero_mint, zero_burn]).await; + + for index in 0..2 { + assert!( + scenario.env.user_tx_succeeded(&block, index), + "zero-amount B-20 mutation {index} must succeed (zero-amount ops are valid per ERC-20)" + ); + } + scenario.assert_total_supply(initial + 20 + 30 - 2 - 3); + + scenario + .assert_staticcall_cases(vec![ + StaticcallCase::word( + "pausedFeatures after unpause", + IB20::pausedFeaturesCall {}.abi_encode(), + U256::from(32), + ) + .with_output(IB20::pausedFeaturesCall::abi_encode_returns( + &Vec::::new(), + )), + StaticcallCase::word( + "supplyCap after update", + IB20::supplyCapCall {}.abi_encode(), + new_cap, + ), + StaticcallCase::output( + "name after update", + IB20::nameCall {}.abi_encode(), + IB20::nameCall::abi_encode_returns(&"Action B20 Updated".to_string()), + ), + StaticcallCase::output( + "symbol after update", + IB20::symbolCall {}.abi_encode(), + IB20::symbolCall::abi_encode_returns(&"AB20U".to_string()), + ), + StaticcallCase::output( + "contractURI after update", + IB20::contractURICall {}.abi_encode(), + IB20::contractURICall::abi_encode_returns(&"ipfs://action".to_string()), + ), + ]) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn b20_permit_updates_allowance_and_nonce() { + let mut scenario = B20TokenScenario::new().await; + let value = U256::from(123); + let deadline = U256::MAX; + let domain_sep = + domain_separator(scenario.env.chain_id(), scenario.token, BerylTestEnv::B20_NAME); + let (v, r, s) = sign_permit( + domain_sep, + BerylTestEnv::alice(), + BerylTestEnv::bob(), + value, + U256::ZERO, + deadline, + ); + + let permit = scenario.call_tx(IB20::permitCall { + owner: BerylTestEnv::alice(), + spender: BerylTestEnv::bob(), + value, + deadline, + v, + r, + s, + }); + let block = scenario.build_block_with_transactions(vec![permit]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "permit() transaction must succeed"); + scenario.assert_log( + &block, + 0, + IB20::Approval { + owner: BerylTestEnv::alice(), + spender: BerylTestEnv::bob(), + amount: value, + } + .encode_log_data(), + ); + scenario.assert_allowance(BerylTestEnv::alice(), BerylTestEnv::bob(), 123); + scenario + .assert_staticcall_cases(vec![StaticcallCase::word( + "nonces after permit", + IB20::noncesCall { owner: BerylTestEnv::alice() }.abi_encode(), + U256::ONE, + )]) + .await; + + scenario.derive().await; +} + +struct B20TokenScenario { + env: BerylTestEnv, + token: Address, + blocks: Vec<(BaseBlock, u64)>, +} + +impl B20TokenScenario { + async fn new() -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_token_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + scenario.build_block_with_transactions(Vec::new()).await; + + let activate_factory = + scenario.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_b20 = scenario.env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let block = + scenario.build_block_with_transactions(vec![activate_factory, activate_b20]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "B20_TOKEN activation must succeed"); + + let create = scenario.env.create_b20_token_tx(); + let block = scenario.build_block_with_transactions(vec![create]).await; + + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "B-20 creation transaction must succeed" + ); + assert!(scenario.env.sequencer.has_code(token), "B-20 token code must be deployed"); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY, 0, 0); + + scenario + } + + async fn build_block_with_transactions( + &mut self, + transactions: Vec, + ) -> BaseBlock { + let block = self.env.sequencer.build_next_block_with_transactions(transactions).await; + let block_number = self.blocks.len() as u64 + 1; + self.blocks.push((block.clone(), block_number)); + block + } + + fn call_tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(self.token), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + fn bob_call_tx(&mut self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_bob_tx( + TxKind::Call(self.token), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + async fn assert_staticcall_cases(&mut self, cases: Vec) { + let mut probes = Vec::with_capacity(cases.len()); + let mut deployments = Vec::with_capacity(cases.len()); + for _ in &cases { + let (probe, deploy) = self.env.deploy_staticcall_probe_tx(self.token); + probes.push(probe); + deployments.push(deploy); + } + + let deploy_block = self.build_block_with_transactions(deployments).await; + for index in 0..cases.len() { + assert!( + self.env.user_tx_succeeded(&deploy_block, index), + "staticcall probe deployment {index} must succeed" + ); + } + + let calls = probes + .iter() + .zip(cases.iter()) + .map(|(probe, case)| { + self.env.call_staticcall_probe_tx( + *probe, + Bytes::from(case.input.clone()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ) + }) + .collect(); + let call_block = self.build_block_with_transactions(calls).await; + for (index, (probe, case)) in probes.iter().zip(cases.iter()).enumerate() { + assert!( + self.env.user_tx_succeeded(&call_block, index), + "{} probe transaction must succeed", + case.label + ); + assert!( + self.env.probe_call_succeeded(*probe), + "{} staticcall must succeed", + case.label + ); + if let Some(expected) = case.expected_word { + assert_eq!( + self.env.probe_return_word(*probe), + expected, + "{} staticcall must return the expected first word", + case.label + ); + } + assert_eq!( + self.env.probe_return_size(*probe), + U256::from(case.expected_output.len()), + "{} staticcall must return the expected byte length", + case.label + ); + assert_eq!( + self.env.probe_return_hash(*probe), + keccak256(&case.expected_output), + "{} staticcall must return the expected ABI payload hash", + case.label + ); + } + } + + fn assert_total_supply(&self, total_supply: u64) { + assert_eq!( + self.env.b20_total_supply(self.token), + U256::from(total_supply), + "B-20 total supply must match expected value" + ); + } + + fn assert_balances(&self, alice: u64, bob: u64, carol: u64) { + assert_eq!( + self.env.b20_balance(self.token, BerylTestEnv::alice()), + U256::from(alice), + "Alice B-20 balance must match expected value" + ); + assert_eq!( + self.env.b20_balance(self.token, BerylTestEnv::bob()), + U256::from(bob), + "Bob B-20 balance must match expected value" + ); + assert_eq!( + self.env.b20_balance(self.token, BerylTestEnv::carol()), + U256::from(carol), + "Carol B-20 balance must match expected value" + ); + } + + fn assert_allowance(&self, owner: Address, spender: Address, amount: u64) { + assert_eq!( + self.env.b20_allowance(self.token, owner, spender), + U256::from(amount), + "B-20 allowance must match expected value" + ); + } + + fn assert_log( + &self, + block: &BaseBlock, + user_tx_index: usize, + expected: alloy_primitives::LogData, + ) { + assert!( + self.env + .user_tx_receipt(block, user_tx_index) + .logs() + .iter() + .any(|log| log.address == self.token && log.data == expected), + "B-20 transaction {user_tx_index} must emit the expected event" + ); + } + + fn assert_transfer_log(&self, block: &BaseBlock, from: Address, to: Address, amount: u64) { + assert!( + self.env.b20_transfer_log_emitted(block, 0, self.token, from, to, U256::from(amount),), + "B-20 transfer must emit a Transfer event" + ); + } + + fn assert_approval_log( + &self, + block: &BaseBlock, + owner: Address, + spender: Address, + amount: u64, + ) { + assert!( + self.env.b20_approval_log_emitted( + block, + 0, + self.token, + owner, + spender, + U256::from(amount), + ), + "B-20 approval must emit an Approval event" + ); + } + + async fn derive(mut self) { + let expected_safe_head = self.blocks.len() as u64; + self.env.derive_blocks(self.blocks, expected_safe_head).await; + } +} + +struct StaticcallCase { + label: &'static str, + input: Vec, + expected_word: Option, + expected_output: Vec, +} + +impl StaticcallCase { + fn word(label: &'static str, input: Vec, expected_word: U256) -> Self { + Self { + label, + input, + expected_word: Some(expected_word), + expected_output: expected_word.abi_encode(), + } + } + + fn output(label: &'static str, input: Vec, expected_output: Vec) -> Self { + let expected_word = expected_output.get(..32).map(U256::from_be_slice); + Self { label, input, expected_word, expected_output } + } + + fn with_output(mut self, expected_output: Vec) -> Self { + self.expected_word = expected_output.get(..32).map(U256::from_be_slice); + self.expected_output = expected_output; + self + } +} + +fn sign_permit( + domain_sep: B256, + owner: Address, + spender: Address, + value: U256, + nonce: U256, + deadline: U256, +) -> (u8, B256, B256) { + let permit_typehash = keccak256(PERMIT_TYPE); + let struct_hash = + keccak256((permit_typehash, owner, spender, value, nonce, deadline).abi_encode()); + + let mut digest = [0u8; 66]; + digest[0] = 0x19; + digest[1] = 0x01; + digest[2..34].copy_from_slice(domain_sep.as_slice()); + digest[34..66].copy_from_slice(struct_hash.as_slice()); + let hash = keccak256(digest); + + let signer = PrivateKeySigner::from_bytes(&TEST_ACCOUNT_KEY).expect("valid test signer"); + let sig = signer.sign_hash_sync(&hash).expect("permit signing must succeed"); + let r = B256::from(sig.r().to_be_bytes::<32>()); + let s = B256::from(sig.s().to_be_bytes::<32>()); + let v = if sig.v() { 28 } else { 27 }; + (v, r, s) +} + +fn domain_separator(chain_id: u64, token: Address, name: &str) -> B256 { + let domain_typehash = keccak256(DOMAIN_TYPE); + let name_hash = keccak256(name.as_bytes()); + let version_hash = keccak256(DOMAIN_VERSION); + keccak256((domain_typehash, name_hash, version_hash, U256::from(chain_id), token).abi_encode()) +} + +fn domain_separator_word(chain_id: u64, token: Address, name: &str) -> U256 { + U256::from_be_slice(domain_separator(chain_id, token, name).as_slice()) +} + +const fn eip712_domain_fields_word() -> U256 { + let mut word = [0u8; 32]; + word[0] = 0x0f; // bits 0+1+2+3: name + version + chainId + verifyingContract + U256::from_be_bytes(word) +} + +fn eip712_domain_return(chain_id: u64, token: Address, name: &str) -> IB20::eip712DomainReturn { + IB20::eip712DomainReturn { + fields: FixedBytes::<1>::from([0x0f]), + name: name.to_string(), + version: "1".to_string(), + chainId: U256::from(chain_id), + verifyingContract: token, + salt: B256::ZERO, + extensions: Vec::new(), + } +} + +struct B20StaticcallProbes { + total_supply: Address, + alice_balance: Address, + bob_balance: Address, + carol_balance: Address, + allowance: Address, + decimals: Address, +} + +impl B20StaticcallProbes { + async fn deploy(scenario: &mut B20TokenScenario) -> Self { + let (total_supply, deploy_total_supply) = + scenario.env.deploy_staticcall_probe_tx(scenario.token); + let (alice_balance, deploy_alice_balance) = + scenario.env.deploy_staticcall_probe_tx(scenario.token); + let (bob_balance, deploy_bob_balance) = + scenario.env.deploy_staticcall_probe_tx(scenario.token); + let (carol_balance, deploy_carol_balance) = + scenario.env.deploy_staticcall_probe_tx(scenario.token); + let (allowance, deploy_allowance) = scenario.env.deploy_staticcall_probe_tx(scenario.token); + let (decimals, deploy_decimals) = scenario.env.deploy_staticcall_probe_tx(scenario.token); + + let block = scenario + .build_block_with_transactions(vec![ + deploy_total_supply, + deploy_alice_balance, + deploy_bob_balance, + deploy_carol_balance, + deploy_allowance, + deploy_decimals, + ]) + .await; + for index in 0..6 { + assert!( + scenario.env.user_tx_succeeded(&block, index), + "B-20 staticcall probe deployment transaction {index} must succeed" + ); + } + + Self { total_supply, alice_balance, bob_balance, carol_balance, allowance, decimals } + } + + fn call_txs(&self, scenario: &B20TokenScenario) -> Vec { + vec![ + scenario.env.probe_b20_total_supply_tx(self.total_supply), + scenario.env.probe_b20_balance_tx(self.alice_balance, BerylTestEnv::alice()), + scenario.env.probe_b20_balance_tx(self.bob_balance, BerylTestEnv::bob()), + scenario.env.probe_b20_balance_tx(self.carol_balance, BerylTestEnv::carol()), + scenario.env.probe_b20_allowance_tx( + self.allowance, + BerylTestEnv::alice(), + BerylTestEnv::bob(), + ), + scenario.env.probe_b20_decimals_tx(self.decimals), + ] + } + + fn assert_returns(&self, scenario: &B20TokenScenario, expected: B20ProbeExpectations) { + Self::assert_probe_return(scenario, self.total_supply, expected.total_supply); + Self::assert_probe_return(scenario, self.alice_balance, expected.alice_balance); + Self::assert_probe_return(scenario, self.bob_balance, expected.bob_balance); + Self::assert_probe_return(scenario, self.carol_balance, expected.carol_balance); + Self::assert_probe_return(scenario, self.allowance, expected.allowance); + Self::assert_probe_return(scenario, self.decimals, u64::from(expected.decimals)); + } + + fn assert_probe_return(scenario: &B20TokenScenario, probe: Address, expected: u64) { + let expected_output = U256::from(expected).abi_encode(); + assert!(scenario.env.probe_call_succeeded(probe), "B-20 staticcall probe must succeed"); + assert_eq!( + scenario.env.probe_return_word(probe), + U256::from(expected), + "B-20 staticcall probe must return the expected word" + ); + assert_eq!( + scenario.env.probe_return_size(probe), + U256::from(expected_output.len()), + "B-20 staticcall probe must return one ABI word" + ); + assert_eq!( + scenario.env.probe_return_hash(probe), + keccak256(&expected_output), + "B-20 staticcall probe must return the expected ABI payload hash" + ); + } +} + +struct B20ProbeExpectations { + total_supply: u64, + alice_balance: u64, + bob_balance: u64, + carol_balance: u64, + allowance: u64, + decimals: u8, +} diff --git a/actions/harness/tests/beryl/b20_policy.rs b/actions/harness/tests/beryl/b20_policy.rs new file mode 100644 index 0000000000..908f152f44 --- /dev/null +++ b/actions/harness/tests/beryl/b20_policy.rs @@ -0,0 +1,301 @@ +//! Action tests proving that B20 token transfers are gated by the policy registry. +//! +//! Each test activates `TOKEN_FACTORY`, `B20_TOKEN`, and `POLICY_REGISTRY` together, +//! creates a token, wires a policy via `updatePolicy`, and asserts that transfers +//! revert or succeed based on policy membership. + +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::SolCall; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_precompiles::{B20PolicyType, IB20, IPolicyRegistry, PolicyRegistryStorage}; + +use crate::env::BerylTestEnv; + +/// Transfer amount used in setup (seeding bob with tokens). +const SEED_AMOUNT: U256 = U256::from_limbs([100_000, 0, 0, 0]); + +/// Amount transferred in each policy-gated transfer assertion. +const TRANSFER_AMOUNT: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// ALLOWLIST policy wired to `TransferSender` blocks non-members from sending. +/// +/// 1. Alice seeds bob with tokens (default `ALWAYS_ALLOW` policy, succeeds). +/// 2. Create ALLOWLIST policy; wire it to `TransferSender`. +/// 3. Bob tries to transfer; reverts (not in allowlist). +/// 4. Admin adds bob to the allowlist. +/// 5. Bob transfers again; succeeds. +#[tokio::test] +async fn b20_allowlist_sender_policy_blocks_non_members() { + let mut scenario = B20PolicyScenario::new().await; + + let seed_bob = + scenario.token_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: SEED_AMOUNT }); + let block = scenario.build_block(vec![seed_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "seeding bob must succeed"); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - 100_000); + scenario.assert_balance(BerylTestEnv::bob(), 100_000); + + let allowlist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::ALLOWLIST, 2); + let create_policy = scenario.policy_tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + }); + let block = scenario.build_block(vec![create_policy]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy(ALLOWLIST) must succeed"); + + let wire = scenario.token_tx(IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: allowlist_id, + }); + let block = scenario.build_block(vec![wire]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updatePolicy must succeed"); + + let blocked = scenario + .bob_token_tx(IB20::transferCall { to: BerylTestEnv::carol(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer from non-member must revert when ALLOWLIST sender policy is set" + ); + scenario.assert_balance(BerylTestEnv::bob(), 100_000); + scenario.assert_balance(BerylTestEnv::carol(), 0); + + let add_bob = scenario.policy_tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block(vec![add_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateAllowlist must succeed"); + + let allowed = scenario + .bob_token_tx(IB20::transferCall { to: BerylTestEnv::carol(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![allowed]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer from allowlisted member must succeed" + ); + scenario.assert_balance(BerylTestEnv::bob(), 99_999); + scenario.assert_balance(BerylTestEnv::carol(), 1); + + scenario.derive().await; +} + +/// BLOCKLIST policy wired to `TransferSender` blocks listed accounts from sending. +/// +/// 1. Alice seeds bob with tokens (default `ALWAYS_ALLOW` policy, succeeds). +/// 2. Create BLOCKLIST policy; wire it to `TransferSender`. +/// 3. Bob transfers; succeeds (not in blocklist). +/// 4. Admin adds bob to the blocklist. +/// 5. Bob tries to transfer; reverts. +#[tokio::test] +async fn b20_blocklist_sender_policy_blocks_listed_accounts() { + let mut scenario = B20PolicyScenario::new().await; + + let seed_bob = + scenario.token_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: SEED_AMOUNT }); + let block = scenario.build_block(vec![seed_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "seeding bob must succeed"); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - 100_000); + scenario.assert_balance(BerylTestEnv::bob(), 100_000); + + let blocklist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::BLOCKLIST, 2); + let create_policy = scenario.policy_tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::BLOCKLIST, + }); + let block = scenario.build_block(vec![create_policy]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy(BLOCKLIST) must succeed"); + + let wire = scenario.token_tx(IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: blocklist_id, + }); + let block = scenario.build_block(vec![wire]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updatePolicy must succeed"); + + let first_transfer = scenario + .bob_token_tx(IB20::transferCall { to: BerylTestEnv::carol(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![first_transfer]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer from non-blocked account must succeed" + ); + scenario.assert_balance(BerylTestEnv::bob(), 99_999); + scenario.assert_balance(BerylTestEnv::carol(), 1); + + let block_bob = scenario.policy_tx(IPolicyRegistry::updateBlocklistCall { + policyId: blocklist_id, + blocked: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block(vec![block_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateBlocklist must succeed"); + + let blocked = scenario + .bob_token_tx(IB20::transferCall { to: BerylTestEnv::carol(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer from blocked account must revert" + ); + scenario.assert_balance(BerylTestEnv::bob(), 99_999); + scenario.assert_balance(BerylTestEnv::carol(), 1); + + scenario.derive().await; +} + +/// Wiring the built-in `ALWAYS_BLOCK` policy to `TransferSender` blocks every sender immediately. +/// +/// No allowlist entries are needed: `ALWAYS_BLOCK_ID` denies all accounts unconditionally. +#[tokio::test] +async fn b20_always_block_sender_policy_blocks_all_transfers() { + let mut scenario = B20PolicyScenario::new().await; + + let wire = scenario.token_tx(IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }); + let block = scenario.build_block(vec![wire]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updatePolicy must succeed"); + + let blocked = + scenario.token_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer must revert when ALWAYS_BLOCK sender policy is set" + ); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balance(BerylTestEnv::bob(), 0); + + scenario.derive().await; +} + +/// ALLOWLIST policy wired to `TransferReceiver` blocks non-members from receiving. +#[tokio::test] +async fn b20_allowlist_receiver_policy_blocks_non_members() { + let mut scenario = B20PolicyScenario::new().await; + + let allowlist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::ALLOWLIST, 2); + let create_policy = scenario.policy_tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + }); + let block = scenario.build_block(vec![create_policy]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy(ALLOWLIST) must succeed"); + + let wire = scenario.token_tx(IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferReceiver.id(), + newPolicyId: allowlist_id, + }); + let block = scenario.build_block(vec![wire]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updatePolicy must succeed"); + + let blocked = + scenario.token_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer to non-member must revert when ALLOWLIST receiver policy is set" + ); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balance(BerylTestEnv::bob(), 0); + + let add_bob = scenario.policy_tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block(vec![add_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateAllowlist must succeed"); + + let allowed = + scenario.token_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: TRANSFER_AMOUNT }); + let block = scenario.build_block(vec![allowed]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer to allowlisted receiver must succeed" + ); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - 1); + scenario.assert_balance(BerylTestEnv::bob(), 1); + + scenario.derive().await; +} + +struct B20PolicyScenario { + env: BerylTestEnv, + token: Address, + blocks: Vec<(BaseBlock, u64)>, +} + +impl B20PolicyScenario { + async fn new() -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_token_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + scenario.build_block(vec![]).await; + + let act_factory = scenario.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let act_b20 = scenario.env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let act_policy = scenario.env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block = scenario.build_block(vec![act_factory, act_b20, act_policy]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "B20_TOKEN activation must succeed"); + assert!( + scenario.env.user_tx_succeeded(&block, 2), + "POLICY_REGISTRY activation must succeed" + ); + + let create = scenario.env.create_b20_token_tx(); + let block = scenario.build_block(vec![create]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20 token creation must succeed"); + + scenario + } + + async fn build_block(&mut self, txs: Vec) -> BaseBlock { + let block = self.env.sequencer.build_next_block_with_transactions(txs).await; + let block_number = self.blocks.len() as u64 + 1; + self.blocks.push((block.clone(), block_number)); + block + } + + fn token_tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(self.token), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + fn bob_token_tx(&mut self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_bob_tx( + TxKind::Call(self.token), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + fn policy_tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(PolicyRegistryStorage::ADDRESS), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + fn assert_balance(&self, account: Address, expected: u64) { + assert_eq!( + self.env.b20_balance(self.token, account), + U256::from(expected), + "B-20 balance for {account} must match expected value" + ); + } + + async fn derive(mut self) { + let expected_safe_head = self.blocks.len() as u64; + self.env.derive_blocks(self.blocks, expected_safe_head).await; + } +} diff --git a/actions/harness/tests/beryl/env.rs b/actions/harness/tests/beryl/env.rs new file mode 100644 index 0000000000..7bc12c43cb --- /dev/null +++ b/actions/harness/tests/beryl/env.rs @@ -0,0 +1,679 @@ +//! Shared test environment for Base Beryl action tests. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, hex, uint}; +use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use base_action_harness::{ + ActionL2Source, ActionTestHarness, Batcher, BatcherConfig, L1MinerConfig, L2Sequencer, + SharedL1Chain, TEST_ACCOUNT_ADDRESS, TestAccount, TestRollupConfigBuilder, TestRollupNode, + VerifierPipeline, +}; +use base_batcher_encoder::{DaType, EncoderConfig}; +use base_common_consensus::{BaseBlock, BaseReceipt, BaseTxEnvelope}; +use base_common_precompiles::{ + ActivationFeature, ActivationRegistryStorage, B20FactoryStorage, B20SecurityStorage, + B20Variant, IActivationRegistry, IB20, IB20Factory, IPolicyRegistry, PolicyRegistryStorage, +}; +use base_precompile_storage::StorageKey; +use base_test_utils::Account; + +/// L2 timestamp where the Beryl fork activates in these tests. +pub(crate) const BERYL_ACTIVATION_TIMESTAMP: u64 = 4; + +/// B-20 token storage slot for `total_supply`. +const B20_TOTAL_SUPPLY_SLOT: U256 = + uint!(0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434003_U256); + +/// B-20 token storage slot for `balances`. +const B20_BALANCES_SLOT: U256 = + uint!(0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434004_U256); + +/// B-20 token storage slot for `allowances`. +const B20_ALLOWANCES_SLOT: U256 = + uint!(0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434005_U256); + +/// Storage slot where staticcall probes store the call success flag. +const PROBE_CALL_SUCCESS_SLOT: U256 = U256::ZERO; + +/// Storage slot where staticcall probes store the first returned word. +const PROBE_RETURN_WORD_SLOT: U256 = U256::from_limbs([1, 0, 0, 0]); + +/// Storage slot where staticcall probes store the returned byte length. +const PROBE_RETURN_SIZE_SLOT: U256 = U256::from_limbs([2, 0, 0, 0]); + +/// Storage slot where staticcall probes store `keccak256(returndata)`. +const PROBE_RETURN_HASH_SLOT: U256 = U256::from_limbs([3, 0, 0, 0]); + +/// Test environment preconfigured to cross Base Beryl at L2 block 2. +pub(crate) struct BerylTestEnv { + /// Sequencer used to build Beryl precompile blocks. + pub(crate) sequencer: L2Sequencer, + harness: ActionTestHarness, + batcher_cfg: BatcherConfig, + node: TestRollupNode, + chain: SharedL1Chain, + chain_id: u64, + bob_account: TestAccount, +} + +impl BerylTestEnv { + /// Gas limit used for B-20 precompile transactions. + pub(crate) const B20_GAS_LIMIT: u64 = 10_000_000; + + /// Gas limit used for B-20 staticcall probe transactions. + pub(crate) const B20_PROBE_GAS_LIMIT: u64 = 1_000_000; + + /// Fixed decimals for the default B-20 token variant. + pub(crate) const B20_DECIMALS: u8 = 18; + + /// Name for the default B-20 token variant. + pub(crate) const B20_NAME: &str = "Action B20"; + + /// Symbol for the default B-20 token variant. + pub(crate) const B20_SYMBOL: &str = "AB20"; + + /// Fixed decimals for the stablecoin B-20 token variant. + pub(crate) const B20_STABLECOIN_DECIMALS: u8 = 6; + + /// Name for the stablecoin B-20 token variant. + pub(crate) const B20_STABLECOIN_NAME: &str = "Action USD"; + + /// Symbol for the stablecoin B-20 token variant. + pub(crate) const B20_STABLECOIN_SYMBOL: &str = "AUSD"; + + /// ISO 4217 currency code for the stablecoin B-20 token variant. + pub(crate) const B20_STABLECOIN_CURRENCY: &str = "USD"; + + /// Fixed decimals for the security B-20 token variant. + pub(crate) const B20_SECURITY_DECIMALS: u8 = 6; + + /// Name for the security B-20 token variant. + pub(crate) const B20_SECURITY_NAME: &str = "Action Security"; + + /// Symbol for the security B-20 token variant. + pub(crate) const B20_SECURITY_SYMBOL: &str = "ASEC"; + + /// ISIN stored on the security B-20 token at creation. + pub(crate) const B20_SECURITY_ISIN: &str = "US0000000001"; + + /// Initial minimum redeemable share amount for the security B-20 token. + pub(crate) const B20_SECURITY_MINIMUM_REDEEMABLE: u64 = 10; + + /// Initial B-20 supply minted to Alice. + pub(crate) const B20_INITIAL_SUPPLY: u64 = 1_000_000; + + /// Amount transferred from Alice to Bob. + pub(crate) const B20_BOB_TRANSFER: u64 = 100_000; + + /// Amount transferred from Bob to Carol. + pub(crate) const B20_CAROL_TRANSFER: u64 = 25_000; + + /// Allowance Alice approves for Bob. + pub(crate) const B20_BOB_ALLOWANCE: u64 = 50_000; + + /// Amount Bob transfers from Alice to Carol using allowance. + pub(crate) const B20_TRANSFER_FROM_CAROL: u64 = 40_000; + + /// Creates an environment with all forks through Azul active at genesis + /// and Base Beryl active at timestamp 4. + pub(crate) fn new() -> Self { + let batcher_cfg = BatcherConfig { + encoder: EncoderConfig { da_type: DaType::Calldata, ..EncoderConfig::default() }, + ..Default::default() + }; + + let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) + .through_isthmus() + .with_jovian_at(0) + .with_azul_at(0) + .with_beryl_at(BERYL_ACTIVATION_TIMESTAMP) + .build(); + let chain_id = rollup_cfg.l2_chain_id.id(); + let harness = ActionTestHarness::new(L1MinerConfig::default(), rollup_cfg); + + let l1_chain = SharedL1Chain::from_blocks(harness.l1.chain().to_vec()); + let mut sequencer = harness.create_l2_sequencer(l1_chain); + + let (node, chain) = harness.create_test_rollup_node_from_sequencer( + &mut sequencer, + SharedL1Chain::from_blocks(harness.l1.chain().to_vec()), + ); + + let bob_account = TestAccount::new(Account::Bob.signer_b256()); + + Self { sequencer, harness, batcher_cfg, node, chain, chain_id, bob_account } + } + + /// Returns the funded test account that creates and holds the B-20 supply. + pub(crate) const fn alice() -> Address { + TEST_ACCOUNT_ADDRESS + } + + /// Returns Bob's recipient address for B-20 transfer assertions. + pub(crate) const fn bob() -> Address { + Account::Bob.address() + } + + /// Returns Carol's recipient address for B-20 transfer assertions. + pub(crate) const fn carol() -> Address { + Account::Charlie.address() + } + + /// Returns the address created by the first test-account deployment. + pub(crate) fn first_contract_address(&self) -> Address { + TEST_ACCOUNT_ADDRESS.create(0) + } + + /// Creates and signs a test-account transaction. + pub(crate) fn create_tx(&self, to: TxKind, input: Bytes, gas_limit: u64) -> BaseTxEnvelope { + let account = self.sequencer.test_account(); + let mut account = account.lock().expect("test account lock"); + account.create_tx(self.chain_id, to, input, U256::ZERO, gas_limit) + } + + /// Creates and signs a transaction from Bob's account. + pub(crate) fn create_bob_tx( + &mut self, + to: TxKind, + input: Bytes, + gas_limit: u64, + ) -> BaseTxEnvelope { + Self::create_account_tx(self.chain_id, &mut self.bob_account, to, input, gas_limit) + } + + /// Returns the L2 chain ID used by the Beryl test environment. + pub(crate) const fn chain_id(&self) -> u64 { + self.chain_id + } + + /// Activation registry feature ID for the token factory precompile. + pub(crate) const fn b20_factory_feature() -> B256 { + ActivationFeature::B20Factory.id() + } + + /// Activation registry feature ID for the B-20 token precompile. + pub(crate) const fn b20_token_feature() -> B256 { + ActivationFeature::B20Token.id() + } + + /// Activation registry feature ID for the B-20 stablecoin precompile. + pub(crate) const fn b20_stablecoin_feature() -> B256 { + ActivationFeature::B20Stablecoin.id() + } + + /// Activation registry feature ID for the B-20 security precompile. + pub(crate) const fn b20_security_feature() -> B256 { + ActivationFeature::B20Security.id() + } + + /// Activation registry feature ID for the policy registry precompile. + pub(crate) const fn policy_registry_feature() -> B256 { + ActivationFeature::PolicyRegistry.id() + } + + /// Computes the expected policy ID for a custom policy. + /// + /// IDs are encoded as `(type_discriminant << 56) | counter` where the counter is a + /// global monotonic sequence. Counters 0 and 1 are reserved for the built-in policies, + /// so the first custom policy always gets counter 2. + pub(crate) const fn policy_id(policy_type: IPolicyRegistry::PolicyType, counter: u64) -> u64 { + (policy_type as u64) << 56 | counter + } + + /// Alternate salt for a second token creation used in deactivation/re-activation tests. + pub(crate) const ALT_SALT: B256 = B256::repeat_byte(0x43); + + /// Returns the deterministic salt used to create the B-20 token. + pub(crate) const fn b20_token_salt() -> B256 { + B256::repeat_byte(0x42) + } + + /// Returns the deterministic salt used to create the B-20 stablecoin token. + pub(crate) const fn b20_stablecoin_salt() -> B256 { + B256::repeat_byte(0x45) + } + + /// Returns the deterministic salt used to create the B-20 security token. + pub(crate) const fn b20_security_salt() -> B256 { + B256::repeat_byte(0x46) + } + + /// Returns the deterministic B-20 token address created by Alice. + pub(crate) fn b20_token_address(&self) -> Address { + B20Variant::B20.compute_address(Self::alice(), Self::b20_token_salt()).0 + } + + /// Returns the deterministic B-20 stablecoin address created by Alice. + pub(crate) fn b20_stablecoin_address(&self) -> Address { + B20Variant::Stablecoin.compute_address(Self::alice(), Self::b20_stablecoin_salt()).0 + } + + /// Returns the deterministic B-20 security token address created by Alice. + pub(crate) fn b20_security_address(&self) -> Address { + B20Variant::Security.compute_address(Self::alice(), Self::b20_security_salt()).0 + } + + /// Creates a transaction that calls the B-20 token factory with the default salt. + pub(crate) fn create_b20_token_tx(&self) -> BaseTxEnvelope { + self.create_b20_token_with_salt_tx(Self::b20_token_salt()) + } + + /// Creates a transaction that calls the B-20 token factory with the given `salt`. + pub(crate) fn create_b20_token_with_salt_tx(&self, salt: B256) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(B20FactoryStorage::ADDRESS), + Bytes::from(self.create_b20_token_call_with_salt(salt).abi_encode()), + Self::B20_GAS_LIMIT, + ) + } + + /// Creates a transaction that calls the B-20 token factory for a stablecoin. + pub(crate) fn create_b20_stablecoin_tx(&self) -> BaseTxEnvelope { + self.create_b20_stablecoin_with_salt_tx(Self::b20_stablecoin_salt()) + } + + /// Creates a stablecoin factory transaction with the given `salt`. + pub(crate) fn create_b20_stablecoin_with_salt_tx(&self, salt: B256) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(B20FactoryStorage::ADDRESS), + Bytes::from(self.create_b20_stablecoin_call_with_salt(salt).abi_encode()), + Self::B20_GAS_LIMIT, + ) + } + + /// Creates a transaction that calls the B-20 token factory for a security token. + pub(crate) fn create_b20_security_tx(&self) -> BaseTxEnvelope { + self.create_b20_security_with_salt_tx(Self::b20_security_salt()) + } + + /// Creates a security-token factory transaction with the given `salt`. + pub(crate) fn create_b20_security_with_salt_tx(&self, salt: B256) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(B20FactoryStorage::ADDRESS), + Bytes::from(self.create_b20_security_call_with_salt(salt).abi_encode()), + Self::B20_GAS_LIMIT, + ) + } + + /// Creates and signs a transaction that deploys a staticcall probe for `target`. + pub(crate) fn deploy_staticcall_probe_tx(&self, target: Address) -> (Address, BaseTxEnvelope) { + let account = self.sequencer.test_account(); + let mut account = account.lock().expect("test account lock"); + let address = account.address().create(account.nonce()); + let tx = account.create_tx( + self.chain_id, + TxKind::Create, + Self::staticcall_probe_init_code(target), + U256::ZERO, + Self::B20_PROBE_GAS_LIMIT, + ); + (address, tx) + } + + /// Creates a transaction that calls a deployed staticcall probe with arbitrary calldata. + pub(crate) fn call_staticcall_probe_tx( + &self, + probe: Address, + input: Bytes, + gas_limit: u64, + ) -> BaseTxEnvelope { + self.create_tx(TxKind::Call(probe), input, gas_limit) + } + + /// Creates a transaction that transfers B-20 tokens from Alice to `to`. + pub(crate) fn transfer_b20_tx( + &self, + token: Address, + to: Address, + amount: U256, + ) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(token), + Bytes::from(IB20::transferCall { to, amount }.abi_encode()), + Self::B20_GAS_LIMIT, + ) + } + + /// Creates a transaction that approves `spender` to spend Alice's B-20 tokens. + pub(crate) fn approve_b20_tx( + &self, + token: Address, + spender: Address, + amount: U256, + ) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(token), + Bytes::from(IB20::approveCall { spender, amount }.abi_encode()), + Self::B20_GAS_LIMIT, + ) + } + + /// Creates a transaction that transfers B-20 tokens from Bob to `to`. + pub(crate) fn transfer_b20_from_bob_tx( + &mut self, + token: Address, + to: Address, + amount: U256, + ) -> BaseTxEnvelope { + let input = Bytes::from(IB20::transferCall { to, amount }.abi_encode()); + Self::create_account_tx( + self.chain_id, + &mut self.bob_account, + TxKind::Call(token), + input, + Self::B20_GAS_LIMIT, + ) + } + + /// Creates a transaction that transfers B-20 tokens from Alice using Bob's allowance. + pub(crate) fn transfer_b20_from_alice_by_bob_tx( + &mut self, + token: Address, + to: Address, + amount: U256, + ) -> BaseTxEnvelope { + let input = + Bytes::from(IB20::transferFromCall { from: Self::alice(), to, amount }.abi_encode()); + Self::create_account_tx( + self.chain_id, + &mut self.bob_account, + TxKind::Call(token), + input, + Self::B20_GAS_LIMIT, + ) + } + + /// Creates an activation registry `activate(feature)` transaction signed by the admin. + /// + /// The test rollup config sets `TEST_ACCOUNT_ADDRESS` as the activation admin. + pub(crate) fn activate_feature_tx(&self, feature: B256) -> BaseTxEnvelope { + let input = Bytes::from(IActivationRegistry::activateCall { feature }.abi_encode()); + self.create_tx(TxKind::Call(ActivationRegistryStorage::ADDRESS), input, Self::B20_GAS_LIMIT) + } + + /// Creates an activation registry `deactivate(feature)` transaction signed by the admin. + pub(crate) fn deactivate_feature_tx(&self, feature: B256) -> BaseTxEnvelope { + let input = Bytes::from(IActivationRegistry::deactivateCall { feature }.abi_encode()); + self.create_tx(TxKind::Call(ActivationRegistryStorage::ADDRESS), input, Self::B20_GAS_LIMIT) + } + + /// Creates a transaction that calls `totalSupply()` through `probe`. + pub(crate) fn probe_b20_total_supply_tx(&self, probe: Address) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(probe), + Bytes::from(IB20::totalSupplyCall {}.abi_encode()), + Self::B20_PROBE_GAS_LIMIT, + ) + } + + /// Creates a transaction that calls `balanceOf(account)` through `probe`. + pub(crate) fn probe_b20_balance_tx(&self, probe: Address, account: Address) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(probe), + Bytes::from(IB20::balanceOfCall { account }.abi_encode()), + Self::B20_PROBE_GAS_LIMIT, + ) + } + + /// Creates a transaction that calls `allowance(owner, spender)` through `probe`. + pub(crate) fn probe_b20_allowance_tx( + &self, + probe: Address, + owner: Address, + spender: Address, + ) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(probe), + Bytes::from(IB20::allowanceCall { owner, spender }.abi_encode()), + Self::B20_PROBE_GAS_LIMIT, + ) + } + + /// Creates a transaction that calls `decimals()` through `probe`. + pub(crate) fn probe_b20_decimals_tx(&self, probe: Address) -> BaseTxEnvelope { + self.create_tx( + TxKind::Call(probe), + Bytes::from(IB20::decimalsCall {}.abi_encode()), + Self::B20_PROBE_GAS_LIMIT, + ) + } + + /// Reads the B-20 token's total supply from storage. + pub(crate) fn b20_total_supply(&self, token: Address) -> U256 { + self.sequencer.storage_at(token, B20_TOTAL_SUPPLY_SLOT) + } + + /// Reads a B-20 account balance from storage. + pub(crate) fn b20_balance(&self, token: Address, account: Address) -> U256 { + self.sequencer.storage_at(token, Self::b20_balance_slot(account)) + } + + /// Reads a B-20 allowance from storage. + pub(crate) fn b20_allowance(&self, token: Address, owner: Address, spender: Address) -> U256 { + self.sequencer.storage_at(token, Self::b20_allowance_slot(owner, spender)) + } + + /// Reads whether a staticcall probe's most recent call succeeded. + pub(crate) fn probe_call_succeeded(&self, probe: Address) -> bool { + self.sequencer.storage_at(probe, PROBE_CALL_SUCCESS_SLOT) == U256::ONE + } + + /// Reads the first returned word from a staticcall probe's most recent call. + pub(crate) fn probe_return_word(&self, probe: Address) -> U256 { + self.sequencer.storage_at(probe, PROBE_RETURN_WORD_SLOT) + } + + /// Reads the returned byte length from a staticcall probe's most recent call. + pub(crate) fn probe_return_size(&self, probe: Address) -> U256 { + self.sequencer.storage_at(probe, PROBE_RETURN_SIZE_SLOT) + } + + /// Reads `keccak256(returndata)` from a staticcall probe's most recent call. + pub(crate) fn probe_return_hash(&self, probe: Address) -> B256 { + B256::from(self.sequencer.storage_at(probe, PROBE_RETURN_HASH_SLOT).to_be_bytes::<32>()) + } + + /// Returns whether a user transaction in `block` succeeded. + pub(crate) fn user_tx_succeeded(&self, block: &BaseBlock, user_tx_index: usize) -> bool { + self.user_tx_receipt(block, user_tx_index).status() + } + + /// Returns the receipt for a non-deposit transaction in `block`. + pub(crate) fn user_tx_receipt(&self, block: &BaseBlock, user_tx_index: usize) -> BaseReceipt { + let deposit_count = block + .body + .transactions + .iter() + .take_while(|tx| matches!(tx, BaseTxEnvelope::Deposit(_))) + .count(); + let receipts = self + .sequencer + .receipts_at(block.header.number) + .unwrap_or_else(|| panic!("receipts must exist for L2 block {}", block.header.number)); + receipts + .into_iter() + .nth(deposit_count + user_tx_index) + .unwrap_or_else(|| panic!("user tx receipt {user_tx_index} must exist")) + } + + /// Returns whether a user transaction emitted the expected B-20 `Transfer` event. + pub(crate) fn b20_transfer_log_emitted( + &self, + block: &BaseBlock, + user_tx_index: usize, + token: Address, + from: Address, + to: Address, + amount: U256, + ) -> bool { + let expected = IB20::Transfer { from, to, amount }.encode_log_data(); + self.user_tx_receipt(block, user_tx_index) + .logs() + .iter() + .any(|log| log.address == token && log.data == expected) + } + + /// Returns whether a user transaction emitted the expected B-20 `Approval` event. + pub(crate) fn b20_approval_log_emitted( + &self, + block: &BaseBlock, + user_tx_index: usize, + token: Address, + owner: Address, + spender: Address, + amount: U256, + ) -> bool { + let expected = IB20::Approval { owner, spender, amount }.encode_log_data(); + self.user_tx_receipt(block, user_tx_index) + .logs() + .iter() + .any(|log| log.address == token && log.data == expected) + } + + /// Batches the supplied L2 blocks, derives each one, and asserts the final safe head. + pub(crate) async fn derive_blocks( + &mut self, + blocks: impl IntoIterator, + expected_safe_head: u64, + ) { + let mut batcher = Batcher::new( + ActionL2Source::new(), + &self.harness.rollup_config, + self.batcher_cfg.clone(), + ); + self.node.initialize().await; + + for (block, i) in blocks { + batcher.push_block(block); + batcher.advance(&mut self.harness.l1).await; + self.chain.push(self.harness.l1.tip().clone()); + let derived = self.node.run_until_idle().await; + assert_eq!(derived, 1, "L1 block {i} should derive exactly one L2 block"); + } + + assert_eq!( + self.node.l2_safe_number(), + expected_safe_head, + "all {expected_safe_head} L2 blocks must derive through the Beryl boundary" + ); + } + + fn create_b20_token_call_with_salt(&self, salt: B256) -> IB20Factory::createB20Call { + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::DEFAULT, + salt, + params: self.b20_token_params().abi_encode().into(), + initCalls: vec![ + IB20::mintCall { to: Self::alice(), amount: U256::from(Self::B20_INITIAL_SUPPLY) } + .abi_encode() + .into(), + ], + } + } + + fn create_b20_stablecoin_call_with_salt(&self, salt: B256) -> IB20Factory::createB20Call { + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::STABLECOIN, + salt, + params: self.b20_stablecoin_params().abi_encode().into(), + initCalls: vec![ + IB20::mintCall { to: Self::alice(), amount: U256::from(Self::B20_INITIAL_SUPPLY) } + .abi_encode() + .into(), + ], + } + } + + fn create_b20_security_call_with_salt(&self, salt: B256) -> IB20Factory::createB20Call { + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::SECURITY, + salt, + params: self.b20_security_params().abi_encode().into(), + initCalls: vec![ + IB20::mintCall { to: Self::alice(), amount: U256::from(Self::B20_INITIAL_SUPPLY) } + .abi_encode() + .into(), + IB20::updatePolicyCall { + policyScope: B20SecurityStorage::REDEEM_SENDER_POLICY, + newPolicyId: PolicyRegistryStorage::ALWAYS_ALLOW_ID, + } + .abi_encode() + .into(), + ], + } + } + + fn create_account_tx( + chain_id: u64, + account: &mut TestAccount, + to: TxKind, + input: Bytes, + gas_limit: u64, + ) -> BaseTxEnvelope { + account.create_tx(chain_id, to, input, U256::ZERO, gas_limit) + } + + fn staticcall_probe_init_code(target: Address) -> Bytes { + let mut runtime = Vec::with_capacity(65); + runtime.extend_from_slice(&hex!("3660006000376000600036600073")); + runtime.extend_from_slice(target.as_slice()); + runtime.extend_from_slice(&hex!( + "5afa" // staticcall(gas(), target, 0, calldatasize(), 0, 0) + "8060005550" // store success in slot 0 + "3d80600255" // store returndatasize in slot 2 + "80600060003e" // copy returndata to memory + "600051600155" // store first returned word in slot 1 + "600020600355" // store keccak256(returndata) in slot 3 + "00" + )); + + let mut init_code = Vec::with_capacity(12 + runtime.len()); + init_code.extend_from_slice(&hex!("6041600c60003960416000f3")); + init_code.extend_from_slice(&runtime); + Bytes::from(init_code) + } + + fn b20_token_params(&self) -> IB20Factory::B20CreateParams { + IB20Factory::B20CreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: Self::B20_NAME.to_string(), + symbol: Self::B20_SYMBOL.to_string(), + initialAdmin: Self::alice(), + } + } + + fn b20_stablecoin_params(&self) -> IB20Factory::B20StablecoinCreateParams { + IB20Factory::B20StablecoinCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: Self::B20_STABLECOIN_NAME.to_string(), + symbol: Self::B20_STABLECOIN_SYMBOL.to_string(), + initialAdmin: Self::alice(), + currency: Self::B20_STABLECOIN_CURRENCY.to_string(), + } + } + + fn b20_security_params(&self) -> IB20Factory::B20SecurityCreateParams { + IB20Factory::B20SecurityCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: Self::B20_SECURITY_NAME.to_string(), + symbol: Self::B20_SECURITY_SYMBOL.to_string(), + initialAdmin: Self::alice(), + isin: Self::B20_SECURITY_ISIN.to_string(), + minimumRedeemable: U256::from(Self::B20_SECURITY_MINIMUM_REDEEMABLE), + } + } + + fn b20_balance_slot(account: Address) -> U256 { + account.mapping_slot(B20_BALANCES_SLOT) + } + + fn b20_allowance_slot(owner: Address, spender: Address) -> U256 { + spender.mapping_slot(owner.mapping_slot(B20_ALLOWANCES_SLOT)) + } +} + +impl Default for BerylTestEnv { + fn default() -> Self { + Self::new() + } +} diff --git a/actions/harness/tests/beryl/factory.rs b/actions/harness/tests/beryl/factory.rs new file mode 100644 index 0000000000..5115f40cb7 --- /dev/null +++ b/actions/harness/tests/beryl/factory.rs @@ -0,0 +1,290 @@ +//! B-20 factory precompile action tests across the Base Beryl boundary. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::{SolCall, SolEvent}; +use base_common_consensus::BaseBlock; +use base_common_precompiles::{B20FactoryStorage, IB20Factory}; + +use crate::env::BerylTestEnv; + +#[tokio::test] +async fn beryl_enables_b20_factory_precompile() { + let mut env = BerylTestEnv::new(); + let token = env.b20_token_address(); + + let pre_beryl_create = env.create_b20_token_tx(); + let block1 = env.sequencer.build_next_block_with_transactions(vec![pre_beryl_create]).await; + + assert!(!env.sequencer.has_code(token), "B-20 token code must not be deployed before Beryl"); + assert_eq!( + env.b20_total_supply(token), + U256::ZERO, + "B-20 total supply must remain unset before Beryl" + ); + + let beryl_boundary = env.sequencer.build_empty_block().await; + let activation_block = B20FactoryPrecompiles::activate(&mut env).await; + + let post_beryl_create = env.create_b20_token_tx(); + let block2 = env.sequencer.build_next_block_with_transactions(vec![post_beryl_create]).await; + + assert!(env.user_tx_succeeded(&block2, 0), "B-20 creation transaction must succeed"); + assert!(env.sequencer.has_code(token), "B-20 token code must be deployed after Beryl"); + assert_eq!( + env.b20_total_supply(token), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + "B-20 total supply must be initialized after Beryl" + ); + assert_eq!( + env.b20_balance(token, BerylTestEnv::alice()), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + "Alice must receive the initial B-20 supply" + ); + assert_eq!( + env.b20_balance(token, BerylTestEnv::bob()), + U256::ZERO, + "Bob must start with no B-20 balance" + ); + assert_eq!( + env.b20_balance(token, BerylTestEnv::carol()), + U256::ZERO, + "Carol must start with no B-20 balance" + ); + + env.derive_blocks([(block1, 1), (beryl_boundary, 2), (activation_block, 3), (block2, 4)], 4) + .await; +} + +#[tokio::test] +async fn duplicate_b20_creation_reverts() { + let mut env = BerylTestEnv::new(); + let token = env.b20_token_address(); + + let block1 = env.sequencer.build_empty_block().await; + let activation_block = B20FactoryPrecompiles::activate(&mut env).await; + + let create = env.create_b20_token_tx(); + let block2 = env.sequencer.build_next_block_with_transactions(vec![create]).await; + + assert!(env.user_tx_succeeded(&block2, 0), "B-20 creation transaction must succeed"); + assert!(env.sequencer.has_code(token), "B-20 token code must be deployed"); + + let duplicate_create = env.create_b20_token_tx(); + let block3 = env.sequencer.build_next_block_with_transactions(vec![duplicate_create]).await; + + assert!(!env.user_tx_succeeded(&block3, 0), "duplicate B-20 creation must revert"); + assert_eq!( + env.b20_total_supply(token), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + "duplicate B-20 creation must leave total supply unchanged" + ); + assert_eq!( + env.b20_balance(token, BerylTestEnv::alice()), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + "duplicate B-20 creation must leave Alice's balance unchanged" + ); + + env.derive_blocks([(block1, 1), (activation_block, 2), (block2, 3), (block3, 4)], 4).await; +} + +#[tokio::test] +async fn b20_creation_reverts_while_factory_feature_is_deactivated() { + let mut env = BerylTestEnv::new(); + + let block1 = env.sequencer.build_empty_block().await; + let activation_block = B20FactoryPrecompiles::activate(&mut env).await; + + let deactivate_factory = env.deactivate_feature_tx(BerylTestEnv::b20_factory_feature()); + let block2 = env.sequencer.build_next_block_with_transactions(vec![deactivate_factory]).await; + + assert!(env.user_tx_succeeded(&block2, 0), "TOKEN_FACTORY deactivation must succeed"); + + let create_while_deactivated = env.create_b20_token_with_salt_tx(BerylTestEnv::ALT_SALT); + let block3 = + env.sequencer.build_next_block_with_transactions(vec![create_while_deactivated]).await; + + assert!( + !env.user_tx_succeeded(&block3, 0), + "token creation must revert when TOKEN_FACTORY is deactivated" + ); + + let reactivate_factory = env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let block4 = env.sequencer.build_next_block_with_transactions(vec![reactivate_factory]).await; + + assert!(env.user_tx_succeeded(&block4, 0), "TOKEN_FACTORY re-activation must succeed"); + + let create_after_reactivate = env.create_b20_token_with_salt_tx(BerylTestEnv::ALT_SALT); + let block5 = + env.sequencer.build_next_block_with_transactions(vec![create_after_reactivate]).await; + + assert!( + env.user_tx_succeeded(&block5, 0), + "token creation must succeed after TOKEN_FACTORY is re-activated" + ); + + env.derive_blocks( + [(block1, 1), (activation_block, 2), (block2, 3), (block3, 4), (block4, 5), (block5, 6)], + 6, + ) + .await; +} + +#[tokio::test] +async fn b20_factory_views_and_events_are_available_after_beryl_activation() { + let mut env = BerylTestEnv::new(); + let token = env.b20_token_address(); + + let block1 = env.sequencer.build_empty_block().await; + let activation_block = B20FactoryPrecompiles::activate(&mut env).await; + + let create = env.create_b20_token_tx(); + let block2 = env.sequencer.build_next_block_with_transactions(vec![create]).await; + + assert!(env.user_tx_succeeded(&block2, 0), "B-20 creation transaction must succeed"); + assert_token_created_log(&env, &block2, token); + + let (probe, deploy_probe) = env.deploy_staticcall_probe_tx(B20FactoryStorage::ADDRESS); + let block3 = env.sequencer.build_next_block_with_transactions(vec![deploy_probe]).await; + assert!(env.user_tx_succeeded(&block3, 0), "factory staticcall probe must deploy"); + + let get_token_address = env.call_staticcall_probe_tx( + probe, + Bytes::from( + IB20Factory::getB20AddressCall { + variant: IB20Factory::B20Variant::DEFAULT, + sender: BerylTestEnv::alice(), + salt: BerylTestEnv::b20_token_salt(), + } + .abi_encode(), + ), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block4 = env.sequencer.build_next_block_with_transactions(vec![get_token_address]).await; + + assert!(env.probe_call_succeeded(probe), "getB20Address() staticcall must succeed"); + assert_eq!( + env.probe_return_word(probe), + word_from_address(token), + "getB20Address() must return the deterministic token address" + ); + + let is_b20 = env.call_staticcall_probe_tx( + probe, + Bytes::from(IB20Factory::isB20Call { token }.abi_encode()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block5 = env.sequencer.build_next_block_with_transactions(vec![is_b20]).await; + + assert!(env.probe_call_succeeded(probe), "isB20() staticcall must succeed"); + assert_eq!(env.probe_return_word(probe), U256::ONE, "created token must be B-20"); + + let is_not_b20 = env.call_staticcall_probe_tx( + probe, + Bytes::from(IB20Factory::isB20Call { token: B20FactoryStorage::ADDRESS }.abi_encode()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block6 = env.sequencer.build_next_block_with_transactions(vec![is_not_b20]).await; + + assert!(env.probe_call_succeeded(probe), "isB20(non-token) staticcall must succeed"); + assert_eq!(env.probe_return_word(probe), U256::ZERO, "factory singleton must not be B-20"); + + let is_initialized = env.call_staticcall_probe_tx( + probe, + Bytes::from(IB20Factory::isB20InitializedCall { token }.abi_encode()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block7 = env.sequencer.build_next_block_with_transactions(vec![is_initialized]).await; + + assert!(env.probe_call_succeeded(probe), "isB20Initialized() staticcall must succeed"); + assert_eq!(env.probe_return_word(probe), U256::ONE, "created token must be initialized"); + + let is_not_initialized = env.call_staticcall_probe_tx( + probe, + Bytes::from( + IB20Factory::isB20InitializedCall { token: Address::repeat_byte(0xab) }.abi_encode(), + ), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block8 = env.sequencer.build_next_block_with_transactions(vec![is_not_initialized]).await; + + assert!(env.probe_call_succeeded(probe), "isB20Initialized(non-token) staticcall must succeed"); + assert_eq!(env.probe_return_word(probe), U256::ZERO, "non-token must not be initialized"); + + let malformed_stablecoin_create = env.create_tx( + TxKind::Call(B20FactoryStorage::ADDRESS), + Bytes::from( + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::STABLECOIN, + salt: BerylTestEnv::ALT_SALT, + params: Bytes::new(), + initCalls: Vec::new(), + } + .abi_encode(), + ), + BerylTestEnv::B20_GAS_LIMIT, + ); + let block9 = + env.sequencer.build_next_block_with_transactions(vec![malformed_stablecoin_create]).await; + + assert!(!env.user_tx_succeeded(&block9, 0), "malformed stablecoin params must revert"); + + env.derive_blocks( + [ + (block1, 1), + (activation_block, 2), + (block2, 3), + (block3, 4), + (block4, 5), + (block5, 6), + (block6, 7), + (block7, 8), + (block8, 9), + (block9, 10), + ], + 10, + ) + .await; +} + +struct B20FactoryPrecompiles; + +impl B20FactoryPrecompiles { + async fn activate(env: &mut BerylTestEnv) -> BaseBlock { + let activate_factory = env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_b20 = env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let block = env + .sequencer + .build_next_block_with_transactions(vec![activate_factory, activate_b20]) + .await; + + assert!(env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(env.user_tx_succeeded(&block, 1), "B20_TOKEN activation must succeed"); + + block + } +} + +fn assert_token_created_log(env: &BerylTestEnv, block: &BaseBlock, token: Address) { + let expected = IB20Factory::B20Created { + token, + variant: IB20Factory::B20Variant::DEFAULT, + name: BerylTestEnv::B20_NAME.to_string(), + symbol: BerylTestEnv::B20_SYMBOL.to_string(), + decimals: BerylTestEnv::B20_DECIMALS, + } + .encode_log_data(); + assert!( + env.user_tx_receipt(block, 0) + .logs() + .iter() + .any(|log| log.address == B20FactoryStorage::ADDRESS && log.data == expected), + "createB20() must emit B20Created" + ); +} + +fn word_from_address(address: Address) -> U256 { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(address.as_slice()); + U256::from_be_slice(&word) +} diff --git a/actions/harness/tests/beryl/main.rs b/actions/harness/tests/beryl/main.rs new file mode 100644 index 0000000000..a34f5661fa --- /dev/null +++ b/actions/harness/tests/beryl/main.rs @@ -0,0 +1,12 @@ +//! Action tests for Base Beryl hardfork activation. + +mod activation; +mod b20; +mod b20_policy; +mod env; +mod factory; +mod policy_registry; +mod policy_transfer; +mod security; +mod stablecoin; +mod test_helpers; diff --git a/actions/harness/tests/beryl/policy_registry.rs b/actions/harness/tests/beryl/policy_registry.rs new file mode 100644 index 0000000000..b3b8eb5654 --- /dev/null +++ b/actions/harness/tests/beryl/policy_registry.rs @@ -0,0 +1,733 @@ +//! Policy registry precompile action tests across the Base Beryl boundary. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Bytes, TxKind, U256, hex}; +use alloy_sol_types::{SolCall, SolEvent}; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_precompiles::{IPolicyRegistry, PolicyRegistryStorage}; + +use crate::env::BerylTestEnv; + +const GAS_LIMIT: u64 = 1_000_000; + +/// Probe-contract init code. +/// +/// Runtime copies calldata, `STATICCALL`s the Beryl policy registry precompile, +/// stores the call success flag in slot 0, and stores the first returned word in slot 1. +const POLICY_REGISTRY_PROBE_INIT_CODE: [u8; 59] = hex!( + "602f600c600039602f6000f3" + "366000600037602060003660007384530000000000000000000000000000000000025afa8060005560005160015500" +); + +const CALL_SUCCESS_SLOT: U256 = U256::ZERO; +const RETURN_WORD_SLOT: U256 = U256::from_limbs([1, 0, 0, 0]); + +#[tokio::test] +async fn beryl_enables_policy_registry_singleton_precompile() { + let mut env = BerylTestEnv::new(); + let probe = env.first_contract_address(); + let policy_exists_call = + Bytes::from(IPolicyRegistry::policyExistsCall { policyId: 0 }.abi_encode()); + + let deploy_probe = env.create_tx( + TxKind::Create, + Bytes::from_static(&POLICY_REGISTRY_PROBE_INIT_CODE), + GAS_LIMIT, + ); + let pre_beryl_probe = env.create_tx(TxKind::Call(probe), policy_exists_call.clone(), GAS_LIMIT); + let block1 = + env.sequencer.build_next_block_with_transactions(vec![deploy_probe, pre_beryl_probe]).await; + + assert!(env.sequencer.has_code(probe), "probe contract must deploy before Beryl"); + assert_ne!( + env.sequencer.storage_at(probe, RETURN_WORD_SLOT), + U256::from(1), + "policy registry must not return true before Beryl" + ); + + // Cross the Beryl activation boundary with an empty block so subsequent blocks execute with + // the Beryl precompile set. + let beryl_boundary = env.sequencer.build_empty_block().await; + + // Activate POLICY_REGISTRY in its own block so the state is committed before the probe runs. + let activate_registry = env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block2 = env.sequencer.build_next_block_with_transactions(vec![activate_registry]).await; + + assert!(env.user_tx_succeeded(&block2, 0), "POLICY_REGISTRY activation must succeed"); + + // Block3: probe runs against the committed activated state. + let post_beryl_probe = + env.create_tx(TxKind::Call(probe), policy_exists_call.clone(), GAS_LIMIT); + let block3 = env.sequencer.build_next_block_with_transactions(vec![post_beryl_probe]).await; + + assert_eq!( + env.sequencer.storage_at(probe, CALL_SUCCESS_SLOT), + U256::from(1), + "policy registry staticcall must succeed after activation" + ); + assert_eq!( + env.sequencer.storage_at(probe, RETURN_WORD_SLOT), + U256::from(1), + "policy registry policyExists(ALWAYS_ALLOW_ID) must return true after activation" + ); + + // -- Deactivation tests -- + // Block4: deactivate POLICY_REGISTRY (committed state before block5). + let deactivate_registry = env.deactivate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block4 = env.sequencer.build_next_block_with_transactions(vec![deactivate_registry]).await; + + assert!(env.user_tx_succeeded(&block4, 0), "POLICY_REGISTRY deactivation must succeed"); + + // Block5: probe's staticcall must fail while POLICY_REGISTRY is deactivated. + let probe_while_deactivated = + env.create_tx(TxKind::Call(probe), policy_exists_call.clone(), GAS_LIMIT); + let block5 = + env.sequencer.build_next_block_with_transactions(vec![probe_while_deactivated]).await; + + assert_eq!( + env.sequencer.storage_at(probe, CALL_SUCCESS_SLOT), + U256::ZERO, + "policy registry staticcall must fail when POLICY_REGISTRY is deactivated" + ); + + // Block6: re-activate POLICY_REGISTRY (committed state before block7). + let reactivate_registry = env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block6 = env.sequencer.build_next_block_with_transactions(vec![reactivate_registry]).await; + + assert!(env.user_tx_succeeded(&block6, 0), "POLICY_REGISTRY re-activation must succeed"); + + // Block7: probe's staticcall must succeed again after re-activation. + let probe_after_reactivate = env.create_tx(TxKind::Call(probe), policy_exists_call, GAS_LIMIT); + let block7 = + env.sequencer.build_next_block_with_transactions(vec![probe_after_reactivate]).await; + + assert_eq!( + env.sequencer.storage_at(probe, CALL_SUCCESS_SLOT), + U256::from(1), + "policy registry staticcall must succeed after re-activation" + ); + assert_eq!( + env.sequencer.storage_at(probe, RETURN_WORD_SLOT), + U256::from(1), + "policy registry policyExists(ALWAYS_ALLOW_ID) must return true after re-activation" + ); + + env.derive_blocks( + [ + (block1, 1), + (beryl_boundary, 2), + (block2, 3), + (block3, 4), + (block4, 5), + (block5, 6), + (block6, 7), + (block7, 8), + ], + 8, + ) + .await; +} + +#[tokio::test] +async fn policy_registry_action_tests_cover_policy_lifecycle_and_views() { + let mut scenario = PolicyRegistryScenario::new().await; + let allowlist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::ALLOWLIST, 2); + let blocklist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::BLOCKLIST, 3); + + let create_allowlist = scenario.tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + }); + let block = scenario.build_block_with_transactions(vec![create_allowlist]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::PolicyCreated { + policyId: allowlist_id, + creator: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + } + .encode_log_data(), + ); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::PolicyAdminUpdated { + policyId: allowlist_id, + previousAdmin: alloy_primitives::Address::ZERO, + newAdmin: BerylTestEnv::alice(), + } + .encode_log_data(), + ); + + scenario + .assert_probe_word( + "policyExists(allowlist)", + IPolicyRegistry::policyExistsCall { policyId: allowlist_id }.abi_encode(), + U256::ONE, + ) + .await; + scenario + .assert_probe_word( + "policyAdmin(allowlist)", + IPolicyRegistry::policyAdminCall { policyId: allowlist_id }.abi_encode(), + word_from_address(BerylTestEnv::alice()), + ) + .await; + scenario + .assert_probe_word( + "pendingPolicyAdmin(allowlist)", + IPolicyRegistry::pendingPolicyAdminCall { policyId: allowlist_id }.abi_encode(), + U256::ZERO, + ) + .await; + + let update_allowlist = scenario.tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![update_allowlist]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateAllowlist() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::AllowlistUpdated { + policyId: allowlist_id, + updater: BerylTestEnv::alice(), + allowed: true, + accounts: vec![BerylTestEnv::bob()], + } + .encode_log_data(), + ); + scenario + .assert_probe_word( + "isAuthorized(allowlist member)", + IPolicyRegistry::isAuthorizedCall { + policyId: allowlist_id, + account: BerylTestEnv::bob(), + } + .abi_encode(), + U256::ONE, + ) + .await; + scenario + .assert_probe_word( + "isAuthorized(allowlist non-member)", + IPolicyRegistry::isAuthorizedCall { + policyId: allowlist_id, + account: BerylTestEnv::carol(), + } + .abi_encode(), + U256::ZERO, + ) + .await; + + let stage_admin = scenario.tx(IPolicyRegistry::stageUpdateAdminCall { + policyId: allowlist_id, + newAdmin: BerylTestEnv::bob(), + }); + let block = scenario.build_block_with_transactions(vec![stage_admin]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "stageUpdateAdmin() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::PolicyAdminStaged { + policyId: allowlist_id, + currentAdmin: BerylTestEnv::alice(), + pendingAdmin: BerylTestEnv::bob(), + } + .encode_log_data(), + ); + scenario + .assert_probe_word( + "pendingPolicyAdmin(staged)", + IPolicyRegistry::pendingPolicyAdminCall { policyId: allowlist_id }.abi_encode(), + word_from_address(BerylTestEnv::bob()), + ) + .await; + + let finalize_admin = + scenario.bob_tx(IPolicyRegistry::finalizeUpdateAdminCall { policyId: allowlist_id }); + let block = scenario.build_block_with_transactions(vec![finalize_admin]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "finalizeUpdateAdmin() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::PolicyAdminUpdated { + policyId: allowlist_id, + previousAdmin: BerylTestEnv::alice(), + newAdmin: BerylTestEnv::bob(), + } + .encode_log_data(), + ); + scenario + .assert_probe_word( + "policyAdmin(after finalize)", + IPolicyRegistry::policyAdminCall { policyId: allowlist_id }.abi_encode(), + word_from_address(BerylTestEnv::bob()), + ) + .await; + + let create_blocklist = scenario.tx(IPolicyRegistry::createPolicyWithAccountsCall { + admin: BerylTestEnv::bob(), + policyType: IPolicyRegistry::PolicyType::BLOCKLIST, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![create_blocklist]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicyWithAccounts() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::PolicyCreated { + policyId: blocklist_id, + creator: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::BLOCKLIST, + } + .encode_log_data(), + ); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::BlocklistUpdated { + policyId: blocklist_id, + updater: BerylTestEnv::alice(), + blocked: true, + accounts: vec![BerylTestEnv::bob()], + } + .encode_log_data(), + ); + scenario + .assert_probe_word( + "isAuthorized(blocked member)", + IPolicyRegistry::isAuthorizedCall { + policyId: blocklist_id, + account: BerylTestEnv::bob(), + } + .abi_encode(), + U256::ZERO, + ) + .await; + + let update_blocklist = scenario.bob_tx(IPolicyRegistry::updateBlocklistCall { + policyId: blocklist_id, + blocked: false, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![update_blocklist]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateBlocklist() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::BlocklistUpdated { + policyId: blocklist_id, + updater: BerylTestEnv::bob(), + blocked: false, + accounts: vec![BerylTestEnv::bob()], + } + .encode_log_data(), + ); + scenario + .assert_probe_word( + "isAuthorized(unblocked member)", + IPolicyRegistry::isAuthorizedCall { + policyId: blocklist_id, + account: BerylTestEnv::bob(), + } + .abi_encode(), + U256::ONE, + ) + .await; + + let renounce_admin = + scenario.bob_tx(IPolicyRegistry::renounceAdminCall { policyId: allowlist_id }); + let block = scenario.build_block_with_transactions(vec![renounce_admin]).await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "renounceAdmin() must succeed"); + scenario.assert_policy_log( + &block, + 0, + IPolicyRegistry::PolicyAdminUpdated { + policyId: allowlist_id, + previousAdmin: BerylTestEnv::bob(), + newAdmin: alloy_primitives::Address::ZERO, + } + .encode_log_data(), + ); + scenario + .assert_probe_word( + "policyAdmin(after renounce)", + IPolicyRegistry::policyAdminCall { policyId: allowlist_id }.abi_encode(), + U256::ZERO, + ) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn policy_registry_action_tests_cover_error_paths() { + let mut scenario = PolicyRegistryScenario::new().await; + let allowlist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::ALLOWLIST, 2); + let blocklist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::BLOCKLIST, 3); + + // Setup: create an allowlist policy and a blocklist policy, both with alice as admin. + let create_allowlist = scenario.tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + }); + let block = scenario.build_block_with_transactions(vec![create_allowlist]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "createPolicy(ALLOWLIST) setup must succeed" + ); + + let create_blocklist = scenario.tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::BLOCKLIST, + }); + let block = scenario.build_block_with_transactions(vec![create_blocklist]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "createPolicy(BLOCKLIST) setup must succeed" + ); + + // Unauthorized: bob calls updateAllowlist on alice's allowlist policy. + let unauthorized = scenario.bob_tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![unauthorized]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "updateAllowlist() by non-admin must revert with Unauthorized" + ); + + // PolicyNotFound: operate on a policy id that does not exist. + let not_found = scenario.tx(IPolicyRegistry::updateAllowlistCall { + policyId: 999, + allowed: true, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![not_found]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "updateAllowlist() on nonexistent policy must revert with PolicyNotFound" + ); + + // IncompatiblePolicyType: updateAllowlist on a BLOCKLIST policy. + let allowlist_on_blocklist = scenario.tx(IPolicyRegistry::updateAllowlistCall { + policyId: blocklist_id, + allowed: true, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![allowlist_on_blocklist]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "updateAllowlist() on a BLOCKLIST policy must revert with IncompatiblePolicyType" + ); + + // IncompatiblePolicyType: updateBlocklist on an ALLOWLIST policy. + let blocklist_on_allowlist = scenario.tx(IPolicyRegistry::updateBlocklistCall { + policyId: allowlist_id, + blocked: true, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![blocklist_on_allowlist]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "updateBlocklist() on an ALLOWLIST policy must revert with IncompatiblePolicyType" + ); + + // StaticCallNotAllowed: route createPolicy through the probe, which issues a STATICCALL. + // The probe tx itself succeeds (it stores the call outcome), but the inner STATICCALL must + // fail because createPolicy is a mutating function. + let probe = scenario.probe; + let staticcall_tx = scenario.env.call_staticcall_probe_tx( + probe, + Bytes::from( + IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + } + .abi_encode(), + ), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block = scenario.build_block_with_transactions(vec![staticcall_tx]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "probe tx for StaticCallNotAllowed must succeed (probe stores the result)" + ); + assert!( + !scenario.env.probe_call_succeeded(probe), + "createPolicy() via STATICCALL must fail with StaticCallNotAllowed" + ); + + // NoPendingAdmin: finalizeUpdateAdmin before any stageUpdateAdmin call. + let no_pending = + scenario.tx(IPolicyRegistry::finalizeUpdateAdminCall { policyId: allowlist_id }); + let block = scenario.build_block_with_transactions(vec![no_pending]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "finalizeUpdateAdmin() without a pending admin must revert with NoPendingAdmin" + ); + + scenario.derive().await; +} + +#[tokio::test] +async fn policy_registry_renounced_policy_is_frozen() { + let mut scenario = PolicyRegistryScenario::new().await; + let allowlist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::ALLOWLIST, 2); + let blocklist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::BLOCKLIST, 3); + + // Setup: alice creates an ALLOWLIST policy. + let create_allowlist = scenario.tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + }); + let block = scenario.build_block_with_transactions(vec![create_allowlist]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy(ALLOWLIST) must succeed"); + + // Setup: alice creates a BLOCKLIST policy (used to isolate Unauthorized from + // IncompatiblePolicyType when testing updateBlocklist after renounce). + let create_blocklist = scenario.tx(IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: IPolicyRegistry::PolicyType::BLOCKLIST, + }); + let block = scenario.build_block_with_transactions(vec![create_blocklist]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy(BLOCKLIST) must succeed"); + + // Setup: alice adds bob as a member of the allowlist. + let add_bob = scenario.tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![add_bob]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateAllowlist() must succeed"); + + // Setup: alice renounces admin on both policies. + let renounce_allowlist = + scenario.tx(IPolicyRegistry::renounceAdminCall { policyId: allowlist_id }); + let block = scenario.build_block_with_transactions(vec![renounce_allowlist]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "renounceAdmin(allowlist) must succeed"); + + let renounce_blocklist = + scenario.tx(IPolicyRegistry::renounceAdminCall { policyId: blocklist_id }); + let block = scenario.build_block_with_transactions(vec![renounce_blocklist]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "renounceAdmin(blocklist) must succeed"); + + // Mutating ops must all revert now that there is no admin. + + // updateAllowlist from alice reverts (Unauthorized). + let update_allowlist = scenario.tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: false, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![update_allowlist]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "updateAllowlist() after renounceAdmin must revert" + ); + + // updateBlocklist from alice reverts (Unauthorized) on the renounced BLOCKLIST policy. + // Using the blocklist policy here (not the allowlist) isolates Unauthorized from + // IncompatiblePolicyType, confirming that renounce freezes blocklist mutations. + let update_blocklist = scenario.tx(IPolicyRegistry::updateBlocklistCall { + policyId: blocklist_id, + blocked: true, + accounts: vec![BerylTestEnv::bob()], + }); + let block = scenario.build_block_with_transactions(vec![update_blocklist]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "updateBlocklist() after renounceAdmin must revert" + ); + + // stageUpdateAdmin from alice reverts (Unauthorized). + let stage_admin = scenario.tx(IPolicyRegistry::stageUpdateAdminCall { + policyId: allowlist_id, + newAdmin: BerylTestEnv::alice(), + }); + let block = scenario.build_block_with_transactions(vec![stage_admin]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "stageUpdateAdmin() after renounceAdmin must revert" + ); + + // finalizeUpdateAdmin reverts (NoPendingAdmin): renounce_admin clears the pending admin + // entry, so the contract hits NoPendingAdmin before it can check Unauthorized. + let finalize_admin = + scenario.tx(IPolicyRegistry::finalizeUpdateAdminCall { policyId: allowlist_id }); + let block = scenario.build_block_with_transactions(vec![finalize_admin]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "finalizeUpdateAdmin() after renounceAdmin must revert" + ); + + // Read-only views must still work correctly. + + // bob is still in the allowlist. + scenario + .assert_probe_word( + "isAuthorized(bob) still true after renounce", + IPolicyRegistry::isAuthorizedCall { + policyId: allowlist_id, + account: BerylTestEnv::bob(), + } + .abi_encode(), + U256::ONE, + ) + .await; + + // carol was never added and must remain unauthorized. + scenario + .assert_probe_word( + "isAuthorized(carol) still false after renounce", + IPolicyRegistry::isAuthorizedCall { + policyId: allowlist_id, + account: BerylTestEnv::carol(), + } + .abi_encode(), + U256::ZERO, + ) + .await; + + // policyAdmin returns Address::ZERO after renounce. + scenario + .assert_probe_word( + "policyAdmin returns zero after renounce", + IPolicyRegistry::policyAdminCall { policyId: allowlist_id }.abi_encode(), + U256::ZERO, + ) + .await; + + // policyExists still returns true. + scenario + .assert_probe_word( + "policyExists still true after renounce", + IPolicyRegistry::policyExistsCall { policyId: allowlist_id }.abi_encode(), + U256::ONE, + ) + .await; + + scenario.derive().await; +} + +struct PolicyRegistryScenario { + env: BerylTestEnv, + probe: alloy_primitives::Address, + blocks: Vec<(BaseBlock, u64)>, +} + +impl PolicyRegistryScenario { + async fn new() -> Self { + let env = BerylTestEnv::new(); + let mut scenario = Self { env, probe: alloy_primitives::Address::ZERO, blocks: Vec::new() }; + + scenario.build_empty_block().await; + + let activate = scenario.env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block = scenario.build_block_with_transactions(vec![activate]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "POLICY_REGISTRY activation must succeed" + ); + + let (probe, deploy_probe) = + scenario.env.deploy_staticcall_probe_tx(PolicyRegistryStorage::ADDRESS); + scenario.probe = probe; + let block = scenario.build_block_with_transactions(vec![deploy_probe]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "policy probe deployment must succeed"); + + scenario + } + + async fn build_empty_block(&mut self) { + let block = self.env.sequencer.build_empty_block().await; + self.push_block(block); + } + + async fn build_block_with_transactions(&mut self, txs: Vec) -> BaseBlock { + let block = self.env.sequencer.build_next_block_with_transactions(txs).await; + self.push_block(block.clone()); + block + } + + fn tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(PolicyRegistryStorage::ADDRESS), + Bytes::from(call.abi_encode()), + GAS_LIMIT, + ) + } + + fn bob_tx(&mut self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_bob_tx( + TxKind::Call(PolicyRegistryStorage::ADDRESS), + Bytes::from(call.abi_encode()), + GAS_LIMIT, + ) + } + + async fn assert_probe_word(&mut self, label: &'static str, calldata: Vec, expected: U256) { + let tx = self.env.call_staticcall_probe_tx( + self.probe, + Bytes::from(calldata), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block = self.build_block_with_transactions(vec![tx]).await; + assert!(self.env.user_tx_succeeded(&block, 0), "{label} probe tx must succeed"); + assert!(self.env.probe_call_succeeded(self.probe), "{label} staticcall must succeed"); + assert_eq!( + self.env.probe_return_word(self.probe), + expected, + "{label} staticcall must return the expected word" + ); + } + + #[track_caller] + fn assert_policy_log( + &self, + block: &BaseBlock, + user_tx_index: usize, + expected: alloy_primitives::LogData, + ) { + let receipt = self.env.user_tx_receipt(block, user_tx_index); + assert!( + receipt + .logs() + .iter() + .any(|log| log.address == PolicyRegistryStorage::ADDRESS && log.data == expected), + "policy-registry transaction {user_tx_index} must emit the expected event; expected={expected:?}, logs={:?}", + receipt.logs() + ); + } + + async fn derive(mut self) { + let expected_safe_head = self.blocks.len() as u64; + self.env.derive_blocks(self.blocks, expected_safe_head).await; + } + + fn push_block(&mut self, block: BaseBlock) { + let block_number = self.blocks.len() as u64 + 1; + self.blocks.push((block, block_number)); + } +} + +fn word_from_address(address: alloy_primitives::Address) -> U256 { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(address.as_slice()); + U256::from_be_slice(&word) +} diff --git a/actions/harness/tests/beryl/policy_transfer.rs b/actions/harness/tests/beryl/policy_transfer.rs new file mode 100644 index 0000000000..4e3dad4c5c --- /dev/null +++ b/actions/harness/tests/beryl/policy_transfer.rs @@ -0,0 +1,438 @@ +//! Policy-gated B-20 transfer action tests across the Base Beryl boundary. +//! +//! These tests verify the cross-precompile integration between the B-20 token precompile and +//! the `PolicyRegistry` precompile: every transfer call checks the sender against the token's +//! configured `TRANSFER_SENDER_POLICY`, and the result drives allow/block decisions end-to-end. + +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::{SolCall, SolValue}; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_precompiles::{ + B20FactoryStorage, B20PolicyType, IB20, IB20Factory, IPolicyRegistry, PolicyRegistryStorage, +}; + +use crate::env::BerylTestEnv; + +const GAS_LIMIT: u64 = 10_000_000; + +/// Transfer amount used across all policy gating tests. +const TRANSFER_AMOUNT: u64 = 1_000; + +// --- ALLOWLIST --- + +#[tokio::test] +async fn allowlist_policy_gates_b20_transfers() { + let allowlist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::ALLOWLIST, 2); + let mut scenario = PolicyTransferScenario::new_with_custom_policy( + IPolicyRegistry::PolicyType::ALLOWLIST, + allowlist_id, + ) + .await; + + // Non-member: transfer must revert because Alice is not yet in the allowlist. + let blocked = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer from non-allowlist member must revert" + ); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balance(BerylTestEnv::bob(), 0); + + // Add Alice to the allowlist. + let add_alice = scenario.policy_tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: true, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![add_alice]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateAllowlist() must succeed"); + + // Allowlist member: transfer must succeed. + let allowed = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![allowed]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer from allowlist member must succeed" + ); + scenario + .assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - TRANSFER_AMOUNT); + scenario.assert_balance(BerylTestEnv::bob(), TRANSFER_AMOUNT); + + // Remove Alice from the allowlist. + let remove_alice = scenario.policy_tx(IPolicyRegistry::updateAllowlistCall { + policyId: allowlist_id, + allowed: false, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![remove_alice]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateAllowlist(remove) must succeed"); + + // Re-blocked: transfer must revert once Alice is removed from the allowlist. + let re_blocked = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![re_blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer from removed allowlist member must revert" + ); + + scenario.derive().await; +} + +// --- BLOCKLIST --- + +#[tokio::test] +async fn blocklist_policy_gates_b20_transfers() { + let blocklist_id = BerylTestEnv::policy_id(IPolicyRegistry::PolicyType::BLOCKLIST, 2); + let mut scenario = PolicyTransferScenario::new_with_custom_policy( + IPolicyRegistry::PolicyType::BLOCKLIST, + blocklist_id, + ) + .await; + + // Non-blocked sender: transfer must succeed. + let allowed = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![allowed]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer from non-blocklisted sender must succeed" + ); + scenario + .assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - TRANSFER_AMOUNT); + scenario.assert_balance(BerylTestEnv::bob(), TRANSFER_AMOUNT); + + // Block Alice. + let block_alice = scenario.policy_tx(IPolicyRegistry::updateBlocklistCall { + policyId: blocklist_id, + blocked: true, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![block_alice]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateBlocklist() must succeed"); + + // Blocked sender: transfer must revert. + let blocked = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![blocked]).await; + assert!(!scenario.env.user_tx_succeeded(&block, 0), "transfer from blocked sender must revert"); + scenario + .assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - TRANSFER_AMOUNT); + scenario.assert_balance(BerylTestEnv::carol(), 0); + + // Unblock Alice. + let unblock_alice = scenario.policy_tx(IPolicyRegistry::updateBlocklistCall { + policyId: blocklist_id, + blocked: false, + accounts: vec![BerylTestEnv::alice()], + }); + let block = scenario.build_block_with_transactions(vec![unblock_alice]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "updateBlocklist(unblock) must succeed"); + + // Unblocked sender: transfer must succeed again. + let unblocked = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![unblocked]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer from unblocked sender must succeed" + ); + scenario.assert_balance( + BerylTestEnv::alice(), + BerylTestEnv::B20_INITIAL_SUPPLY - TRANSFER_AMOUNT * 2, + ); + scenario.assert_balance(BerylTestEnv::bob(), TRANSFER_AMOUNT); + scenario.assert_balance(BerylTestEnv::carol(), TRANSFER_AMOUNT); + + scenario.derive().await; +} + +// --- ALWAYS_ALLOW (built-in id = 0) --- + +#[tokio::test] +async fn always_allow_policy_never_blocks_b20_transfers() { + // The TRANSFER_SENDER_POLICY slot defaults to ALWAYS_ALLOW (0) when never written. + // This test verifies that the zero-initialized default permits all senders. + let mut scenario = PolicyTransferScenario::new_with_default_policy().await; + + let transfer = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![transfer]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "transfer must always succeed under ALWAYS_ALLOW policy" + ); + scenario + .assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY - TRANSFER_AMOUNT); + scenario.assert_balance(BerylTestEnv::bob(), TRANSFER_AMOUNT); + + scenario.derive().await; +} + +// --- ALWAYS_BLOCK (built-in id = 1) --- + +#[tokio::test] +async fn always_block_policy_always_blocks_b20_transfers() { + // ALWAYS_BLOCK_ID = 1; set via updatePolicy init call. + let mut scenario = + PolicyTransferScenario::new_with_builtin_policy(PolicyRegistryStorage::ALWAYS_BLOCK_ID) + .await; + + let blocked = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(TRANSFER_AMOUNT), + ); + let block = scenario.build_block_with_transactions(vec![blocked]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "transfer must always fail under ALWAYS_BLOCK policy" + ); + scenario.assert_balance(BerylTestEnv::alice(), BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balance(BerylTestEnv::bob(), 0); + + scenario.derive().await; +} + +// --------------------------------------------------------------------------- +// Scenario helpers +// --------------------------------------------------------------------------- + +/// Test fixture: a funded B-20 token whose `TRANSFER_SENDER_POLICY` is pre-configured. +struct PolicyTransferScenario { + env: BerylTestEnv, + token: Address, + blocks: Vec<(BaseBlock, u64)>, +} + +impl PolicyTransferScenario { + /// Sets up with `TOKEN_FACTORY`, `B20_TOKEN`, and `POLICY_REGISTRY` active, creates a custom + /// `policy_type` policy (Alice as admin), then deploys a B-20 token with the + /// `TRANSFER_SENDER_POLICY` wired to that policy via an `updatePolicy` init call. + async fn new_with_custom_policy( + policy_type: IPolicyRegistry::PolicyType, + policy_id: u64, + ) -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_token_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + // Empty block to cross the Beryl activation boundary. + let beryl_boundary = scenario.env.sequencer.build_empty_block().await; + scenario.push_block(beryl_boundary); + + // Activate all three features in one block. + let activate_factory = + scenario.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_b20 = scenario.env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let activate_registry = + scenario.env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block = scenario + .build_block_with_transactions(vec![activate_factory, activate_b20, activate_registry]) + .await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "B20_TOKEN activation must succeed"); + assert!( + scenario.env.user_tx_succeeded(&block, 2), + "POLICY_REGISTRY activation must succeed" + ); + + // Create the custom policy with Alice as admin in its own block so that the policy ID + // exists in committed state when the token's init call checks it. + let create_policy = scenario.env.create_tx( + TxKind::Call(PolicyRegistryStorage::ADDRESS), + Bytes::from( + IPolicyRegistry::createPolicyCall { + admin: BerylTestEnv::alice(), + policyType: policy_type, + } + .abi_encode(), + ), + GAS_LIMIT, + ); + let block = scenario.build_block_with_transactions(vec![create_policy]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "createPolicy() must succeed"); + + // Deploy the B-20 token with the TRANSFER_SENDER_POLICY wired to the custom policy. + let create_token = scenario.create_token_tx(Some(policy_id)); + let block = scenario.build_block_with_transactions(vec![create_token]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "B-20 token creation with custom policy must succeed" + ); + assert!(scenario.env.sequencer.has_code(token), "B-20 token must be deployed"); + + scenario + } + + /// Sets up with all three features active, then deploys a B-20 token with the + /// `TRANSFER_SENDER_POLICY` set to one of the built-in IDs via an `updatePolicy` init call. + async fn new_with_builtin_policy(builtin_policy_id: u64) -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_token_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + let beryl_boundary = scenario.env.sequencer.build_empty_block().await; + scenario.push_block(beryl_boundary); + + let activate_factory = + scenario.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_b20 = scenario.env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let activate_registry = + scenario.env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block = scenario + .build_block_with_transactions(vec![activate_factory, activate_b20, activate_registry]) + .await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "B20_TOKEN activation must succeed"); + assert!( + scenario.env.user_tx_succeeded(&block, 2), + "POLICY_REGISTRY activation must succeed" + ); + + let create_token = scenario.create_token_tx(Some(builtin_policy_id)); + let block = scenario.build_block_with_transactions(vec![create_token]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "B-20 token creation with built-in policy must succeed" + ); + assert!(scenario.env.sequencer.has_code(token), "B-20 token must be deployed"); + + scenario + } + + /// Sets up with `TOKEN_FACTORY` and `B20_TOKEN` active, then deploys a B-20 token without + /// an `updatePolicy` init call. The `TRANSFER_SENDER_POLICY` slot defaults to `ALWAYS_ALLOW` (0), + /// so all transfers are permitted. + async fn new_with_default_policy() -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_token_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + let beryl_boundary = scenario.env.sequencer.build_empty_block().await; + scenario.push_block(beryl_boundary); + + let activate_factory = + scenario.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_b20 = scenario.env.activate_feature_tx(BerylTestEnv::b20_token_feature()); + let block = + scenario.build_block_with_transactions(vec![activate_factory, activate_b20]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "B20_TOKEN activation must succeed"); + + // No updatePolicy init call: the TRANSFER_SENDER_POLICY slot reads zero (ALWAYS_ALLOW). + let create_token = scenario.create_token_tx(None); + let block = scenario.build_block_with_transactions(vec![create_token]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "B-20 token creation must succeed"); + assert!(scenario.env.sequencer.has_code(token), "B-20 token must be deployed"); + + scenario + } + + /// Builds a `createToken` transaction. + /// + /// When `transfer_sender_policy_id` is `Some`, an `updatePolicy` init call wires the + /// `TRANSFER_SENDER_POLICY` to that ID before minting the initial supply to Alice. + /// When `None`, only the mint init call is included (default `ALWAYS_ALLOW` semantics). + fn create_token_tx(&self, transfer_sender_policy_id: Option) -> BaseTxEnvelope { + let mut init_calls: Vec = Vec::new(); + + if let Some(policy_id) = transfer_sender_policy_id { + init_calls.push(Bytes::from( + IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: policy_id, + } + .abi_encode(), + )); + } + + init_calls.push(Bytes::from( + IB20::mintCall { + to: BerylTestEnv::alice(), + amount: U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + } + .abi_encode(), + )); + + self.env.create_tx( + TxKind::Call(B20FactoryStorage::ADDRESS), + Bytes::from( + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::DEFAULT, + salt: BerylTestEnv::b20_token_salt(), + params: Self::token_params().abi_encode().into(), + initCalls: init_calls, + } + .abi_encode(), + ), + GAS_LIMIT, + ) + } + + /// Creates a transaction that calls the `PolicyRegistry` precompile, signed by Alice. + fn policy_tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(PolicyRegistryStorage::ADDRESS), + Bytes::from(call.abi_encode()), + GAS_LIMIT, + ) + } + + async fn build_block_with_transactions(&mut self, txs: Vec) -> BaseBlock { + let block = self.env.sequencer.build_next_block_with_transactions(txs).await; + self.push_block(block.clone()); + block + } + + fn push_block(&mut self, block: BaseBlock) { + let block_number = self.blocks.len() as u64 + 1; + self.blocks.push((block, block_number)); + } + + fn assert_balance(&self, account: Address, expected: u64) { + assert_eq!( + self.env.b20_balance(self.token, account), + U256::from(expected), + "B-20 balance for {account} must match expected value" + ); + } + + async fn derive(mut self) { + let expected_safe_head = self.blocks.len() as u64; + self.env.derive_blocks(self.blocks, expected_safe_head).await; + } + + fn token_params() -> IB20Factory::B20CreateParams { + IB20Factory::B20CreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "Policy B20".to_string(), + symbol: "PB20".to_string(), + initialAdmin: BerylTestEnv::alice(), + } + } +} diff --git a/actions/harness/tests/beryl/security.rs b/actions/harness/tests/beryl/security.rs new file mode 100644 index 0000000000..f8b397c6fd --- /dev/null +++ b/actions/harness/tests/beryl/security.rs @@ -0,0 +1,712 @@ +//! Security B-20 precompile action tests across the Base Beryl boundary. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Address, B256, Bytes, LogData, TxKind, U256, keccak256}; +use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_precompiles::{ + B20FactoryStorage, B20SecurityStorage, B20TokenRole, IB20, IB20Factory, IB20Security, + PolicyRegistryStorage, +}; + +use crate::{ + env::BerylTestEnv, + test_helpers::{self, StaticcallCase, word_from_address}, +}; + +const WAD: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); +const UPDATED_RATIO: U256 = U256::from_limbs([2_000_000_000_000_000_000, 0, 0, 0]); +const UPDATED_MINIMUM_REDEEMABLE: U256 = U256::from_limbs([20, 0, 0, 0]); +const BOB_MINT_AMOUNT: u64 = 100; +const CAROL_MINT_AMOUNT: u64 = 200; +const BOB_BURN_AMOUNT: u64 = 10; +const CAROL_BURN_AMOUNT: u64 = 20; +const REDEEM_AMOUNT: u64 = 20; +const REDEEM_WITH_MEMO_AMOUNT: u64 = 30; +const REDEEM_MEMO: B256 = B256::repeat_byte(0x61); +const CUSIP: &str = "123456789"; +const FIGI: &str = "BBG000000001"; +const ANNOUNCEMENT_ID: &str = "security-action-1"; +const ANNOUNCEMENT_DESCRIPTION: &str = "update FIGI"; +const ANNOUNCEMENT_URI: &str = "ipfs://security-action"; + +#[tokio::test] +async fn security_creation_initializes_identifiers_and_factory_views() { + let mut scenario = B20SecurityScenario::new().await; + + scenario + .assert_staticcall_cases( + B20FactoryStorage::ADDRESS, + vec![ + StaticcallCase::word( + "factory getB20Address(SECURITY)", + IB20Factory::getB20AddressCall { + variant: IB20Factory::B20Variant::SECURITY, + sender: BerylTestEnv::alice(), + salt: BerylTestEnv::b20_security_salt(), + } + .abi_encode(), + word_from_address(scenario.token), + ), + StaticcallCase::word( + "factory isB20(security)", + IB20Factory::isB20Call { token: scenario.token }.abi_encode(), + U256::ONE, + ), + StaticcallCase::word( + "factory isB20Initialized(security)", + IB20Factory::isB20InitializedCall { token: scenario.token }.abi_encode(), + U256::ONE, + ), + ], + ) + .await; + + scenario + .assert_staticcall_cases( + scenario.token, + vec![ + StaticcallCase::string( + "name", + IB20::nameCall {}.abi_encode(), + BerylTestEnv::B20_SECURITY_NAME, + ), + StaticcallCase::string( + "symbol", + IB20::symbolCall {}.abi_encode(), + BerylTestEnv::B20_SECURITY_SYMBOL, + ), + StaticcallCase::string("contractURI", IB20::contractURICall {}.abi_encode(), ""), + StaticcallCase::string( + "securityIdentifier(ISIN)", + IB20Security::securityIdentifierCall { identifierType: "ISIN".to_string() } + .abi_encode(), + BerylTestEnv::B20_SECURITY_ISIN, + ), + StaticcallCase::word( + "decimals", + IB20::decimalsCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_SECURITY_DECIMALS), + ), + StaticcallCase::word( + "totalSupply", + IB20::totalSupplyCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "balanceOf(alice)", + IB20::balanceOfCall { account: BerylTestEnv::alice() }.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "sharesToTokensRatio", + IB20Security::sharesToTokensRatioCall {}.abi_encode(), + WAD, + ), + StaticcallCase::word( + "WAD_PRECISION", + IB20Security::WAD_PRECISIONCall {}.abi_encode(), + WAD, + ), + StaticcallCase::word( + "toShares", + IB20Security::toSharesCall { balance: U256::from(100) }.abi_encode(), + U256::from(100), + ), + StaticcallCase::word( + "sharesOf(alice)", + IB20Security::sharesOfCall { account: BerylTestEnv::alice() }.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "minimumRedeemable", + IB20Security::minimumRedeemableCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_SECURITY_MINIMUM_REDEEMABLE), + ), + StaticcallCase::word( + "isAnnouncementIdUsed(fresh)", + IB20Security::isAnnouncementIdUsedCall { id: ANNOUNCEMENT_ID.to_string() } + .abi_encode(), + U256::ZERO, + ), + StaticcallCase::bytes32( + "SECURITY_OPERATOR_ROLE", + IB20Security::SECURITY_OPERATOR_ROLECall {}.abi_encode(), + security_operator_role(), + ), + StaticcallCase::bytes32( + "BURN_FROM_ROLE", + IB20Security::BURN_FROM_ROLECall {}.abi_encode(), + burn_from_role(), + ), + StaticcallCase::bytes32( + "REDEEM_SENDER_POLICY", + IB20Security::REDEEM_SENDER_POLICYCall {}.abi_encode(), + B20SecurityStorage::REDEEM_SENDER_POLICY, + ), + StaticcallCase::word( + "policyId(REDEEM_SENDER_POLICY)", + IB20::policyIdCall { policyScope: B20SecurityStorage::REDEEM_SENDER_POLICY } + .abi_encode(), + U256::from(PolicyRegistryStorage::ALWAYS_ALLOW_ID), + ), + StaticcallCase::returndata( + "pausedFeatures", + IB20::pausedFeaturesCall {}.abi_encode(), + Vec::::new().abi_encode(), + ), + ], + ) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn security_mutations_update_state_and_emit_events() { + let mut scenario = B20SecurityScenario::new().await; + scenario + .grant_roles([security_operator_role(), burn_from_role(), B20TokenRole::Mint.id()]) + .await; + + let update_ratio = scenario + .call_tx(IB20Security::updateShareRatioCall { newSharesToTokensRatio: UPDATED_RATIO }); + let update_minimum = scenario.call_tx(IB20Security::updateMinimumRedeemableCall { + newMinimumRedeemable: UPDATED_MINIMUM_REDEEMABLE, + }); + let update_cusip = scenario.call_tx(IB20Security::updateSecurityIdentifierCall { + identifierType: "CUSIP".to_string(), + value: CUSIP.to_string(), + }); + let batch_mint = scenario.call_tx(IB20Security::batchMintCall { + recipients: vec![BerylTestEnv::bob(), BerylTestEnv::carol()], + amounts: vec![U256::from(BOB_MINT_AMOUNT), U256::from(CAROL_MINT_AMOUNT)], + }); + let batch_burn = scenario.call_tx(IB20Security::batchBurnCall { + accounts: vec![BerylTestEnv::bob(), BerylTestEnv::carol()], + amounts: vec![U256::from(BOB_BURN_AMOUNT), U256::from(CAROL_BURN_AMOUNT)], + }); + let redeem = scenario.call_tx(IB20Security::redeemCall { amount: U256::from(REDEEM_AMOUNT) }); + let redeem_with_memo = scenario.call_tx(IB20Security::redeemWithMemoCall { + amount: U256::from(REDEEM_WITH_MEMO_AMOUNT), + memo: REDEEM_MEMO, + }); + let announced_identifier = IB20Security::updateSecurityIdentifierCall { + identifierType: "FIGI".to_string(), + value: FIGI.to_string(), + }; + let announce = scenario.call_tx(IB20Security::announceCall { + internalCalls: vec![Bytes::from(announced_identifier.abi_encode())], + id: ANNOUNCEMENT_ID.to_string(), + description: ANNOUNCEMENT_DESCRIPTION.to_string(), + uri: ANNOUNCEMENT_URI.to_string(), + }); + let block = scenario + .build_block_with_transactions(vec![ + update_ratio, + update_minimum, + update_cusip, + batch_mint, + batch_burn, + redeem, + redeem_with_memo, + announce, + ]) + .await; + + for index in 0..8 { + assert!( + scenario.env.user_tx_succeeded(&block, index), + "security mutation {index} must succeed" + ); + } + + scenario.assert_log( + &block, + 0, + IB20Security::ShareRatioUpdated { sharesToTokensRatio: UPDATED_RATIO }.encode_log_data(), + ); + scenario.assert_log( + &block, + 1, + IB20Security::MinimumRedeemableUpdated { + caller: BerylTestEnv::alice(), + newMinimumRedeemable: UPDATED_MINIMUM_REDEEMABLE, + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 2, + IB20Security::SecurityIdentifierUpdated { + identifierType: "CUSIP".to_string(), + value: CUSIP.to_string(), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 3, + IB20::Transfer { + from: Address::ZERO, + to: BerylTestEnv::bob(), + amount: U256::from(BOB_MINT_AMOUNT), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 3, + IB20::Transfer { + from: Address::ZERO, + to: BerylTestEnv::carol(), + amount: U256::from(CAROL_MINT_AMOUNT), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 4, + IB20::Transfer { + from: BerylTestEnv::bob(), + to: Address::ZERO, + amount: U256::from(BOB_BURN_AMOUNT), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 4, + IB20::Transfer { + from: BerylTestEnv::carol(), + to: Address::ZERO, + amount: U256::from(CAROL_BURN_AMOUNT), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 5, + IB20Security::Redeemed { + from: BerylTestEnv::alice(), + amt: U256::from(REDEEM_AMOUNT), + sharesToTokensRatio: UPDATED_RATIO, + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 6, + IB20::Memo { caller: BerylTestEnv::alice(), memo: REDEEM_MEMO }.encode_log_data(), + ); + scenario.assert_log( + &block, + 6, + IB20Security::Redeemed { + from: BerylTestEnv::alice(), + amt: U256::from(REDEEM_WITH_MEMO_AMOUNT), + sharesToTokensRatio: UPDATED_RATIO, + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 7, + IB20Security::Announcement { + caller: BerylTestEnv::alice(), + id: ANNOUNCEMENT_ID.to_string(), + description: ANNOUNCEMENT_DESCRIPTION.to_string(), + uri: ANNOUNCEMENT_URI.to_string(), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 7, + IB20Security::SecurityIdentifierUpdated { + identifierType: "FIGI".to_string(), + value: FIGI.to_string(), + } + .encode_log_data(), + ); + scenario.assert_log( + &block, + 7, + IB20Security::EndAnnouncement { id: ANNOUNCEMENT_ID.to_string() }.encode_log_data(), + ); + + scenario.assert_total_supply( + BerylTestEnv::B20_INITIAL_SUPPLY + BOB_MINT_AMOUNT + CAROL_MINT_AMOUNT + - BOB_BURN_AMOUNT + - CAROL_BURN_AMOUNT + - REDEEM_AMOUNT + - REDEEM_WITH_MEMO_AMOUNT, + ); + scenario.assert_balances( + BerylTestEnv::B20_INITIAL_SUPPLY - REDEEM_AMOUNT - REDEEM_WITH_MEMO_AMOUNT, + BOB_MINT_AMOUNT - BOB_BURN_AMOUNT, + CAROL_MINT_AMOUNT - CAROL_BURN_AMOUNT, + ); + + scenario + .assert_staticcall_cases( + scenario.token, + vec![ + StaticcallCase::word( + "sharesToTokensRatio after update", + IB20Security::sharesToTokensRatioCall {}.abi_encode(), + UPDATED_RATIO, + ), + StaticcallCase::word( + "toShares after update", + IB20Security::toSharesCall { balance: U256::from(50) }.abi_encode(), + U256::from(100), + ), + StaticcallCase::word( + "sharesOf(alice) after redeem", + IB20Security::sharesOfCall { account: BerylTestEnv::alice() }.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY - 50) * U256::from(2), + ), + StaticcallCase::word( + "minimumRedeemable after update", + IB20Security::minimumRedeemableCall {}.abi_encode(), + UPDATED_MINIMUM_REDEEMABLE, + ), + StaticcallCase::string( + "securityIdentifier(CUSIP)", + IB20Security::securityIdentifierCall { identifierType: "CUSIP".to_string() } + .abi_encode(), + CUSIP, + ), + StaticcallCase::string( + "securityIdentifier(FIGI)", + IB20Security::securityIdentifierCall { identifierType: "FIGI".to_string() } + .abi_encode(), + FIGI, + ), + StaticcallCase::word( + "isAnnouncementIdUsed", + IB20Security::isAnnouncementIdUsedCall { id: ANNOUNCEMENT_ID.to_string() } + .abi_encode(), + U256::ONE, + ), + StaticcallCase::word( + "totalSupply after security mutations", + IB20::totalSupplyCall {}.abi_encode(), + U256::from(1_000_220), + ), + ], + ) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn security_mutations_revert_on_invalid_inputs() { + let mut scenario = B20SecurityScenario::new().await; + scenario + .grant_roles([security_operator_role(), burn_from_role(), B20TokenRole::Mint.id()]) + .await; + + let first_announcement = scenario.call_tx(IB20Security::announceCall { + internalCalls: Vec::new(), + id: "duplicate-id".to_string(), + description: "initial".to_string(), + uri: "ipfs://initial".to_string(), + }); + let empty_batch_mint = scenario + .call_tx(IB20Security::batchMintCall { recipients: Vec::new(), amounts: Vec::new() }); + let mismatched_batch_mint = scenario.call_tx(IB20Security::batchMintCall { + recipients: vec![BerylTestEnv::bob()], + amounts: vec![U256::from(1), U256::from(2)], + }); + let mismatched_batch_burn = scenario.call_tx(IB20Security::batchBurnCall { + accounts: vec![BerylTestEnv::bob()], + amounts: vec![U256::from(1), U256::from(2)], + }); + let below_minimum_redeem = scenario.call_tx(IB20Security::redeemCall { amount: U256::from(1) }); + let empty_identifier_type = scenario.call_tx(IB20Security::updateSecurityIdentifierCall { + identifierType: String::new(), + value: "x".to_string(), + }); + let duplicate_announcement = scenario.call_tx(IB20Security::announceCall { + internalCalls: Vec::new(), + id: "duplicate-id".to_string(), + description: "again".to_string(), + uri: "ipfs://again".to_string(), + }); + let malformed_internal_call = scenario.call_tx(IB20Security::announceCall { + internalCalls: vec![Bytes::from(vec![1, 2, 3])], + id: "malformed-id".to_string(), + description: "malformed".to_string(), + uri: "ipfs://malformed".to_string(), + }); + let recursive_call = IB20Security::announceCall { + internalCalls: Vec::new(), + id: "inner".to_string(), + description: "inner".to_string(), + uri: "ipfs://inner".to_string(), + }; + let recursive_announcement = scenario.call_tx(IB20Security::announceCall { + internalCalls: vec![Bytes::from(recursive_call.abi_encode())], + id: "recursive-id".to_string(), + description: "recursive".to_string(), + uri: "ipfs://recursive".to_string(), + }); + let block = scenario + .build_block_with_transactions(vec![ + first_announcement, + empty_batch_mint, + mismatched_batch_mint, + mismatched_batch_burn, + below_minimum_redeem, + empty_identifier_type, + duplicate_announcement, + malformed_internal_call, + recursive_announcement, + ]) + .await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "first announce() must succeed"); + for index in 1..9 { + assert!( + !scenario.env.user_tx_succeeded(&block, index), + "invalid security mutation {index} must revert" + ); + } + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY, 0, 0); + + scenario + .assert_staticcall_cases( + scenario.token, + vec![ + StaticcallCase::word( + "duplicate announcement id remains used", + IB20Security::isAnnouncementIdUsedCall { id: "duplicate-id".to_string() } + .abi_encode(), + U256::ONE, + ), + StaticcallCase::word( + "failed malformed announcement id is rolled back", + IB20Security::isAnnouncementIdUsedCall { id: "malformed-id".to_string() } + .abi_encode(), + U256::ZERO, + ), + StaticcallCase::word( + "failed recursive announcement id is rolled back", + IB20Security::isAnnouncementIdUsedCall { id: "recursive-id".to_string() } + .abi_encode(), + U256::ZERO, + ), + ], + ) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn security_calls_revert_while_security_feature_is_deactivated() { + let mut scenario = B20SecurityScenario::new().await; + + let deactivate_security = + scenario.env.deactivate_feature_tx(BerylTestEnv::b20_security_feature()); + let block = scenario.build_block_with_transactions(vec![deactivate_security]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20_SECURITY deactivation must succeed"); + + let transfer_while_deactivated = + scenario.call_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: U256::from(1) }); + let block = scenario.build_block_with_transactions(vec![transfer_while_deactivated]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "security token call must revert while B20_SECURITY is deactivated" + ); + + let (probe, deploy_probe) = scenario.env.deploy_staticcall_probe_tx(scenario.token); + let block = scenario.build_block_with_transactions(vec![deploy_probe]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "staticcall probe must deploy"); + + let probe_call = scenario.env.call_staticcall_probe_tx( + probe, + Bytes::from(IB20Security::sharesToTokensRatioCall {}.abi_encode()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block = scenario.build_block_with_transactions(vec![probe_call]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "probe transaction must succeed"); + assert!( + !scenario.env.probe_call_succeeded(probe), + "security staticcall must fail while B20_SECURITY is deactivated" + ); + + let reactivate_security = + scenario.env.activate_feature_tx(BerylTestEnv::b20_security_feature()); + let block = scenario.build_block_with_transactions(vec![reactivate_security]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20_SECURITY re-activation must succeed"); + + let transfer_after_reactivate = + scenario.call_tx(IB20::transferCall { to: BerylTestEnv::bob(), amount: U256::from(1) }); + let block = scenario.build_block_with_transactions(vec![transfer_after_reactivate]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "security token call must succeed after B20_SECURITY is re-activated" + ); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY - 1, 1, 0); + + scenario.derive().await; +} + +struct B20SecurityScenario { + env: BerylTestEnv, + token: Address, + blocks: Vec<(BaseBlock, u64)>, +} + +impl B20SecurityScenario { + async fn new() -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_security_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + scenario.build_block_with_transactions(Vec::new()).await; + + let activate_factory = + scenario.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_security = + scenario.env.activate_feature_tx(BerylTestEnv::b20_security_feature()); + let activate_policy = + scenario.env.activate_feature_tx(BerylTestEnv::policy_registry_feature()); + let block = scenario + .build_block_with_transactions(vec![ + activate_factory, + activate_security, + activate_policy, + ]) + .await; + + assert!(scenario.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "B20_SECURITY activation must succeed"); + assert!( + scenario.env.user_tx_succeeded(&block, 2), + "POLICY_REGISTRY activation must succeed" + ); + + let create = scenario.env.create_b20_security_tx(); + let block = scenario.build_block_with_transactions(vec![create]).await; + + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "security B-20 creation transaction must succeed" + ); + assert!(scenario.env.sequencer.has_code(token), "security B-20 code must be deployed"); + scenario.assert_token_created_log(&block); + scenario.assert_log( + &block, + 0, + IB20::Transfer { + from: Address::ZERO, + to: BerylTestEnv::alice(), + amount: U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + } + .encode_log_data(), + ); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY, 0, 0); + + scenario + } + + async fn build_block_with_transactions( + &mut self, + transactions: Vec, + ) -> BaseBlock { + test_helpers::build_block_with_transactions(&mut self.env, &mut self.blocks, transactions) + .await + } + + async fn grant_roles(&mut self, roles: impl IntoIterator) { + let grants = roles + .into_iter() + .map(|role| self.call_tx(IB20::grantRoleCall { role, account: BerylTestEnv::alice() })) + .collect::>(); + let grant_count = grants.len(); + let block = self.build_block_with_transactions(grants).await; + for index in 0..grant_count { + assert!(self.env.user_tx_succeeded(&block, index), "role grant {index} must succeed"); + } + } + + fn call_tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(self.token), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + async fn assert_staticcall_cases(&mut self, target: Address, cases: Vec) { + test_helpers::assert_staticcall_cases( + &mut self.env, + &mut self.blocks, + target, + cases, + "security", + ) + .await; + } + + fn assert_total_supply(&self, total_supply: u64) { + test_helpers::assert_total_supply(&self.env, self.token, "security B-20", total_supply); + } + + fn assert_balances(&self, alice: u64, bob: u64, carol: u64) { + test_helpers::assert_balances(&self.env, self.token, "security B-20", alice, bob, carol); + } + + fn assert_token_created_log(&self, block: &BaseBlock) { + let expected = IB20Factory::B20Created { + token: self.token, + variant: IB20Factory::B20Variant::SECURITY, + name: BerylTestEnv::B20_SECURITY_NAME.to_string(), + symbol: BerylTestEnv::B20_SECURITY_SYMBOL.to_string(), + decimals: BerylTestEnv::B20_SECURITY_DECIMALS, + } + .encode_log_data(); + self.assert_receipt_log(block, 0, B20FactoryStorage::ADDRESS, expected); + } + + fn assert_log(&self, block: &BaseBlock, user_tx_index: usize, expected: LogData) { + self.assert_receipt_log(block, user_tx_index, self.token, expected); + } + + fn assert_receipt_log( + &self, + block: &BaseBlock, + user_tx_index: usize, + address: Address, + expected: LogData, + ) { + assert!( + self.env + .user_tx_receipt(block, user_tx_index) + .logs() + .iter() + .any(|log| log.address == address && log.data == expected), + "security B-20 transaction {user_tx_index} must emit the expected event" + ); + } + + async fn derive(mut self) { + let expected_safe_head = self.blocks.len() as u64; + self.env.derive_blocks(self.blocks, expected_safe_head).await; + } +} + +fn security_operator_role() -> B256 { + keccak256("SECURITY_OPERATOR_ROLE") +} + +fn burn_from_role() -> B256 { + keccak256("BURN_FROM_ROLE") +} diff --git a/actions/harness/tests/beryl/stablecoin.rs b/actions/harness/tests/beryl/stablecoin.rs new file mode 100644 index 0000000000..5971eaf6bf --- /dev/null +++ b/actions/harness/tests/beryl/stablecoin.rs @@ -0,0 +1,475 @@ +//! Stablecoin B-20 precompile action tests across the Base Beryl boundary. + +use alloy_consensus::TxReceipt; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; +use base_common_precompiles::{B20FactoryStorage, B20TokenRole, IB20, IB20Factory, IB20Stablecoin}; + +use crate::{ + env::BerylTestEnv, + test_helpers::{self, StaticcallCase, word_from_address}, +}; + +#[tokio::test] +async fn stablecoin_creation_initializes_currency_and_factory_views() { + let mut scenario = StablecoinScenario::new().await; + + scenario + .assert_staticcall_cases( + B20FactoryStorage::ADDRESS, + vec![ + StaticcallCase::word( + "getB20Address(STABLECOIN)", + IB20Factory::getB20AddressCall { + variant: IB20Factory::B20Variant::STABLECOIN, + sender: BerylTestEnv::alice(), + salt: BerylTestEnv::b20_stablecoin_salt(), + } + .abi_encode(), + word_from_address(scenario.token), + ), + StaticcallCase::word( + "isB20(stablecoin)", + IB20Factory::isB20Call { token: scenario.token }.abi_encode(), + U256::ONE, + ), + StaticcallCase::word( + "isB20Initialized(stablecoin)", + IB20Factory::isB20InitializedCall { token: scenario.token }.abi_encode(), + U256::ONE, + ), + ], + ) + .await; + + scenario + .assert_staticcall_cases( + scenario.token, + vec![ + StaticcallCase::string( + "currency", + IB20Stablecoin::currencyCall {}.abi_encode(), + BerylTestEnv::B20_STABLECOIN_CURRENCY, + ), + StaticcallCase::string( + "name", + IB20::nameCall {}.abi_encode(), + BerylTestEnv::B20_STABLECOIN_NAME, + ), + StaticcallCase::string( + "symbol", + IB20::symbolCall {}.abi_encode(), + BerylTestEnv::B20_STABLECOIN_SYMBOL, + ), + StaticcallCase::word( + "decimals", + IB20::decimalsCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_STABLECOIN_DECIMALS), + ), + StaticcallCase::word( + "totalSupply", + IB20::totalSupplyCall {}.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "balanceOf(alice)", + IB20::balanceOfCall { account: BerylTestEnv::alice() }.abi_encode(), + U256::from(BerylTestEnv::B20_INITIAL_SUPPLY), + ), + StaticcallCase::word( + "allowance(alice,bob)", + IB20::allowanceCall { + owner: BerylTestEnv::alice(), + spender: BerylTestEnv::bob(), + } + .abi_encode(), + U256::ZERO, + ), + StaticcallCase::word("supplyCap", IB20::supplyCapCall {}.abi_encode(), U256::MAX), + StaticcallCase::string("contractURI", IB20::contractURICall {}.abi_encode(), ""), + StaticcallCase::word( + "nonces(alice)", + IB20::noncesCall { owner: BerylTestEnv::alice() }.abi_encode(), + U256::ZERO, + ), + ], + ) + .await; + + scenario.derive().await; +} + +#[tokio::test] +async fn stablecoin_inherited_b20_operations_update_state_and_emit_events() { + let mut scenario = StablecoinScenario::new().await; + + let grant_mint_role = scenario.call_tx(IB20::grantRoleCall { + role: B20TokenRole::Mint.id(), + account: BerylTestEnv::alice(), + }); + let grant_burn_role = scenario.call_tx(IB20::grantRoleCall { + role: B20TokenRole::Burn.id(), + account: BerylTestEnv::alice(), + }); + let block = + scenario.build_block_with_transactions(vec![grant_mint_role, grant_burn_role]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "MINT_ROLE grant must succeed"); + assert!(scenario.env.user_tx_succeeded(&block, 1), "BURN_ROLE grant must succeed"); + + let transfer_to_bob = scenario.env.transfer_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_TRANSFER), + ); + let approve_bob = scenario.env.approve_b20_tx( + scenario.token, + BerylTestEnv::bob(), + U256::from(BerylTestEnv::B20_BOB_ALLOWANCE), + ); + let transfer_from_alice_to_carol = scenario.env.transfer_b20_from_alice_by_bob_tx( + scenario.token, + BerylTestEnv::carol(), + U256::from(BerylTestEnv::B20_TRANSFER_FROM_CAROL), + ); + let mint_to_carol = + scenario.call_tx(IB20::mintCall { to: BerylTestEnv::carol(), amount: U256::from(30) }); + let burn_from_alice = scenario.call_tx(IB20::burnCall { amount: U256::from(5) }); + let block = scenario + .build_block_with_transactions(vec![ + transfer_to_bob, + approve_bob, + transfer_from_alice_to_carol, + mint_to_carol, + burn_from_alice, + ]) + .await; + + for index in 0..5 { + assert!( + scenario.env.user_tx_succeeded(&block, index), + "stablecoin inherited B-20 mutation {index} must succeed" + ); + } + scenario.assert_transfer_log( + &block, + 0, + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_TRANSFER, + ); + scenario.assert_approval_log( + &block, + 1, + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_ALLOWANCE, + ); + scenario.assert_transfer_log( + &block, + 2, + BerylTestEnv::alice(), + BerylTestEnv::carol(), + BerylTestEnv::B20_TRANSFER_FROM_CAROL, + ); + scenario.assert_transfer_log(&block, 3, Address::ZERO, BerylTestEnv::carol(), 30); + scenario.assert_transfer_log(&block, 4, BerylTestEnv::alice(), Address::ZERO, 5); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY + 25); + scenario.assert_balances( + BerylTestEnv::B20_INITIAL_SUPPLY + - BerylTestEnv::B20_BOB_TRANSFER + - BerylTestEnv::B20_TRANSFER_FROM_CAROL + - 5, + BerylTestEnv::B20_BOB_TRANSFER, + BerylTestEnv::B20_TRANSFER_FROM_CAROL + 30, + ); + scenario.assert_allowance( + BerylTestEnv::alice(), + BerylTestEnv::bob(), + BerylTestEnv::B20_BOB_ALLOWANCE - BerylTestEnv::B20_TRANSFER_FROM_CAROL, + ); + + scenario.derive().await; +} + +#[tokio::test] +async fn stablecoin_calls_revert_while_stablecoin_feature_is_deactivated() { + let mut scenario = StablecoinScenario::new().await; + + let (probe, deploy_probe) = scenario.env.deploy_staticcall_probe_tx(scenario.token); + let block = scenario.build_block_with_transactions(vec![deploy_probe]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "stablecoin probe must deploy"); + + let deactivate_stablecoin = + scenario.env.deactivate_feature_tx(BerylTestEnv::b20_stablecoin_feature()); + let block = scenario.build_block_with_transactions(vec![deactivate_stablecoin]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20_STABLECOIN deactivation must succeed"); + + let probe_while_deactivated = scenario.env.call_staticcall_probe_tx( + probe, + Bytes::from(IB20Stablecoin::currencyCall {}.abi_encode()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block = scenario.build_block_with_transactions(vec![probe_while_deactivated]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "probe transaction must succeed even when the inner staticcall reverts" + ); + assert!( + !scenario.env.probe_call_succeeded(probe), + "currency() staticcall must fail when B20_STABLECOIN is deactivated" + ); + + let transfer_while_deactivated = + scenario.env.transfer_b20_tx(scenario.token, BerylTestEnv::bob(), U256::ONE); + let block = scenario.build_block_with_transactions(vec![transfer_while_deactivated]).await; + assert!( + !scenario.env.user_tx_succeeded(&block, 0), + "stablecoin transfer must revert when B20_STABLECOIN is deactivated" + ); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY, 0, 0); + + let reactivate_stablecoin = + scenario.env.activate_feature_tx(BerylTestEnv::b20_stablecoin_feature()); + let block = scenario.build_block_with_transactions(vec![reactivate_stablecoin]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "B20_STABLECOIN re-activation must succeed"); + + let probe_after_reactivate = scenario.env.call_staticcall_probe_tx( + probe, + Bytes::from(IB20Stablecoin::currencyCall {}.abi_encode()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ); + let block = scenario.build_block_with_transactions(vec![probe_after_reactivate]).await; + assert!(scenario.env.user_tx_succeeded(&block, 0), "probe transaction must succeed"); + assert!(scenario.env.probe_call_succeeded(probe), "currency() staticcall must succeed again"); + test_helpers::assert_probe_string( + &scenario.env, + probe, + "currency after reactivation", + BerylTestEnv::B20_STABLECOIN_CURRENCY, + ); + + let transfer_after_reactivate = + scenario.env.transfer_b20_tx(scenario.token, BerylTestEnv::bob(), U256::ONE); + let block = scenario.build_block_with_transactions(vec![transfer_after_reactivate]).await; + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "stablecoin transfer must succeed after B20_STABLECOIN is re-activated" + ); + scenario.assert_transfer_log(&block, 0, BerylTestEnv::alice(), BerylTestEnv::bob(), 1); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY - 1, 1, 0); + + scenario.derive().await; +} + +#[tokio::test] +async fn stablecoin_creation_reverts_for_invalid_currency() { + let mut env = BerylTestEnv::new(); + let token = env.b20_stablecoin_address(); + + let block1 = env.sequencer.build_empty_block().await; + let activate_factory = env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_stablecoin = env.activate_feature_tx(BerylTestEnv::b20_stablecoin_feature()); + let block2 = env + .sequencer + .build_next_block_with_transactions(vec![activate_factory, activate_stablecoin]) + .await; + assert!(env.user_tx_succeeded(&block2, 0), "TOKEN_FACTORY activation must succeed"); + assert!(env.user_tx_succeeded(&block2, 1), "B20_STABLECOIN activation must succeed"); + + let invalid_currency = create_stablecoin_with_currency_tx(&env, "usd"); + let block3 = env.sequencer.build_next_block_with_transactions(vec![invalid_currency]).await; + + assert!(!env.user_tx_succeeded(&block3, 0), "lowercase stablecoin currency must revert"); + assert!(!env.sequencer.has_code(token), "invalid stablecoin creation must not deploy code"); + assert_eq!( + env.b20_total_supply(token), + U256::ZERO, + "invalid stablecoin creation must not initialize supply" + ); + + env.derive_blocks([(block1, 1), (block2, 2), (block3, 3)], 3).await; +} + +struct StablecoinScenario { + env: BerylTestEnv, + token: Address, + blocks: Vec<(BaseBlock, u64)>, +} + +impl StablecoinScenario { + async fn new() -> Self { + let env = BerylTestEnv::new(); + let token = env.b20_stablecoin_address(); + let mut scenario = Self { env, token, blocks: Vec::new() }; + + scenario.build_block_with_transactions(Vec::new()).await; + scenario.activate_precompiles().await; + + let create = scenario.env.create_b20_stablecoin_tx(); + let block = scenario.build_block_with_transactions(vec![create]).await; + + assert!( + scenario.env.user_tx_succeeded(&block, 0), + "stablecoin creation transaction must succeed" + ); + assert!(scenario.env.sequencer.has_code(token), "stablecoin token code must be deployed"); + scenario.assert_created_log(&block); + scenario.assert_transfer_log( + &block, + 0, + Address::ZERO, + BerylTestEnv::alice(), + BerylTestEnv::B20_INITIAL_SUPPLY, + ); + scenario.assert_total_supply(BerylTestEnv::B20_INITIAL_SUPPLY); + scenario.assert_balances(BerylTestEnv::B20_INITIAL_SUPPLY, 0, 0); + + scenario + } + + async fn activate_precompiles(&mut self) { + let activate_factory = self.env.activate_feature_tx(BerylTestEnv::b20_factory_feature()); + let activate_stablecoin = + self.env.activate_feature_tx(BerylTestEnv::b20_stablecoin_feature()); + let block = + self.build_block_with_transactions(vec![activate_factory, activate_stablecoin]).await; + + assert!(self.env.user_tx_succeeded(&block, 0), "TOKEN_FACTORY activation must succeed"); + assert!(self.env.user_tx_succeeded(&block, 1), "B20_STABLECOIN activation must succeed"); + } + + async fn build_block_with_transactions( + &mut self, + transactions: Vec, + ) -> BaseBlock { + test_helpers::build_block_with_transactions(&mut self.env, &mut self.blocks, transactions) + .await + } + + fn call_tx(&self, call: impl SolCall) -> BaseTxEnvelope { + self.env.create_tx( + TxKind::Call(self.token), + Bytes::from(call.abi_encode()), + BerylTestEnv::B20_GAS_LIMIT, + ) + } + + async fn assert_staticcall_cases(&mut self, target: Address, cases: Vec) { + test_helpers::assert_staticcall_cases( + &mut self.env, + &mut self.blocks, + target, + cases, + "stablecoin", + ) + .await; + } + + fn assert_total_supply(&self, total_supply: u64) { + test_helpers::assert_total_supply(&self.env, self.token, "stablecoin", total_supply); + } + + fn assert_balances(&self, alice: u64, bob: u64, carol: u64) { + test_helpers::assert_balances(&self.env, self.token, "stablecoin", alice, bob, carol); + } + + fn assert_allowance(&self, owner: Address, spender: Address, amount: u64) { + assert_eq!( + self.env.b20_allowance(self.token, owner, spender), + U256::from(amount), + "stablecoin allowance must match expected value" + ); + } + + fn assert_created_log(&self, block: &BaseBlock) { + let expected = IB20Factory::B20Created { + token: self.token, + variant: IB20Factory::B20Variant::STABLECOIN, + name: BerylTestEnv::B20_STABLECOIN_NAME.to_string(), + symbol: BerylTestEnv::B20_STABLECOIN_SYMBOL.to_string(), + decimals: BerylTestEnv::B20_STABLECOIN_DECIMALS, + } + .encode_log_data(); + assert!( + self.env + .user_tx_receipt(block, 0) + .logs() + .iter() + .any(|log| log.address == B20FactoryStorage::ADDRESS && log.data == expected), + "createB20(STABLECOIN) must emit B20Created" + ); + } + + fn assert_transfer_log( + &self, + block: &BaseBlock, + user_tx_index: usize, + from: Address, + to: Address, + amount: u64, + ) { + assert!( + self.env.b20_transfer_log_emitted( + block, + user_tx_index, + self.token, + from, + to, + U256::from(amount), + ), + "stablecoin transaction {user_tx_index} must emit a Transfer event" + ); + } + + fn assert_approval_log( + &self, + block: &BaseBlock, + user_tx_index: usize, + owner: Address, + spender: Address, + amount: u64, + ) { + assert!( + self.env.b20_approval_log_emitted( + block, + user_tx_index, + self.token, + owner, + spender, + U256::from(amount), + ), + "stablecoin transaction {user_tx_index} must emit an Approval event" + ); + } + + async fn derive(mut self) { + let expected_safe_head = self.blocks.len() as u64; + self.env.derive_blocks(self.blocks, expected_safe_head).await; + } +} + +fn create_stablecoin_with_currency_tx(env: &BerylTestEnv, currency: &str) -> BaseTxEnvelope { + let params = IB20Factory::B20StablecoinCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: BerylTestEnv::B20_STABLECOIN_NAME.to_string(), + symbol: BerylTestEnv::B20_STABLECOIN_SYMBOL.to_string(), + initialAdmin: BerylTestEnv::alice(), + currency: currency.to_string(), + }; + + env.create_tx( + TxKind::Call(B20FactoryStorage::ADDRESS), + Bytes::from( + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::STABLECOIN, + salt: BerylTestEnv::b20_stablecoin_salt(), + params: params.abi_encode().into(), + initCalls: Vec::new(), + } + .abi_encode(), + ), + BerylTestEnv::B20_GAS_LIMIT, + ) +} diff --git a/actions/harness/tests/beryl/test_helpers.rs b/actions/harness/tests/beryl/test_helpers.rs new file mode 100644 index 0000000000..173e20c362 --- /dev/null +++ b/actions/harness/tests/beryl/test_helpers.rs @@ -0,0 +1,200 @@ +//! Shared helpers for Beryl precompile action tests. + +use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; +use alloy_sol_types::SolValue; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; + +use crate::env::BerylTestEnv; + +/// Expected output for a staticcall probe invocation. +pub(crate) struct StaticcallCase { + label: &'static str, + input: Vec, + expected_word: U256, + expected_returndata: Vec, +} + +impl StaticcallCase { + /// Creates a staticcall case from full expected ABI returndata. + pub(crate) fn returndata( + label: &'static str, + input: Vec, + expected_returndata: Vec, + ) -> Self { + let expected_word = first_word(&expected_returndata); + Self { label, input, expected_word, expected_returndata } + } + + /// Creates a staticcall case for a single-word ABI value. + pub(crate) fn word(label: &'static str, input: Vec, expected_word: U256) -> Self { + Self::returndata(label, input, expected_word.abi_encode()) + } + + /// Creates a staticcall case for a `bytes32` ABI value. + pub(crate) fn bytes32(label: &'static str, input: Vec, expected: B256) -> Self { + Self::returndata(label, input, expected.abi_encode()) + } + + /// Creates a staticcall case for a dynamic string ABI value. + pub(crate) fn string(label: &'static str, input: Vec, expected: &str) -> Self { + Self::returndata(label, input, expected.to_string().abi_encode()) + } +} + +/// Builds an L2 block and records it for derivation replay. +pub(crate) async fn build_block_with_transactions( + env: &mut BerylTestEnv, + blocks: &mut Vec<(BaseBlock, u64)>, + transactions: Vec, +) -> BaseBlock { + let block = env.sequencer.build_next_block_with_transactions(transactions).await; + let block_number = blocks.len() as u64 + 1; + blocks.push((block.clone(), block_number)); + block +} + +/// Deploys staticcall probes, executes all cases, and asserts the full returndata payload. +pub(crate) async fn assert_staticcall_cases( + env: &mut BerylTestEnv, + blocks: &mut Vec<(BaseBlock, u64)>, + target: Address, + cases: Vec, + probe_label: &str, +) { + let mut probes = Vec::with_capacity(cases.len()); + let mut deployments = Vec::with_capacity(cases.len()); + for _ in &cases { + let (probe, deploy) = env.deploy_staticcall_probe_tx(target); + probes.push(probe); + deployments.push(deploy); + } + + let deploy_block = build_block_with_transactions(env, blocks, deployments).await; + for index in 0..cases.len() { + assert!( + env.user_tx_succeeded(&deploy_block, index), + "{probe_label} staticcall probe deployment {index} must succeed" + ); + } + + let calls = probes + .iter() + .zip(cases.iter()) + .map(|(probe, case)| { + env.call_staticcall_probe_tx( + *probe, + Bytes::from(case.input.clone()), + BerylTestEnv::B20_PROBE_GAS_LIMIT, + ) + }) + .collect(); + let call_block = build_block_with_transactions(env, blocks, calls).await; + + for (index, (probe, case)) in probes.iter().zip(cases.iter()).enumerate() { + assert!( + env.user_tx_succeeded(&call_block, index), + "{} probe transaction must succeed", + case.label + ); + assert!(env.probe_call_succeeded(*probe), "{} staticcall must succeed", case.label); + assert_eq!( + env.probe_return_word(*probe), + case.expected_word, + "{} staticcall must return the expected first word", + case.label + ); + assert_eq!( + env.probe_return_size(*probe), + U256::from(case.expected_returndata.len()), + "{} staticcall must return the expected byte length", + case.label + ); + assert_eq!( + env.probe_return_hash(*probe), + returndata_hash(&case.expected_returndata), + "{} staticcall must return the expected ABI payload", + case.label + ); + } +} + +/// Asserts a token's total supply from storage. +pub(crate) fn assert_total_supply( + env: &BerylTestEnv, + token: Address, + token_label: &str, + total_supply: u64, +) { + assert_eq!( + env.b20_total_supply(token), + U256::from(total_supply), + "{token_label} total supply must match expected value" + ); +} + +/// Asserts the Alice, Bob, and Carol token balances from storage. +pub(crate) fn assert_balances( + env: &BerylTestEnv, + token: Address, + token_label: &str, + alice: u64, + bob: u64, + carol: u64, +) { + assert_eq!( + env.b20_balance(token, BerylTestEnv::alice()), + U256::from(alice), + "Alice {token_label} balance must match expected value" + ); + assert_eq!( + env.b20_balance(token, BerylTestEnv::bob()), + U256::from(bob), + "Bob {token_label} balance must match expected value" + ); + assert_eq!( + env.b20_balance(token, BerylTestEnv::carol()), + U256::from(carol), + "Carol {token_label} balance must match expected value" + ); +} + +/// Asserts a probe returned the ABI encoding of `expected`. +pub(crate) fn assert_probe_string(env: &BerylTestEnv, probe: Address, label: &str, expected: &str) { + assert_probe_returndata(env, probe, label, &expected.to_string().abi_encode()); +} + +/// ABI-encodes an address as a returned word. +pub(crate) fn word_from_address(address: Address) -> U256 { + let mut word = [0u8; 32]; + word[12..].copy_from_slice(address.as_slice()); + U256::from_be_slice(&word) +} + +fn first_word(returndata: &[u8]) -> U256 { + let mut word = [0u8; 32]; + let copied = returndata.len().min(word.len()); + word[..copied].copy_from_slice(&returndata[..copied]); + U256::from_be_bytes(word) +} + +fn assert_probe_returndata( + env: &BerylTestEnv, + probe: Address, + label: &str, + expected_returndata: &[u8], +) { + assert_eq!( + env.probe_return_size(probe), + U256::from(expected_returndata.len()), + "{label} staticcall must return the expected byte length" + ); + assert_eq!( + env.probe_return_hash(probe), + returndata_hash(expected_returndata), + "{label} staticcall must return the expected ABI payload" + ); +} + +fn returndata_hash(returndata: &[u8]) -> B256 { + keccak256(returndata) +} diff --git a/actions/harness/tests/derivation/main.rs b/actions/harness/tests/derivation/main.rs index baeab3b6d1..6b0b8ce83e 100644 --- a/actions/harness/tests/derivation/main.rs +++ b/actions/harness/tests/derivation/main.rs @@ -2525,7 +2525,7 @@ async fn large_l1_gaps_within_sequence_window() { /// Configuration: /// - `seq_window_size = 4` (small window so epochs expire quickly) /// - Zero batches submitted -/// - 20 empty L1 blocks mined +/// - More than two sequence windows of empty L1 blocks mined /// /// Expected result: the safe head advances well past genesis as the pipeline /// synthesises deposit-only blocks for each expired epoch. @@ -2533,6 +2533,7 @@ async fn large_l1_gaps_within_sequence_window() { #[tokio::test] async fn extended_sequence_window_exhaustion_fills_with_deposit_only_blocks() { const SEQ_WINDOW: u64 = 4; + const EMPTY_L1_BLOCKS: u64 = SEQ_WINDOW * 2 + 2; let batcher_cfg = BatcherConfig::default(); let rollup_cfg = TestRollupConfigBuilder::base_mainnet(&batcher_cfg) .with_seq_window_size(SEQ_WINDOW) @@ -2546,17 +2547,15 @@ async fn extended_sequence_window_exhaustion_fills_with_deposit_only_blocks() { SharedL1Chain::from_blocks(h.l1.chain().to_vec()), ); - // Mine 20 empty L1 blocks — no batches submitted anywhere. - for _ in 0..20 { + // Mine empty L1 blocks across multiple sequence windows with no batches + // submitted anywhere. + for _ in 0..EMPTY_L1_BLOCKS { h.mine_and_push(&chain); } node.initialize().await; - let mut total_derived = 0; - for _ in 1..=20u64 { - total_derived += node.run_until_idle().await; - } + let total_derived = node.run_until_idle().await; // With no batches, the pipeline generates deposit-only blocks for each // expired sequence window. The safe head must advance past genesis. @@ -2571,4 +2570,10 @@ async fn extended_sequence_window_exhaustion_fills_with_deposit_only_blocks() { "pipeline must have generated deposit-only blocks for expired epochs; \ total_derived = {total_derived}" ); + for block_number in 1..=node.l2_safe_number() { + let block = node + .derived_block(block_number) + .expect("every derived block should be recorded by the node"); + assert!(block.is_deposit_only(), "derived block {block_number} must be deposit-only"); + } } diff --git a/actions/harness/tests/hardfork/activation.rs b/actions/harness/tests/hardfork/activation.rs index 863fb0cc7f..06a51f362b 100644 --- a/actions/harness/tests/hardfork/activation.rs +++ b/actions/harness/tests/hardfork/activation.rs @@ -99,7 +99,8 @@ fn each_hardfork_activates_at_its_mainnet_timestamp() { /// trips at a different second, so there is never a spurious simultaneous cascade. #[test] fn mainnet_hardfork_timestamps_are_strictly_ordered() { - let h = &TestRollupConfigBuilder::mainnet().hardforks; + let rc = TestRollupConfigBuilder::mainnet(); + let h = &rc.hardforks; let ordered: &[(&str, u64)] = &[ ("canyon", h.canyon_time.expect("canyon_time")), diff --git a/actions/harness/tests/sync_status.rs b/actions/harness/tests/sync_status.rs new file mode 100644 index 0000000000..7ca046045a --- /dev/null +++ b/actions/harness/tests/sync_status.rs @@ -0,0 +1,38 @@ +//! Action tests for consensus sync-status L1 reporting. + +use std::sync::Arc; + +use base_action_harness::{ActionL1BlockFetcher, ActionTestHarness, SharedL1Chain}; +use base_consensus_node::L1WatcherQueryExecutor; +use base_consensus_rpc::L1WatcherQueries; +use tokio::sync::{oneshot, watch}; + +#[tokio::test] +async fn sync_status_current_l1_tracks_verifier_depth_origin_not_l1_head() { + const L1_HEAD: u64 = 100; + const VERIFIER_L1_CONFS: u64 = 4; + + let mut harness = ActionTestHarness::default(); + harness.mine_l1_blocks(L1_HEAD); + + let l1_chain = SharedL1Chain::from_blocks(harness.l1.chain().to_vec()); + let derivation_origin = harness.l1.block_info_at(L1_HEAD - VERIFIER_L1_CONFS); + let live_head = harness.l1.tip_info(); + let (_derivation_origin_tx, derivation_origin_rx) = watch::channel(Some(derivation_origin)); + let executor = L1WatcherQueryExecutor::new( + Arc::new(harness.rollup_config.clone()), + Arc::new(ActionL1BlockFetcher::new(l1_chain)), + derivation_origin_rx, + ); + let (sender, receiver) = oneshot::channel(); + + executor.execute(L1WatcherQueries::L1State(sender)).await; + + let state = receiver.await.expect("state query should return a response"); + assert_eq!(state.current_l1, Some(derivation_origin)); + assert_eq!(state.head_l1, Some(live_head)); + assert_ne!( + state.current_l1, state.head_l1, + "verifier_l1_confs should make current_l1 report derivation origin, not live L1 head" + ); +} diff --git a/baseup/README.md b/baseup/README.md index 186c479536..b8e33fa858 100644 --- a/baseup/README.md +++ b/baseup/README.md @@ -15,7 +15,9 @@ curl -fsSL https://raw.githubusercontent.com/base/base/main/baseup/install | bas ```bash baseup # Install the latest release binaries baseup -i v0.6.0 # Install a specific release tag +baseup --bin base # Install only the unified base binary baseup --bin base-reth-node # Install only the node binary +baseup --bin base-consensus # Install only the consensus binary baseup --bin basectl # Install only basectl baseup --bin all # Install all published binaries baseup -v # Print the baseup installer version @@ -27,9 +29,22 @@ baseup --help # Show help By default, `baseup` installs every binary this repo publishes in GitHub releases today: +- `base` - `base-reth-node` +- `base-consensus` - `basectl` +## Verification + +`baseup` verifies every release archive before installing it: + +- downloads `--.tar.gz` +- checks `.sha256` +- verifies `.asc` with GPG +- verifies GitHub SLSA provenance when `gh` is installed and authenticated + +Use `--unsafe-skip-verify` only for local testing; checksum verification is still required. + ## Supported Targets `baseup` matches the release workflow in this repo: diff --git a/baseup/baseup b/baseup/baseup index 2f5e4e2b46..3a429c6db6 100755 --- a/baseup/baseup +++ b/baseup/baseup @@ -5,7 +5,7 @@ set -e # Downloads release binaries from GitHub releases and installs them locally. # NOTE: If you change this script, increment the version below. -BASEUP_INSTALLER_VERSION="0.1.3" +BASEUP_INSTALLER_VERSION="0.1.4" BASEUP_REPO="${BASEUP_REPO:-base/base}" BASEUP_REPO_REF="${BASEUP_REPO_REF:-main}" @@ -14,11 +14,14 @@ BIN_DIR="${BASE_BIN_DIR:-$BASEUP_HOME/bin}" BASEUP_HOST_BASE_URL="${BASEUP_HOST_BASE_URL:-https://raw.githubusercontent.com/${BASEUP_REPO}/${BASEUP_REPO_REF}/baseup}" BASEUP_BIN_URL="${BASEUP_BIN_URL:-${BASEUP_HOST_BASE_URL}/baseup}" BASEUP_BIN_PATH="$BIN_DIR/baseup" +BASEUP_RELEASE_GPG_FINGERPRINT="${BASEUP_RELEASE_GPG_FINGERPRINT:-}" +BASEUP_RELEASE_GPG_KEY_URL="${BASEUP_RELEASE_GPG_KEY_URL:-}" -DEFAULT_BINS=("base-reth-node" "basectl") +DEFAULT_BINS=("base" "base-reth-node" "base-consensus" "basectl") SELECTED_BINS=() VERSION="" VERSION_EXPLICIT=0 +UNSAFE_SKIP_VERIFY=0 UPDATE_TMP_FILE="" MAIN_TMP_DIR="" UPDATE_CHECK_CACHE_FILE="$BASEUP_HOME/baseup-update-check" @@ -199,6 +202,96 @@ compute_sha256() { fi } +normalize_fingerprint() { + printf '%s' "$1" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]' +} + +ensure_gpg_key() { + local fingerprint + fingerprint="$(normalize_fingerprint "$BASEUP_RELEASE_GPG_FINGERPRINT")" + + [[ -n "$fingerprint" ]] || return 0 + + if gpg --batch --list-keys "$fingerprint" >/dev/null 2>&1; then + return 0 + fi + + if [[ -z "$BASEUP_RELEASE_GPG_KEY_URL" ]]; then + error "Base release signing key $fingerprint is not in your keyring. Set BASEUP_RELEASE_GPG_KEY_URL or import the key before installing." + fi + + info "Fetching Base release signing key..." + if ! fetch_text "$BASEUP_RELEASE_GPG_KEY_URL" | gpg --batch --import >/dev/null 2>&1; then + error "Failed to import Base release signing key" + fi + + if ! gpg --batch --list-keys "$fingerprint" >/dev/null 2>&1; then + error "Imported key does not match Base release signing fingerprint $fingerprint" + fi +} + +verify_gpg_signature() { + local archive_name="$1" + local archive_path="$2" + local signature_path="$3" + local expected_fingerprint + local actual_fingerprint + local status_output + + if [[ "$UNSAFE_SKIP_VERIFY" -eq 1 ]]; then + warn "Skipping GPG signature verification for $archive_name (--unsafe-skip-verify)." + return + fi + + if ! command -v gpg >/dev/null 2>&1; then + error "gpg not found. Install gpg, or re-run with --unsafe-skip-verify to bypass signature verification." + fi + + ensure_gpg_key + + if ! status_output="$(gpg --batch --status-fd 1 --verify "$signature_path" "$archive_path" 2>/dev/null)"; then + error "GPG signature verification failed for $archive_name" + fi + + expected_fingerprint="$(normalize_fingerprint "$BASEUP_RELEASE_GPG_FINGERPRINT")" + if [[ -n "$expected_fingerprint" ]]; then + actual_fingerprint="$(printf '%s\n' "$status_output" | awk '/^\[GNUPG:\] VALIDSIG / { print $3; exit }')" + actual_fingerprint="$(normalize_fingerprint "$actual_fingerprint")" + + if [[ "$actual_fingerprint" != "$expected_fingerprint" ]]; then + error "GPG signature for $archive_name was made by $actual_fingerprint, expected $expected_fingerprint" + fi + else + warn "BASEUP_RELEASE_GPG_FINGERPRINT is not set; accepted any locally available GPG signing key." + fi + + info "GPG signature verified for $archive_name" +} + +verify_attestation() { + local archive_name="$1" + local archive_path="$2" + + if [[ "$UNSAFE_SKIP_VERIFY" -eq 1 ]]; then + warn "Skipping SLSA attestation verification for $archive_name (--unsafe-skip-verify)." + return + fi + + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + info "Verifying SLSA build provenance for $archive_name..." + if gh attestation verify "$archive_path" --repo "$BASEUP_REPO" \ + --predicate-type https://slsa.dev/provenance/v1 >/dev/null 2>&1; then + info "SLSA provenance verified for $archive_name" + else + warn "SLSA provenance attestation not found or failed verification for $archive_name." + fi + elif command -v gh >/dev/null 2>&1; then + warn "gh is not authenticated; skipping optional SLSA attestation verification for $archive_name." + else + info "gh not found; skipping optional SLSA attestation verification for $archive_name." + fi +} + validate_archive() { local archive_path="$1" local archive_listing @@ -375,9 +468,15 @@ normalize_bin_name() { all) echo "all" ;; + base) + echo "base" + ;; node | reth-node | base-reth-node) echo "base-reth-node" ;; + consensus | base-consensus) + echo "base-consensus" + ;; ctl | basectl) echo "basectl" ;; @@ -389,7 +488,7 @@ normalize_bin_name() { add_selected_bin() { local normalized - normalized="$(normalize_bin_name "$1")" || error "Unknown binary '$1'. Valid values: base-reth-node, basectl, all." + normalized="$(normalize_bin_name "$1")" || error "Unknown binary '$1'. Valid values: base, base-reth-node, base-consensus, basectl, all." if [[ "$normalized" == "all" ]]; then local item @@ -553,13 +652,16 @@ Usage: baseup [OPTIONS] Options: -v, --version Print the baseup installer version -i, --install Install a specific release tag (for example: v0.6.0) - -b, --bin Install only one binary (base-reth-node, basectl, all) + -b, --bin Install only one binary (base, base-reth-node, base-consensus, basectl, all) -U, --update Update baseup itself + --unsafe-skip-verify + Skip GPG signature and SLSA attestation verification -h, --help Show this help message Examples: baseup baseup -i v0.6.0 + baseup --bin base baseup --bin base-reth-node baseup --bin basectl baseup --update @@ -586,6 +688,10 @@ while [[ $# -gt 0 ]]; do -U | --update) update_baseup ;; + --unsafe-skip-verify) + UNSAFE_SKIP_VERIFY=1 + shift + ;; -h | --help) usage ;; @@ -628,6 +734,7 @@ main() { local archive_name local archive_path local checksum_path + local signature_path local expected_checksum local actual_checksum local extract_dir @@ -636,10 +743,14 @@ main() { archive_name="${binary_name}-${VERSION}-${target}.tar.gz" archive_path="$MAIN_TMP_DIR/$archive_name" checksum_path="${archive_path}.sha256" + signature_path="${archive_path}.asc" extract_dir="$MAIN_TMP_DIR/${binary_name}-extract" download_file "$VERSION" "$archive_name" "$MAIN_TMP_DIR" download_file "$VERSION" "${archive_name}.sha256" "$MAIN_TMP_DIR" + if [[ "$UNSAFE_SKIP_VERIFY" -eq 0 ]]; then + download_file "$VERSION" "${archive_name}.asc" "$MAIN_TMP_DIR" + fi expected_checksum="$(head -n 1 "$checksum_path" | awk '{print $1}')" actual_checksum="$(compute_sha256 "$archive_path")" @@ -649,6 +760,8 @@ main() { fi info "Checksum verified for $archive_name" + verify_gpg_signature "$archive_name" "$archive_path" "$signature_path" + verify_attestation "$archive_name" "$archive_path" mkdir -p "$extract_dir" extract_archive "$archive_path" "$extract_dir" diff --git a/bin/audit-archiver/README.md b/bin/audit-archiver/README.md index a3af93668f..a6b995d6a4 100644 --- a/bin/audit-archiver/README.md +++ b/bin/audit-archiver/README.md @@ -1,3 +1,3 @@ # `audit-archiver` -Reads audit log events from a Kafka topic and archives them to S3. +Reads audit log events via RPC and archives them to S3. diff --git a/bin/audit-archiver/src/main.rs b/bin/audit-archiver/src/main.rs index a2ea85a9c4..e3bf1ccd00 100644 --- a/bin/audit-archiver/src/main.rs +++ b/bin/audit-archiver/src/main.rs @@ -14,7 +14,7 @@ use clap::{Parser, ValueEnum}; use jsonrpsee::server::ServerBuilder; use moka::{policy::EvictionPolicy, sync::Cache}; use tokio::sync::mpsc; -use tracing::{info, warn}; +use tracing::info; base_cli_utils::define_log_args!("TIPS_AUDIT"); base_cli_utils::define_metrics_args!("TIPS_AUDIT", 9002); @@ -28,18 +28,6 @@ enum S3ConfigType { #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { - /// Deprecated: bundle events are now ingested over RPC. Accepted for - /// backward compatibility with existing deploy configs and ignored at - /// runtime (a deprecation warning is logged when set). - #[arg(long, env = "TIPS_AUDIT_KAFKA_PROPERTIES_FILE")] - kafka_properties_file: Option, - - /// Deprecated: bundle events are now ingested over RPC. Accepted for - /// backward compatibility with existing deploy configs and ignored at - /// runtime (a deprecation warning is logged when set). - #[arg(long, env = "TIPS_AUDIT_KAFKA_TOPIC")] - kafka_topic: Option, - #[arg(long, env = "TIPS_AUDIT_S3_BUCKET")] s3_bucket: String, @@ -111,14 +99,6 @@ async fn main() -> Result<()> { "Starting audit archiver" ); - if args.kafka_properties_file.is_some() || args.kafka_topic.is_some() { - warn!( - "TIPS_AUDIT_KAFKA_PROPERTIES_FILE / TIPS_AUDIT_KAFKA_TOPIC are deprecated and ignored: \ - bundle events are now ingested over RPC via base_persistBatchedBundleEvent. \ - Remove these args from the deploy config." - ); - } - let s3_client = create_s3_client(&args).await?; let s3_bucket = args.s3_bucket.clone(); let writer = S3EventReaderWriter::new(s3_client, s3_bucket); diff --git a/bin/base/Cargo.toml b/bin/base/Cargo.toml index b142131f1f..fcb8ac751a 100644 --- a/bin/base/Cargo.toml +++ b/bin/base/Cargo.toml @@ -19,15 +19,25 @@ workspace = true # workspace base-cli-utils.workspace = true base-common-chains.workspace = true +base-consensus-cli.workspace = true +base-execution-cli.workspace = true +base-execution-chainspec.workspace = true + +# alloy +alloy-chains = { workspace = true, features = ["std"] } + +# reth +reth-cli-runner.workspace = true # cli clap = { workspace = true, features = ["env"] } -# tracing -tracing = { workspace = true, features = ["std"] } - # misc +url.workspace = true eyre.workspace = true +tokio.workspace = true +tracing.workspace = true +tokio-util.workspace = true serde = { workspace = true, features = ["derive"] } figment = { workspace = true, features = ["env", "toml"] } diff --git a/bin/base/README.md b/bin/base/README.md index 2bda643c5e..ca3429c8a4 100644 --- a/bin/base/README.md +++ b/bin/base/README.md @@ -1,29 +1,54 @@ # `base` -Minimal scaffolding for the unified Base node binary. +Unified Base node binary. -The current implementation only does four things: +## `base rpc` -- parses the public `base` CLI surface for `--chain` and `node rpc` -- initializes workspace-standard logging -- initializes the Prometheus recorder when metrics are enabled -- logs `Hello, I'm running this chain` with the resolved chain config +`base rpc` starts a validator-oriented node by launching an embedded execution node and an embedded +consensus node in the same process. The execution node exposes the Engine API over auth IPC, and the +consensus node connects to that IPC endpoint internally. -Supported CLI forms: +The execution CLI surface is shared with the standalone execution binaries through +`base-execution-cli`. `base rpc` intentionally filters out flags for roles it does not run, including +sequencer, builder, conductor, metering, and transaction-forwarding options. + +Supported forms: ```text -base node rpc -base --chain sepolia node rpc -base -c sepolia node rpc -base --chain zeronet node rpc -base --chain ./chain.toml node rpc -base -c ./chain.toml node rpc +base rpc +base --chain sepolia rpc +base -c sepolia rpc +base --chain zeronet rpc +base --chain ./chain.toml rpc +base -c ./chain.toml rpc ``` -Chain selection currently supports: +The command also accepts an execution chain override when the root `--chain` selection is used only +for consensus chain resolution: + +```text +base rpc --execution-chain dev +``` + +## `base update` + +`base update` updates the installed `base` binary by running `baseup --bin base` against the same +directory as the currently running executable. `baseup` downloads the `GitHub` release artifact, +checks the archive checksum, verifies the release signature, and installs the verified binary. + +Supported forms: + +```text +base update +base update --install v0.6.0 +base update --update-installer +``` + +## Chain Selection + +Chain selection supports: - built-in names: `mainnet`, `sepolia`, `zeronet` -- TOML files with optional fields: - TOML files for custom chains: ```toml @@ -31,3 +56,5 @@ name = "custom-chain" l2_chain_id = 84532 l1_chain_id = 11155111 ``` + +TOML values can be overridden with environment variables using the `BASE_CHAIN_` prefix. diff --git a/bin/base/src/app.rs b/bin/base/src/app.rs deleted file mode 100644 index e065c7e18e..0000000000 --- a/bin/base/src/app.rs +++ /dev/null @@ -1,36 +0,0 @@ -use base_cli_utils::{LogConfig, MetricsConfig}; -use eyre::WrapErr; - -use crate::{cli::BaseCli, config::ChainResolver}; - -/// Runs the `base` binary. -#[derive(Debug, Clone)] -pub(crate) struct BaseApp { - /// Parsed CLI input. - pub cli: BaseCli, -} - -impl BaseApp { - /// Creates a new app from parsed CLI input. - pub(crate) const fn new(cli: BaseCli) -> Self { - Self { cli } - } - - /// Runs the requested command. - pub(crate) fn run(self) -> eyre::Result<()> { - let BaseCli { chain, logging, metrics, command } = self.cli; - - LogConfig::from(logging) - .init_tracing_subscriber() - .wrap_err("failed to initialize tracing")?; - - MetricsConfig::from(metrics) - .init_with(|| { - base_cli_utils::register_version_metrics!(); - }) - .wrap_err("failed to install Prometheus recorder")?; - - let resolved_chain = ChainResolver::new(chain).resolve()?; - command.run(resolved_chain) - } -} diff --git a/bin/base/src/cli.rs b/bin/base/src/cli.rs index 969d0874fe..12e082c688 100644 --- a/bin/base/src/cli.rs +++ b/bin/base/src/cli.rs @@ -1,7 +1,11 @@ -use clap::{Args, Parser, Subcommand}; -use tracing::info; +use base_cli_utils::{LogConfig, MetricsConfig}; +use clap::Parser; +use eyre::WrapErr; -use crate::config::{ChainArg, ResolvedChainConfig}; +use crate::{ + commands::BaseCommand, + config::{ChainArg, ChainResolver}, +}; base_cli_utils::define_log_args!("BASE_NODE"); base_cli_utils::define_metrics_args!("BASE_NODE", 9090); @@ -33,61 +37,24 @@ pub(crate) struct BaseCli { pub(crate) command: BaseCommand, } -/// Top-level commands for `base`. -#[derive(Subcommand, Clone, Debug)] -#[non_exhaustive] -pub(crate) enum BaseCommand { - /// Start the integrated Base node. - #[command(name = "node")] - Node(NodeArgs), -} +impl BaseCli { + /// Runs the selected command with shared process initialization. + pub(crate) fn run(self) -> eyre::Result<()> { + let Self { chain, logging, metrics, command } = self; -impl BaseCommand { - /// Runs the selected top-level command. - pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { - match self { - Self::Node(node) => node.run(resolved_chain), - } - } -} + LogConfig::from(logging) + .init_tracing_subscriber() + .wrap_err("failed to initialize tracing")?; -/// Arguments for `base node`. -#[derive(Args, Clone, Debug)] -pub(crate) struct NodeArgs { - /// The node flavor to run. - #[command(subcommand)] - pub(crate) command: NodeSubcommand, -} + MetricsConfig::from(metrics) + .init_with(|| { + base_cli_utils::register_version_metrics!(); + }) + .wrap_err("failed to install Prometheus recorder")?; -impl NodeArgs { - /// Runs the selected `node` subcommand. - pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { - match self.command { - NodeSubcommand::Rpc(rpc) => rpc.run(resolved_chain), - } + command.run(ChainResolver::new(chain)) } } - -/// Subcommands for `base node`. -#[derive(Subcommand, Clone, Debug)] -pub(crate) enum NodeSubcommand { - /// Run the integrated node in RPC mode. - #[command(name = "rpc")] - Rpc(RpcCommand), -} - -/// Arguments for `base node rpc`. -#[derive(Args, Clone, Debug, Default)] -pub(crate) struct RpcCommand; - -impl RpcCommand { - /// Runs the `rpc` flavor. - pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { - info!(chain = ?resolved_chain, "Hello, I'm running this chain"); - Ok(()) - } -} - #[cfg(test)] mod tests { use std::ffi::OsStr; @@ -98,23 +65,37 @@ mod tests { use crate::config::BuiltInChain; #[test] - fn parses_default_chain_for_node_rpc() { - let cli = BaseCli::parse_from(["base", "node", "rpc"]); + fn parses_default_chain_for_rpc() { + let cli = BaseCli::parse_from([ + "base", + "rpc", + "--l1-eth-rpc", + "http://localhost:8545", + "--l1-beacon", + "http://localhost:5052", + ]); assert!(matches!(cli.chain, ChainArg::BuiltIn(BuiltInChain::Mainnet))); - assert!(matches!(cli.command, BaseCommand::Node(_))); + assert!(matches!(cli.command, BaseCommand::Rpc(_))); } #[test] fn parses_named_chain_selector() { - let cli = BaseCli::parse_from(["base", "-c", "sepolia", "node", "rpc"]); + let cli = BaseCli::parse_from(["base", "-c", "sepolia", "bootnode"]); + + assert!(matches!(cli.chain, ChainArg::BuiltIn(BuiltInChain::Sepolia))); + } + + #[test] + fn parses_global_chain_after_subcommand() { + let cli = BaseCli::parse_from(["base", "bootnode", "--chain", "sepolia"]); assert!(matches!(cli.chain, ChainArg::BuiltIn(BuiltInChain::Sepolia))); } #[test] fn parses_path_chain_selector() { - let cli = BaseCli::parse_from(["base", "--chain", "./chain.toml", "node", "rpc"]); + let cli = BaseCli::parse_from(["base", "--chain", "./chain.toml", "bootnode"]); assert!(matches!(cli.chain, ChainArg::File(_))); } @@ -131,7 +112,7 @@ mod tests { #[test] fn rejects_multiple_chain_selectors() { let err = - BaseCli::try_parse_from(["base", "-c", "mainnet", "--chain", "sepolia", "node", "rpc"]) + BaseCli::try_parse_from(["base", "-c", "mainnet", "--chain", "sepolia", "bootnode"]) .unwrap_err(); let rendered = err.to_string(); diff --git a/bin/base/src/commands/bootnode.rs b/bin/base/src/commands/bootnode.rs new file mode 100644 index 0000000000..a0934d2e57 --- /dev/null +++ b/bin/base/src/commands/bootnode.rs @@ -0,0 +1,138 @@ +//! Combined consensus and execution bootnode command. + +use base_consensus_cli::{BootnodeP2PArgs, CliMetrics, L2ConfigFile}; +use base_execution_cli::commands::p2p::bootnode::Command as ExecutionBootnodeCommand; +use clap::Args; +use eyre::WrapErr; +use reth_cli_runner::CliRunner; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; + +use crate::config::ResolvedChainConfig; + +/// Arguments for `base bootnode`. +#[derive(Args, Clone, Debug)] +pub(crate) struct BootnodeCommand { + /// L2 configuration file. + #[clap(flatten)] + pub(crate) l2_config: L2ConfigFile, + + /// Consensus bootnode P2P discovery arguments. + #[command(flatten)] + pub(crate) consensus: BootnodeP2PArgs, + + /// Execution bootnode discovery arguments. + #[command(flatten)] + pub(crate) execution: ExecutionBootnodeCommand, +} + +impl BootnodeCommand { + /// Runs both discovery-only bootnodes. + pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { + let consensus_chain = resolved_chain.consensus_chain_args(); + let rollup_config = self.l2_config.load(&consensus_chain.l2_chain_id)?; + + CliMetrics::init_rollup_config(&rollup_config); + CliMetrics::init_bootnode_p2p(&self.consensus); + + CliRunner::try_default_runtime()?.run_command_until_exit(|_| async move { + let chain_id = rollup_config.l2_chain_id.id(); + self.consensus.check_ports()?; + + let mut consensus_bootnode = + tokio::spawn(Self::run_consensus(self.consensus, chain_id)); + let mut execution_bootnode = tokio::spawn(Self::run_execution(self.execution)); + + tokio::select! { + result = &mut consensus_bootnode => { + warn!(layer = "consensus", "bootnode task exited"); + if let Err(error) = Self::stop_task("execution", execution_bootnode).await { + warn!(error = %error, "failed to stop execution bootnode"); + } + Self::task_result("consensus", result) + } + result = &mut execution_bootnode => { + warn!(layer = "execution", "bootnode task exited"); + if let Err(error) = Self::stop_task("consensus", consensus_bootnode).await { + warn!(error = %error, "failed to stop consensus bootnode"); + } + Self::task_result("execution", result) + } + } + }) + } + + async fn run_consensus(consensus: BootnodeP2PArgs, chain_id: u64) -> eyre::Result<()> { + let driver = consensus.discovery_driver(chain_id)?; + let (handler, mut discovered_enrs) = driver.start(); + let local_enr = handler.local_enr().await.wrap_err("discovery service stopped")?; + consensus.write_enr_output(&local_enr)?; + + info!( + target: "rollup_node::bootnode", + chain_id = chain_id, + enr = %local_enr, + "Consensus bootnode started" + ); + CliMetrics::record_bootnode_up(); + + while let Some(enr) = discovered_enrs.recv().await { + debug!( + target: "rollup_node::bootnode", + peer_id = %enr.node_id(), + enr = %enr, + "Discovered consensus peer" + ); + } + + warn!(target: "rollup_node::bootnode", "Discovery ENR stream closed"); + Ok(()) + } + + async fn run_execution(execution: ExecutionBootnodeCommand) -> eyre::Result<()> { + execution.execute().await + } + + async fn stop_task( + layer: &'static str, + task: JoinHandle>, + ) -> eyre::Result<()> { + task.abort(); + match task.await { + Ok(result) => { + result.wrap_err_with(|| format!("{layer} bootnode exited while stopping")) + } + Err(error) if error.is_cancelled() => Ok(()), + Err(error) => Err(eyre::eyre!("{layer} bootnode task failed while stopping: {error}")), + } + } + + fn task_result( + layer: &'static str, + result: Result, tokio::task::JoinError>, + ) -> eyre::Result<()> { + match result { + Ok(result) => result.wrap_err_with(|| format!("{layer} bootnode exited with an error")), + Err(error) => Err(eyre::eyre!("{layer} bootnode task failed: {error}")), + } + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use crate::{cli::BaseCli, commands::BaseCommand, config::ChainArg}; + + #[test] + fn parses_bootnode_command() { + let cli = BaseCli::parse_from(["base", "bootnode"]); + + assert!(matches!(cli.chain, ChainArg::BuiltIn(_))); + let BaseCommand::Bootnode(bootnode) = cli.command else { + panic!("expected bootnode command"); + }; + assert_eq!(bootnode.consensus.listen_tcp_port, 9222); + assert_eq!(bootnode.execution.v4_addr.to_string(), "0.0.0.0:30301"); + } +} diff --git a/bin/base/src/commands/command.rs b/bin/base/src/commands/command.rs new file mode 100644 index 0000000000..618224da86 --- /dev/null +++ b/bin/base/src/commands/command.rs @@ -0,0 +1,49 @@ +//! Top-level command dispatch for the unified Base binary. + +use clap::Subcommand; + +use crate::{ + commands::{bootnode::BootnodeCommand, rpc::RpcCommand, update::UpdateCommand}, + config::ChainResolver, +}; + +/// Top-level commands for `base`. +#[derive(Subcommand, Clone, Debug)] +#[non_exhaustive] +pub(crate) enum BaseCommand { + /// Run consensus and execution discovery-only bootnodes. + #[command(name = "bootnode")] + Bootnode(Box), + /// Run the integrated node in RPC mode. + #[command(name = "rpc")] + Rpc(Box), + /// Update the base binary to the latest release. + #[command(name = "update")] + Update(Box), +} + +impl BaseCommand { + /// Runs the selected top-level command. + pub(crate) fn run(self, chain_resolver: ChainResolver) -> eyre::Result<()> { + match self { + Self::Bootnode(bootnode) => (*bootnode).run(chain_resolver.resolve()?), + Self::Rpc(rpc) => (*rpc).run(chain_resolver.resolve()?), + Self::Update(update) => (*update).run(), + } + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use crate::cli::BaseCli; + + #[test] + fn rejects_legacy_node_rpc_path() { + let err = BaseCli::try_parse_from(["base", "node", "rpc"]).unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("node")); + } +} diff --git a/bin/base/src/commands/mod.rs b/bin/base/src/commands/mod.rs new file mode 100644 index 0000000000..2972bbdf0f --- /dev/null +++ b/bin/base/src/commands/mod.rs @@ -0,0 +1,7 @@ +//! Top-level command implementations for the unified Base binary. + +mod bootnode; +mod command; +pub(crate) use command::BaseCommand; +mod rpc; +mod update; diff --git a/bin/base/src/commands/rpc.rs b/bin/base/src/commands/rpc.rs new file mode 100644 index 0000000000..6c380d467d --- /dev/null +++ b/bin/base/src/commands/rpc.rs @@ -0,0 +1,319 @@ +//! Integrated RPC node command. + +use std::{path::Path, sync::Arc}; + +use base_consensus_cli::{ + ConsensusNodeArgs, ConsensusNodeOverrides, EmbeddedConsensusNodeConfigArgs, +}; +use base_execution_chainspec::BaseChainSpec; +use base_execution_cli::{ExecutionNodeArgs, chainspec::chain_value_parser}; +use clap::Args; +use reth_cli_runner::CliRunner; +use tokio_util::sync::CancellationToken; +use url::Url; + +use crate::config::ResolvedChainConfig; + +/// Arguments for `base rpc`. +#[derive(Args, Clone, Debug)] +#[command( + mut_arg("builder_disallow", |arg| arg.hide(true).long("__builder-disallow-disabled")), + mut_arg("sequencer", |arg| arg.hide(true).long("__rollup-sequencer-disabled")), + mut_arg("sequencer_headers", |arg| arg.hide(true).long("__rollup-sequencer-headers-disabled")) +)] +pub(crate) struct RpcCommand { + /// Execution chain spec to use instead of the root chain selection. + #[arg(long = "execution-chain", value_parser = chain_value_parser)] + pub(crate) execution_chain: Option>, + + /// Execution node arguments. + #[command(flatten)] + pub(crate) execution: ExecutionNodeArgs, + + /// Consensus node arguments. + #[command(flatten)] + pub(crate) consensus: EmbeddedConsensusNodeConfigArgs, +} + +impl RpcCommand { + /// Runs the `rpc` flavor. + pub(crate) fn run(self, resolved_chain: ResolvedChainConfig) -> eyre::Result<()> { + let execution_chain = match self.execution_chain { + Some(chain) => chain, + None => resolved_chain.execution_chain_spec()?, + }; + let consensus_chain = resolved_chain.consensus_chain_args(); + let consensus_args = ConsensusNodeArgs::new(consensus_chain, self.consensus.into()); + let rollup_config = consensus_args.load_rollup_config()?; + + let execution = self.execution.into_launch_config(execution_chain).with_auth_ipc(); + let l2_engine_rpc = engine_ipc_url(execution.auth_ipc_path())?; + + CliRunner::try_default_runtime()?.run_command_until_exit(|ctx| async move { + let task_executor = ctx.task_executor.clone(); + let launched = execution.launch_default(ctx).await?; + let handle = launched.handle; + // Keep the execution node handle alive until both services have coordinated shutdown. + let execution_node = handle.node; + let execution_exit = handle.node_exit_future; + + let overrides = ConsensusNodeOverrides { + l2_engine_rpc: Some(l2_engine_rpc), + l2_engine_jwt_secret: None, + }; + + let consensus_cancellation = CancellationToken::new(); + let consensus_exit = consensus_args.start_with_overrides_and_cancellation( + rollup_config, + overrides, + consensus_cancellation.clone(), + ); + tokio::pin!(execution_exit); + tokio::pin!(consensus_exit); + + let result = tokio::select! { + result = &mut execution_exit => { + consensus_cancellation.cancel(); + let consensus_result = consensus_exit.await; + result?; + consensus_result + } + result = &mut consensus_exit => { + let consensus_result = result; + task_executor + .initiate_graceful_shutdown() + .map_err(|e| eyre::eyre!("failed to signal execution node shutdown: {e}"))? + .ignore_guard() + .await; + let execution_result = execution_exit.await; + consensus_result?; + execution_result + } + }; + + drop(execution_node); + result + }) + } +} + +fn engine_ipc_url(path: &str) -> eyre::Result { + let path = Path::new(path); + let path = + if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir()?.join(path) }; + Url::from_file_path(&path).map_err(|()| { + eyre::eyre!("failed to convert auth IPC path to file URL: {}", path.display()) + }) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use crate::{cli::BaseCli, commands::BaseCommand, config::ChainArg}; + + const REQUIRED_CONSENSUS_ARGS: &[&str] = + &["--l1-eth-rpc", "http://localhost:8545", "--l1-beacon", "http://localhost:5052"]; + + fn rpc_args(args: &'static [&'static str]) -> Vec<&'static str> { + let mut full_args = Vec::from(args); + full_args.extend_from_slice(REQUIRED_CONSENSUS_ARGS); + full_args + } + + #[test] + fn parses_execution_port_and_consensus_rpc_port() { + let cli = BaseCli::parse_from(rpc_args(&[ + "base", + "rpc", + "--port", + "30333", + "--rpc.port", + "9546", + ])); + + let BaseCommand::Rpc(rpc) = cli.command else { + panic!("expected rpc command"); + }; + + assert_eq!(rpc.execution.network.port, 30333); + assert_eq!(rpc.consensus.rpc_flags.listen_port, 9546); + } + + #[test] + fn parses_devnet_unified_client_args() { + let cli = BaseCli::parse_from([ + "base", + "rpc", + "--chain", + "dev", + "--execution-chain", + "dev", + "--datadir=/data", + "--http", + "--http.addr=0.0.0.0", + "--http.port=8545", + "--ws", + "--ws.addr=0.0.0.0", + "--ws.port=8546", + "--authrpc.port=8551", + "--authrpc.addr=0.0.0.0", + "--authrpc.jwtsecret=/genesis/jwt.hex", + "--auth-ipc.path=/data/engine.ipc", + "--port=30303", + "--discovery.port=30303", + "--metrics=0.0.0.0:8090", + "--txpool.nolocals", + "--rollup.txpool-max-inflight-delegated-slots=32768", + "--txpool.pending-max-count=200000", + "--txpool.pending-max-size=512", + "--txpool.basefee-max-count=200000", + "--txpool.basefee-max-size=512", + "--txpool.queued-max-count=200000", + "--txpool.queued-max-size=512", + "--txpool.max-account-slots=256", + "--txpool.max-batch-size=1024", + "--rpc.txfeecap=0", + "--rpc.gascap=600000000", + "--rpc.eth-proof-window=1209600", + "--flashblocks-url=ws://base-builder:7111", + "--bootnodes=enode://4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa385b6b1b8ead809ca67454d9683fcf2ba03456d6fe2c4abe2b07f0fbdbb2f1c1@172.30.0.10:9303", + "--rollup.discovery.v4", + "--l1-eth-rpc", + "http://l1-el:8545", + "--l1-beacon", + "http://l1-cl:5052", + "--l2-config-file", + "/genesis/l2/rollup.json", + "--l1-config-file", + "/genesis/el/chain-config.json", + "--l1-slot-duration-override", + "4", + "--rpc.addr", + "0.0.0.0", + "--rpc.port", + "8549", + "--p2p.listen.tcp", + "8003", + "--p2p.listen.udp", + "8003", + "--p2p.advertise.ip", + "127.0.0.1", + "--p2p.bootnodes-file", + "/bootnodes/enr.txt", + "--p2p.scoring", + "Off", + "--l1.verifier-confs", + "15", + "-vvv", + ]); + + assert!(matches!(cli.chain, ChainArg::File(_))); + let BaseCommand::Rpc(rpc) = cli.command else { + panic!("expected rpc command"); + }; + + assert_eq!(rpc.execution.rpc.auth_ipc_path, "/data/engine.ipc"); + assert_eq!(rpc.execution.network.port, 30303); + assert!(rpc.execution_chain.is_some()); + assert_eq!(rpc.consensus.rpc_flags.listen_port, 8549); + assert_eq!(rpc.consensus.p2p_flags.network.listen_tcp_port, 8003); + } + + #[test] + fn rejects_rpc_mode_arg() { + let err = + BaseCli::try_parse_from(rpc_args(&["base", "rpc", "--mode", "sequencer"])).unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--mode")); + } + + #[test] + fn rejects_rpc_sequencer_args() { + let err = + BaseCli::try_parse_from(rpc_args(&["base", "rpc", "--sequencer.stopped"])).unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--sequencer.stopped")); + } + + #[test] + fn rejects_rpc_conductor_args() { + let err = BaseCli::try_parse_from(rpc_args(&[ + "base", + "rpc", + "--conductor.rpc", + "http://localhost:9090", + ])) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--conductor.rpc")); + } + + #[test] + fn rejects_rpc_builder_args() { + let err = BaseCli::try_parse_from(rpc_args(&["base", "rpc", "--builder.max-tasks", "1"])) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--builder.max-tasks")); + } + + #[test] + fn rejects_rpc_builder_disallow_arg() { + let err = + BaseCli::try_parse_from(rpc_args(&["base", "rpc", "--builder.disallow", "deny.json"])) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--builder.disallow")); + } + + #[test] + fn rejects_rpc_rollup_sequencer_arg() { + let err = BaseCli::try_parse_from(rpc_args(&[ + "base", + "rpc", + "--rollup.sequencer", + "http://localhost:8545", + ])) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--rollup.sequencer")); + } + + #[test] + fn rejects_rpc_metering_args() { + let err = + BaseCli::try_parse_from(rpc_args(&["base", "rpc", "--enable-metering"])).unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--enable-metering")); + } + + #[test] + fn rejects_rpc_tx_forwarding_args() { + let err = BaseCli::try_parse_from(rpc_args(&["base", "rpc", "--enable-tx-forwarding"])) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--enable-tx-forwarding")); + } + + #[test] + fn rejects_rpc_p2p_signer_args() { + let err = BaseCli::try_parse_from(rpc_args(&[ + "base", + "rpc", + "--p2p.sequencer.key", + "bcc617ea05150ff60490d3c6058630ba94ae9f12a02a87efd291349ca0e54e0a", + ])) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("--p2p.sequencer.key")); + } +} diff --git a/bin/base/src/commands/update.rs b/bin/base/src/commands/update.rs new file mode 100644 index 0000000000..e325fbac04 --- /dev/null +++ b/bin/base/src/commands/update.rs @@ -0,0 +1,113 @@ +//! Base binary update command. + +use std::{ffi::OsString, path::Path, process::Command}; + +use clap::Args; +use eyre::{OptionExt, WrapErr}; + +/// Arguments for `base update`. +#[derive(Args, Clone, Debug)] +pub(crate) struct UpdateCommand { + /// Install a specific release tag instead of the latest release. + #[arg(short = 'i', long = "install", value_name = "VER")] + pub(crate) version: Option, + + /// Update the baseup installer instead of the base binary. + #[arg(long, conflicts_with = "version")] + pub(crate) update_installer: bool, + + /// Skip release signature and attestation verification. + #[arg(long)] + pub(crate) unsafe_skip_verify: bool, +} + +impl UpdateCommand { + /// Updates the `base` binary by delegating release fetch and verification to `baseup`. + pub(crate) fn run(self) -> eyre::Result<()> { + let bin_dir = std::env::current_exe() + .wrap_err("failed to locate current base executable")? + .parent() + .map(Path::to_path_buf) + .ok_or_eyre("failed to locate current base executable directory")?; + + let mut command = Command::new(Self::baseup_path(&bin_dir)); + command.env("BASE_BIN_DIR", &bin_dir); + + if self.update_installer { + command.arg("--update"); + } else { + command.args(["--bin", "base"]); + if let Some(version) = self.version { + command.arg("--install").arg(version); + } + } + + if self.unsafe_skip_verify { + command.arg("--unsafe-skip-verify"); + } + + let status = command + .status() + .wrap_err("failed to execute baseup; install it with the baseup bootstrap first")?; + + if !status.success() { + eyre::bail!("baseup exited with status {status}"); + } + + Ok(()) + } + + fn baseup_path(bin_dir: &Path) -> OsString { + let sibling = bin_dir.join("baseup"); + if sibling.is_file() { sibling.into_os_string() } else { OsString::from("baseup") } + } +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use crate::{cli::BaseCli, commands::BaseCommand}; + + #[test] + fn parses_update_command() { + let cli = BaseCli::parse_from(["base", "update"]); + + assert!(matches!(cli.command, BaseCommand::Update(_))); + } + + #[test] + fn parses_update_command_with_version() { + let cli = BaseCli::parse_from(["base", "update", "--install", "v0.6.0"]); + let BaseCommand::Update(update) = cli.command else { + panic!("expected update command"); + }; + + assert_eq!(update.version.as_deref(), Some("v0.6.0")); + } + + #[test] + fn parses_update_installer_command() { + let cli = BaseCli::parse_from(["base", "update", "--update-installer"]); + let BaseCommand::Update(update) = cli.command else { + panic!("expected update command"); + }; + + assert!(update.update_installer); + } + + #[test] + fn rejects_update_installer_with_version() { + let err = BaseCli::try_parse_from([ + "base", + "update", + "--update-installer", + "--install", + "v0.6.0", + ]) + .unwrap_err(); + + let rendered = err.to_string(); + assert!(rendered.contains("cannot be used with")); + } +} diff --git a/bin/base/src/config.rs b/bin/base/src/config.rs index 6d4287e205..5a63b61df0 100644 --- a/bin/base/src/config.rs +++ b/bin/base/src/config.rs @@ -2,9 +2,13 @@ use std::{ fmt, path::{Path, PathBuf}, str::FromStr, + sync::Arc, }; +use alloy_chains::Chain; use base_common_chains::ChainConfig as BuiltInChainConfig; +use base_consensus_cli::ConsensusChainArgs; +use base_execution_chainspec::BaseChainSpec; use eyre::WrapErr; use figment::{ Figment, @@ -147,6 +151,20 @@ impl ResolvedChainConfig { source, } } + + /// Returns the execution chainspec for this chain. + pub(crate) fn execution_chain_spec(&self) -> eyre::Result> { + let config = + base_common_chains::ChainConfig::by_chain_id(self.l2_chain_id).ok_or_else(|| { + eyre::eyre!("no built-in execution chainspec for L2 chain ID {}", self.l2_chain_id) + })?; + Ok(Arc::new(BaseChainSpec::try_from(config)?)) + } + + /// Returns the consensus chain arguments for this chain. + pub(crate) fn consensus_chain_args(&self) -> ConsensusChainArgs { + ConsensusChainArgs { l2_chain_id: Chain::from(self.l2_chain_id) } + } } /// Resolves a chain selection into a concrete config. diff --git a/bin/base/src/main.rs b/bin/base/src/main.rs index b81da5c6ff..11144fe440 100644 --- a/bin/base/src/main.rs +++ b/bin/base/src/main.rs @@ -3,20 +3,10 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use clap::Parser; - -mod app; mod cli; +mod commands; mod config; -use app::BaseApp; -use cli::BaseCli; - fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = BaseApp::new(BaseCli::parse()).run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(cli::BaseCli); } diff --git a/bin/basectl/Cargo.toml b/bin/basectl/Cargo.toml index c8da7f5276..4e80de6131 100644 --- a/bin/basectl/Cargo.toml +++ b/bin/basectl/Cargo.toml @@ -12,8 +12,9 @@ path = "src/main.rs" workspace = true [dependencies] +url = { workspace = true } anyhow = { workspace = true } basectl-cli = { workspace = true } rustls = { workspace = true, features = ["ring"] } -clap = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive", "env"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/bin/basectl/src/cli.rs b/bin/basectl/src/cli.rs index 282da562a9..b0b828d7a5 100644 --- a/bin/basectl/src/cli.rs +++ b/bin/basectl/src/cli.rs @@ -1,6 +1,7 @@ //! Contains the CLI arguments for the basectl binary. use clap::{Parser, Subcommand}; +use url::Url; /// Base infrastructure control CLI. #[derive(Debug, Parser)] @@ -10,6 +11,22 @@ pub(crate) struct Cli { /// Chain configuration (mainnet, sepolia, devnet, or path to config file) #[arg(short = 'c', long = "config", default_value = "mainnet", global = true)] pub(crate) config: String, + /// Bootstrap conductor JSON-RPC URL for runtime cluster discovery. + /// + /// When set, basectl ignores any hardcoded conductor list in the chain + /// config and instead asks this URL for the live raft membership, then + /// polls all discovered peers via templated ports. + /// + /// Only applies to the conductor view (and views that embed it, like the + /// command center). Ignored by `flashblocks --json` and other non-TUI + /// subcommands. + #[arg( + long = "conductor-rpc", + env = "BASECTL_CONDUCTOR_RPC", + global = true, + default_value = "http://localhost:5545" + )] + pub(crate) conductor_rpc: Option, #[command(subcommand)] pub(crate) command: Option, } diff --git a/bin/basectl/src/main.rs b/bin/basectl/src/main.rs index 126fdf9fea..0ce967ee20 100644 --- a/bin/basectl/src/main.rs +++ b/bin/basectl/src/main.rs @@ -14,18 +14,21 @@ async fn main() -> anyhow::Result<()> { let cli = cli::Cli::parse(); let config = &cli.config; + let conductor_rpc = cli.conductor_rpc.clone(); match cli.command { - Some(cli::Commands::Config) => run_app(ViewId::Config, config).await, + Some(cli::Commands::Config) => run_app(ViewId::Config, config, conductor_rpc).await, Some(cli::Commands::Flashblocks { json: true }) => { run_flashblocks_json(MonitoringConfig::load(config).await?).await } Some(cli::Commands::Flashblocks { json: false }) => { - run_app(ViewId::Flashblocks, config).await + run_app(ViewId::Flashblocks, config, conductor_rpc).await } - Some(cli::Commands::Da) => run_app(ViewId::DaMonitor, config).await, - Some(cli::Commands::CommandCenter) => run_app(ViewId::CommandCenter, config).await, - Some(cli::Commands::Conductor) => run_app(ViewId::Conductor, config).await, - Some(cli::Commands::Upgrades) => run_app(ViewId::Upgrades, config).await, - None => run_app(ViewId::Home, config).await, + Some(cli::Commands::Da) => run_app(ViewId::DaMonitor, config, conductor_rpc).await, + Some(cli::Commands::CommandCenter) => { + run_app(ViewId::CommandCenter, config, conductor_rpc).await + } + Some(cli::Commands::Conductor) => run_app(ViewId::Conductor, config, conductor_rpc).await, + Some(cli::Commands::Upgrades) => run_app(ViewId::Upgrades, config, conductor_rpc).await, + None => run_app(ViewId::Home, config, conductor_rpc).await, } } diff --git a/bin/batcher/src/main.rs b/bin/batcher/src/main.rs index d3d25dc354..afd5160de9 100644 --- a/bin/batcher/src/main.rs +++ b/bin/batcher/src/main.rs @@ -1,14 +1,7 @@ #![doc = include_str!("../README.md")] -use clap::Parser; - mod cli; fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = cli::Cli::parse().run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(cli::Cli); } diff --git a/bin/builder/src/main.rs b/bin/builder/src/main.rs index 44e20f0953..c8fc10d605 100644 --- a/bin/builder/src/main.rs +++ b/bin/builder/src/main.rs @@ -33,8 +33,12 @@ fn main() { let builder_config = builder_args .into_builder_config(Arc::clone(&metering_provider)) .expect("Failed to convert rollup args to builder config"); + let da_config = builder_config.da_config.clone(); + let gas_limit_config = builder_config.gas_limit_config.clone(); let mut runner = BaseNodeRunner::new(rollup_args) + .with_da_config(da_config) + .with_gas_limit_config(gas_limit_config) .with_service_builder(FlashblocksServiceBuilder(builder_config)); runner.install_ext::(metering_provider); runner.install_ext::(TxPoolRpcConfig::default()); diff --git a/bin/challenger/src/main.rs b/bin/challenger/src/main.rs index a1d407b543..d10ce7d893 100644 --- a/bin/challenger/src/main.rs +++ b/bin/challenger/src/main.rs @@ -3,15 +3,8 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use clap::Parser as _; - mod cli; fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = cli::Cli::parse().run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(cli::Cli); } diff --git a/bin/consensus/src/main.rs b/bin/consensus/src/main.rs index 18dbd5666a..2d2affb69a 100644 --- a/bin/consensus/src/main.rs +++ b/bin/consensus/src/main.rs @@ -3,13 +3,6 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use clap::Parser; - fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = base_consensus_cli::ConsensusCli::parse().run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(base_consensus_cli::ConsensusCli); } diff --git a/bin/ingress-rpc/Cargo.toml b/bin/ingress-rpc/Cargo.toml index 0855f57b51..efc42725a1 100644 --- a/bin/ingress-rpc/Cargo.toml +++ b/bin/ingress-rpc/Cargo.toml @@ -20,7 +20,6 @@ serde.workspace = true tokio.workspace = true anyhow.workspace = true dotenvy.workspace = true -rdkafka.workspace = true jsonrpsee.workspace = true base-bundles.workspace = true alloy-provider.workspace = true diff --git a/bin/ingress-rpc/README.md b/bin/ingress-rpc/README.md index 18aa82cb66..30993638ed 100644 --- a/bin/ingress-rpc/README.md +++ b/bin/ingress-rpc/README.md @@ -2,4 +2,4 @@ JSON-RPC ingress server for the Base Stack. -Receives transactions and bundles from external clients, enqueues them to Kafka, and forwards metering responses back to connected builder nodes. +Receives transactions and bundles from external clients, submits them to the mempool, and forwards metering responses back to connected builder nodes. diff --git a/bin/ingress-rpc/src/main.rs b/bin/ingress-rpc/src/main.rs index c82c60f014..97bab84aa2 100644 --- a/bin/ingress-rpc/src/main.rs +++ b/bin/ingress-rpc/src/main.rs @@ -2,22 +2,18 @@ use std::time::Duration; -use alloy_provider::ProviderBuilder; -use audit_archiver_lib::{ - AuditConnector, BundleEvent, RpcBundleEventPublisher, load_kafka_config_from_file, -}; +use alloy_provider::RootProvider; +use audit_archiver_lib::{AuditConnector, BundleEvent, RpcBundleEventPublisher}; use base_bundles::MeterBundleResponse; use base_cli_utils::LogConfig; use base_common_network::Base; use clap::Parser; use ingress_rpc_lib::{ - BuilderConnector, Config, HealthServer, IngressApiServer, IngressService, KafkaMessageQueue, - Providers, + BuilderConnector, Config, HealthServer, IngressApiServer, IngressService, Providers, }; use jsonrpsee::server::Server; -use rdkafka::{ClientConfig, producer::FutureProducer}; use tokio::sync::{broadcast, mpsc}; -use tracing::{info, warn}; +use tracing::info; base_cli_utils::define_log_args!("TIPS_INGRESS"); base_cli_utils::define_metrics_args!("TIPS_INGRESS", 9002); @@ -64,33 +60,11 @@ async fn main() -> anyhow::Result<()> { ); let providers = Providers { - mempool: ProviderBuilder::new() - .disable_recommended_fillers() - .network::() - .connect_http(config.mempool_url), - simulation: ProviderBuilder::new() - .disable_recommended_fillers() - .network::() - .connect_http(config.simulation_rpc), - raw_tx_forward: config.raw_tx_forward_rpc.clone().map(|url| { - ProviderBuilder::new().disable_recommended_fillers().network::().connect_http(url) - }), + mempool: RootProvider::::new_http(config.mempool_url), + simulation: RootProvider::::new_http(config.simulation_rpc), + raw_tx_forward: config.raw_tx_forward_rpc.clone().map(RootProvider::::new_http), }; - let ingress_client_config = - ClientConfig::from_iter(load_kafka_config_from_file(&config.ingress_kafka_properties)?); - - let queue_producer: FutureProducer = ingress_client_config.create()?; - - let queue = KafkaMessageQueue::new(queue_producer); - - if config.audit_kafka_properties.is_some() || config.audit_topic.is_some() { - warn!( - "audit_kafka_properties / audit_topic CLI args are deprecated and ignored; \ - audit events are now published over RPC via --audit-rpc-url" - ); - } - let audit_publisher = RpcBundleEventPublisher::new( config.audit_rpc_url.as_str(), Duration::from_secs(config.audit_rpc_timeout_secs), @@ -123,7 +97,7 @@ async fn main() -> anyhow::Result<()> { ); let bind_addr = format!("{}:{}", config.address, config.port); - let service = IngressService::new(providers, queue, audit_tx, builder_tx, cli.config); + let service = IngressService::new(providers, audit_tx, builder_tx, cli.config); let server = Server::builder().build(&bind_addr).await?; let addr = server.local_addr()?; diff --git a/bin/load-tester/src/main.rs b/bin/load-tester/src/main.rs index 343397e4d4..a71d333c32 100644 --- a/bin/load-tester/src/main.rs +++ b/bin/load-tester/src/main.rs @@ -1,14 +1,7 @@ //! Base load tester binary entrypoint. -use clap::Parser as _; - mod cli; fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = cli::Cli::parse().run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(cli::Cli); } diff --git a/bin/mempool-rebroadcaster/Cargo.toml b/bin/mempool-rebroadcaster/Cargo.toml deleted file mode 100644 index d747af3bbf..0000000000 --- a/bin/mempool-rebroadcaster/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "mempool-rebroadcaster-bin" -version.workspace = true -edition.workspace = true -license.workspace = true - -[[bin]] -name = "mempool-rebroadcaster" -path = "src/main.rs" - -[lints] -workspace = true - -[dependencies] -serde.workspace = true -dotenvy.workspace = true -clap = { workspace = true } -tokio = { workspace = true } -base-cli-utils.workspace = true -mempool-rebroadcaster = { workspace = true } -tracing = { workspace = true, features = ["std"] } diff --git a/bin/mempool-rebroadcaster/README.md b/bin/mempool-rebroadcaster/README.md deleted file mode 100644 index 1b5dd9ff03..0000000000 --- a/bin/mempool-rebroadcaster/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# `mempool-rebroadcaster` - -Bridges pending transactions between Geth and Reth mempools, rebroadcasting in both directions. diff --git a/bin/mempool-rebroadcaster/src/main.rs b/bin/mempool-rebroadcaster/src/main.rs deleted file mode 100644 index 6e7e4b65cf..0000000000 --- a/bin/mempool-rebroadcaster/src/main.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Mempool rebroadcaster binary entry point. - -use base_cli_utils::LogConfig; -use clap::Parser; -use dotenvy::dotenv; -use mempool_rebroadcaster::Rebroadcaster; -use tracing::{error, info}; - -base_cli_utils::define_log_args!("MEMPOOL_REBROADCASTER"); - -#[derive(Parser, Debug)] -#[command(author, version, about = "A mempool rebroadcaster service")] -struct Args { - #[arg(long, env, required = true)] - geth_mempool_endpoint: String, - - #[arg(long, env, required = true)] - reth_mempool_endpoint: String, - - #[command(flatten)] - log: LogArgs, -} - -#[tokio::main] -async fn main() { - dotenv().ok(); - let args = Args::parse(); - - LogConfig::from(args.log).init_tracing_subscriber().expect("failed to initialize tracing"); - - let rebroadcaster = Rebroadcaster::new(args.geth_mempool_endpoint, args.reth_mempool_endpoint); - let result = rebroadcaster.run().await; - - match result { - Ok(result) => { - info!( - success_geth_to_reth = result.success_geth_to_reth, - success_reth_to_geth = result.success_reth_to_geth, - unexpected_failed_geth_to_reth = result.unexpected_failed_geth_to_reth, - unexpected_failed_reth_to_geth = result.unexpected_failed_reth_to_geth, - "finished broadcasting txns", - ); - } - Err(e) => { - error!(error = ?e, "error running rebroadcaster"); - std::process::exit(1); - } - } -} diff --git a/bin/node/src/firehose.rs b/bin/node/src/firehose.rs index 2746c5719e..045055ab16 100644 --- a/bin/node/src/firehose.rs +++ b/bin/node/src/firehose.rs @@ -138,21 +138,24 @@ impl BaseNodeExtension for FirehoseFlashblocksExtension { FirehoseFlashblocksProcessor::new(full_node.provider.clone(), tracer); info!(url = %ws_url_for_node, "starting Firehose flashblocks streamer"); let streamer = FirehoseFlashblocksStreamer::new(processor, ws_url_for_node); - let processor_for_canonical = streamer.processor(); + // Both canonical signals feed the processor's single serialized command queue, so + // they are applied in strict arrival order relative to each other and to the + // WebSocket flashblock stream — never concurrently. + let canonical_block_sender = streamer.canonical_block_sender(); streamer.start(); // Earliest in-engine signal: drain canonical blocks forwarded by the engine-event // listener installed above. - let processor_for_engine_events = processor_for_canonical.clone(); + let canonical_block_sender_for_engine = canonical_block_sender.clone(); tokio::spawn(async move { while let Some((number, hash)) = canonical_rx.recv().await { - processor_for_engine_events.on_canonical_block(number, hash); + canonical_block_sender_for_engine.send(number, hash); } }); // Fallback path: canonical-state notification fires after the canonical chain has - // been committed. `final_part_sent` inside the processor prevents double-emission - // when both signals deliver the same block. + // been committed. The serialized queue applies it after the early signal (when both + // deliver the same block), so `final_part_sent` reliably suppresses double-emission. let mut canonical_stream = BroadcastStream::new(full_node.provider.subscribe_to_canonical_state()); tokio::spawn(async move { @@ -165,7 +168,7 @@ impl BaseNodeExtension for FirehoseFlashblocksExtension { } }; for block in notification.committed().blocks_iter() { - processor_for_canonical.on_canonical_block(block.number, block.hash()); + canonical_block_sender.send(block.number, block.hash()); } } }); diff --git a/bin/proposer/src/main.rs b/bin/proposer/src/main.rs index e71a1c081f..f925edb966 100644 --- a/bin/proposer/src/main.rs +++ b/bin/proposer/src/main.rs @@ -3,16 +3,9 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use clap::Parser as _; - mod cli; #[tokio::main] async fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = cli::Cli::parse().run().await { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(async cli::Cli); } diff --git a/bin/prover-registrar/Cargo.toml b/bin/prover-registrar/Cargo.toml index e097a0eb3c..7c01c4a023 100644 --- a/bin/prover-registrar/Cargo.toml +++ b/bin/prover-registrar/Cargo.toml @@ -28,6 +28,7 @@ base-proof-tee-nitro-attestation-prover = { workspace = true, features = ["prove alloy-provider.workspace = true alloy-primitives.workspace = true alloy-signer-local.workspace = true +boundless-market.workspace = true # AWS aws-sdk-ec2.workspace = true diff --git a/bin/prover-registrar/src/cli.rs b/bin/prover-registrar/src/cli.rs index 6884b5672a..fec33ce750 100644 --- a/bin/prover-registrar/src/cli.rs +++ b/bin/prover-registrar/src/cli.rs @@ -11,7 +11,6 @@ use std::{ use alloy_primitives::Address; use alloy_provider::ProviderBuilder; -use alloy_signer_local::PrivateKeySigner; use base_balance_monitor::BalanceMonitorLayer; use base_cli_utils::RuntimeManager; use base_health::HealthServer; @@ -27,6 +26,10 @@ use base_proof_tee_registrar::{ RegistrarMetrics, RegistrationDriver, RegistryContractClient, }; use base_tx_manager::{BaseTxMetrics, SignerConfig, SimpleTxManager, TxManagerConfig}; +use boundless_market::{ + alloy::signers::local::PrivateKeySigner, + price_oracle::{Amount, Asset}, +}; use clap::{Args, Parser, ValueEnum}; use eyre::WrapErr; use tokio_util::sync::CancellationToken; @@ -193,6 +196,26 @@ struct BoundlessArgs { #[arg(long, env = cli_env!("BOUNDLESS_TIMEOUT_SECS"), default_value_t = 600)] boundless_timeout_secs: u64, + /// Minimum Boundless offer price in ETH for each submitted proof request. + /// + /// Accepts either a plain ETH amount (for example, `0.01`) or an explicit + /// ETH amount (for example, `0.01 ETH`). Must be set together with + /// `--boundless-max-price-eth`. + #[arg(long, env = cli_env!("BOUNDLESS_MIN_PRICE_ETH"))] + boundless_min_price_eth: Option, + + /// Maximum Boundless offer price in ETH for each submitted proof request. + /// + /// Accepts either a plain ETH amount (for example, `0.03`) or an explicit + /// ETH amount (for example, `0.03 ETH`). Must be greater than or equal to + /// `--boundless-min-price-eth`. + #[arg(long, env = cli_env!("BOUNDLESS_MAX_PRICE_ETH"))] + boundless_max_price_eth: Option, + + /// Optional duration for the Boundless offer price to ramp from min to max. + #[arg(long, env = cli_env!("BOUNDLESS_OFFER_RAMP_UP_PERIOD_SECS"))] + boundless_offer_ramp_up_period_secs: Option, + /// Maximum number of deterministic request-ID slots to probe when /// recovering in-flight proofs after an instance rotation. #[arg( @@ -271,6 +294,16 @@ fn parse_image_id(s: &str) -> std::result::Result<[u32; 8], RegistrarError> { Ok(id) } +/// Parse an ETH-denominated Boundless offer price. +fn parse_boundless_eth_amount(field: &str, s: &str) -> std::result::Result { + let amount = Amount::parse(s, Some(Asset::ETH)) + .map_err(|e| RegistrarError::Config(format!("{field}: {e}")))?; + if amount.asset != Asset::ETH { + return Err(RegistrarError::Config(format!("{field}: expected ETH amount"))); + } + Ok(amount) +} + impl Cli { /// Validate the CLI arguments for logical conflicts and parse into a [`RegistrarConfig`]. pub(crate) fn into_config(self) -> std::result::Result { @@ -303,6 +336,40 @@ impl Cli { .image_id .as_deref() .ok_or_else(|| RegistrarError::Config("--image-id is required".into()))?; + let offer_min_price = self + .boundless + .boundless_min_price_eth + .as_deref() + .map(|s| parse_boundless_eth_amount("--boundless-min-price-eth", s)) + .transpose()?; + let offer_max_price = self + .boundless + .boundless_max_price_eth + .as_deref() + .map(|s| parse_boundless_eth_amount("--boundless-max-price-eth", s)) + .transpose()?; + + match (&offer_min_price, &offer_max_price) { + (Some(_), None) => { + return Err(RegistrarError::Config( + "--boundless-max-price-eth is required when --boundless-min-price-eth is set" + .into(), + )); + } + (None, Some(_)) => { + return Err(RegistrarError::Config( + "--boundless-min-price-eth is required when --boundless-max-price-eth is set" + .into(), + )); + } + (Some(min_price), Some(max_price)) if max_price.value < min_price.value => { + return Err(RegistrarError::Config( + "--boundless-max-price-eth must be greater than or equal to --boundless-min-price-eth" + .into(), + )); + } + _ => {} + } ProvingConfig::Boundless(Box::new(BoundlessConfig { rpc_url: self.boundless.boundless_rpc_url.ok_or_else(|| { @@ -324,6 +391,9 @@ impl Cli { max_attestation_age: Duration::from_secs( self.boundless.max_attestation_age_secs, ), + offer_min_price, + offer_max_price, + offer_ramp_up_period_secs: self.boundless.boundless_offer_ramp_up_period_secs, })) } ProvingMode::Direct => { @@ -521,6 +591,9 @@ impl Cli { trusted_certs_prefix_len: DEFAULT_TRUSTED_CERTS_PREFIX, max_recovery_attempts: boundless.max_recovery_attempts, max_attestation_age: boundless.max_attestation_age, + offer_min_price: boundless.offer_min_price.clone(), + offer_max_price: boundless.offer_max_price.clone(), + offer_ramp_up_period_secs: boundless.offer_ramp_up_period_secs, submit_lock: Arc::new(tokio::sync::Mutex::new(())), recovery_blocked: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), }), @@ -624,6 +697,9 @@ mod tests { const TEST_VERIFIER_URL: &str = "https://gateway.pinata.cloud/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; const TEST_IMAGE_ID: &str = "0x0100000002000000030000000400000005000000060000000700000008000000"; + const TEST_BOUNDLESS_MIN_PRICE_ETH: &str = "0.01"; + const TEST_BOUNDLESS_MAX_PRICE_ETH: &str = "0.03"; + const TEST_BOUNDLESS_RAMP_UP_PERIOD_SECS: u32 = 30; const TEST_ELF_PATH: &str = "/tmp/guest.elf"; const TEST_SIGNER_ENDPOINT: &str = "http://localhost:8546"; const TEST_SIGNER_ADDR: &str = "0x0000000000000000000000000000000000000002"; @@ -825,6 +901,83 @@ mod tests { assert_eq!(b.image_id, [1, 2, 3, 4, 5, 6, 7, 8]); } + #[rstest] + fn boundless_offer_pricing_defaults_to_sdk() { + let config = Cli::parse_from(boundless_args()).into_config().unwrap(); + let ProvingConfig::Boundless(b) = &config.proving else { + panic!("expected Boundless proving config"); + }; + + assert!(b.offer_min_price.is_none()); + assert!(b.offer_max_price.is_none()); + assert!(b.offer_ramp_up_period_secs.is_none()); + } + + #[rstest] + fn boundless_offer_pricing_parses_eth_amounts() { + let mut args = boundless_args(); + args.extend([ + "--boundless-min-price-eth", + TEST_BOUNDLESS_MIN_PRICE_ETH, + "--boundless-max-price-eth", + TEST_BOUNDLESS_MAX_PRICE_ETH, + "--boundless-offer-ramp-up-period-secs", + "30", + ]); + + let config = Cli::parse_from(args).into_config().unwrap(); + let ProvingConfig::Boundless(b) = &config.proving else { + panic!("expected Boundless proving config"); + }; + + assert_eq!( + b.offer_min_price, + Some( + parse_boundless_eth_amount( + "--boundless-min-price-eth", + TEST_BOUNDLESS_MIN_PRICE_ETH, + ) + .unwrap(), + ), + ); + assert_eq!( + b.offer_max_price, + Some( + parse_boundless_eth_amount( + "--boundless-max-price-eth", + TEST_BOUNDLESS_MAX_PRICE_ETH, + ) + .unwrap(), + ), + ); + assert_eq!(b.offer_ramp_up_period_secs, Some(TEST_BOUNDLESS_RAMP_UP_PERIOD_SECS)); + } + + #[rstest] + fn boundless_offer_min_price_requires_max_price() { + let mut args = boundless_args(); + args.extend(["--boundless-min-price-eth", TEST_BOUNDLESS_MIN_PRICE_ETH]); + + let result = Cli::parse_from(args).into_config(); + + assert!(result.is_err()); + } + + #[rstest] + fn boundless_offer_max_price_must_cover_min_price() { + let mut args = boundless_args(); + args.extend([ + "--boundless-min-price-eth", + TEST_BOUNDLESS_MAX_PRICE_ETH, + "--boundless-max-price-eth", + TEST_BOUNDLESS_MIN_PRICE_ETH, + ]); + + let result = Cli::parse_from(args).into_config(); + + assert!(result.is_err()); + } + #[rstest] fn tx_manager_config_has_defaults() { let config = Cli::parse_from(boundless_args()).into_config().unwrap(); diff --git a/bin/prover/nitro-host/src/cli.rs b/bin/prover/nitro-host/src/cli.rs index c2b8e49905..1bbe165438 100644 --- a/bin/prover/nitro-host/src/cli.rs +++ b/bin/prover/nitro-host/src/cli.rs @@ -9,7 +9,7 @@ use std::time::Duration; use alloy_primitives::Address; use base_cli_utils::{LogConfig, RuntimeManager}; #[cfg(any(target_os = "linux", feature = "local"))] -use base_common_chains::Registry; +use base_common_chains::rollup_config; #[cfg(any(target_os = "linux", feature = "local"))] use base_proof_host::ProverConfig; #[cfg(feature = "local")] @@ -143,9 +143,8 @@ impl Cli { #[cfg(target_os = "linux")] impl ServerArgs { async fn run(self) -> eyre::Result<()> { - let rollup_config = Registry::rollup_config(self.server.l2_chain_id) - .ok_or_else(|| eyre!("unknown L2 chain ID: {}", self.server.l2_chain_id))? - .clone(); + let rollup_config = rollup_config!(self.server.l2_chain_id) + .ok_or_else(|| eyre!("unknown L2 chain ID: {}", self.server.l2_chain_id))?; let l1_config = base_common_chains::L1_CONFIGS .get(&rollup_config.l1_chain_id) @@ -206,9 +205,8 @@ struct LocalArgs { #[cfg(feature = "local")] impl LocalArgs { async fn run(self) -> eyre::Result<()> { - let rollup_config = Registry::rollup_config(self.server.l2_chain_id) - .ok_or_else(|| eyre!("unknown L2 chain ID: {}", self.server.l2_chain_id))? - .clone(); + let rollup_config = rollup_config!(self.server.l2_chain_id) + .ok_or_else(|| eyre!("unknown L2 chain ID: {}", self.server.l2_chain_id))?; let l1_config = base_common_chains::L1_CONFIGS .get(&rollup_config.l1_chain_id) diff --git a/bin/prover/nitro-host/src/main.rs b/bin/prover/nitro-host/src/main.rs index 1dbf6f9005..48f4fb57c0 100644 --- a/bin/prover/nitro-host/src/main.rs +++ b/bin/prover/nitro-host/src/main.rs @@ -9,7 +9,6 @@ use base_common_chains as _; use base_proof_host as _; #[cfg(not(any(target_os = "linux", feature = "local")))] use base_proof_tee_nitro_host as _; -use clap::Parser as _; use serde as _; use tokio as _; #[cfg(not(any(target_os = "linux", feature = "local")))] @@ -18,10 +17,5 @@ use tracing as _; mod cli; fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = cli::Cli::parse().run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(cli::Cli); } diff --git a/bin/prover/zk/README.md b/bin/prover/zk/README.md index cd09453589..63a599d010 100644 --- a/bin/prover/zk/README.md +++ b/bin/prover/zk/README.md @@ -3,3 +3,5 @@ ZK prover service binary. Runs the gRPC ZK prover server. Reads proof requests from a database outbox, dispatches them to a cluster backend, and stores artifacts in Redis, S3, or GCS. + +Set `SP1_PROVER=dry-run` to generate a real witness and execute the SP1 range program locally without producing a proof. Dry-run results are returned from `GetProofResponse.execution_stats`. diff --git a/bin/prover/zk/src/cli.rs b/bin/prover/zk/src/cli.rs index eded60b10c..8490640a4c 100644 --- a/bin/prover/zk/src/cli.rs +++ b/bin/prover/zk/src/cli.rs @@ -9,9 +9,9 @@ use base_zk_db::{DatabaseConfig, ProofRequestRepo}; use base_zk_outbox::{DatabaseOutboxReader, OutboxProcessor}; use base_zk_service::{ ArtifactClientWrapper, ArtifactStorageConfig, BackendConfig, BackendRegistry, - OpSuccinctClusterBackend, OpSuccinctMockBackend, OpSuccinctNetworkBackend, OpSuccinctProvider, - ProofRequestManager, ProverServiceServer, ProverWorkerPool, ProxyConfigs, RateLimitConfig, - StatusPoller, start_all_proxies, + OpSuccinctClusterBackend, OpSuccinctDryRunBackend, OpSuccinctMockBackend, + OpSuccinctNetworkBackend, OpSuccinctProvider, ProofRequestManager, ProverServiceServer, + ProverWorkerPool, ProxyConfigs, RateLimitConfig, StatusPoller, start_all_proxies, }; use clap::Parser; use eyre::eyre; @@ -206,21 +206,41 @@ impl ZkArgs { .map_err(|e| eyre!("invalid L2 node RPC URL: {e}"))?, }; - info!("computing range and aggregation verifying keys"); - let (range_pk, range_vk, agg_pk, agg_vk) = - base_proof_succinct_proof_utils::cluster_setup_keys() - .await - .map_err(|e| eyre!("failed to compute verifying keys: {e}"))?; - info!("verifying keys computed successfully"); - let mut backend_registry = BackendRegistry::new(); - if self.prover_mode == "mock" { + if self.prover_mode == "dry-run" { + info!("SP1_PROVER=dry-run: using local SP1 execution backend"); + + let fetcher = Arc::new( + base_proof_succinct_host_utils::fetcher::OPSuccinctDataFetcher::from_rpc_config_with_rollup_config(rpc_config) + .await + .map_err(|e| eyre!("failed to create OPSuccinctDataFetcher: {e}"))?, + ); + let provider = OpSuccinctProvider::new(fetcher); + let backend = OpSuccinctDryRunBackend::new( + provider, + self.base_consensus_address.clone(), + l1_url.clone(), + self.default_sequence_window, + ); + backend_registry.register(Arc::new(backend)); + } else if self.prover_mode == "mock" { info!("SP1_PROVER=mock: using MockBackend (instant fake proofs, no cluster)"); + info!("computing range and aggregation verifying keys"); + let (range_vk, agg_vk) = base_proof_succinct_proof_utils::cluster_setup_vkeys() + .await + .map_err(|e| eyre!("failed to compute verifying keys: {e}"))?; + info!("verifying keys computed successfully"); let mock_backend = OpSuccinctMockBackend::new(range_vk, agg_vk); backend_registry.register(Arc::new(mock_backend)); } else if self.prover_mode == "network" { info!("SP1_PROVER=network: using Succinct SP1 Network backend"); + info!("computing range and aggregation proving keys"); + let (range_pk, range_vk, agg_pk, agg_vk) = + base_proof_succinct_proof_utils::cluster_setup_keys() + .await + .map_err(|e| eyre!("failed to compute proving keys: {e}"))?; + info!("proving keys computed successfully"); let fetcher = Arc::new( base_proof_succinct_host_utils::fetcher::OPSuccinctDataFetcher::from_rpc_config_with_rollup_config(rpc_config) @@ -280,6 +300,11 @@ impl ZkArgs { backend_registry.register(backend); } else { info!("SP1_PROVER=cluster: using Succinct cluster backend"); + info!("computing range and aggregation verifying keys"); + let (range_vk, _) = base_proof_succinct_proof_utils::cluster_setup_vkeys() + .await + .map_err(|e| eyre!("failed to compute verifying keys: {e}"))?; + info!("verifying keys computed successfully"); info!("creating Succinct data fetcher"); let fetcher = Arc::new( @@ -434,15 +459,15 @@ impl ZkArgs { } fn validate_config(&self) -> eyre::Result<()> { - if !matches!(self.prover_mode.as_str(), "cluster" | "mock" | "network") { + if !matches!(self.prover_mode.as_str(), "cluster" | "mock" | "network" | "dry-run") { eyre::bail!( - "SP1_PROVER must be set to 'cluster', 'mock', or 'network', got '{}'", + "SP1_PROVER must be set to 'cluster', 'mock', 'network', or 'dry-run', got '{}'", self.prover_mode ); } - if self.prover_mode == "mock" { - info!(prover_mode = "mock", "configuration validated"); + if matches!(self.prover_mode.as_str(), "mock" | "dry-run") { + info!(prover_mode = %self.prover_mode, "configuration validated"); return Ok(()); } diff --git a/bin/prover/zk/src/main.rs b/bin/prover/zk/src/main.rs index a1d407b543..d10ce7d893 100644 --- a/bin/prover/zk/src/main.rs +++ b/bin/prover/zk/src/main.rs @@ -3,15 +3,8 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -use clap::Parser as _; - mod cli; fn main() { - base_cli_utils::init_common!(); - - if let Err(err) = cli::Cli::parse().run() { - eprintln!("Error: {err:?}"); - std::process::exit(1); - } + base_cli_utils::run_cli_main!(cli::Cli); } diff --git a/bin/snapshotter/Cargo.toml b/bin/snapshotter/Cargo.toml new file mode 100644 index 0000000000..38107051bf --- /dev/null +++ b/bin/snapshotter/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "base-snapshotter-bin" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[[bin]] +name = "snapshotter" +path = "main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +rayon = { workspace = true } +tracing = { workspace = true } +base-snapshotter = { workspace = true } +aws-credential-types = { workspace = true } +tokio = { workspace = true, features = ["full"] } +clap = { workspace = true, features = ["derive", "env"] } +aws-config = { workspace = true, features = ["default-https-client", "rt-tokio"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +aws-sdk-s3 = { workspace = true, features = ["rustls", "default-https-client", "rt-tokio"] } diff --git a/bin/snapshotter/main.rs b/bin/snapshotter/main.rs new file mode 100644 index 0000000000..33ebece5be --- /dev/null +++ b/bin/snapshotter/main.rs @@ -0,0 +1,69 @@ +//! Binary entry point for the snapshotter sidecar. + +use anyhow::Result; +use aws_config::BehaviorVersion; +use aws_credential_types::Credentials; +use aws_sdk_s3::{Client as S3Client, config::Builder as S3ConfigBuilder}; +use base_snapshotter::{ + DockerContainerManager, S3ConfigType, SnapshotUploader, Snapshotter, SnapshotterConfig, +}; +use clap::Parser; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init(); + + let config = SnapshotterConfig::parse(); + + if let Some(threads) = config.snapshot_threads + && let Err(e) = rayon::ThreadPoolBuilder::new().num_threads(threads).build_global() + { + warn!( + threads, + error = %e, + "failed to set global rayon thread pool, --snapshot-threads will be ignored" + ); + } + + let container_manager = DockerContainerManager::new(&config.docker_socket)?; + let storage_client = create_s3_client(&config).await?; + let uploader = + SnapshotUploader::new(storage_client, config.bucket.clone(), config.prefix.clone()); + + let snapshotter = Snapshotter::new(container_manager, uploader, config); + snapshotter.run().await +} + +async fn create_s3_client(config: &SnapshotterConfig) -> Result { + match config.s3_config_type { + S3ConfigType::Manual => { + let region = aws_sdk_s3::config::Region::new(config.s3_region.clone()); + let mut loader = aws_config::defaults(BehaviorVersion::latest()).region(region); + + if let Some(ref endpoint) = config.s3_endpoint { + loader = loader.endpoint_url(endpoint); + } + + if let (Some(access_key), Some(secret_key)) = + (&config.s3_access_key_id, &config.s3_secret_access_key) + { + let credentials = + Credentials::new(access_key, secret_key, None, None, "snapshotter"); + loader = loader.credentials_provider(credentials); + } + + let sdk_config = loader.load().await; + let s3_config = S3ConfigBuilder::from(&sdk_config).force_path_style(true); + + info!("using manual S3 client configuration"); + Ok(S3Client::from_conf(s3_config.build())) + } + S3ConfigType::Aws => { + info!("using AWS default S3 client configuration"); + let sdk_config = aws_config::load_defaults(BehaviorVersion::latest()).await; + Ok(S3Client::new(&sdk_config)) + } + } +} diff --git a/crates/batcher/service/src/l1_source.rs b/crates/batcher/service/src/l1_source.rs index fbf99567f8..336561485f 100644 --- a/crates/batcher/service/src/l1_source.rs +++ b/crates/batcher/service/src/l1_source.rs @@ -4,8 +4,7 @@ use std::sync::Arc; use alloy_provider::Provider; use async_trait::async_trait; -use base_batcher_source::{L1HeadPolling, L1HeadSubscription, SourceError}; -use futures::{StreamExt, stream::BoxStream}; +use base_batcher_source::{KeepAliveSubscription, L1HeadPolling, PendingSubscription, SourceError}; /// Polling source that fetches the latest L1 head block number from an L1 RPC endpoint. #[derive(derive_more::Debug)] @@ -28,49 +27,18 @@ impl L1HeadPolling for RpcL1HeadPollingSource { } } -/// An [`L1HeadSubscription`] backed by a WebSocket provider. +/// A WebSocket-backed L1 head subscription. /// -/// Owns the WS provider via a type-erased [`Arc`] so the underlying connection -/// is not dropped when the stream is handed to [`HybridL1HeadSource`]. The stream -/// is produced once at construction; [`take_stream`] moves it out on the first call. +/// Owns the WS provider so the underlying connection is not dropped when the +/// stream is handed to [`HybridL1HeadSource`]. /// /// [`HybridL1HeadSource`]: base_batcher_source::HybridL1HeadSource -/// [`take_stream`]: L1HeadSubscription::take_stream -#[derive(derive_more::Debug)] -pub struct WsL1HeadSubscription { - #[debug(skip)] - _provider: Arc, - #[debug("{:?}", stream.as_ref().map(|_| ""))] - stream: Option>>, -} - -impl WsL1HeadSubscription { - /// Create a new [`WsL1HeadSubscription`] from a provider and its head number stream. - pub fn new( - provider: Arc

, - stream: BoxStream<'static, Result>, - ) -> Self { - Self { _provider: provider, stream: Some(stream) } - } -} +pub type WsL1HeadSubscription = KeepAliveSubscription; -impl L1HeadSubscription for WsL1HeadSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - self.stream.take().expect("take_stream called more than once") - } -} - -/// A no-op [`L1HeadSubscription`] that never yields head numbers. +/// A no-op L1 head subscription that never yields head numbers. /// /// Used when no L1 WebSocket URL is configured; [`HybridL1HeadSource`] falls /// back entirely to the polling path. /// /// [`HybridL1HeadSource`]: base_batcher_source::HybridL1HeadSource -#[derive(Debug)] -pub struct NullL1HeadSubscription; - -impl L1HeadSubscription for NullL1HeadSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - futures::stream::pending().boxed() - } -} +pub type NullL1HeadSubscription = PendingSubscription; diff --git a/crates/batcher/service/src/service.rs b/crates/batcher/service/src/service.rs index ebf14d73fc..898e4127bc 100644 --- a/crates/batcher/service/src/service.rs +++ b/crates/batcher/service/src/service.rs @@ -167,14 +167,14 @@ impl BatcherService { fetch_provider: Arc + Send + Sync>, ) -> Subscription { let Some(url) = url else { - return Subscription::Null(NullSubscription); + return Subscription::Null(NullSubscription::new()); }; let ws_provider = match ProviderBuilder::new().connect(url.as_str()).await { Ok(p) => Arc::new(p), Err(e) => { warn!(error = %e, l2_rpc = %url, "failed to connect L2 WS provider; falling back to polling"); - return Subscription::Null(NullSubscription); + return Subscription::Null(NullSubscription::new()); } }; @@ -182,7 +182,7 @@ impl BatcherService { Ok(s) => s, Err(e) => { warn!(error = %e, "failed to subscribe to new L2 blocks; falling back to polling"); - return Subscription::Null(NullSubscription); + return Subscription::Null(NullSubscription::new()); } }; @@ -222,14 +222,14 @@ impl BatcherService { /// [`HybridL1HeadSource`]: base_batcher_source::HybridL1HeadSource async fn build_l1_subscription(url: Option<&Url>) -> L1Subscription { let Some(url) = url else { - return L1Subscription::Null(NullL1HeadSubscription); + return L1Subscription::Null(NullL1HeadSubscription::new()); }; let ws_provider = match ProviderBuilder::new().connect(url.as_str()).await { Ok(p) => Arc::new(p), Err(e) => { warn!(error = %e, l1_ws = %url, "failed to connect L1 WS provider; falling back to polling"); - return L1Subscription::Null(NullL1HeadSubscription); + return L1Subscription::Null(NullL1HeadSubscription::new()); } }; @@ -237,7 +237,7 @@ impl BatcherService { Ok(s) => s, Err(e) => { warn!(error = %e, "failed to subscribe to new L1 blocks; falling back to polling"); - return L1Subscription::Null(NullL1HeadSubscription); + return L1Subscription::Null(NullL1HeadSubscription::new()); } }; diff --git a/crates/batcher/service/src/subscription.rs b/crates/batcher/service/src/subscription.rs index 015f934c9e..cc7a6656ad 100644 --- a/crates/batcher/service/src/subscription.rs +++ b/crates/batcher/service/src/subscription.rs @@ -1,61 +1,20 @@ //! Block subscription implementations for the batcher service. -use std::sync::Arc; - -use base_batcher_source::{BlockSubscription, SourceError}; +use base_batcher_source::{KeepAliveSubscription, PendingSubscription}; use base_common_consensus::BaseBlock; -use futures::{StreamExt, stream::BoxStream}; -/// A [`BlockSubscription`] backed by a WebSocket provider. -/// -/// Owns the WS provider via a type-erased [`Arc`] so the underlying connection -/// is not dropped when the stream is handed to [`HybridBlockSource`]. The stream -/// is produced once at construction; [`take_stream`] moves it out on the first call. +/// A WebSocket-backed block subscription. /// -/// The provider is stored as `Arc` because the exact -/// alloy provider type varies by transport and we only need to hold a reference -/// for keepalive purposes, not to call any methods on it. +/// Owns the WS provider so the underlying connection is not dropped when the +/// stream is handed to [`HybridBlockSource`]. /// /// [`HybridBlockSource`]: base_batcher_source::HybridBlockSource -/// [`take_stream`]: BlockSubscription::take_stream -#[derive(derive_more::Debug)] -pub struct WsBlockSubscription { - #[debug(skip)] - _provider: Arc, - #[debug("{:?}", stream.as_ref().map(|_| ""))] - stream: Option>>, -} - -impl WsBlockSubscription { - /// Create a new [`WsBlockSubscription`] from a provider and its subscription stream. - /// - /// `provider` can be any `Send + Sync + 'static` type — typically the alloy - /// WS root provider returned by [`ProviderBuilder::connect`]. - pub fn new( - provider: Arc

, - stream: BoxStream<'static, Result>, - ) -> Self { - Self { _provider: provider, stream: Some(stream) } - } -} +pub type WsBlockSubscription = KeepAliveSubscription; -impl BlockSubscription for WsBlockSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - self.stream.take().expect("take_stream called more than once") - } -} - -/// A no-op [`BlockSubscription`] that never yields blocks. +/// A no-op block subscription that never yields blocks. /// /// Used when the L2 RPC is not a WebSocket URL and subscription is unavailable; /// [`HybridBlockSource`] will rely entirely on the polling path. /// /// [`HybridBlockSource`]: base_batcher_source::HybridBlockSource -#[derive(Debug)] -pub struct NullSubscription; - -impl BlockSubscription for NullSubscription { - fn take_stream(&mut self) -> BoxStream<'static, Result> { - futures::stream::pending().boxed() - } -} +pub type NullSubscription = PendingSubscription; diff --git a/crates/batcher/source/src/l1_subscription.rs b/crates/batcher/source/src/l1_subscription.rs index a8f24e3cf5..9b6b7f0186 100644 --- a/crates/batcher/source/src/l1_subscription.rs +++ b/crates/batcher/source/src/l1_subscription.rs @@ -2,7 +2,7 @@ use futures::stream::BoxStream; -use crate::SourceError; +use crate::{KeepAliveSubscription, PendingSubscription, SourceError, StreamSubscription}; /// A source of an L1 head number stream that may hold ancillary resources. /// @@ -20,3 +20,21 @@ pub trait L1HeadSubscription: Send { /// Must be called at most once; implementors may panic on a second call. fn take_stream(&mut self) -> BoxStream<'static, Result>; } + +impl L1HeadSubscription for StreamSubscription { + fn take_stream(&mut self) -> BoxStream<'static, Result> { + Self::take_stream(self) + } +} + +impl L1HeadSubscription for KeepAliveSubscription { + fn take_stream(&mut self) -> BoxStream<'static, Result> { + Self::take_stream(self) + } +} + +impl L1HeadSubscription for PendingSubscription { + fn take_stream(&mut self) -> BoxStream<'static, Result> { + Self::take_stream(self) + } +} diff --git a/crates/batcher/source/src/lib.rs b/crates/batcher/source/src/lib.rs index 44e3744719..b2433914e8 100644 --- a/crates/batcher/source/src/lib.rs +++ b/crates/batcher/source/src/lib.rs @@ -22,6 +22,9 @@ pub use polling::PollingSource; mod subscription; pub use subscription::BlockSubscription; +mod stream_subscription; +pub use stream_subscription::{KeepAliveSubscription, PendingSubscription, StreamSubscription}; + mod hybrid; pub use hybrid::HybridBlockSource; diff --git a/crates/batcher/source/src/stream_subscription.rs b/crates/batcher/source/src/stream_subscription.rs new file mode 100644 index 0000000000..83306edca8 --- /dev/null +++ b/crates/batcher/source/src/stream_subscription.rs @@ -0,0 +1,89 @@ +//! Generic stream subscription wrappers. + +use std::{any::Any, sync::Arc}; + +use futures::{StreamExt, stream::BoxStream}; + +use crate::SourceError; + +/// A subscription backed directly by a stream. +#[derive(derive_more::Debug)] +pub struct StreamSubscription { + #[debug("{:?}", stream.as_ref().map(|_| ""))] + stream: Option>>, +} + +impl StreamSubscription { + /// Creates a subscription from a stream. + pub fn new(stream: BoxStream<'static, Result>) -> Self { + Self { stream: Some(stream) } + } + + /// Extracts the underlying stream. + /// + /// # Panics + /// + /// Panics if called more than once. + pub fn take_stream(&mut self) -> BoxStream<'static, Result> { + self.stream.take().expect("take_stream called more than once") + } +} + +/// A [`StreamSubscription`] that keeps an ancillary resource alive. +/// +/// This is used for WebSocket subscriptions where the provider connection must +/// outlive the stream handed to a hybrid source. +#[derive(derive_more::Debug)] +pub struct KeepAliveSubscription { + #[debug(skip)] + _resource: Arc, + inner: StreamSubscription, +} + +impl KeepAliveSubscription { + /// Creates a subscription from a resource and stream. + pub fn new( + resource: Arc

, + stream: BoxStream<'static, Result>, + ) -> Self { + Self { _resource: resource, inner: StreamSubscription::new(stream) } + } + + /// Extracts the underlying stream. + /// + /// # Panics + /// + /// Panics if called more than once. + pub fn take_stream(&mut self) -> BoxStream<'static, Result> { + self.inner.take_stream() + } +} + +/// A subscription that never yields items. +#[derive(Debug)] +pub struct PendingSubscription { + _marker: std::marker::PhantomData, +} + +impl PendingSubscription { + /// Creates a pending subscription. + pub const fn new() -> Self { + Self { _marker: std::marker::PhantomData } + } +} + +impl PendingSubscription +where + T: Send + 'static, +{ + /// Returns a stream that never yields. + pub fn take_stream(&mut self) -> BoxStream<'static, Result> { + futures::stream::pending().boxed() + } +} + +impl Default for PendingSubscription { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/batcher/source/src/subscription.rs b/crates/batcher/source/src/subscription.rs index f778ce7bc2..903b313b03 100644 --- a/crates/batcher/source/src/subscription.rs +++ b/crates/batcher/source/src/subscription.rs @@ -3,7 +3,7 @@ use base_common_consensus::BaseBlock; use futures::stream::BoxStream; -use crate::SourceError; +use crate::{KeepAliveSubscription, PendingSubscription, SourceError, StreamSubscription}; /// A source of an unsafe-block stream that may hold ancillary resources. /// @@ -21,3 +21,21 @@ pub trait BlockSubscription: Send { /// Must be called at most once; implementors may panic on a second call. fn take_stream(&mut self) -> BoxStream<'static, Result>; } + +impl BlockSubscription for StreamSubscription { + fn take_stream(&mut self) -> BoxStream<'static, Result> { + Self::take_stream(self) + } +} + +impl BlockSubscription for KeepAliveSubscription { + fn take_stream(&mut self) -> BoxStream<'static, Result> { + Self::take_stream(self) + } +} + +impl BlockSubscription for PendingSubscription { + fn take_stream(&mut self) -> BoxStream<'static, Result> { + Self::take_stream(self) + } +} diff --git a/crates/builder/core/Cargo.toml b/crates/builder/core/Cargo.toml index 209357d153..bcd4677ce4 100644 --- a/crates/builder/core/Cargo.toml +++ b/crates/builder/core/Cargo.toml @@ -34,7 +34,6 @@ reth-cli-util.workspace = true base-node-core.workspace = true reth-chainspec.workspace = true reth-rpc-layer.workspace = true -reth-primitives.workspace = true reth-storage-api.workspace = true reth-chain-state.workspace = true reth-payload-util.workspace = true @@ -138,7 +137,6 @@ secp256k1.workspace = true serde_with.workspace = true parking_lot.workspace = true derive_more.workspace = true -shellexpand.workspace = true tar = { workspace = true, optional = true } ctor = { workspace = true, optional = true } hyper = { workspace = true, optional = true } @@ -227,7 +225,6 @@ test-utils = [ "reth-node-ethereum/test-utils", "reth-payload-builder/test-utils", "reth-primitives-traits/test-utils", - "reth-primitives/test-utils", "reth-provider/test-utils", "reth-revm/test-utils", "reth-tasks/test-utils", diff --git a/crates/builder/core/src/flashblocks/context.rs b/crates/builder/core/src/flashblocks/context.rs index 5fa0b3be8a..529a189c06 100644 --- a/crates/builder/core/src/flashblocks/context.rs +++ b/crates/builder/core/src/flashblocks/context.rs @@ -7,9 +7,9 @@ use std::{ use alloy_consensus::{Eip658Value, Transaction}; use alloy_eips::{Encodable2718, Typed2718}; use alloy_evm::Database; -use alloy_primitives::{BlockHash, Bytes, TxHash, U256}; #[cfg(any(test, feature = "test-utils"))] use alloy_primitives::B256; +use alloy_primitives::{BlockHash, Bytes, TxHash, U256}; use alloy_rpc_types_eth::Withdrawals; use base_access_lists::FBALBuilderDb; use base_bundles::{MeterBundleResponse, RejectedTransaction, RejectionReason}; @@ -31,9 +31,8 @@ use reth_evm::{ }; use reth_node_api::PayloadBuilderError; use reth_payload_builder::PayloadId; -use reth_payload_primitives::PayloadBuilderAttributes; -use reth_primitives::SealedHeader; -use reth_primitives_traits::{InMemorySize, SignedTransaction}; +use reth_payload_primitives::PayloadAttributes; +use reth_primitives_traits::{InMemorySize, SealedHeader, SignedTransaction}; use reth_revm::{State, context::Block}; use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction}; use revm::{DatabaseCommit, context::result::ResultAndState, interpreter::as_u64_saturated}; @@ -129,9 +128,7 @@ pub struct FlashblockDiagnostics { pub txs_rejected_other: u64, /// Minimum effective priority fee (tip per gas) among included transactions. pub min_priority_fee: Option, - /// Transaction hashes permanently rejected due to per-tx intrinsic limits - /// (e.g. tx DA size exceeded, tx execution time exceeded). These will never - /// be includable and should be evicted from the pool. + /// Transaction hashes permanently rejected due to per-tx intrinsic limits. pub permanently_rejected_txs: Vec, } @@ -352,7 +349,7 @@ impl BasePayloadBuilderCtx { } /// Returns the block number for the block. - pub const fn block_number(&self) -> u64 { + pub fn block_number(&self) -> u64 { as_u64_saturated!(self.evm_env.block_env.number) } @@ -416,7 +413,7 @@ impl BasePayloadBuilderCtx { /// Returns the unique id for this payload job. pub fn payload_id(&self) -> PayloadId { - self.attributes().payload_id() + self.attributes().payload_id(&self.parent_hash()) } /// Returns true if regolith is active for the payload. @@ -610,7 +607,7 @@ impl BasePayloadBuilderCtx { }; // add gas used by the transaction to cumulative gas used, before creating the receipt - let gas_used = result.gas_used(); + let gas_used = result.tx_gas_used(); info.cumulative_gas_used += gas_used; if !sequencer_tx.is_deposit() { @@ -867,10 +864,10 @@ impl BasePayloadBuilderCtx { let priority_fee = tx.effective_tip_per_gas(base_fee).unwrap_or(0) as f64; record_rejected_tx_priority_fee(&err, priority_fee); - if err.is_permanent() { diag.permanently_rejected_txs.push(tx_hash); } + log_txn(Err(err)); best_txs.mark_invalid(tx.signer(), tx.nonce()); continue; @@ -905,7 +902,7 @@ impl BasePayloadBuilderCtx { return Ok(diag); } - let tx_simulation_start_time = Instant::now(); + let execution_start_time = Instant::now(); let ResultAndState { result, state } = match evm.transact(&tx) { Ok(res) => res, Err(err) => { @@ -940,11 +937,13 @@ impl BasePayloadBuilderCtx { } }; - let actual_execution_time_us = tx_simulation_start_time.elapsed().as_micros(); + let execution_time = execution_start_time.elapsed(); - BuilderMetrics::tx_simulation_duration().record(tx_simulation_start_time.elapsed()); + // The "simulation" terminology comes from upstream op-rbuilder's name for + // locally executing a candidate transaction before committing it to the payload; + // this is not metering service simulation data from MeterBundleResponse. + BuilderMetrics::tx_simulation_duration().record(execution_time); BuilderMetrics::tx_byte_size().record(tx.inner().size() as f64); - BuilderMetrics::tx_actual_execution_time_us().record(actual_execution_time_us as f64); num_txs_simulated += 1; // Record state modification counts (trie work proxy) @@ -956,16 +955,16 @@ impl BasePayloadBuilderCtx { // Record execution time for unmetered transactions (race condition indicator) if resource_usage.is_none() { BuilderMetrics::unmetered_tx_actual_execution_time_us() - .record(actual_execution_time_us as f64); + .record(execution_time.as_micros() as f64); } // Record prediction accuracy if let Some(predicted_us) = predicted_execution_time_us { - let error = predicted_us as f64 - actual_execution_time_us as f64; + let error = predicted_us as f64 - execution_time.as_micros() as f64; BuilderMetrics::execution_time_prediction_error_us().record(error); } - let gas_used = result.gas_used(); + let gas_used = result.tx_gas_used(); let is_success = result.is_success(); if is_success { log_txn(Ok(TxnOutcome::Success)); @@ -1135,7 +1134,7 @@ impl BasePayloadBuilderCtx { } #[cfg(any(test, feature = "test-utils"))] -use alloy_primitives::B256; +use base_execution_payload_builder::payload::EthPayloadBuilderAttributes; #[cfg(any(test, feature = "test-utils"))] impl BasePayloadBuilderCtx { @@ -1148,7 +1147,7 @@ impl BasePayloadBuilderCtx { let timestamp = parent.timestamp + 2; let attributes = BasePayloadBuilderAttributes { - payload_attributes: reth_payload_builder::EthPayloadBuilderAttributes { + payload_attributes: EthPayloadBuilderAttributes { id: PayloadId::new([0; 8]), parent: parent.hash(), timestamp, @@ -1172,7 +1171,8 @@ impl BasePayloadBuilderCtx { .next_evm_env(&parent, &block_env_attributes) .expect("failed to create test evm env"); - let config = PayloadConfig::new(parent, attributes); + let payload_id = attributes.payload_id(&parent.hash()); + let config = PayloadConfig::new(parent, attributes, payload_id); Self { evm_config, @@ -1197,8 +1197,7 @@ mod tests { use base_common_consensus::BaseTypedTransaction; use base_execution_chainspec::BaseChainSpec; use reth_chainspec::ChainSpec; - use reth_primitives::SealedHeader; - use reth_primitives_traits::WithEncoded; + use reth_primitives_traits::{SealedHeader, WithEncoded}; use reth_provider::noop::NoopProvider; use reth_revm::{State, database::StateProviderDatabase}; @@ -1346,7 +1345,7 @@ mod tests { let genesis = serde_json::from_value(genesis).expect("valid genesis"); let inner = ChainSpec::builder().chain(901.into()).genesis(genesis).cancun_activated().build(); - let chain_spec = Arc::new(BaseChainSpec { inner }); + let chain_spec = Arc::new(BaseChainSpec::from(inner)); let parent_header = Header { gas_limit: 30_000_000, timestamp: 0, ..Default::default() }; let parent = Arc::new(SealedHeader::seal_slow(parent_header)); diff --git a/crates/builder/core/src/flashblocks/generator.rs b/crates/builder/core/src/flashblocks/generator.rs index 4b16e49246..5bf9e835b8 100644 --- a/crates/builder/core/src/flashblocks/generator.rs +++ b/crates/builder/core/src/flashblocks/generator.rs @@ -8,15 +8,16 @@ use alloy_primitives::B256; use futures::{Future, FutureExt}; use parking_lot::Mutex; use reth_basic_payload_builder::{HeaderForPayload, PayloadConfig, PrecachedState}; -use reth_node_api::{NodePrimitives, PayloadBuilderAttributes, PayloadKind}; +use reth_node_api::{NodePrimitives, PayloadKind}; use reth_payload_builder::{ - KeepPayloadJobAlive, PayloadBuilderError, PayloadJob, PayloadJobGenerator, + BuildNewPayload, KeepPayloadJobAlive, PayloadBuilderError, PayloadId, PayloadJob, + PayloadJobGenerator, }; -use reth_payload_primitives::BuiltPayload; +use reth_payload_primitives::{BuiltPayload, PayloadAttributes}; use reth_primitives_traits::HeaderTy; use reth_provider::{BlockReaderIdExt, CanonStateNotification, StateProviderFactory}; use reth_revm::cached::CachedReads; -use reth_tasks::TaskSpawner; +use reth_tasks::Runtime; use tokio::{ sync::oneshot, time::{Duration, Sleep}, @@ -28,11 +29,11 @@ use crate::PayloadBuilder; /// The generator type that creates new jobs that build empty blocks. #[derive(Debug)] -pub struct BlockPayloadJobGenerator { +pub struct BlockPayloadJobGenerator { /// The client that can interact with the chain. client: Client, /// How to spawn building tasks - executor: Tasks, + executor: Runtime, /// The type responsible for building payloads. /// /// See [`PayloadBuilder`] @@ -49,12 +50,12 @@ pub struct BlockPayloadJobGenerator { // === impl BlockPayloadJobGenerator === -impl BlockPayloadJobGenerator { +impl BlockPayloadJobGenerator { /// Creates a new [`BlockPayloadJobGenerator`] with the given config and custom /// [`PayloadBuilder`] pub fn with_builder( client: Client, - executor: Tasks, + executor: Runtime, builder: Builder, ensure_only_one_payload: bool, extra_block_deadline: std::time::Duration, @@ -77,26 +78,25 @@ impl BlockPayloadJobGenerator { } } -impl PayloadJobGenerator - for BlockPayloadJobGenerator +impl PayloadJobGenerator for BlockPayloadJobGenerator where Client: StateProviderFactory + BlockReaderIdExt

> + Clone + Unpin + 'static, - Tasks: TaskSpawner + Clone + Unpin + 'static, Builder: PayloadBuilder + Unpin + 'static, Builder::Attributes: Unpin + Clone, Builder::BuiltPayload: Unpin + Clone, { - type Job = BlockPayloadJob; + type Job = BlockPayloadJob; /// This is invoked when the node receives payload attributes from the beacon node via /// `engine_forkchoiceUpdatedVX` fn new_payload_job( &self, - attributes: ::Attributes, + input: BuildNewPayload<::Attributes>, + id: PayloadId, ) -> Result { let cancel_token = if self.ensure_only_one_payload { // Cancel existing payload @@ -116,15 +116,15 @@ where CancellationToken::new() }; - let parent_header = if attributes.parent().is_zero() { + let parent_header = if input.parent_hash.is_zero() { // use latest block if parent is zero: genesis block self.client .latest_header()? - .ok_or_else(|| PayloadBuilderError::MissingParentBlock(attributes.parent()))? + .ok_or_else(|| PayloadBuilderError::MissingParentBlock(input.parent_hash))? } else { self.client - .sealed_header_by_hash(attributes.parent())? - .ok_or_else(|| PayloadBuilderError::MissingParentBlock(attributes.parent()))? + .sealed_header_by_hash(input.parent_hash)? + .ok_or_else(|| PayloadBuilderError::MissingParentBlock(input.parent_hash))? }; info!("Spawn block building job"); @@ -145,13 +145,13 @@ where // "remember" the payloads long enough to accommodate this corner-case // (without it we are losing blocks). Postponing the deadline for 5s // (not just 0.5s) because of that. - let deadline = job_deadline(attributes.timestamp()) + self.extra_block_deadline; + let deadline = job_deadline(input.attributes.timestamp()) + self.extra_block_deadline; let deadline = Box::pin(tokio::time::sleep(deadline)); // Extract hash before moving parent_header into Arc to avoid cloning let parent_hash = parent_header.hash(); - let config = PayloadConfig::new(Arc::new(parent_header), attributes); + let config = PayloadConfig::new(Arc::new(parent_header), input.attributes, id); // Create shared mutex for synchronizing cancellation with payload publishing let publish_guard = Arc::new(Mutex::new(())); @@ -200,14 +200,14 @@ use std::{ }; /// A [`PayloadJob`] that builds empty blocks. -pub struct BlockPayloadJob +pub struct BlockPayloadJob where Builder: PayloadBuilder, { /// The configuration for how the payload will be created. pub(crate) config: PayloadConfig>, /// How to spawn building tasks - pub(crate) executor: Tasks, + pub(crate) executor: Runtime, /// The type responsible for building payloads. /// /// See [`PayloadBuilder`] @@ -229,7 +229,7 @@ where pub(crate) cached_reads: Option, } -impl std::fmt::Debug for BlockPayloadJob +impl std::fmt::Debug for BlockPayloadJob where Builder: PayloadBuilder, { @@ -238,9 +238,8 @@ where } } -impl PayloadJob for BlockPayloadJob +impl PayloadJob for BlockPayloadJob where - Tasks: TaskSpawner + Clone + 'static, Builder: PayloadBuilder + Unpin + 'static, Builder::Attributes: Unpin + Clone, Builder::BuiltPayload: Unpin + Clone, @@ -291,9 +290,8 @@ pub struct BuildArguments { } /// A [`PayloadJob`] is a future that's being polled by the `PayloadBuilderService` -impl BlockPayloadJob +impl BlockPayloadJob where - Tasks: TaskSpawner + Clone + 'static, Builder: PayloadBuilder + Unpin + 'static, Builder::Attributes: Unpin + Clone, Builder::BuiltPayload: Unpin + Clone, @@ -326,9 +324,8 @@ where } /// A [`PayloadJob`] is a future that's being polled by the `PayloadBuilderService` -impl Future for BlockPayloadJob +impl Future for BlockPayloadJob where - Tasks: TaskSpawner + Clone + 'static, Builder: PayloadBuilder + Unpin + 'static, Builder::Attributes: Unpin + Clone, Builder::BuiltPayload: Unpin + Clone, @@ -526,14 +523,12 @@ mod tests { use alloy_eips::eip7685::Requests; use alloy_primitives::U256; use base_common_consensus::BasePrimitives; - use base_execution_payload_builder::{ - PayloadPrimitives, payload::BasePayloadBuilderAttributes, - }; + use base_execution_payload_builder::{BasePayloadBuilderAttributes, PayloadPrimitives}; use rand::rng; use reth_node_api::{BuiltPayloadExecutedBlock, NodePrimitives}; - use reth_primitives::SealedBlock; + use reth_primitives_traits::SealedBlock; use reth_provider::test_utils::MockEthProvider; - use reth_tasks::TokioTaskExecutor; + use reth_tasks::Runtime; use reth_testing_utils::generators::{BlockRangeParams, random_block_range}; use tokio::{ task, @@ -721,7 +716,7 @@ mod tests { let mut rng = rng(); let client = MockEthProvider::default(); - let executor = TokioTaskExecutor::default(); + let executor = Runtime::test(); let builder = MockBuilder::::new(); let (start, count) = (1, 10); @@ -746,7 +741,14 @@ mod tests { attr.payload_attributes.parent = client.latest_header()?.unwrap().hash(); { - let job = generator.new_payload_job(attr.clone())?; + let parent_hash = attr.payload_attributes.parent; + let input = BuildNewPayload { + attributes: attr.clone(), + parent_hash, + cache: None, + trie_handle: None, + }; + let job = generator.new_payload_job(input, attr.payload_id(&parent_hash))?; let _ = job.await; // you need to give one second for the job to be dropped and cancelled the internal job @@ -758,7 +760,14 @@ mod tests { { // job resolve triggers cancellations from the build task - let mut job = generator.new_payload_job(attr.clone())?; + let parent_hash = attr.payload_attributes.parent; + let input = BuildNewPayload { + attributes: attr.clone(), + parent_hash, + cache: None, + trie_handle: None, + }; + let mut job = generator.new_payload_job(input, attr.payload_id(&parent_hash))?; let _ = job.resolve(); let _ = job.await; diff --git a/crates/builder/core/src/flashblocks/payload.rs b/crates/builder/core/src/flashblocks/payload.rs index 06aad88a6e..53a40c764a 100644 --- a/crates/builder/core/src/flashblocks/payload.rs +++ b/crates/builder/core/src/flashblocks/payload.rs @@ -29,7 +29,7 @@ use reth_basic_payload_builder::BuildOutcome; use reth_evm::{ConfigureEvm, execute::BlockBuilder}; use reth_execution_types::ChangedAccount; use reth_node_api::{Block, BuiltPayloadExecutedBlock, PayloadBuilderError}; -use reth_payload_primitives::PayloadBuilderAttributes; +use reth_payload_primitives::PayloadAttributes; use reth_payload_util::BestPayloadTransactions; use reth_primitives_traits::RecoveredBlock; use reth_provider::{ @@ -170,8 +170,8 @@ where let block_env_attributes = BaseNextBlockEnvAttributes { timestamp, - suggested_fee_recipient: config.attributes.suggested_fee_recipient(), - prev_randao: config.attributes.prev_randao(), + suggested_fee_recipient: config.attributes.payload_attributes.suggested_fee_recipient, + prev_randao: config.attributes.payload_attributes.prev_randao, gas_limit: config.attributes.gas_limit.unwrap_or(config.parent_header.gas_limit), parent_beacon_block_root: config.attributes.payload_attributes.parent_beacon_block_root, extra_data, @@ -1041,6 +1041,8 @@ where blob_gas_used, excess_blob_gas, requests_hash, + block_access_list_hash: None, + slot_number: ctx.attributes().payload_attributes.slot_number, }; // seal the block @@ -1140,7 +1142,7 @@ where ) })?, parent_hash: ctx.parent().hash(), - fee_recipient: ctx.attributes().suggested_fee_recipient(), + fee_recipient: ctx.attributes().payload_attributes.suggested_fee_recipient, prev_randao: ctx.attributes().payload_attributes.prev_randao, block_number: ctx.parent().number + 1, gas_limit: ctx.block_gas_limit(), @@ -1206,7 +1208,7 @@ mod tests { let inner = ChainSpec::builder().chain(901.into()).genesis(genesis).cancun_activated().build(); - Arc::new(BaseChainSpec { inner }) + Arc::new(BaseChainSpec::from(inner)) } /// Builds a sealed genesis header consistent with [`minimal_chain_spec`]. diff --git a/crates/builder/core/src/flashblocks/traits.rs b/crates/builder/core/src/flashblocks/traits.rs index bc78a2aa29..e81432bb48 100644 --- a/crates/builder/core/src/flashblocks/traits.rs +++ b/crates/builder/core/src/flashblocks/traits.rs @@ -1,8 +1,7 @@ //! Contains the payload builder trait. -use reth_node_api::PayloadBuilderAttributes; use reth_payload_builder::PayloadBuilderError; -use reth_payload_primitives::BuiltPayload; +use reth_payload_primitives::{BuiltPayload, PayloadAttributes}; use crate::{BlockCell, BuildArguments}; @@ -14,7 +13,7 @@ use crate::{BlockCell, BuildArguments}; #[async_trait::async_trait] pub trait PayloadBuilder: Send + Sync + Clone { /// The payload attributes type to accept for building. - type Attributes: PayloadBuilderAttributes; + type Attributes: PayloadAttributes; /// The type of the built payload. type BuiltPayload: BuiltPayload; diff --git a/crates/builder/core/src/metrics.rs b/crates/builder/core/src/metrics.rs index de187070a7..d78feb1d92 100644 --- a/crates/builder/core/src/metrics.rs +++ b/crates/builder/core/src/metrics.rs @@ -80,7 +80,9 @@ base_metrics::define_metrics! { reverted_tx_gas_used: histogram, #[describe("Gas used by reverted transactions in the latest block")] payload_reverted_tx_gas_used: gauge, - #[describe("Histogram of tx simulation duration")] + #[describe( + "Histogram of local builder EVM transaction execution/simulation duration in seconds" + )] tx_simulation_duration: histogram, #[describe("Byte size of transactions")] tx_byte_size: histogram, @@ -134,8 +136,6 @@ base_metrics::define_metrics! { execution_time_prediction_error_us: histogram, #[describe("Distribution of predicted execution times from metering service (microseconds)")] tx_predicted_execution_time_us: histogram, - #[describe("Distribution of actual execution times (microseconds)")] - tx_actual_execution_time_us: histogram, #[describe("Per-transaction state root gas (computed from metering data)")] tx_state_root_gas: histogram, #[describe("Cumulative state root gas per block")] diff --git a/crates/builder/core/src/test_utils/driver.rs b/crates/builder/core/src/test_utils/driver.rs index b8f1db5f75..75e47fb455 100644 --- a/crates/builder/core/src/test_utils/driver.rs +++ b/crates/builder/core/src/test_utils/driver.rs @@ -5,10 +5,11 @@ use alloy_primitives::{B64, B256, Bytes, TxKind, U256, address, hex}; use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types_engine::{ForkchoiceUpdated, PayloadAttributes, PayloadStatusEnum}; use alloy_rpc_types_eth::Block; -use base_common_consensus::{BaseTypedTransaction, TxDeposit}; +use base_common_consensus::{BaseTxEnvelope, BaseTypedTransaction, TxDeposit}; use base_common_network::Base; use base_common_rpc_types::Transaction; use base_common_rpc_types_engine::BasePayloadAttributes; +use base_execution_payload_builder::BasePayloadBuilderAttributes; use chrono::Utc; use super::{ @@ -177,6 +178,7 @@ impl ChainDriver { timestamp: block_timestamp, parent_beacon_block_root: Some(B256::ZERO), withdrawals: Some(vec![]), + slot_number: None, ..Default::default() }, transactions: Some(vec![block_info_tx].into_iter().chain(txs).collect()), @@ -318,6 +320,7 @@ impl ChainDriver { impl ChainDriver { async fn fcu(&self, attribs: BasePayloadAttributes) -> eyre::Result { let latest = self.latest().await?.header.hash; + let attribs = BasePayloadBuilderAttributes::::try_new(latest, attribs, 3)?; let response = self.engine_api.update_forkchoice(latest, latest, Some(attribs)).await?; Ok(response) diff --git a/crates/builder/core/src/test_utils/mod.rs b/crates/builder/core/src/test_utils/mod.rs index 75f8b1bdfb..65330e8d67 100644 --- a/crates/builder/core/src/test_utils/mod.rs +++ b/crates/builder/core/src/test_utils/mod.rs @@ -20,7 +20,7 @@ pub use external::*; pub use instance::*; use k256::sha2::{Digest, Sha256}; use reth_node_builder::NodeConfig; -use reth_primitives::Recovered; +use reth_primitives_traits::Recovered; pub use txs::*; pub use utils::*; diff --git a/crates/builder/core/src/test_utils/txs.rs b/crates/builder/core/src/test_utils/txs.rs index fc93e75344..248a1c7ca0 100644 --- a/crates/builder/core/src/test_utils/txs.rs +++ b/crates/builder/core/src/test_utils/txs.rs @@ -10,7 +10,7 @@ use base_common_network::Base; use base_execution_txpool::BasePooledTransaction; use dashmap::DashMap; use futures::StreamExt; -use reth_primitives::Recovered; +use reth_primitives_traits::Recovered; use reth_transaction_pool::{AllTransactionsEvents, FullTransactionEvent, TransactionEvent}; use tokio::sync::watch; use tracing::debug; diff --git a/crates/builder/core/tests/miner_gas_limit.rs b/crates/builder/core/tests/miner_gas_limit.rs index 11dfa7b692..0d29ca0272 100644 --- a/crates/builder/core/tests/miner_gas_limit.rs +++ b/crates/builder/core/tests/miner_gas_limit.rs @@ -3,21 +3,23 @@ use alloy_provider::Provider; use base_builder_core::test_utils::{BlockTransactionsExt, setup_test_instance}; -/// This test ensures that the miner gas limit is respected -/// We will set the limit to 60,000 and see that the builder will not include any transactions +/// This test ensures that the miner gas limit is respected. +/// We set the limit to 200,000 — enough for the deposit tx (~182,706 gas) but too low +/// to fit any additional user transactions (~21,000 gas each). #[tokio::test] async fn miner_gas_limit() -> eyre::Result<()> { let rbuilder = setup_test_instance().await?; let driver = rbuilder.driver().await?; - let call = - driver.provider().raw_request::<(u64,), bool>("miner_setGasLimit".into(), (60000,)).await?; + let call = driver + .provider() + .raw_request::<(u64,), bool>("miner_setGasLimit".into(), (200_000,)) + .await?; assert!(call, "miner_setGasLimit should be executed successfully"); let unfit_tx = driver.create_transaction().send().await?; let block = driver.build_new_block().await?; - // tx should not be included because the gas limit is less than the transaction gas assert!(!block.includes(unfit_tx.tx_hash()), "transaction should not be included in the block"); Ok(()) @@ -78,14 +80,15 @@ async fn reset_gas_limit() -> eyre::Result<()> { let rbuilder = setup_test_instance().await?; let driver = rbuilder.driver().await?; - let call = - driver.provider().raw_request::<(u64,), bool>("miner_setGasLimit".into(), (60000,)).await?; + let call = driver + .provider() + .raw_request::<(u64,), bool>("miner_setGasLimit".into(), (200_000,)) + .await?; assert!(call, "miner_setGasLimit should be executed successfully"); let unfit_tx = driver.create_transaction().send().await?; let block = driver.build_new_block().await?; - // tx should not be included because the gas limit is less than the transaction gas assert!(!block.includes(unfit_tx.tx_hash()), "transaction should not be included in the block"); let reset_call = @@ -97,7 +100,6 @@ async fn reset_gas_limit() -> eyre::Result<()> { let fit_tx = driver.create_transaction().send().await?; let block = driver.build_new_block().await?; - // tx should be included because the gas limit is reset to the default value assert!(block.includes(fit_tx.tx_hash()), "transaction should be in block"); Ok(()) diff --git a/crates/common/access-lists/src/db.rs b/crates/common/access-lists/src/db.rs index c4f988e51f..d2dd9ed312 100644 --- a/crates/common/access-lists/src/db.rs +++ b/crates/common/access-lists/src/db.rs @@ -1,7 +1,7 @@ use alloy_primitives::{Address, B256}; use revm::{ Database, DatabaseCommit, - primitives::{HashMap, KECCAK_EMPTY, StorageKey, StorageValue}, + primitives::{AddressMap, KECCAK_EMPTY, StorageKey, StorageValue}, state::{Account, AccountInfo, Bytecode}, }; use tracing::error; @@ -59,10 +59,7 @@ where /// Attempts to commit the changes to the underlying database /// as well as applies account/storage changes to the access list builder - fn try_commit( - &mut self, - changes: HashMap, - ) -> Result<(), ::Error> { + fn try_commit(&mut self, changes: AddressMap) -> Result<(), ::Error> { for (address, account) in &changes { let account_changes = self.access_list.changes.entry(*address).or_default(); @@ -168,7 +165,7 @@ impl DatabaseCommit for FBALBuilderDb where DB: DatabaseCommit + Database, { - fn commit(&mut self, changes: HashMap) { + fn commit(&mut self, changes: AddressMap) { if let Err(e) = self.try_commit(changes) { error!(error = ?e, "Failed to commit changes via FBALBuilderDb"); self.error = Some(e); diff --git a/crates/common/access-lists/tests/builder/main.rs b/crates/common/access-lists/tests/builder/main.rs index b091a5f6bb..fa7926cb99 100644 --- a/crates/common/access-lists/tests/builder/main.rs +++ b/crates/common/access-lists/tests/builder/main.rs @@ -42,6 +42,7 @@ fn block_env() -> BlockEnv { excess_blob_gas: 0, blob_gasprice: 1, }), + slot_num: 0, } } diff --git a/crates/common/chains/Cargo.toml b/crates/common/chains/Cargo.toml index 4c4370e125..93c8a860d0 100644 --- a/crates/common/chains/Cargo.toml +++ b/crates/common/chains/Cargo.toml @@ -61,5 +61,6 @@ serde = [ "alloy-primitives/serde", "base-common-genesis/serde", "dep:serde", + "revm/serde", ] test-utils = [] diff --git a/crates/common/chains/res/genesis/zeronet_base.json b/crates/common/chains/res/genesis/zeronet_base.json index 879ea8ff2e..de9e8cc824 100644 --- a/crates/common/chains/res/genesis/zeronet_base.json +++ b/crates/common/chains/res/genesis/zeronet_base.json @@ -27,6 +27,9 @@ "holoceneTime": 0, "isthmusTime": 0, "jovianTime": 0, + "base": { + "azul": 1775152800 + }, "terminalTotalDifficulty": 0, "depositContractAddress": "0x0000000000000000000000000000000000000000", "optimism": { @@ -15354,4 +15357,3 @@ "excessBlobGas": "0x0", "blobGasUsed": "0x0" } - diff --git a/crates/common/chains/src/config.rs b/crates/common/chains/src/config.rs index 5cd340cf13..bb3c9c1e29 100644 --- a/crates/common/chains/src/config.rs +++ b/crates/common/chains/src/config.rs @@ -95,6 +95,8 @@ pub struct ChainConfig { // Roles /// Unsafe block signer address. pub unsafe_block_signer: Option
, + /// Activation registry admin address. + pub activation_admin_address: Option
, // Gas limits /// Maximum gas limit for L2 blocks. @@ -156,6 +158,15 @@ impl ChainConfig { Self::DEVNET_NAME, ]; + /// Base Mainnet chain configuration. + pub const MAINNET: &'static Self = Self::mainnet(); + /// Base Sepolia chain configuration. + pub const SEPOLIA: &'static Self = Self::sepolia(); + /// Local dev chain configuration (all forks active at genesis). + pub const DEVNET: &'static Self = Self::devnet(); + /// Base Zeronet chain configuration. + pub const ZERONET: &'static Self = Self::zeronet(); + /// Base Mainnet chain configuration. pub const fn mainnet() -> &'static Self { &MAINNET @@ -197,11 +208,22 @@ impl ChainConfig { match id { 8453 => Some(&MAINNET), 84532 => Some(&SEPOLIA), + 1337 => Some(&DEVNET), 763360 => Some(&ZERONET), _ => None, } } + /// Returns the full [`RollupConfig`] for the given L2 chain ID. + pub fn rollup_config_by_chain_id(id: u64) -> Option { + Self::by_chain_id(id).map(Self::rollup_config) + } + + /// Returns the full [`RollupConfig`] for the given [`Chain`] identifier. + pub fn rollup_config_by_chain(chain: &Chain) -> Option { + Self::rollup_config_by_chain_id(chain.id()) + } + /// Returns the EIP-1559 [`FeeConfig`] for this chain. pub const fn fee_config(&self) -> FeeConfig { FeeConfig { @@ -340,6 +362,7 @@ const MAINNET: ChainConfig = ChainConfig { protocol_versions_address: address!("8062abc286f5e7d9428a0ccb9abd71e50d93b935"), unsafe_block_signer: Some(address!("Af6E19BE0F9cE7f8afd49a1824851023A8249e8a")), + activation_admin_address: Some(address!("331C9d37BbcebBC9dfAf98FBE3C5B8A39Dd6E771")), max_gas_limit: 105_000_000, prune_delete_limit: 20_000, @@ -412,6 +435,7 @@ const SEPOLIA: ChainConfig = ChainConfig { protocol_versions_address: address!("79add5713b383daa0a138d3c4780c7a1804a8090"), unsafe_block_signer: Some(address!("b830b99c95Ea32300039624Cb567d324D4b1D83C")), + activation_admin_address: Some(address!("5Be7Dd3678e999D5F7bC508c413db239F7D4Ac59")), max_gas_limit: 45_000_000, prune_delete_limit: 10_000, @@ -475,6 +499,7 @@ const DEVNET: ChainConfig = ChainConfig { protocol_versions_address: Address::ZERO, unsafe_block_signer: None, + activation_admin_address: Some(address!("9965507D1a55bcC2695C58ba16FB37d819B0A4dc")), max_gas_limit: 30_000_000, prune_delete_limit: 20_000, @@ -527,6 +552,7 @@ const ZERONET: ChainConfig = ChainConfig { protocol_versions_address: address!("646c8604cf62b23e0cf094f2e790c6c75547ff85"), unsafe_block_signer: Some(address!("cf17274338d3128f6C96d9af54511a17e8b38a08")), + activation_admin_address: Some(address!("F5969A85a555671EeD766C4ff0C61426AA626b11")), max_gas_limit: 25_000_000, prune_delete_limit: 10_000, @@ -564,5 +590,24 @@ mod tests { assert!(ChainConfig::by_name(name).is_some(), "{name} should resolve"); } assert_eq!(ChainConfig::by_name(ChainConfig::SEPOLIA_ALIAS), Some(ChainConfig::sepolia())); + assert_eq!( + ChainConfig::by_chain_id(ChainConfig::devnet().chain_id), + Some(ChainConfig::devnet()) + ); + assert_eq!( + ChainConfig::rollup_config_by_chain_id(ChainConfig::devnet().chain_id) + .map(|cfg| cfg.l2_chain_id.id()), + Some(ChainConfig::devnet().chain_id) + ); + assert_eq!(ChainConfig::MAINNET, ChainConfig::mainnet()); + assert_eq!(ChainConfig::SEPOLIA, ChainConfig::sepolia()); + assert_eq!(ChainConfig::DEVNET, ChainConfig::devnet()); + assert_eq!(ChainConfig::ZERONET, ChainConfig::zeronet()); + } + + #[test] + fn zeronet_beryl_is_unscheduled() { + assert_eq!(ChainConfig::zeronet().beryl_timestamp, None); + assert_eq!(ChainConfig::zeronet().hardfork_config().base.beryl, None); } } diff --git a/crates/common/chains/src/ethereum/holesky.rs b/crates/common/chains/src/ethereum/holesky.rs index 63dcdb2ccc..85216b67ce 100644 --- a/crates/common/chains/src/ethereum/holesky.rs +++ b/crates/common/chains/src/ethereum/holesky.rs @@ -36,6 +36,7 @@ impl Holesky { cancun_time: alloy_hardforks::EthereumHardfork::Cancun.holesky_activation_timestamp(), prague_time: alloy_hardforks::EthereumHardfork::Prague.holesky_activation_timestamp(), osaka_time: alloy_hardforks::EthereumHardfork::Osaka.holesky_activation_timestamp(), + amsterdam_time: None, bpo1_time: alloy_hardforks::EthereumHardfork::Bpo1.holesky_activation_timestamp(), bpo2_time: alloy_hardforks::EthereumHardfork::Bpo2.holesky_activation_timestamp(), bpo3_time: alloy_hardforks::EthereumHardfork::Bpo3.holesky_activation_timestamp(), @@ -50,6 +51,7 @@ impl Holesky { parlia: None, extra_fields: Default::default(), terminal_total_difficulty_passed: false, + _non_exhaustive: (), } } } diff --git a/crates/common/chains/src/ethereum/hoodi.rs b/crates/common/chains/src/ethereum/hoodi.rs index 171c888df0..0bfe607ee0 100644 --- a/crates/common/chains/src/ethereum/hoodi.rs +++ b/crates/common/chains/src/ethereum/hoodi.rs @@ -36,6 +36,7 @@ impl Hoodi { cancun_time: alloy_hardforks::EthereumHardfork::Cancun.hoodi_activation_timestamp(), prague_time: alloy_hardforks::EthereumHardfork::Prague.hoodi_activation_timestamp(), osaka_time: alloy_hardforks::EthereumHardfork::Osaka.hoodi_activation_timestamp(), + amsterdam_time: None, bpo1_time: alloy_hardforks::EthereumHardfork::Bpo1.hoodi_activation_timestamp(), bpo2_time: alloy_hardforks::EthereumHardfork::Bpo2.hoodi_activation_timestamp(), bpo3_time: alloy_hardforks::EthereumHardfork::Bpo3.hoodi_activation_timestamp(), @@ -50,6 +51,7 @@ impl Hoodi { parlia: None, extra_fields: Default::default(), terminal_total_difficulty_passed: false, + _non_exhaustive: (), } } } diff --git a/crates/common/chains/src/ethereum/mainnet.rs b/crates/common/chains/src/ethereum/mainnet.rs index ef831a6df0..9c7d7a3dd7 100644 --- a/crates/common/chains/src/ethereum/mainnet.rs +++ b/crates/common/chains/src/ethereum/mainnet.rs @@ -46,6 +46,7 @@ impl Mainnet { cancun_time: alloy_hardforks::EthereumHardfork::Cancun.mainnet_activation_timestamp(), prague_time: alloy_hardforks::EthereumHardfork::Prague.mainnet_activation_timestamp(), osaka_time: alloy_hardforks::EthereumHardfork::Osaka.mainnet_activation_timestamp(), + amsterdam_time: None, bpo1_time: alloy_hardforks::EthereumHardfork::Bpo1.mainnet_activation_timestamp(), bpo2_time: alloy_hardforks::EthereumHardfork::Bpo2.mainnet_activation_timestamp(), bpo3_time: alloy_hardforks::EthereumHardfork::Bpo3.mainnet_activation_timestamp(), @@ -60,6 +61,7 @@ impl Mainnet { parlia: None, extra_fields: Default::default(), terminal_total_difficulty_passed: false, + _non_exhaustive: (), } } } diff --git a/crates/common/chains/src/ethereum/sepolia.rs b/crates/common/chains/src/ethereum/sepolia.rs index b6bece77da..d6261627f1 100644 --- a/crates/common/chains/src/ethereum/sepolia.rs +++ b/crates/common/chains/src/ethereum/sepolia.rs @@ -47,6 +47,7 @@ impl Sepolia { cancun_time: alloy_hardforks::EthereumHardfork::Cancun.sepolia_activation_timestamp(), prague_time: alloy_hardforks::EthereumHardfork::Prague.sepolia_activation_timestamp(), osaka_time: alloy_hardforks::EthereumHardfork::Osaka.sepolia_activation_timestamp(), + amsterdam_time: None, bpo1_time: alloy_hardforks::EthereumHardfork::Bpo1.sepolia_activation_timestamp(), bpo2_time: alloy_hardforks::EthereumHardfork::Bpo2.sepolia_activation_timestamp(), bpo3_time: alloy_hardforks::EthereumHardfork::Bpo3.sepolia_activation_timestamp(), @@ -61,6 +62,7 @@ impl Sepolia { parlia: None, extra_fields: Default::default(), terminal_total_difficulty_passed: false, + _non_exhaustive: (), } } } diff --git a/crates/common/chains/src/lib.rs b/crates/common/chains/src/lib.rs index 34300474bf..145671ad6b 100644 --- a/crates/common/chains/src/lib.rs +++ b/crates/common/chains/src/lib.rs @@ -17,8 +17,8 @@ pub use upgrades::Upgrades; mod chain; pub use chain::ChainUpgrades; -mod registry; -pub use registry::Registry; +mod macros; +pub use macros::RollupConfigSource; mod ethereum; pub use ethereum::{Holesky, Hoodi, L1_CONFIGS, Mainnet, Sepolia}; diff --git a/crates/common/chains/src/macros.rs b/crates/common/chains/src/macros.rs new file mode 100644 index 0000000000..8dde9eb28a --- /dev/null +++ b/crates/common/chains/src/macros.rs @@ -0,0 +1,108 @@ +//! Macros and helper traits for ergonomic chain config access. + +use alloy_chains::Chain; +use base_common_genesis::RollupConfig; + +use crate::ChainConfig; + +/// Input accepted by the [`rollup_config!`] macro. +pub trait RollupConfigSource { + /// Type returned after resolving the input. + type Output; + + /// Resolves the input into a derived [`RollupConfig`]. + fn resolve_rollup_config(self) -> Self::Output; +} + +impl RollupConfigSource for u64 { + type Output = Option; + + fn resolve_rollup_config(self) -> Self::Output { + ChainConfig::rollup_config_by_chain_id(self) + } +} + +impl RollupConfigSource for &u64 { + type Output = Option; + + fn resolve_rollup_config(self) -> Self::Output { + ChainConfig::rollup_config_by_chain_id(*self) + } +} + +impl RollupConfigSource for Chain { + type Output = Option; + + fn resolve_rollup_config(self) -> Self::Output { + ChainConfig::rollup_config_by_chain(&self) + } +} + +impl RollupConfigSource for &Chain { + type Output = Option; + + fn resolve_rollup_config(self) -> Self::Output { + ChainConfig::rollup_config_by_chain(self) + } +} + +impl RollupConfigSource for ChainConfig { + type Output = RollupConfig; + + fn resolve_rollup_config(self) -> Self::Output { + self.rollup_config() + } +} + +impl RollupConfigSource for &ChainConfig { + type Output = RollupConfig; + + fn resolve_rollup_config(self) -> Self::Output { + self.rollup_config() + } +} + +/// Resolves a [`RollupConfig`] from a Base [`ChainConfig`], [`Chain`], or L2 chain ID. +/// +/// Chain config inputs resolve directly to [`RollupConfig`]. Chain ID and [`Chain`] inputs resolve +/// to `Option` because those inputs may not identify a built-in Base chain. +#[macro_export] +macro_rules! rollup_config { + ($source:expr $(,)?) => { + $crate::RollupConfigSource::resolve_rollup_config($source) + }; +} + +#[cfg(test)] +mod tests { + use alloy_chains::Chain; + + use crate::ChainConfig; + + #[test] + fn rollup_config_macro_accepts_chain_id() { + let config = crate::rollup_config!(8453).expect("Base mainnet config should resolve"); + + assert_eq!(config.l2_chain_id.id(), ChainConfig::mainnet().chain_id); + } + + #[test] + fn rollup_config_macro_accepts_chain() { + let chain = Chain::base_mainnet(); + let config = crate::rollup_config!(&chain).expect("Base mainnet config should resolve"); + + assert_eq!(config.l2_chain_id.id(), ChainConfig::mainnet().chain_id); + } + + #[test] + fn rollup_config_macro_accepts_chain_config() { + let config = crate::rollup_config!(ChainConfig::SEPOLIA); + + assert_eq!(config.l2_chain_id.id(), ChainConfig::sepolia().chain_id); + } + + #[test] + fn rollup_config_macro_returns_none_for_unknown_chain_id() { + assert!(crate::rollup_config!(999_999_999).is_none()); + } +} diff --git a/crates/common/chains/src/registry.rs b/crates/common/chains/src/registry.rs deleted file mode 100644 index 633a7f9656..0000000000 --- a/crates/common/chains/src/registry.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Rollup chain configuration registry. - -use alloy_primitives::{Address, map::HashMap}; -use base_common_genesis::RollupConfig; -use spin::Lazy; - -use crate::ChainConfig; - -/// Rollup configurations derived from [`ChainConfig`] instances. -static ROLLUP_CONFIGS: Lazy> = Lazy::new(|| { - let mut map = HashMap::default(); - for cfg in ChainConfig::all() { - map.insert(cfg.chain_id, cfg.rollup_config()); - } - map -}); - -/// A registry of chain configurations for Base networks. -/// -/// Provides access to rollup configs and the unsafe block signer for supported chain IDs. -/// Rollup configs are derived from the compile-time [`ChainConfig`] instances in this crate. -#[derive(Debug)] -pub struct Registry; - -impl Registry { - /// Returns a [`RollupConfig`] for the given chain ID. - pub fn rollup_config(chain_id: u64) -> Option<&'static RollupConfig> { - ROLLUP_CONFIGS.get(&chain_id) - } - - /// Returns a [`RollupConfig`] by its [`alloy_chains::Chain`] identifier. - pub fn rollup_config_by_chain(chain: &alloy_chains::Chain) -> Option<&'static RollupConfig> { - ROLLUP_CONFIGS.get(&chain.id()) - } - - /// Returns the `unsafe_block_signer` address for the given chain ID. - pub fn unsafe_block_signer(chain_id: u64) -> Option
{ - ChainConfig::by_chain_id(chain_id)?.unsafe_block_signer - } -} - -#[cfg(test)] -mod tests { - use alloy_chains::Chain as AlloyChain; - - use super::*; - - #[test] - fn unsafe_block_signer_mainnet() { - let signer = Registry::unsafe_block_signer(8453).unwrap(); - assert_eq!( - signer, - "0xAf6E19BE0F9cE7f8afd49a1824851023A8249e8a".parse::
().unwrap() - ); - } - - #[test] - fn unsafe_block_signer_sepolia() { - let signer = Registry::unsafe_block_signer(84532).unwrap(); - assert_eq!( - signer, - "0xb830b99c95Ea32300039624Cb567d324D4b1D83C".parse::
().unwrap() - ); - } - - #[test] - fn unsafe_block_signer_unknown_chain() { - assert!(Registry::unsafe_block_signer(99999).is_none()); - } - - #[test] - fn rollup_config_derived_from_chain_config() { - let mainnet = Registry::rollup_config(8453).unwrap(); - assert_eq!(*mainnet, ChainConfig::mainnet().rollup_config()); - - let sepolia = Registry::rollup_config(84532).unwrap(); - assert_eq!(*sepolia, ChainConfig::sepolia().rollup_config()); - } - - #[test] - fn rollup_config_by_chain() { - const ALLOY_BASE: AlloyChain = AlloyChain::base_mainnet(); - - let by_chain = Registry::rollup_config_by_chain(&ALLOY_BASE).unwrap(); - let by_id = Registry::rollup_config(8453).unwrap(); - - assert_eq!(by_chain, by_id); - } - - #[test] - fn jovian_timestamps() { - let base_mainnet = Registry::rollup_config(8453).unwrap(); - assert_eq!( - base_mainnet.hardforks.jovian_time, - Some(ChainConfig::mainnet().jovian_timestamp) - ); - - let base_sepolia = Registry::rollup_config(84532).unwrap(); - assert_eq!( - base_sepolia.hardforks.jovian_time, - Some(ChainConfig::sepolia().jovian_timestamp) - ); - } -} diff --git a/crates/common/chains/src/test_utils.rs b/crates/common/chains/src/test_utils.rs index b6d5cf7c43..26b831c303 100644 --- a/crates/common/chains/src/test_utils.rs +++ b/crates/common/chains/src/test_utils.rs @@ -3,12 +3,12 @@ use base_common_genesis::RollupConfig; use spin::Lazy; -use crate::ChainConfig; +use crate::{ChainConfig, rollup_config}; /// The [`RollupConfig`] for Base Mainnet, derived from [`ChainConfig::mainnet`]. pub static BASE_MAINNET_ROLLUP_CONFIG: Lazy = - Lazy::new(|| ChainConfig::mainnet().rollup_config()); + Lazy::new(|| rollup_config!(ChainConfig::MAINNET)); /// The [`RollupConfig`] for Base Sepolia, derived from [`ChainConfig::sepolia`]. pub static BASE_SEPOLIA_ROLLUP_CONFIG: Lazy = - Lazy::new(|| ChainConfig::sepolia().rollup_config()); + Lazy::new(|| rollup_config!(ChainConfig::SEPOLIA)); diff --git a/crates/common/chains/src/upgrades.rs b/crates/common/chains/src/upgrades.rs index 45d3a2124b..632cb375bd 100644 --- a/crates/common/chains/src/upgrades.rs +++ b/crates/common/chains/src/upgrades.rs @@ -1,4 +1,5 @@ use alloy_hardforks::{EthereumHardforks, ForkCondition}; +use alloy_primitives::Address; use base_common_genesis::RollupConfig; use crate::BaseUpgrade; @@ -10,6 +11,11 @@ pub trait Upgrades: EthereumHardforks { /// [`ForkCondition::Never`]. fn upgrade_activation(&self, fork: BaseUpgrade) -> ForkCondition; + /// Returns the activation registry admin address. + fn activation_admin_address(&self) -> Option
{ + None + } + /// Convenience method to check if [`BaseUpgrade::Bedrock`] is active at a given block /// number. fn is_bedrock_active_at_block(&self, block_number: u64) -> bool { diff --git a/crates/common/chains/tests/hardfork_consistency.rs b/crates/common/chains/tests/hardfork_consistency.rs index b65151aebb..82c6f03e68 100644 --- a/crates/common/chains/tests/hardfork_consistency.rs +++ b/crates/common/chains/tests/hardfork_consistency.rs @@ -1,4 +1,4 @@ -//! Integration tests verifying that the registry's rollup configs agree with chain hardfork +//! Integration tests verifying that the derived rollup configs agree with chain hardfork //! schedules for every [`BaseUpgrade`] variant. use base_common_chains::{ diff --git a/crates/common/consensus/Cargo.toml b/crates/common/consensus/Cargo.toml index 933be0473b..23b64ee58c 100644 --- a/crates/common/consensus/Cargo.toml +++ b/crates/common/consensus/Cargo.toml @@ -45,8 +45,7 @@ reth-codecs = { workspace = true, optional = true } reth-db-api = { workspace = true, optional = true } modular-bitfield = { workspace = true, optional = true } reth-zstd-compressors = { workspace = true, optional = true } -reth-primitives-traits = { workspace = true, optional = true, features = ["serde-bincode-compat"] } -reth-ethereum-primitives = { workspace = true, optional = true, features = ["serde-bincode-compat"] } +reth-primitives-traits = { workspace = true, optional = true } # firehose reth-firehose = { workspace = true } @@ -70,6 +69,8 @@ std = [ "alloy-rlp/std", "alloy-rpc-types-eth?/std", "alloy-serde?/std", + "reth-codecs?/std", + "reth-primitives-traits?/std", "reth-zstd-compressors?/std", "revm?/std", "serde?/std", @@ -88,7 +89,6 @@ arbitrary = [ "dep:arbitrary", "reth-codecs?/arbitrary", "reth-db-api?/arbitrary", - "reth-ethereum-primitives?/arbitrary", "reth-primitives-traits?/arbitrary", "revm?/arbitrary", "std", @@ -102,7 +102,6 @@ serde = [ "dep:alloy-serde", "dep:serde", "reth-codecs?/serde", - "reth-ethereum-primitives?/serde", "reth-primitives-traits?/serde", "revm?/serde", ] @@ -113,7 +112,6 @@ reth = [ "dep:modular-bitfield", "dep:reth-codecs", "dep:reth-db-api", - "dep:reth-ethereum-primitives", "dep:reth-primitives-traits", "dep:reth-zstd-compressors", "k256", diff --git a/crates/common/consensus/src/extra/encoder.rs b/crates/common/consensus/src/extra/encoder.rs new file mode 100644 index 0000000000..d45f1917c4 --- /dev/null +++ b/crates/common/consensus/src/extra/encoder.rs @@ -0,0 +1,56 @@ +use alloy_eips::eip1559::BaseFeeParams; +use alloy_primitives::B64; + +use super::{EIP1559ParamError, HoloceneExtraData}; + +/// Encoder for EIP-1559 extra-data parameters. +#[derive(Debug)] +pub struct EIP1559ParamEncoder; + +impl EIP1559ParamEncoder { + /// Encodes the EIP-1559 parameters into `extra_data`. + /// + /// If `eip_1559_params` is zero, uses `default_base_fee_params` instead. + /// Requires `extra_data` to be at least 9 bytes. + pub fn encode( + eip_1559_params: B64, + default_base_fee_params: BaseFeeParams, + extra_data: &mut [u8], + ) -> Result<(), EIP1559ParamError> { + if extra_data.len() < 9 { + return Err(EIP1559ParamError::InvalidExtraDataLength); + } + if eip_1559_params.is_zero() { + let max_change_denominator: u32 = (default_base_fee_params.max_change_denominator) + .try_into() + .map_err(|_| EIP1559ParamError::DenominatorOverflow)?; + let elasticity_multiplier: u32 = (default_base_fee_params.elasticity_multiplier) + .try_into() + .map_err(|_| EIP1559ParamError::ElasticityOverflow)?; + extra_data[1..5].copy_from_slice(&max_change_denominator.to_be_bytes()); + extra_data[5..9].copy_from_slice(&elasticity_multiplier.to_be_bytes()); + } else { + let (elasticity, denominator) = HoloceneExtraData::decode_params(eip_1559_params); + extra_data[1..5].copy_from_slice(&denominator.to_be_bytes()); + extra_data[5..9].copy_from_slice(&elasticity.to_be_bytes()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use alloy_eips::eip1559::BaseFeeParams; + use alloy_primitives::B64; + + use super::EIP1559ParamEncoder; + use crate::extra::EIP1559ParamError; + + #[test] + fn test_encode_eip_1559_params_invalid_length() { + let mut extra_data = [0u8; 8]; + let result = + EIP1559ParamEncoder::encode(B64::ZERO, BaseFeeParams::new(80, 60), &mut extra_data); + assert_eq!(result.unwrap_err(), EIP1559ParamError::InvalidExtraDataLength); + } +} diff --git a/crates/common/consensus/src/extra/error.rs b/crates/common/consensus/src/extra/error.rs new file mode 100644 index 0000000000..caa0ca43da --- /dev/null +++ b/crates/common/consensus/src/extra/error.rs @@ -0,0 +1,28 @@ +/// Error type for EIP-1559 parameters. +#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] +pub enum EIP1559ParamError { + /// Thrown if the extra data begins with the wrong version byte. + #[error("Invalid EIP1559 version byte: {0}")] + InvalidVersion(u8), + /// No EIP-1559 parameters provided. + #[error("No EIP1559 parameters provided")] + NoEIP1559Params, + /// Denominator overflow. + #[error("Denominator overflow")] + DenominatorOverflow, + /// Elasticity overflow. + #[error("Elasticity overflow")] + ElasticityOverflow, + /// Extra data is not the correct length. + #[error("Extra data is not the correct length")] + InvalidExtraDataLength, + /// Invalid EIP-1559 parameter combination. + #[error("EIP-1559 denominator and elasticity must both be zero or both be non-zero")] + InvalidParams, + /// Minimum base fee must be None before Jovian. + #[error("Minimum base fee must be None before Jovian")] + MinBaseFeeMustBeNone, + /// Minimum base fee cannot be None after Jovian. + #[error("Minimum base fee cannot be None after Jovian")] + MinBaseFeeNotSet, +} diff --git a/crates/common/consensus/src/extra/holocene.rs b/crates/common/consensus/src/extra/holocene.rs index fb4e5968e3..4fd69093b6 100644 --- a/crates/common/consensus/src/extra/holocene.rs +++ b/crates/common/consensus/src/extra/holocene.rs @@ -1,7 +1,7 @@ use alloy_eips::eip1559::BaseFeeParams; use alloy_primitives::{B64, Bytes}; -use super::{EIP1559ParamError, encode_eip_1559_params}; +use super::{EIP1559ParamEncoder, EIP1559ParamError}; const VERSION_BYTE: u8 = 0; @@ -49,7 +49,7 @@ impl HoloceneExtraData { default_base_fee_params: BaseFeeParams, ) -> Result { let mut extra_data = [0u8; 9]; - encode_eip_1559_params(eip_1559_params, default_base_fee_params, &mut extra_data)?; + EIP1559ParamEncoder::encode(eip_1559_params, default_base_fee_params, &mut extra_data)?; Ok(Bytes::copy_from_slice(&extra_data)) } } diff --git a/crates/common/consensus/src/extra/jovian.rs b/crates/common/consensus/src/extra/jovian.rs index 0766bae51d..f57161e75f 100644 --- a/crates/common/consensus/src/extra/jovian.rs +++ b/crates/common/consensus/src/extra/jovian.rs @@ -1,7 +1,7 @@ use alloy_eips::eip1559::BaseFeeParams; use alloy_primitives::{B64, Bytes}; -use super::{EIP1559ParamError, encode_eip_1559_params}; +use super::{EIP1559ParamEncoder, EIP1559ParamError}; const VERSION_BYTE: u8 = 1; @@ -50,7 +50,7 @@ impl JovianExtraData { ) -> Result { let mut extra_data = [0u8; 17]; extra_data[0] = VERSION_BYTE; - encode_eip_1559_params(eip_1559_params, default_base_fee_params, &mut extra_data)?; + EIP1559ParamEncoder::encode(eip_1559_params, default_base_fee_params, &mut extra_data)?; extra_data[9..17].copy_from_slice(&min_base_fee.to_be_bytes()); Ok(Bytes::copy_from_slice(&extra_data)) } diff --git a/crates/common/consensus/src/extra/mod.rs b/crates/common/consensus/src/extra/mod.rs index 8523b07aef..00db92fb17 100644 --- a/crates/common/consensus/src/extra/mod.rs +++ b/crates/common/consensus/src/extra/mod.rs @@ -1,82 +1,13 @@ //! Block extra-data encodings for Holocene and Jovian fork upgrades. +mod encoder; +pub use encoder::EIP1559ParamEncoder; + +mod error; +pub use error::EIP1559ParamError; + mod holocene; pub use holocene::HoloceneExtraData; mod jovian; -use alloy_eips::eip1559::BaseFeeParams; -use alloy_primitives::B64; pub use jovian::JovianExtraData; - -/// Error type for EIP-1559 parameters. -#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] -pub enum EIP1559ParamError { - /// Thrown if the extra data begins with the wrong version byte. - #[error("Invalid EIP1559 version byte: {0}")] - InvalidVersion(u8), - /// No EIP-1559 parameters provided. - #[error("No EIP1559 parameters provided")] - NoEIP1559Params, - /// Denominator overflow. - #[error("Denominator overflow")] - DenominatorOverflow, - /// Elasticity overflow. - #[error("Elasticity overflow")] - ElasticityOverflow, - /// Extra data is not the correct length. - #[error("Extra data is not the correct length")] - InvalidExtraDataLength, - /// Invalid EIP-1559 parameter combination. - #[error("EIP-1559 denominator and elasticity must both be zero or both be non-zero")] - InvalidParams, - /// Minimum base fee must be None before Jovian. - #[error("Minimum base fee must be None before Jovian")] - MinBaseFeeMustBeNone, - /// Minimum base fee cannot be None after Jovian. - #[error("Minimum base fee cannot be None after Jovian")] - MinBaseFeeNotSet, -} - -/// Encodes the EIP-1559 parameters into `extra_data`. -/// -/// If `eip_1559_params` is zero, uses `default_base_fee_params` instead. -/// Requires `extra_data` to be at least 9 bytes. -fn encode_eip_1559_params( - eip_1559_params: B64, - default_base_fee_params: BaseFeeParams, - extra_data: &mut [u8], -) -> Result<(), EIP1559ParamError> { - if extra_data.len() < 9 { - return Err(EIP1559ParamError::InvalidExtraDataLength); - } - if eip_1559_params.is_zero() { - let max_change_denominator: u32 = (default_base_fee_params.max_change_denominator) - .try_into() - .map_err(|_| EIP1559ParamError::DenominatorOverflow)?; - let elasticity_multiplier: u32 = (default_base_fee_params.elasticity_multiplier) - .try_into() - .map_err(|_| EIP1559ParamError::ElasticityOverflow)?; - extra_data[1..5].copy_from_slice(&max_change_denominator.to_be_bytes()); - extra_data[5..9].copy_from_slice(&elasticity_multiplier.to_be_bytes()); - } else { - let (elasticity, denominator) = HoloceneExtraData::decode_params(eip_1559_params); - extra_data[1..5].copy_from_slice(&denominator.to_be_bytes()); - extra_data[5..9].copy_from_slice(&elasticity.to_be_bytes()); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use alloy_eips::eip1559::BaseFeeParams; - use alloy_primitives::B64; - - use super::{EIP1559ParamError, encode_eip_1559_params}; - - #[test] - fn test_encode_eip_1559_params_invalid_length() { - let mut extra_data = [0u8; 8]; - let result = encode_eip_1559_params(B64::ZERO, BaseFeeParams::new(80, 60), &mut extra_data); - assert_eq!(result.unwrap_err(), EIP1559ParamError::InvalidExtraDataLength); - } -} diff --git a/crates/common/consensus/src/lib.rs b/crates/common/consensus/src/lib.rs index 3499f7343e..f44eaa824c 100644 --- a/crates/common/consensus/src/lib.rs +++ b/crates/common/consensus/src/lib.rs @@ -10,6 +10,9 @@ extern crate alloc; +#[cfg(feature = "evm")] +use revm as _; + #[cfg(feature = "reth")] mod reth_compat; #[cfg(feature = "reth")] @@ -24,12 +27,15 @@ mod transaction; #[cfg(feature = "serde")] pub use transaction::serde_deposit_tx_rpc; pub use transaction::{ - BasePooledTransaction, BaseTransaction, BaseTransactionInfo, BaseTxEnvelope, - BaseTypedTransaction, DEPOSIT_TX_TYPE_ID, DepositInfo, DepositTransaction, OpTxType, TxDeposit, + AccountChange, BasePooledTransaction, BaseTransaction, BaseTransactionInfo, BaseTxEnvelope, + BaseTypedTransaction, Call, ConfigChange, CreateEntry, DEPOSIT_TX_TYPE_ID, Delegation, + DepositInfo, DepositTransaction, EIP8130_REJECTION_MSG, EIP8130_TX_TYPE_ID, Eip8130Constants, + Eip8130Signed, InitialOwner, OpTxType, OwnerChange, OwnerChangeType, Scope, TxDeposit, + TxEip8130, }; mod extra; -pub use extra::{EIP1559ParamError, HoloceneExtraData, JovianExtraData}; +pub use extra::{EIP1559ParamEncoder, EIP1559ParamError, HoloceneExtraData, JovianExtraData}; mod source; pub use source::{ diff --git a/crates/common/consensus/src/receipts/envelope.rs b/crates/common/consensus/src/receipts/envelope.rs index f13516957e..ad1189a10a 100644 --- a/crates/common/consensus/src/receipts/envelope.rs +++ b/crates/common/consensus/src/receipts/envelope.rs @@ -49,6 +49,12 @@ pub enum BaseReceiptEnvelope { /// [deposit]: https://specs.base.org/protocol/bridging/deposits #[cfg_attr(feature = "serde", serde(rename = "0x7e", alias = "0x7E"))] Deposit(ReceiptWithBloom), + /// Receipt envelope with type flag 125, containing an [EIP-8130] Account + /// Abstraction receipt. + /// + /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + #[cfg_attr(feature = "serde", serde(rename = "0x7d", alias = "0x7D"))] + Eip8130(ReceiptWithBloom>), } impl BaseReceiptEnvelope { @@ -78,6 +84,9 @@ impl BaseReceiptEnvelope { OpTxType::Eip7702 => { Self::Eip7702(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) } + OpTxType::Eip8130 => { + Self::Eip8130(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } OpTxType::Deposit => { let inner = DepositReceiptWithBloom { receipt: DepositReceipt { @@ -101,6 +110,7 @@ impl BaseReceiptEnvelope { Self::Eip2930(_) => OpTxType::Eip2930, Self::Eip1559(_) => OpTxType::Eip1559, Self::Eip7702(_) => OpTxType::Eip7702, + Self::Eip8130(_) => OpTxType::Eip8130, Self::Deposit(_) => OpTxType::Deposit, } } @@ -133,9 +143,11 @@ impl BaseReceiptEnvelope { /// Return the receipt's bloom. pub const fn logs_bloom(&self) -> &Bloom { match self { - Self::Legacy(t) | Self::Eip2930(t) | Self::Eip1559(t) | Self::Eip7702(t) => { - &t.logs_bloom - } + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip7702(t) + | Self::Eip8130(t) => &t.logs_bloom, Self::Deposit(t) => &t.logs_bloom, } } @@ -169,7 +181,11 @@ impl BaseReceiptEnvelope { /// Consumes the type and returns the underlying [`Receipt`]. pub fn into_receipt(self) -> Receipt { match self { - Self::Legacy(t) | Self::Eip2930(t) | Self::Eip1559(t) | Self::Eip7702(t) => t.receipt, + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip7702(t) + | Self::Eip8130(t) => t.receipt, Self::Deposit(t) => t.receipt.into_inner(), } } @@ -178,9 +194,11 @@ impl BaseReceiptEnvelope { /// receipt types may be added. pub const fn as_receipt(&self) -> Option<&Receipt> { match self { - Self::Legacy(t) | Self::Eip2930(t) | Self::Eip1559(t) | Self::Eip7702(t) => { - Some(&t.receipt) - } + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip7702(t) + | Self::Eip8130(t) => Some(&t.receipt), Self::Deposit(t) => Some(&t.receipt.inner), } } @@ -190,7 +208,11 @@ impl BaseReceiptEnvelope { /// Get the length of the inner receipt in the 2718 encoding. pub fn inner_length(&self) -> usize { match self { - Self::Legacy(t) | Self::Eip2930(t) | Self::Eip1559(t) | Self::Eip7702(t) => t.length(), + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip7702(t) + | Self::Eip8130(t) => t.length(), Self::Deposit(t) => t.length(), } } @@ -264,6 +286,7 @@ impl Typed2718 for BaseReceiptEnvelope { Self::Eip2930(_) => OpTxType::Eip2930, Self::Eip1559(_) => OpTxType::Eip1559, Self::Eip7702(_) => OpTxType::Eip7702, + Self::Eip8130(_) => OpTxType::Eip8130, Self::Deposit(_) => OpTxType::Deposit, }; ty as u8 @@ -287,10 +310,12 @@ impl Encodable2718 for BaseReceiptEnvelope { Some(ty) => out.put_u8(ty), } match self { + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip7702(t) + | Self::Eip8130(t) => t.encode(out), Self::Deposit(t) => t.encode(out), - Self::Legacy(t) | Self::Eip2930(t) | Self::Eip1559(t) | Self::Eip7702(t) => { - t.encode(out) - } } } } @@ -305,6 +330,7 @@ impl Decodable2718 for BaseReceiptEnvelope { OpTxType::Eip1559 => Ok(Self::Eip1559(Decodable::decode(buf)?)), OpTxType::Eip7702 => Ok(Self::Eip7702(Decodable::decode(buf)?)), OpTxType::Eip2930 => Ok(Self::Eip2930(Decodable::decode(buf)?)), + OpTxType::Eip8130 => Ok(Self::Eip8130(Decodable::decode(buf)?)), OpTxType::Deposit => Ok(Self::Deposit(Decodable::decode(buf)?)), } } @@ -329,12 +355,13 @@ impl From for Receipt { #[cfg(all(test, feature = "arbitrary"))] impl<'a> arbitrary::Arbitrary<'a> for BaseReceiptEnvelope { fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { - match u.int_in_range(0..=4)? { + match u.int_in_range(0..=5)? { 0 => Ok(Self::Legacy(ReceiptWithBloom::arbitrary(u)?)), 1 => Ok(Self::Eip2930(ReceiptWithBloom::arbitrary(u)?)), 2 => Ok(Self::Eip1559(ReceiptWithBloom::arbitrary(u)?)), 3 => Ok(Self::Eip7702(ReceiptWithBloom::arbitrary(u)?)), - _ => Ok(Self::Deposit(DepositReceiptWithBloom::arbitrary(u)?)), + 4 => Ok(Self::Deposit(DepositReceiptWithBloom::arbitrary(u)?)), + _ => Ok(Self::Eip8130(ReceiptWithBloom::arbitrary(u)?)), } } } diff --git a/crates/common/consensus/src/receipts/receipt.rs b/crates/common/consensus/src/receipts/receipt.rs index 836b1087db..73a30b19fd 100644 --- a/crates/common/consensus/src/receipts/receipt.rs +++ b/crates/common/consensus/src/receipts/receipt.rs @@ -37,6 +37,9 @@ pub enum BaseReceipt { /// Deposit receipt #[cfg_attr(feature = "serde", serde(rename = "0x7e", alias = "0x7E"))] Deposit(DepositReceipt), + /// EIP-8130 Account Abstraction receipt + #[cfg_attr(feature = "serde", serde(rename = "0x7d", alias = "0x7D"))] + Eip8130(Receipt), } impl BaseReceipt { @@ -47,6 +50,7 @@ impl BaseReceipt { Self::Eip2930(_) => OpTxType::Eip2930, Self::Eip1559(_) => OpTxType::Eip1559, Self::Eip7702(_) => OpTxType::Eip7702, + Self::Eip8130(_) => OpTxType::Eip8130, Self::Deposit(_) => OpTxType::Deposit, } } @@ -57,7 +61,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt, + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt, Self::Deposit(receipt) => &receipt.inner, } } @@ -68,7 +73,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt, + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt, Self::Deposit(receipt) => &mut receipt.inner, } } @@ -79,7 +85,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt, + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt, Self::Deposit(receipt) => receipt.inner, } } @@ -93,6 +100,7 @@ impl BaseReceipt { Self::Eip2930(receipt) => BaseReceipt::Eip2930(receipt.map_logs(f)), Self::Eip1559(receipt) => BaseReceipt::Eip1559(receipt.map_logs(f)), Self::Eip7702(receipt) => BaseReceipt::Eip7702(receipt.map_logs(f)), + Self::Eip8130(receipt) => BaseReceipt::Eip8130(receipt.map_logs(f)), Self::Deposit(receipt) => BaseReceipt::Deposit(receipt.map_logs(f)), } } @@ -106,7 +114,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt.rlp_encoded_fields_length_with_bloom(bloom), + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt.rlp_encoded_fields_length_with_bloom(bloom), Self::Deposit(receipt) => receipt.rlp_encoded_fields_length_with_bloom(bloom), } } @@ -120,7 +129,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt.rlp_encode_fields_with_bloom(bloom, out), + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt.rlp_encode_fields_with_bloom(bloom, out), Self::Deposit(receipt) => receipt.rlp_encode_fields_with_bloom(bloom, out), } } @@ -171,6 +181,11 @@ impl BaseReceipt { RlpDecodableReceipt::rlp_decode_with_bloom(buf)?; Ok(ReceiptWithBloom { receipt: Self::Eip7702(receipt), logs_bloom }) } + OpTxType::Eip8130 => { + let ReceiptWithBloom { receipt, logs_bloom } = + RlpDecodableReceipt::rlp_decode_with_bloom(buf)?; + Ok(ReceiptWithBloom { receipt: Self::Eip8130(receipt), logs_bloom }) + } OpTxType::Deposit => { let ReceiptWithBloom { receipt, logs_bloom } = RlpDecodableReceipt::rlp_decode_with_bloom(buf)?; @@ -189,7 +204,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => { + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => { receipt.status.encode(out); receipt.cumulative_gas_used.encode(out); receipt.logs.encode(out); @@ -218,7 +234,8 @@ impl BaseReceipt { Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => { + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => { receipt.status.length() + receipt.cumulative_gas_used.length() + receipt.logs.length() @@ -246,7 +263,6 @@ impl BaseReceipt { let mut deposit_nonce = None; let mut deposit_receipt_version = None; - // For deposit receipts, try to decode nonce and version if they exist if tx_type == OpTxType::Deposit && !buf.is_empty() { deposit_nonce = Some(Decodable::decode(buf)?); if !buf.is_empty() { @@ -259,6 +275,7 @@ impl BaseReceipt { OpTxType::Eip2930 => Ok(Self::Eip2930(Receipt { status, cumulative_gas_used, logs })), OpTxType::Eip1559 => Ok(Self::Eip1559(Receipt { status, cumulative_gas_used, logs })), OpTxType::Eip7702 => Ok(Self::Eip7702(Receipt { status, cumulative_gas_used, logs })), + OpTxType::Eip8130 => Ok(Self::Eip8130(Receipt { status, cumulative_gas_used, logs })), OpTxType::Deposit => Ok(Self::Deposit(DepositReceipt { inner: Receipt { status, cumulative_gas_used, logs }, deposit_nonce, @@ -403,7 +420,8 @@ impl> TxReceipt for BaseReceipt Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt.logs, + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt.logs, Self::Deposit(receipt) => receipt.inner.logs, } } @@ -444,6 +462,7 @@ impl From for BaseReceipt { super::BaseReceiptEnvelope::Eip2930(receipt) => Self::Eip2930(receipt.receipt), super::BaseReceiptEnvelope::Eip1559(receipt) => Self::Eip1559(receipt.receipt), super::BaseReceiptEnvelope::Eip7702(receipt) => Self::Eip7702(receipt.receipt), + super::BaseReceiptEnvelope::Eip8130(receipt) => Self::Eip8130(receipt.receipt), super::BaseReceiptEnvelope::Deposit(receipt) => Self::Deposit(DepositReceipt { deposit_nonce: receipt.receipt.deposit_nonce, deposit_receipt_version: receipt.receipt.deposit_receipt_version, @@ -467,6 +486,9 @@ impl From> for BaseReceiptEnvelope { BaseReceipt::Eip7702(receipt) => { Self::Eip7702(ReceiptWithBloom { receipt, logs_bloom }) } + BaseReceipt::Eip8130(receipt) => { + Self::Eip8130(ReceiptWithBloom { receipt, logs_bloom }) + } BaseReceipt::Deposit(receipt) => { Self::Deposit(ReceiptWithBloom { receipt, logs_bloom }) } @@ -507,6 +529,8 @@ pub(super) mod serde_bincode_compat { Eip7702(alloy_consensus::serde_bincode_compat::Receipt<'a, alloy_primitives::Log>), /// Deposit receipt Deposit(crate::serde_bincode_compat::DepositReceipt<'a, alloy_primitives::Log>), + /// EIP-8130 Account Abstraction receipt + Eip8130(alloy_consensus::serde_bincode_compat::Receipt<'a, alloy_primitives::Log>), } impl<'a> From<&'a super::BaseReceipt> for BaseReceipt<'a> { @@ -516,6 +540,7 @@ pub(super) mod serde_bincode_compat { super::BaseReceipt::Eip2930(receipt) => Self::Eip2930(receipt.into()), super::BaseReceipt::Eip1559(receipt) => Self::Eip1559(receipt.into()), super::BaseReceipt::Eip7702(receipt) => Self::Eip7702(receipt.into()), + super::BaseReceipt::Eip8130(receipt) => Self::Eip8130(receipt.into()), super::BaseReceipt::Deposit(receipt) => Self::Deposit(receipt.into()), } } @@ -528,6 +553,7 @@ pub(super) mod serde_bincode_compat { BaseReceipt::Eip2930(receipt) => Self::Eip2930(receipt.into()), BaseReceipt::Eip1559(receipt) => Self::Eip1559(receipt.into()), BaseReceipt::Eip7702(receipt) => Self::Eip7702(receipt.into()), + BaseReceipt::Eip8130(receipt) => Self::Eip8130(receipt.into()), BaseReceipt::Deposit(receipt) => Self::Deposit(receipt.into()), } } @@ -597,7 +623,8 @@ where Self::Legacy(receipt) | Self::Eip2930(receipt) | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt.size(), + | Self::Eip7702(receipt) + | Self::Eip8130(receipt) => receipt.size(), Self::Deposit(receipt) => receipt.size(), } } diff --git a/crates/common/consensus/src/reth_compat.rs b/crates/common/consensus/src/reth_compat.rs index 5b6975e027..1687b80d42 100644 --- a/crates/common/consensus/src/reth_compat.rs +++ b/crates/common/consensus/src/reth_compat.rs @@ -1,10 +1,9 @@ -//! Reth compatibility implementations for base-alloy consensus types. +//! Reth compatibility implementations for Base consensus types. //! //! This module provides implementations of reth traits gated behind the `reth` feature flag, -//! including `InMemorySize`, `SignedTransaction`, `SerdeBincodeCompat`, `Compact`, -//! `Envelope`, `ToTxCompact`, `FromTxCompact`, `Compress`, and `Decompress`. +//! including `Compact`, `Envelope`, `ToTxCompact`, `FromTxCompact`, `Compress`, and +//! `Decompress`. -// Ensure `reth-ethereum-primitives` serde-bincode-compat feature is activated. use alloc::{borrow::Cow, vec::Vec}; use alloy_consensus::{ @@ -14,132 +13,18 @@ use alloy_consensus::{ use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; use bytes::{Buf, BufMut}; use reth_codecs::{ - Compact, CompactZstd, + Compact, CompactZstd, DecompressError, txtype::{ COMPACT_EXTENDED_IDENTIFIER_FLAG, COMPACT_IDENTIFIER_EIP1559, COMPACT_IDENTIFIER_EIP2930, COMPACT_IDENTIFIER_LEGACY, }, }; -use reth_ethereum_primitives as _; use crate::{ - BaseBlock, BasePooledTransaction, BaseReceipt, BaseTxEnvelope, BaseTypedTransaction, - DEPOSIT_TX_TYPE_ID, DepositReceipt, OpTxType, TxDeposit, + BaseBlock, BaseReceipt, BaseTxEnvelope, BaseTypedTransaction, DEPOSIT_TX_TYPE_ID, + DepositReceipt, EIP8130_TX_TYPE_ID, OpTxType, TxDeposit, }; -// --------------------------------------------------------------------------- -// InMemorySize (reth-primitives-traits) -// --------------------------------------------------------------------------- - -impl reth_primitives_traits::InMemorySize for OpTxType { - #[inline] - fn size(&self) -> usize { - core::mem::size_of::() - } -} - -impl reth_primitives_traits::InMemorySize for TxDeposit { - #[inline] - fn size(&self) -> usize { - Self::size(self) - } -} - -impl reth_primitives_traits::InMemorySize for DepositReceipt { - fn size(&self) -> usize { - self.inner.size() - + core::mem::size_of_val(&self.deposit_nonce) - + core::mem::size_of_val(&self.deposit_receipt_version) - } -} - -impl reth_primitives_traits::InMemorySize for BaseReceipt { - fn size(&self) -> usize { - match self { - Self::Legacy(receipt) - | Self::Eip2930(receipt) - | Self::Eip1559(receipt) - | Self::Eip7702(receipt) => receipt.size(), - Self::Deposit(receipt) => receipt.size(), - } - } -} - -impl reth_primitives_traits::InMemorySize for BaseTypedTransaction { - fn size(&self) -> usize { - match self { - Self::Legacy(tx) => tx.size(), - Self::Eip2930(tx) => tx.size(), - Self::Eip1559(tx) => tx.size(), - Self::Eip7702(tx) => tx.size(), - Self::Deposit(tx) => tx.size(), - } - } -} - -impl reth_primitives_traits::InMemorySize for BasePooledTransaction { - fn size(&self) -> usize { - match self { - Self::Legacy(tx) => tx.size(), - Self::Eip2930(tx) => tx.size(), - Self::Eip1559(tx) => tx.size(), - Self::Eip7702(tx) => tx.size(), - } - } -} - -impl reth_primitives_traits::InMemorySize for BaseTxEnvelope { - fn size(&self) -> usize { - match self { - Self::Legacy(tx) => tx.size(), - Self::Eip2930(tx) => tx.size(), - Self::Eip1559(tx) => tx.size(), - Self::Eip7702(tx) => tx.size(), - Self::Deposit(tx) => tx.size(), - } - } -} - -// --------------------------------------------------------------------------- -// SignedTransaction (reth-primitives-traits) -// --------------------------------------------------------------------------- - -impl reth_primitives_traits::SignedTransaction for BasePooledTransaction {} - -impl reth_primitives_traits::SignedTransaction for BaseTxEnvelope { - fn is_system_tx(&self) -> bool { - self.is_system_transaction() - } -} - -// --------------------------------------------------------------------------- -// SerdeBincodeCompat (reth-primitives-traits) -// --------------------------------------------------------------------------- - -impl reth_primitives_traits::serde_bincode_compat::SerdeBincodeCompat for BaseTxEnvelope { - type BincodeRepr<'a> = crate::serde_bincode_compat::transaction::BaseTxEnvelope<'a>; - - fn as_repr(&self) -> Self::BincodeRepr<'_> { - self.into() - } - - fn from_repr(repr: Self::BincodeRepr<'_>) -> Self { - repr.into() - } -} - -impl reth_primitives_traits::serde_bincode_compat::SerdeBincodeCompat for BaseReceipt { - type BincodeRepr<'a> = crate::serde_bincode_compat::BaseReceipt<'a>; - - fn as_repr(&self) -> Self::BincodeRepr<'_> { - self.into() - } - - fn from_repr(repr: Self::BincodeRepr<'_>) -> Self { - repr.into() - } -} - // --------------------------------------------------------------------------- // Compact – TxDeposit // --------------------------------------------------------------------------- @@ -227,6 +112,10 @@ impl Compact for OpTxType { buf.put_u8(DEPOSIT_TX_TYPE_ID); COMPACT_EXTENDED_IDENTIFIER_FLAG } + Self::Eip8130 => { + buf.put_u8(EIP8130_TX_TYPE_ID); + COMPACT_EXTENDED_IDENTIFIER_FLAG + } } } @@ -241,6 +130,7 @@ impl Compact for OpTxType { match extended_identifier { EIP7702_TX_TYPE_ID => Self::Eip7702, DEPOSIT_TX_TYPE_ID => Self::Deposit, + EIP8130_TX_TYPE_ID => Self::Eip8130, _ => panic!("Unsupported OpTxType identifier: {extended_identifier}"), } } @@ -267,6 +157,9 @@ impl Compact for BaseTypedTransaction { Self::Eip1559(tx) => tx.to_compact(out), Self::Eip7702(tx) => tx.to_compact(out), Self::Deposit(tx) => tx.to_compact(out), + Self::Eip8130(_) => unimplemented!( + "Compact encoding for EIP-8130 BaseTypedTransaction is not yet implemented" + ), }; identifier } @@ -294,6 +187,9 @@ impl Compact for BaseTypedTransaction { let (tx, buf) = Compact::from_compact(buf, buf.len()); (Self::Deposit(tx), buf) } + OpTxType::Eip8130 => unimplemented!( + "Compact decoding for EIP-8130 BaseTypedTransaction is not yet implemented" + ), } } } @@ -310,6 +206,9 @@ impl reth_codecs::alloy::transaction::ToTxCompact for BaseTxEnvelope { Self::Eip1559(tx) => tx.tx().to_compact(buf), Self::Eip7702(tx) => tx.tx().to_compact(buf), Self::Deposit(tx) => tx.to_compact(buf), + Self::Eip8130(_) => unimplemented!( + "Compact encoding for EIP-8130 BaseTxEnvelope is not yet implemented" + ), }; } } @@ -344,6 +243,9 @@ impl reth_codecs::alloy::transaction::FromTxCompact for BaseTxEnvelope { let tx = Sealed::new(tx); (Self::Deposit(tx), buf) } + OpTxType::Eip8130 => unimplemented!( + "Compact decoding for EIP-8130 BaseTxEnvelope is not yet implemented" + ), } } } @@ -362,7 +264,12 @@ impl reth_codecs::alloy::transaction::Envelope for BaseTxEnvelope { Self::Eip2930(tx) => tx.signature(), Self::Eip1559(tx) => tx.signature(), Self::Eip7702(tx) => tx.signature(), - Self::Deposit(_) => &DEPOSIT_SIGNATURE, + // The `Envelope` trait forces a `&Signature` return, so neither variant can + // signal absence the way `BaseTxEnvelope::signature` (which returns `Option`) + // does. Both Deposit and EIP-8130 AA transactions carry their own auth model + // and have no meaningful ECDSA signature: callers MUST NOT feed this value + // into ECDSA recovery — it is an all-zero placeholder. + Self::Deposit(_) | Self::Eip8130(_) => &DEPOSIT_SIGNATURE, } } @@ -411,7 +318,6 @@ struct CompactBaseReceipt<'a> { impl<'a> From<&'a BaseReceipt> for CompactBaseReceipt<'a> { fn from(receipt: &'a BaseReceipt) -> Self { Self { - tx_type: receipt.tx_type(), success: receipt.status(), cumulative_gas_used: receipt.cumulative_gas_used(), logs: Cow::Borrowed(&receipt.as_receipt().logs), @@ -425,6 +331,7 @@ impl<'a> From<&'a BaseReceipt> for CompactBaseReceipt<'a> { } else { None }, + tx_type: receipt.tx_type(), } } } @@ -451,6 +358,9 @@ impl From> for BaseReceipt { OpTxType::Deposit => { Self::Deposit(DepositReceipt { inner, deposit_nonce, deposit_receipt_version }) } + OpTxType::Eip8130 => { + unimplemented!("Compact decoding for EIP-8130 BaseReceipt is not yet implemented") + } } } } @@ -482,7 +392,7 @@ impl reth_db_api::table::Compress for BaseTxEnvelope { } impl reth_db_api::table::Decompress for BaseTxEnvelope { - fn decompress(value: &[u8]) -> Result { + fn decompress(value: &[u8]) -> Result { let (obj, _) = Compact::from_compact(value, value.len()); Ok(obj) } @@ -497,7 +407,7 @@ impl reth_db_api::table::Compress for BaseReceipt { } impl reth_db_api::table::Decompress for BaseReceipt { - fn decompress(value: &[u8]) -> Result { + fn decompress(value: &[u8]) -> Result { let (obj, _) = Compact::from_compact(value, value.len()); Ok(obj) } diff --git a/crates/common/consensus/src/transaction/eip8130/account_changes.rs b/crates/common/consensus/src/transaction/eip8130/account_changes.rs new file mode 100644 index 0000000000..7116da80bb --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/account_changes.rs @@ -0,0 +1,438 @@ +//! [EIP-8130] `account_changes` entry types. +//! +//! An [`AccountChange`] is a tagged-union entry inside `TxEip8130::account_changes`. +//! On the wire, each entry is encoded as `type_byte || rlp([entry_fields...])`. +//! +//! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + +use alloc::vec::Vec; + +use alloy_primitives::{Address, B256, Bytes}; +use alloy_rlp::{ + Buf, BufMut, Decodable, Encodable, Header, RlpDecodable, RlpEncodable, length_of_length, +}; + +use crate::transaction::eip8130::constants::Eip8130Constants; + +/// Bitmask describing the contexts in which an owner is valid. +/// +/// On the wire, `Scope` is encoded as a single RLP-encoded byte (matching the +/// EIP-8130 `uint8` spec), not as a one-element list. The derived RLP impls +/// from `alloy_rlp` would wrap the inner byte in a list header, so the +/// `Encodable`/`Decodable` impls are written by hand. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +pub struct Scope(pub u8); + +impl Encodable for Scope { + fn encode(&self, out: &mut dyn BufMut) { + self.0.encode(out); + } + + fn length(&self) -> usize { + self.0.length() + } +} + +impl Decodable for Scope { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + u8::decode(buf).map(Self) + } +} + +impl Scope { + /// Unrestricted scope (owner valid in all contexts). + pub const UNRESTRICTED: Self = Self(Eip8130Constants::SCOPE_UNRESTRICTED); + + /// Returns the raw bitmask. + pub const fn bits(&self) -> u8 { + self.0 + } + + /// Returns true if the scope grants the `SCOPE_SIGNATURE` context. + pub const fn has_signature(&self) -> bool { + self.0 & Eip8130Constants::SCOPE_SIGNATURE != 0 + } + + /// Returns true if the scope grants the `SCOPE_SENDER` context. + pub const fn has_sender(&self) -> bool { + self.0 & Eip8130Constants::SCOPE_SENDER != 0 + } + + /// Returns true if the scope grants the `SCOPE_PAYER` context. + pub const fn has_payer(&self) -> bool { + self.0 & Eip8130Constants::SCOPE_PAYER != 0 + } + + /// Returns true if the scope grants the `SCOPE_CONFIG` context. + pub const fn has_config(&self) -> bool { + self.0 & Eip8130Constants::SCOPE_CONFIG != 0 + } +} + +/// Initial owner installed on a newly-created account. +#[derive(Debug, Clone, PartialEq, Eq, Hash, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct InitialOwner { + /// Address of the verifier contract (e.g. an ERC-1271 verifier). + pub verifier: Address, + /// Owner identifier passed to the verifier. + pub owner_id: B256, + /// Scope bitmask granted to this owner. + pub scope: Scope, +} + +/// Operation performed by an [`OwnerChange`] inside a [`ConfigChange`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum OwnerChangeType { + /// Authorize a new owner (op byte `0x01`). + Authorize, + /// Revoke an existing owner (op byte `0x02`). + Revoke, +} + +impl OwnerChangeType { + /// Returns the on-wire op byte. + pub const fn op_byte(&self) -> u8 { + match self { + Self::Authorize => Eip8130Constants::OWNER_CHANGE_AUTHORIZE, + Self::Revoke => Eip8130Constants::OWNER_CHANGE_REVOKE, + } + } + + /// Parses a wire op byte. + pub const fn from_op_byte(byte: u8) -> Option { + match byte { + Eip8130Constants::OWNER_CHANGE_AUTHORIZE => Some(Self::Authorize), + Eip8130Constants::OWNER_CHANGE_REVOKE => Some(Self::Revoke), + _ => None, + } + } +} + +/// A single owner authorization or revocation inside a [`ConfigChange`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct OwnerChange { + /// Operation (authorize / revoke). + pub change_type: OwnerChangeType, + /// Verifier contract address. + pub verifier: Address, + /// Owner identifier. + pub owner_id: B256, + /// Scope bitmask (relevant for `Authorize`; ignored on `Revoke`). + pub scope: Scope, +} + +impl OwnerChange { + fn rlp_fields_len(&self) -> usize { + self.change_type.op_byte().length() + + self.verifier.length() + + self.owner_id.length() + + self.scope.length() + } +} + +impl Encodable for OwnerChange { + fn encode(&self, out: &mut dyn BufMut) { + let fields_len = self.rlp_fields_len(); + let header = Header { list: true, payload_length: fields_len }; + header.encode(out); + self.change_type.op_byte().encode(out); + self.verifier.encode(out); + self.owner_id.encode(out); + self.scope.encode(out); + } + + fn length(&self) -> usize { + let fields_len = self.rlp_fields_len(); + length_of_length(fields_len) + fields_len + } +} + +impl Decodable for OwnerChange { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + let started_len = buf.len(); + let op = u8::decode(buf)?; + let change_type = OwnerChangeType::from_op_byte(op) + .ok_or(alloy_rlp::Error::Custom("invalid OwnerChange op byte"))?; + let verifier = Address::decode(buf)?; + let owner_id = B256::decode(buf)?; + let scope = Scope::decode(buf)?; + let consumed = started_len - buf.len(); + if consumed != header.payload_length { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: consumed, + }); + } + Ok(Self { change_type, verifier, owner_id, scope }) + } +} + +/// Body of an [`AccountChange::Create`] entry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct CreateEntry { + /// User-chosen salt used in the deterministic deploy address derivation. + pub user_salt: B256, + /// Account bytecode to install. + pub code: Bytes, + /// Initial owners authorized on the new account. + pub initial_owners: Vec, +} + +/// Body of an [`AccountChange::ConfigChange`] entry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct ConfigChange { + /// Chain ID this config change is bound to (replay protection). + pub chain_id: u64, + /// Per-account config-change sequence number. + pub sequence: u64, + /// Owner authorize/revoke operations applied in order. + pub owner_changes: Vec, + /// Authorization payload validated against an existing owner with `SCOPE_CONFIG`. + pub auth: Bytes, +} + +/// Body of an [`AccountChange::Delegation`] entry. +#[derive(Debug, Clone, PartialEq, Eq, Hash, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct Delegation { + /// Delegation target address. Zero means clear the existing delegation. + pub target: Address, +} + +/// A tagged-union entry inside `TxEip8130::account_changes`. +/// +/// On the wire each entry is `type_byte || rlp([body_fields...])`: +/// - `0x00` -> [`AccountChange::Create`] +/// - `0x01` -> [`AccountChange::ConfigChange`] +/// - `0x02` -> [`AccountChange::Delegation`] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "camelCase"))] +pub enum AccountChange { + /// Create a new account. + Create(CreateEntry), + /// Change an existing account's owner set. + ConfigChange(ConfigChange), + /// Set or clear an [EIP-7702]-style delegation. + /// + /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + Delegation(Delegation), +} + +impl AccountChange { + /// Returns the on-wire type byte for this entry. + pub const fn type_byte(&self) -> u8 { + match self { + Self::Create(_) => Eip8130Constants::ACCOUNT_CHANGE_TYPE_CREATE, + Self::ConfigChange(_) => Eip8130Constants::ACCOUNT_CHANGE_TYPE_CONFIG, + Self::Delegation(_) => Eip8130Constants::ACCOUNT_CHANGE_TYPE_DELEGATION, + } + } + + fn body_len(&self) -> usize { + match self { + Self::Create(b) => b.length(), + Self::ConfigChange(b) => b.length(), + Self::Delegation(b) => b.length(), + } + } +} + +impl Encodable for AccountChange { + fn encode(&self, out: &mut dyn BufMut) { + out.put_u8(self.type_byte()); + match self { + Self::Create(b) => b.encode(out), + Self::ConfigChange(b) => b.encode(out), + Self::Delegation(b) => b.encode(out), + } + } + + fn length(&self) -> usize { + 1 + self.body_len() + } +} + +impl Decodable for AccountChange { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + if buf.is_empty() { + return Err(alloy_rlp::Error::InputTooShort); + } + let type_byte = buf[0]; + buf.advance(1); + match type_byte { + Eip8130Constants::ACCOUNT_CHANGE_TYPE_CREATE => { + CreateEntry::decode(buf).map(Self::Create) + } + Eip8130Constants::ACCOUNT_CHANGE_TYPE_CONFIG => { + ConfigChange::decode(buf).map(Self::ConfigChange) + } + Eip8130Constants::ACCOUNT_CHANGE_TYPE_DELEGATION => { + Delegation::decode(buf).map(Self::Delegation) + } + _ => Err(alloy_rlp::Error::Custom("invalid AccountChange type byte")), + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{address, b256, bytes}; + + use super::*; + + #[test] + fn scope_bit_helpers() { + let s = Scope( + Eip8130Constants::SCOPE_SIGNATURE + | Eip8130Constants::SCOPE_SENDER + | Eip8130Constants::SCOPE_PAYER + | Eip8130Constants::SCOPE_CONFIG, + ); + assert!(s.has_signature()); + assert!(s.has_sender()); + assert!(s.has_payer()); + assert!(s.has_config()); + assert!(!Scope::UNRESTRICTED.has_signature()); + } + + #[test] + fn owner_change_type_roundtrip() { + for ct in [OwnerChangeType::Authorize, OwnerChangeType::Revoke] { + assert_eq!(OwnerChangeType::from_op_byte(ct.op_byte()), Some(ct)); + } + assert_eq!(OwnerChangeType::from_op_byte(0x00), None); + assert_eq!(OwnerChangeType::from_op_byte(0xff), None); + } + + #[test] + fn owner_change_rlp_roundtrip() { + let oc = OwnerChange { + change_type: OwnerChangeType::Authorize, + verifier: address!("0x00000000000000000000000000000000000000aa"), + owner_id: b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), + scope: Scope(Eip8130Constants::SCOPE_SIGNATURE | Eip8130Constants::SCOPE_SENDER), + }; + let mut buf = Vec::new(); + oc.encode(&mut buf); + assert_eq!(buf.len(), oc.length()); + let decoded = OwnerChange::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(oc, decoded); + } + + #[test] + fn account_change_create_roundtrip() { + let ac = AccountChange::Create(CreateEntry { + user_salt: b256!("0x2222222222222222222222222222222222222222222222222222222222222222"), + code: bytes!("6080604052"), + initial_owners: vec![InitialOwner { + verifier: address!("0x00000000000000000000000000000000000000bb"), + owner_id: b256!( + "0x3333333333333333333333333333333333333333333333333333333333333333" + ), + scope: Scope(Eip8130Constants::SCOPE_SIGNATURE), + }], + }); + let mut buf = Vec::new(); + ac.encode(&mut buf); + assert_eq!(buf[0], Eip8130Constants::ACCOUNT_CHANGE_TYPE_CREATE); + assert_eq!(buf.len(), ac.length()); + let decoded = AccountChange::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(ac, decoded); + } + + #[test] + fn account_change_config_roundtrip() { + let ac = AccountChange::ConfigChange(ConfigChange { + chain_id: 8453, + sequence: 7, + owner_changes: vec![OwnerChange { + change_type: OwnerChangeType::Revoke, + verifier: address!("0x00000000000000000000000000000000000000cc"), + owner_id: b256!( + "0x4444444444444444444444444444444444444444444444444444444444444444" + ), + scope: Scope::UNRESTRICTED, + }], + auth: bytes!("aabbcc"), + }); + let mut buf = Vec::new(); + ac.encode(&mut buf); + assert_eq!(buf[0], Eip8130Constants::ACCOUNT_CHANGE_TYPE_CONFIG); + let decoded = AccountChange::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(ac, decoded); + } + + #[test] + fn account_change_delegation_roundtrip() { + let ac = AccountChange::Delegation(Delegation { + target: address!("0x00000000000000000000000000000000000000dd"), + }); + let mut buf = Vec::new(); + ac.encode(&mut buf); + assert_eq!(buf[0], Eip8130Constants::ACCOUNT_CHANGE_TYPE_DELEGATION); + let decoded = AccountChange::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(ac, decoded); + } + + #[test] + fn account_change_clear_delegation() { + let ac = AccountChange::Delegation(Delegation { target: Address::ZERO }); + let mut buf = Vec::new(); + ac.encode(&mut buf); + let decoded = AccountChange::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(ac, decoded); + } + + #[test] + fn account_change_invalid_type_byte() { + let buf = [0xffu8, 0xc0]; + let mut slice = &buf[..]; + let res = AccountChange::decode(&mut slice); + assert!(res.is_err()); + } + + #[test] + fn scope_encodes_as_bare_uint8() { + let mut buf = Vec::new(); + Scope(0x05).encode(&mut buf); + assert_eq!(buf, vec![0x05], "Scope must serialize as a single RLP byte, not a list"); + + let mut zero = Vec::new(); + Scope(0x00).encode(&mut zero); + assert_eq!(zero, vec![0x80], "Zero byte RLP encodes as 0x80"); + + let mut high = Vec::new(); + Scope(0x80).encode(&mut high); + assert_eq!(high, vec![0x81, 0x80], "High-bit byte RLP encodes as 0x81 0x80"); + + let mut slice = buf.as_slice(); + let decoded = Scope::decode(&mut slice).unwrap(); + assert_eq!(decoded, Scope(0x05)); + assert!(slice.is_empty()); + } +} diff --git a/crates/common/consensus/src/transaction/eip8130/call.rs b/crates/common/consensus/src/transaction/eip8130/call.rs new file mode 100644 index 0000000000..ae57f756a6 --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/call.rs @@ -0,0 +1,54 @@ +//! Per-call payload used inside the [EIP-8130] `calls` field. +//! +//! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + +use alloy_primitives::{Address, Bytes}; +use alloy_rlp::{RlpDecodable, RlpEncodable}; + +/// A single call dispatched by the protocol during AA transaction execution. +/// +/// Spec wire form: `rlp([to, data])` where `to` is a 20-byte address and `data` +/// is the calldata. The dispatched call carries no value (`msg.value == 0`); +/// ETH transfers must be performed by the wallet bytecode via the `CALL` opcode. +/// +/// AA transactions group calls into phases (`Vec>`); see +/// [`super::tx::TxEip8130::calls`]. +#[derive(Debug, Clone, PartialEq, Eq, Hash, RlpEncodable, RlpDecodable)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct Call { + /// Recipient address of the call. + pub to: Address, + /// Calldata passed to the recipient. + pub data: Bytes, +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{address, bytes}; + use alloy_rlp::{Decodable, Encodable}; + + use super::*; + + #[test] + fn rlp_roundtrip() { + let call = Call { + to: address!("0x00000000000000000000000000000000000000aa"), + data: bytes!("deadbeef"), + }; + let mut buf = Vec::new(); + call.encode(&mut buf); + let decoded = Call::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(call, decoded); + } + + #[test] + fn rlp_roundtrip_empty_data() { + let call = Call { to: Address::ZERO, data: Bytes::new() }; + let mut buf = Vec::new(); + call.encode(&mut buf); + let decoded = Call::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(call, decoded); + } +} diff --git a/crates/common/consensus/src/transaction/eip8130/constants.rs b/crates/common/consensus/src/transaction/eip8130/constants.rs new file mode 100644 index 0000000000..1a5723b206 --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/constants.rs @@ -0,0 +1,139 @@ +//! Constants for the [EIP-8130] Account Abstraction transaction type. +//! +//! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + +use alloy_primitives::U256; + +/// Container for [EIP-8130] protocol constants. +/// +/// All constants are exposed as associated `pub const` items so the public API +/// is type-anchored (per repo convention: "the public API exports types, not loose +/// functions"). +/// +/// Spec status (as of writing): EIP-8130 is in Draft. Several numeric constants +/// are marked TBD in the spec; concrete values used here are project choices +/// that can be renumbered when the spec finalizes. +/// for rationale. +/// +/// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 +#[derive(Debug)] +pub struct Eip8130Constants; + +impl Eip8130Constants { + /// [EIP-2718] transaction type byte for AA transactions (`EIP8130_TX_TYPE`). + /// + /// Spec value: TBD. We use `0x7D`, picked to live in the high "OP-style" + /// type-byte range adjacent to (but distinct from) the deposit type `0x7E`, + /// and to be easy to renumber once the EIP finalizes. + /// + /// [EIP-2718]: https://eips.ethereum.org/EIPS/eip-2718 + pub const EIP8130_TX_TYPE: u8 = 0x7D; + + /// Magic prefix byte for payer signature domain separation (`EIP8130_PAYER_TYPE`). + /// + /// Used in the payer signature preimage: + /// `keccak256(EIP8130_PAYER_TYPE || rlp([...fields through calls...]))`. + /// + /// Spec value: TBD. We use `0xFA`, distinct from any registered EIP-2718 + /// transaction type byte to prevent cross-domain reuse. + pub const EIP8130_PAYER_TYPE: u8 = 0xFA; + + /// Base intrinsic gas cost for any AA transaction (`EIP8130_BASE_COST`). + pub const EIP8130_BASE_COST: u64 = 15_000; + + /// Sentinel `nonce_key` value selecting nonce-free mode (`NONCE_KEY_MAX`). + /// + /// When `nonce_key == NONCE_KEY_MAX`, no nonce state is read or written + /// and replay protection relies on `expiry` (which must be non-zero). + pub const NONCE_KEY_MAX: U256 = U256::MAX; + + /// Owner scope bit: ERC-1271 `verifySignature()` context. + pub const SCOPE_SIGNATURE: u8 = 0x01; + + /// Owner scope bit: `sender_auth` validation context. + pub const SCOPE_SENDER: u8 = 0x02; + + /// Owner scope bit: `payer_auth` validation context. + pub const SCOPE_PAYER: u8 = 0x04; + + /// Owner scope bit: config change `auth` context. + pub const SCOPE_CONFIG: u8 = 0x08; + + /// Unrestricted scope value (owner is valid in all contexts). + pub const SCOPE_UNRESTRICTED: u8 = 0x00; + + /// [EIP-7702]-style delegation indicator code prefix. + /// + /// A delegated account's code is exactly `DELEGATION_INDICATOR_PREFIX || target` + /// where `target` is a 20-byte address. + /// + /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + pub const DELEGATION_INDICATOR_PREFIX: [u8; 3] = [0xef, 0x01, 0x00]; + + /// Total length in bytes of an [EIP-7702] delegation indicator + /// (`DELEGATION_INDICATOR_PREFIX || target`). + /// + /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + pub const DELEGATION_INDICATOR_SIZE: usize = 23; + + /// `account_changes` entry type byte: account creation. + pub const ACCOUNT_CHANGE_TYPE_CREATE: u8 = 0x00; + + /// `account_changes` entry type byte: owner config change. + pub const ACCOUNT_CHANGE_TYPE_CONFIG: u8 = 0x01; + + /// `account_changes` entry type byte: code delegation. + pub const ACCOUNT_CHANGE_TYPE_DELEGATION: u8 = 0x02; + + /// `owner_change` operation byte: authorize a new owner. + pub const OWNER_CHANGE_AUTHORIZE: u8 = 0x01; + + /// `owner_change` operation byte: revoke an existing owner. + pub const OWNER_CHANGE_REVOKE: u8 = 0x02; +} + +#[cfg(test)] +mod tests { + use super::*; + + const LEGACY_TX_TYPE: u8 = 0x00; + const EIP2930_TX_TYPE: u8 = 0x01; + const EIP1559_TX_TYPE: u8 = 0x02; + const EIP7702_TX_TYPE: u8 = 0x04; + const DEPOSIT_TX_TYPE: u8 = 0x7E; + + #[test] + fn type_bytes_are_distinct() { + assert_ne!(Eip8130Constants::EIP8130_TX_TYPE, Eip8130Constants::EIP8130_PAYER_TYPE); + assert_ne!(Eip8130Constants::EIP8130_TX_TYPE, LEGACY_TX_TYPE); + assert_ne!(Eip8130Constants::EIP8130_TX_TYPE, EIP2930_TX_TYPE); + assert_ne!(Eip8130Constants::EIP8130_TX_TYPE, EIP1559_TX_TYPE); + assert_ne!(Eip8130Constants::EIP8130_TX_TYPE, EIP7702_TX_TYPE); + assert_ne!(Eip8130Constants::EIP8130_TX_TYPE, DEPOSIT_TX_TYPE); + } + + #[test] + fn scope_bits_are_orthogonal() { + let bits = [ + Eip8130Constants::SCOPE_SIGNATURE, + Eip8130Constants::SCOPE_SENDER, + Eip8130Constants::SCOPE_PAYER, + Eip8130Constants::SCOPE_CONFIG, + ]; + let mut acc: u8 = 0; + for b in bits { + assert_eq!(b.count_ones(), 1, "scope bit must be a single bit"); + assert_eq!(acc & b, 0, "scope bits must be orthogonal"); + acc |= b; + } + assert_eq!(Eip8130Constants::SCOPE_UNRESTRICTED, 0); + } + + #[test] + fn delegation_indicator_size_matches_prefix_plus_address() { + assert_eq!( + Eip8130Constants::DELEGATION_INDICATOR_SIZE, + Eip8130Constants::DELEGATION_INDICATOR_PREFIX.len() + 20 + ); + } +} diff --git a/crates/common/consensus/src/transaction/eip8130/mod.rs b/crates/common/consensus/src/transaction/eip8130/mod.rs new file mode 100644 index 0000000000..efd8b35d4e --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/mod.rs @@ -0,0 +1,25 @@ +//! [EIP-8130] Account Abstraction by Account Configuration transaction type. +//! +//! Provides type-only plumbing for the new transaction kind: +//! [`TxEip8130`] (unsigned), [`Eip8130Signed`] (signed envelope), [`AccountChange`] +//! (tagged-union account-mutation entries), and [`Call`] (per-call payload). +//! +//! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + +mod constants; +pub use constants::Eip8130Constants; + +mod call; +pub use call::Call; + +mod account_changes; +pub use account_changes::{ + AccountChange, ConfigChange, CreateEntry, Delegation, InitialOwner, OwnerChange, + OwnerChangeType, Scope, +}; + +mod tx; +pub use tx::TxEip8130; + +mod signed; +pub use signed::Eip8130Signed; diff --git a/crates/common/consensus/src/transaction/eip8130/signed.rs b/crates/common/consensus/src/transaction/eip8130/signed.rs new file mode 100644 index 0000000000..d585941ece --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/signed.rs @@ -0,0 +1,476 @@ +//! Signed [EIP-8130] Account Abstraction transaction envelope ([`Eip8130Signed`]). +//! +//! [`Eip8130Signed`] wraps a [`TxEip8130`] together with the two opaque byte strings +//! `sender_auth` and `payer_auth` that authenticate the sender and (optional) +//! payer respectively. The wire format is: +//! +//! ```text +//! EIP8130_TX_TYPE || rlp([...TxEip8130 fields..., sender_auth, payer_auth]) +//! ``` +//! +//! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + +use alloc::vec::Vec; + +use alloy_consensus::{InMemorySize, Transaction, Typed2718}; +use alloy_eips::{ + eip2718::{Decodable2718, Eip2718Error, Eip2718Result, Encodable2718, IsTyped2718}, + eip2930::AccessList, + eip7702::SignedAuthorization, +}; +use alloy_primitives::{Address, B256, Bytes, ChainId, TxKind, U256, bytes::BufMut, keccak256}; +use alloy_rlp::{Decodable, Encodable, Header, length_of_length}; + +use crate::transaction::eip8130::{constants::Eip8130Constants, tx::TxEip8130}; + +/// Signed [EIP-8130] Account Abstraction transaction envelope. +/// +/// Holds the unsigned [`TxEip8130`] body plus the two authentication byte +/// strings. The transaction hash is computed at construction and cached. +/// +/// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Eip8130Signed { + /// Unsigned transaction body. + tx: TxEip8130, + /// Sender authentication payload. + /// + /// On the EOA path (`tx.sender == None`) this is a 65-byte ECDSA signature + /// (`r || s || v`) over [`TxEip8130::sender_signature_hash`]. + /// On the configured-owner path (`tx.sender == Some(_)`) this is + /// `verifier(20) || verifier_data`. + sender_auth: Bytes, + /// Payer authentication payload, or empty for self-pay. + /// + /// When `tx.payer.is_some()` this carries the payer's authorization, + /// formatted as `verifier(20) || verifier_data` and validated against + /// [`TxEip8130::payer_signature_hash`] (with the resolved sender substituted). + /// When `tx.payer.is_none()` this is empty. + payer_auth: Bytes, + /// Cached EIP-2718 transaction hash (`keccak256(encode_2718(self))`). + hash: B256, +} + +#[cfg(feature = "serde")] +mod serde_impl { + use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + + use super::{Bytes, Eip8130Signed, TxEip8130}; + + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + struct Eip8130SignedRepr { + tx: TxEip8130, + sender_auth: Bytes, + payer_auth: Bytes, + } + + impl Serialize for Eip8130Signed { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + Eip8130SignedRepr { + tx: self.tx.clone(), + sender_auth: self.sender_auth.clone(), + payer_auth: self.payer_auth.clone(), + } + .serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for Eip8130Signed { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let repr = Eip8130SignedRepr::deserialize(deserializer).map_err(de::Error::custom)?; + Ok(Self::new(repr.tx, repr.sender_auth, repr.payer_auth)) + } + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for Eip8130Signed { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self::new(TxEip8130::arbitrary(u)?, Bytes::arbitrary(u)?, Bytes::arbitrary(u)?)) + } +} + +impl Eip8130Signed { + /// Constructs a new [`Eip8130Signed`] from its parts, computing and caching + /// the EIP-2718 transaction hash. + pub fn new(tx: TxEip8130, sender_auth: Bytes, payer_auth: Bytes) -> Self { + let mut this = Self { tx, sender_auth, payer_auth, hash: B256::ZERO }; + this.hash = this.recompute_hash(); + this + } + + /// Returns the unsigned transaction body. + pub const fn tx(&self) -> &TxEip8130 { + &self.tx + } + + /// Consumes the envelope and returns the unsigned transaction body. + pub fn into_tx(self) -> TxEip8130 { + self.tx + } + + /// Returns the sender authentication payload. + pub const fn sender_auth(&self) -> &Bytes { + &self.sender_auth + } + + /// Returns the payer authentication payload. + pub const fn payer_auth(&self) -> &Bytes { + &self.payer_auth + } + + /// Returns the cached EIP-2718 transaction hash. + pub const fn hash(&self) -> &B256 { + &self.hash + } + + fn recompute_hash(&self) -> B256 { + let mut buf = Vec::with_capacity(self.encode_2718_len()); + self.encode_2718(&mut buf); + keccak256(&buf) + } + + /// Returns the sender address if it is explicitly provided by the + /// transaction body (configured-owner path). + pub const fn explicit_sender(&self) -> Option
{ + self.tx.sender + } + + fn rlp_payload_length(&self) -> usize { + self.tx.rlp_encoded_fields_length() + self.sender_auth.length() + self.payer_auth.length() + } + + fn rlp_header(&self) -> Header { + Header { list: true, payload_length: self.rlp_payload_length() } + } + + /// RLP-encodes the signed body (with list header) as + /// `rlp([...tx fields..., sender_auth, payer_auth])`. + pub fn rlp_encode_signed(&self, out: &mut dyn BufMut) { + self.rlp_header().encode(out); + self.tx.rlp_encode_fields(out); + self.sender_auth.encode(out); + self.payer_auth.encode(out); + } + + fn rlp_encoded_signed_length(&self) -> usize { + let payload = self.rlp_payload_length(); + length_of_length(payload) + payload + } + + /// RLP-decodes the signed body produced by [`Self::rlp_encode_signed`]. + pub fn rlp_decode_signed(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + let started = buf.len(); + let tx = TxEip8130::rlp_decode_fields(buf)?; + let sender_auth = Bytes::decode(buf)?; + let payer_auth = Bytes::decode(buf)?; + let consumed = started - buf.len(); + if consumed != header.payload_length { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: consumed, + }); + } + Ok(Self::new(tx, sender_auth, payer_auth)) + } +} + +impl Encodable for Eip8130Signed { + fn encode(&self, out: &mut dyn BufMut) { + let len = self.encode_2718_len(); + Header { list: false, payload_length: len }.encode(out); + self.encode_2718(out); + } + + fn length(&self) -> usize { + let inner = self.encode_2718_len(); + length_of_length(inner) + inner + } +} + +impl Decodable for Eip8130Signed { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = Header::decode(buf)?; + if header.list { + return Err(alloy_rlp::Error::Custom("expected EIP-2718 envelope, got list")); + } + if buf.len() < header.payload_length { + return Err(alloy_rlp::Error::InputTooShort); + } + let (mut payload, rest) = buf.split_at(header.payload_length); + *buf = rest; + let decoded = Self::decode_2718(&mut payload) + .map_err(|_| alloy_rlp::Error::Custom("invalid EIP-8130 envelope"))?; + if !payload.is_empty() { + return Err(alloy_rlp::Error::Custom("trailing bytes in EIP-8130 envelope")); + } + Ok(decoded) + } +} + +impl Typed2718 for Eip8130Signed { + fn ty(&self) -> u8 { + Eip8130Constants::EIP8130_TX_TYPE + } +} + +impl IsTyped2718 for Eip8130Signed { + fn is_type(ty: u8) -> bool { + ty == Eip8130Constants::EIP8130_TX_TYPE + } +} + +impl Encodable2718 for Eip8130Signed { + fn type_flag(&self) -> Option { + Some(Eip8130Constants::EIP8130_TX_TYPE) + } + + fn encode_2718_len(&self) -> usize { + 1 + self.rlp_encoded_signed_length() + } + + fn encode_2718(&self, out: &mut dyn BufMut) { + out.put_u8(Eip8130Constants::EIP8130_TX_TYPE); + self.rlp_encode_signed(out); + } + + fn trie_hash(&self) -> B256 { + self.hash + } +} + +impl Decodable2718 for Eip8130Signed { + fn typed_decode(ty: u8, buf: &mut &[u8]) -> Eip2718Result { + if ty != Eip8130Constants::EIP8130_TX_TYPE { + return Err(Eip2718Error::UnexpectedType(ty)); + } + Self::rlp_decode_signed(buf).map_err(Into::into) + } + + fn fallback_decode(_buf: &mut &[u8]) -> Eip2718Result { + Err(Eip2718Error::UnexpectedType(0)) + } +} + +impl InMemorySize for Eip8130Signed { + fn size(&self) -> usize { + InMemorySize::size(&self.tx) + self.sender_auth.len() + self.payer_auth.len() + } +} + +impl Transaction for Eip8130Signed { + fn chain_id(&self) -> Option { + self.tx.chain_id() + } + + fn nonce(&self) -> u64 { + self.tx.nonce() + } + + fn gas_limit(&self) -> u64 { + self.tx.gas_limit() + } + + fn gas_price(&self) -> Option { + self.tx.gas_price() + } + + fn max_fee_per_gas(&self) -> u128 { + self.tx.max_fee_per_gas() + } + + fn max_priority_fee_per_gas(&self) -> Option { + self.tx.max_priority_fee_per_gas() + } + + fn max_fee_per_blob_gas(&self) -> Option { + self.tx.max_fee_per_blob_gas() + } + + fn priority_fee_or_price(&self) -> u128 { + self.tx.priority_fee_or_price() + } + + fn effective_gas_price(&self, base_fee: Option) -> u128 { + self.tx.effective_gas_price(base_fee) + } + + fn is_dynamic_fee(&self) -> bool { + self.tx.is_dynamic_fee() + } + + fn kind(&self) -> TxKind { + self.tx.kind() + } + + fn is_create(&self) -> bool { + self.tx.is_create() + } + + fn value(&self) -> U256 { + self.tx.value() + } + + fn input(&self) -> &Bytes { + self.tx.input() + } + + fn access_list(&self) -> Option<&AccessList> { + self.tx.access_list() + } + + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + self.tx.blob_versioned_hashes() + } + + fn authorization_list(&self) -> Option<&[SignedAuthorization]> { + self.tx.authorization_list() + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{address, bytes}; + + use super::*; + use crate::transaction::eip8130::{ + account_changes::{AccountChange, Delegation}, + call::Call, + }; + + fn sample_signed(payer_present: bool) -> Eip8130Signed { + let tx = TxEip8130 { + chain_id: 8453, + sender: Some(address!("0x00000000000000000000000000000000000000aa")), + nonce_key: U256::from(7u64), + nonce_sequence: 3, + expiry: 0, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 5_000_000_000, + gas_limit: 250_000, + account_changes: vec![AccountChange::Delegation(Delegation { target: Address::ZERO })], + calls: vec![vec![Call { + to: address!("0x00000000000000000000000000000000000000bb"), + data: bytes!("01020304"), + }]], + payer: if payer_present { + Some(address!("0x00000000000000000000000000000000000000cc")) + } else { + None + }, + }; + Eip8130Signed::new( + tx, + bytes!("deadbeef"), + if payer_present { bytes!("cafebabe") } else { Bytes::new() }, + ) + } + + #[test] + fn eip2718_roundtrip_self_pay() { + let signed = sample_signed(false); + let mut buf = Vec::new(); + signed.encode_2718(&mut buf); + assert_eq!(buf[0], Eip8130Constants::EIP8130_TX_TYPE); + assert_eq!(buf.len(), signed.encode_2718_len()); + + let decoded = Eip8130Signed::decode_2718(&mut buf.as_slice()).unwrap(); + assert_eq!(signed, decoded); + } + + #[test] + fn eip2718_roundtrip_sponsored() { + let signed = sample_signed(true); + let mut buf = Vec::new(); + signed.encode_2718(&mut buf); + let decoded = Eip8130Signed::decode_2718(&mut buf.as_slice()).unwrap(); + assert_eq!(signed, decoded); + } + + #[test] + fn rlp_envelope_roundtrip() { + let signed = sample_signed(true); + let mut buf = Vec::new(); + signed.encode(&mut buf); + let decoded = Eip8130Signed::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(signed, decoded); + } + + #[test] + fn hash_is_keccak_of_eip2718_payload() { + let signed = sample_signed(false); + let mut buf = Vec::new(); + signed.encode_2718(&mut buf); + assert_eq!(*signed.hash(), keccak256(&buf)); + } + + #[test] + fn hash_is_deterministic() { + let signed = sample_signed(false); + assert_eq!(signed.hash(), signed.hash()); + } + + #[test] + fn ty_byte() { + let signed = sample_signed(false); + assert_eq!(signed.ty(), Eip8130Constants::EIP8130_TX_TYPE); + assert_eq!(signed.type_flag(), Some(Eip8130Constants::EIP8130_TX_TYPE)); + } + + #[test] + fn typed_decode_rejects_wrong_type() { + let signed = sample_signed(false); + let mut buf = Vec::new(); + signed.rlp_encode_signed(&mut buf); + let res = Eip8130Signed::typed_decode(0x00, &mut buf.as_slice()); + assert!(res.is_err()); + } + + #[test] + fn explicit_sender_returns_field() { + let signed = sample_signed(false); + assert_eq!( + signed.explicit_sender(), + Some(address!("0x00000000000000000000000000000000000000aa")) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip_recomputes_hash() { + let signed = sample_signed(true); + let json = serde_json::to_string(&signed).unwrap(); + + assert!(!json.contains("\"hash\"")); + + let decoded: Eip8130Signed = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, signed); + assert_eq!(decoded.hash(), signed.hash()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_computes_hash_from_payload() { + let signed = sample_signed(false); + let mut value = serde_json::to_value(&signed).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("hash".to_string(), serde_json::Value::String(format!("{:?}", B256::ZERO))); + + let decoded: Eip8130Signed = serde_json::from_value(value).unwrap(); + assert_eq!(*decoded.hash(), *signed.hash()); + assert_ne!(*decoded.hash(), B256::ZERO); + } +} diff --git a/crates/common/consensus/src/transaction/eip8130/tx.rs b/crates/common/consensus/src/transaction/eip8130/tx.rs new file mode 100644 index 0000000000..34e3c35d2f --- /dev/null +++ b/crates/common/consensus/src/transaction/eip8130/tx.rs @@ -0,0 +1,541 @@ +//! Unsigned [EIP-8130] Account Abstraction transaction body ([`TxEip8130`]). +//! +//! This module defines the unsigned payload of an EIP-8130 transaction. The +//! signed envelope (which wraps this type alongside the `sender_auth` and +//! `payer_auth` byte strings) lives in [`super::signed`]. +//! +//! [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + +use alloc::vec::Vec; +use core::mem; + +use alloy_consensus::{InMemorySize, SignableTransaction, Transaction, Typed2718}; +use alloy_eips::{eip2718::IsTyped2718, eip2930::AccessList, eip7702::SignedAuthorization}; +use alloy_primitives::{ + Address, B256, Bytes, ChainId, Signature, TxKind, U256, bytes::BufMut, keccak256, +}; +use alloy_rlp::{Decodable, Encodable, Header, length_of_length}; + +use crate::transaction::eip8130::{ + account_changes::AccountChange, call::Call, constants::Eip8130Constants, +}; + +/// Unsigned body of an [EIP-8130] Account Abstraction transaction. +/// +/// On the wire, the signed form (an [`super::Eip8130Signed`]) is +/// `EIP8130_TX_TYPE || rlp([...all fields..., sender_auth, payer_auth])`. The +/// unsigned struct here carries only the consensus fields; signature material +/// is held by [`super::Eip8130Signed`]. +/// +/// Field semantics follow the [EIP-8130] draft. Two fields are nullable on the +/// wire (encoded as a zero-length byte string when absent): +/// +/// - [`Self::sender`]: `None` selects the EOA path (recovered from +/// `sender_auth` as a 65-byte ECDSA signature); `Some` selects the +/// configured-owner path with an explicit account address. +/// - [`Self::payer`]: `None` selects self-pay (the resolved sender pays); +/// `Some` selects sponsored pay (the payer address pays). +/// +/// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct TxEip8130 { + /// EIP-155 chain ID this transaction is bound to. + pub chain_id: ChainId, + /// Explicit sender account address, or `None` for the EOA path. + pub sender: Option
, + /// High 192 bits of the compound nonce; with `nonce_sequence` forms the + /// per-account replay protection key. + pub nonce_key: U256, + /// Sequence number within the nonce key. + pub nonce_sequence: u64, + /// Unix-seconds expiry timestamp; `0` means no expiry. + pub expiry: u64, + /// Max priority fee per gas (tip) the sender is willing to pay. + pub max_priority_fee_per_gas: u128, + /// Max total fee per gas (base + tip cap) the sender is willing to pay. + pub max_fee_per_gas: u128, + /// Gas limit for the entire AA transaction execution. + pub gas_limit: u64, + /// Account-mutation entries applied before calls execute. + pub account_changes: Vec, + /// Calls dispatched by the protocol after account changes apply, grouped + /// into phases (`Vec>`). + pub calls: Vec>, + /// Optional explicit payer; `None` means the resolved sender pays gas. + pub payer: Option
, +} + +impl TxEip8130 { + /// Encodes an `Option
` as the AA wire format: zero-length byte + /// string when `None`, 20-byte string when `Some`. + fn encode_address_opt(addr: &Option
, out: &mut dyn BufMut) { + match addr { + None => Bytes::new().encode(out), + Some(a) => Bytes::copy_from_slice(a.as_slice()).encode(out), + } + } + + /// Length contribution of an `Option
` under [`Self::encode_address_opt`]. + const fn address_opt_encoded_length(addr: &Option
) -> usize { + match addr { + None => 1, + Some(_) => 21, + } + } + + /// Decodes the [`Self::encode_address_opt`] wire format. + fn decode_address_opt(buf: &mut &[u8]) -> alloy_rlp::Result> { + let raw = Bytes::decode(buf)?; + match raw.len() { + 0 => Ok(None), + 20 => Ok(Some(Address::from_slice(&raw))), + _ => Err(alloy_rlp::Error::Custom("invalid Option
length")), + } + } + + /// Encodes the inner phase list of `calls` as `rlp([rlp([Call, ...]), ...])`. + fn encode_calls(calls: &[Vec], out: &mut dyn BufMut) { + let mut payload_len = 0usize; + for phase in calls { + payload_len += phase.length(); + } + Header { list: true, payload_length: payload_len }.encode(out); + for phase in calls { + phase.encode(out); + } + } + + /// Total RLP length of the `calls` field as encoded by [`Self::encode_calls`]. + fn calls_encoded_length(calls: &[Vec]) -> usize { + let mut payload_len = 0usize; + for phase in calls { + payload_len += phase.length(); + } + length_of_length(payload_len) + payload_len + } + + fn decode_calls(buf: &mut &[u8]) -> alloy_rlp::Result>> { + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + let started = buf.len(); + let mut phases = Vec::new(); + while started - buf.len() < header.payload_length { + phases.push(Vec::::decode(buf)?); + } + let consumed = started - buf.len(); + if consumed != header.payload_length { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: consumed, + }); + } + Ok(phases) + } + + /// Length of all RLP fields (no list header). + pub fn rlp_encoded_fields_length(&self) -> usize { + self.chain_id.length() + + Self::address_opt_encoded_length(&self.sender) + + self.nonce_key.length() + + self.nonce_sequence.length() + + self.expiry.length() + + self.max_priority_fee_per_gas.length() + + self.max_fee_per_gas.length() + + self.gas_limit.length() + + self.account_changes.length() + + Self::calls_encoded_length(&self.calls) + + Self::address_opt_encoded_length(&self.payer) + } + + /// Encodes the RLP fields (no list header) in canonical order. + pub fn rlp_encode_fields(&self, out: &mut dyn BufMut) { + self.chain_id.encode(out); + Self::encode_address_opt(&self.sender, out); + self.nonce_key.encode(out); + self.nonce_sequence.encode(out); + self.expiry.encode(out); + self.max_priority_fee_per_gas.encode(out); + self.max_fee_per_gas.encode(out); + self.gas_limit.encode(out); + self.account_changes.encode(out); + Self::encode_calls(&self.calls, out); + Self::encode_address_opt(&self.payer, out); + } + + /// Decodes the RLP fields (no list header) in canonical order. + pub fn rlp_decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { + Ok(Self { + chain_id: Decodable::decode(buf)?, + sender: Self::decode_address_opt(buf)?, + nonce_key: Decodable::decode(buf)?, + nonce_sequence: Decodable::decode(buf)?, + expiry: Decodable::decode(buf)?, + max_priority_fee_per_gas: Decodable::decode(buf)?, + max_fee_per_gas: Decodable::decode(buf)?, + gas_limit: Decodable::decode(buf)?, + account_changes: Decodable::decode(buf)?, + calls: Self::decode_calls(buf)?, + payer: Self::decode_address_opt(buf)?, + }) + } + + fn rlp_header(&self) -> Header { + Header { list: true, payload_length: self.rlp_encoded_fields_length() } + } + + /// RLP-encodes the unsigned transaction body (with list header). + pub fn rlp_encode(&self, out: &mut dyn BufMut) { + self.rlp_header().encode(out); + self.rlp_encode_fields(out); + } + + /// Returns the RLP-encoded length of the unsigned transaction body. + pub fn rlp_encoded_length(&self) -> usize { + self.rlp_header().length_with_payload() + } + + /// Signing-hash preimage for the sender, per [EIP-8130]. + /// + /// `keccak256(EIP8130_TX_TYPE || rlp([...unsigned body fields...]))`. + /// + /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + pub fn sender_signature_hash(&self) -> B256 { + let mut buf = Vec::with_capacity(self.rlp_encoded_length() + 1); + buf.put_u8(Eip8130Constants::EIP8130_TX_TYPE); + self.rlp_encode(&mut buf); + keccak256(&buf) + } + + /// Signing-hash preimage for the payer, per [EIP-8130]. + /// + /// `keccak256(EIP8130_PAYER_TYPE || rlp(unsigned body fields with the + /// `sender` slot replaced by the recovered sender address))`. + /// + /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + pub fn payer_signature_hash(&self, resolved_sender: Address) -> B256 { + let with_resolved = Self { sender: Some(resolved_sender), ..self.clone() }; + let mut buf = Vec::with_capacity(with_resolved.rlp_encoded_length() + 1); + buf.put_u8(Eip8130Constants::EIP8130_PAYER_TYPE); + with_resolved.rlp_encode(&mut buf); + keccak256(&buf) + } + + /// In-memory size heuristic. + pub fn size(&self) -> usize { + mem::size_of::() + + mem::size_of::>() + + mem::size_of::() + + mem::size_of::() + + mem::size_of::() + + mem::size_of::() + + mem::size_of::() + + mem::size_of::() + + self.account_changes.capacity() * mem::size_of::() + + self.calls.iter().map(|p| p.capacity() * mem::size_of::()).sum::() + + mem::size_of::>() + } +} + +impl Encodable for TxEip8130 { + fn encode(&self, out: &mut dyn BufMut) { + self.rlp_encode(out); + } + + fn length(&self) -> usize { + self.rlp_encoded_length() + } +} + +impl Decodable for TxEip8130 { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + let started = buf.len(); + let this = Self::rlp_decode_fields(buf)?; + let consumed = started - buf.len(); + if consumed != header.payload_length { + return Err(alloy_rlp::Error::ListLengthMismatch { + expected: header.payload_length, + got: consumed, + }); + } + Ok(this) + } +} + +impl Typed2718 for TxEip8130 { + fn ty(&self) -> u8 { + Eip8130Constants::EIP8130_TX_TYPE + } +} + +impl IsTyped2718 for TxEip8130 { + fn is_type(ty: u8) -> bool { + ty == Eip8130Constants::EIP8130_TX_TYPE + } +} + +impl InMemorySize for TxEip8130 { + fn size(&self) -> usize { + Self::size(self) + } +} + +impl Transaction for TxEip8130 { + fn chain_id(&self) -> Option { + Some(self.chain_id) + } + + fn nonce(&self) -> u64 { + self.nonce_sequence + } + + fn gas_limit(&self) -> u64 { + self.gas_limit + } + + fn gas_price(&self) -> Option { + None + } + + fn max_fee_per_gas(&self) -> u128 { + self.max_fee_per_gas + } + + fn max_priority_fee_per_gas(&self) -> Option { + Some(self.max_priority_fee_per_gas) + } + + fn max_fee_per_blob_gas(&self) -> Option { + None + } + + fn priority_fee_or_price(&self) -> u128 { + self.max_priority_fee_per_gas + } + + fn effective_gas_price(&self, base_fee: Option) -> u128 { + base_fee.map_or(self.max_fee_per_gas, |bf| { + (bf as u128).saturating_add(self.max_priority_fee_per_gas).min(self.max_fee_per_gas) + }) + } + + fn is_dynamic_fee(&self) -> bool { + true + } + + fn kind(&self) -> TxKind { + TxKind::Call(Address::ZERO) + } + + fn is_create(&self) -> bool { + false + } + + fn value(&self) -> U256 { + U256::ZERO + } + + fn input(&self) -> &Bytes { + static EMPTY: Bytes = Bytes::new(); + &EMPTY + } + + fn access_list(&self) -> Option<&AccessList> { + None + } + + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + None + } + + fn authorization_list(&self) -> Option<&[SignedAuthorization]> { + None + } +} + +impl SignableTransaction for TxEip8130 { + fn set_chain_id(&mut self, chain_id: ChainId) { + self.chain_id = chain_id; + } + + fn encode_for_signing(&self, out: &mut dyn BufMut) { + out.put_u8(Eip8130Constants::EIP8130_TX_TYPE); + self.rlp_encode(out); + } + + fn payload_len_for_signature(&self) -> usize { + 1 + self.rlp_encoded_length() + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{address, bytes}; + + use super::*; + use crate::transaction::eip8130::account_changes::Delegation; + + fn sample_tx() -> TxEip8130 { + TxEip8130 { + chain_id: 8453, + sender: Some(address!("0x00000000000000000000000000000000000000aa")), + nonce_key: U256::from(0x1234u64), + nonce_sequence: 7, + expiry: 0, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 5_000_000_000, + gas_limit: 200_000, + account_changes: vec![AccountChange::Delegation(Delegation { + target: address!("0x00000000000000000000000000000000000000bb"), + })], + calls: vec![vec![Call { + to: address!("0x00000000000000000000000000000000000000cc"), + data: bytes!("deadbeef"), + }]], + payer: None, + } + } + + #[test] + fn rlp_roundtrip_full() { + let tx = sample_tx(); + let mut buf = Vec::new(); + tx.rlp_encode(&mut buf); + assert_eq!(buf.len(), tx.rlp_encoded_length()); + let decoded = TxEip8130::rlp_decode_fields(&mut { + let header = Header::decode(&mut &buf[..]).unwrap(); + assert!(header.list); + &buf[buf.len() - header.payload_length..] + }) + .unwrap(); + assert_eq!(tx, decoded); + } + + #[test] + fn rlp_roundtrip_via_decodable() { + let tx = sample_tx(); + let mut buf = Vec::new(); + tx.encode(&mut buf); + let decoded = TxEip8130::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(tx, decoded); + } + + #[test] + fn rlp_roundtrip_minimal_empty() { + let tx = TxEip8130::default(); + let mut buf = Vec::new(); + tx.encode(&mut buf); + let decoded = TxEip8130::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(tx, decoded); + } + + #[test] + fn address_opt_roundtrip_none() { + let mut buf = Vec::new(); + TxEip8130::encode_address_opt(&None, &mut buf); + assert_eq!(buf, vec![0x80]); + let decoded = TxEip8130::decode_address_opt(&mut buf.as_slice()).unwrap(); + assert_eq!(decoded, None); + } + + #[test] + fn address_opt_roundtrip_some() { + let addr = address!("0x00000000000000000000000000000000000000ff"); + let mut buf = Vec::new(); + TxEip8130::encode_address_opt(&Some(addr), &mut buf); + let decoded = TxEip8130::decode_address_opt(&mut buf.as_slice()).unwrap(); + assert_eq!(decoded, Some(addr)); + } + + #[test] + fn address_opt_rejects_wrong_length() { + let mut buf = Vec::new(); + Bytes::copy_from_slice(&[0u8; 19]).encode(&mut buf); + let res = TxEip8130::decode_address_opt(&mut buf.as_slice()); + assert!(res.is_err()); + } + + #[test] + fn signing_hashes_are_distinct() { + let tx = sample_tx(); + let sender_hash = tx.sender_signature_hash(); + let payer_hash = + tx.payer_signature_hash(address!("0x00000000000000000000000000000000000000dd")); + assert_ne!(sender_hash, payer_hash); + } + + #[test] + fn signing_hashes_use_prefix_bytes() { + let tx = sample_tx(); + let h = tx.sender_signature_hash(); + assert_ne!(h, B256::ZERO); + } + + #[test] + fn ty_byte_matches_constant() { + assert_eq!(sample_tx().ty(), Eip8130Constants::EIP8130_TX_TYPE); + assert!(::is_type(Eip8130Constants::EIP8130_TX_TYPE)); + assert!(!::is_type(0x00)); + } + + #[test] + fn nested_calls_roundtrip() { + let tx = TxEip8130 { + chain_id: 1, + calls: vec![ + vec![Call { to: Address::ZERO, data: bytes!("01") }], + vec![], + vec![ + Call { to: Address::ZERO, data: bytes!("02") }, + Call { to: Address::ZERO, data: bytes!("03") }, + ], + ], + ..Default::default() + }; + let mut buf = Vec::new(); + tx.encode(&mut buf); + let decoded = TxEip8130::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(tx, decoded); + } + + #[test] + fn account_change_roundtrip_in_tx() { + let tx = TxEip8130 { + chain_id: 1, + account_changes: vec![ + AccountChange::Delegation(Delegation { target: Address::ZERO }), + AccountChange::Delegation(Delegation { + target: address!("0x00000000000000000000000000000000000000ee"), + }), + ], + ..Default::default() + }; + let mut buf = Vec::new(); + tx.encode(&mut buf); + let decoded = TxEip8130::decode(&mut buf.as_slice()).unwrap(); + assert_eq!(tx.account_changes, decoded.account_changes); + } + + #[test] + fn payer_signature_hash_uses_substituted_sender() { + let mut tx = sample_tx(); + tx.sender = None; + let resolved = address!("0x00000000000000000000000000000000000000dd"); + let payer_hash_v1 = tx.payer_signature_hash(resolved); + + let tx2 = TxEip8130 { sender: Some(resolved), ..tx }; + let mut buf = Vec::with_capacity(tx2.rlp_encoded_length() + 1); + buf.put_u8(Eip8130Constants::EIP8130_PAYER_TYPE); + tx2.rlp_encode(&mut buf); + let payer_hash_v2 = keccak256(&buf); + assert_eq!(payer_hash_v1, payer_hash_v2); + } +} diff --git a/crates/common/consensus/src/transaction/envelope.rs b/crates/common/consensus/src/transaction/envelope.rs index be2229243e..1be8c474b4 100644 --- a/crates/common/consensus/src/transaction/envelope.rs +++ b/crates/common/consensus/src/transaction/envelope.rs @@ -20,7 +20,7 @@ use revm::context::TxEnv; use crate::{ BasePooledTransaction, TxDeposit, - transaction::{BaseTransactionInfo, DepositInfo}, + transaction::{BaseTransactionInfo, DepositInfo, Eip8130Signed, TxEip8130}, }; /// The Ethereum [EIP-2718] Transaction Envelope, modified for Base. @@ -53,6 +53,11 @@ pub enum BaseTxEnvelope { #[envelope(ty = 126)] #[serde(serialize_with = "crate::serde_deposit_tx_rpc")] Deposit(Sealed), + /// An [EIP-8130] Account Abstraction transaction tagged with type 0x7D. + /// + /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + #[envelope(ty = 125, typed = TxEip8130)] + Eip8130(Eip8130Signed), } /// Represents a transaction envelope for Base chains. @@ -152,6 +157,17 @@ impl From> for BaseTxEnvelope { let tx = Signed::new_unchecked(tx_eip7702, sig, hash); Self::Eip7702(tx) } + BaseTypedTransaction::Eip8130(tx) => { + debug_assert!( + tx.sender.is_none(), + "configured-owner EIP-8130 transactions must not be wrapped through the ECDSA Signed path; route them via BaseTxEnvelope::Eip8130 directly with the appropriate sender_auth", + ); + debug_assert!( + tx.payer.is_none(), + "sponsored EIP-8130 transactions must not be wrapped through the ECDSA Signed path; the payer_auth would be silently dropped", + ); + Self::Eip8130(Eip8130Signed::new(tx, sig.as_bytes().into(), Bytes::new())) + } BaseTypedTransaction::Deposit(tx) => Self::Deposit(Sealed::new_unchecked(tx, hash)), } } @@ -199,6 +215,9 @@ impl FromRecoveredTx for TxEnv { BaseTxEnvelope::Eip1559(tx) => Self::from_recovered_tx(tx.tx(), caller), BaseTxEnvelope::Eip2930(tx) => Self::from_recovered_tx(tx.tx(), caller), BaseTxEnvelope::Eip7702(tx) => Self::from_recovered_tx(tx.tx(), caller), + BaseTxEnvelope::Eip8130(_) => { + unimplemented!("EIP-8130 AA transactions cannot be converted to TxEnv yet") + } BaseTxEnvelope::Deposit(tx) => Self::from_recovered_tx(tx.inner(), caller), } } @@ -222,8 +241,11 @@ impl From for alloy_rpc_types_eth::TransactionRequest { BaseTxEnvelope::Eip2930(tx) => tx.into_parts().0.into(), BaseTxEnvelope::Eip1559(tx) => tx.into_parts().0.into(), BaseTxEnvelope::Eip7702(tx) => tx.into_parts().0.into(), - BaseTxEnvelope::Deposit(tx) => tx.into_inner().into(), BaseTxEnvelope::Legacy(tx) => tx.into_parts().0.into(), + BaseTxEnvelope::Eip8130(_) => unimplemented!( + "BaseTxEnvelope::Eip8130 cannot be converted to an alloy TransactionRequest; AA transactions have no single sender/recipient/value to project into the legacy request shape" + ), + BaseTxEnvelope::Deposit(tx) => tx.into_inner().into(), } } } @@ -319,6 +341,7 @@ impl BaseTxEnvelope { Self::Eip2930(tx) => Ok(tx.into()), Self::Eip1559(tx) => Ok(tx.into()), Self::Eip7702(tx) => Ok(tx.into()), + Self::Eip8130(tx) => Ok(tx.into()), Self::Deposit(tx) => { Err(ValueError::new(tx.into(), "Deposit transactions cannot be pooled")) } @@ -327,12 +350,21 @@ impl BaseTxEnvelope { /// Attempts to convert the envelope into the ethereum pooled variant. /// - /// Returns an error if the envelope's variant is incompatible with the pooled format: - /// [`TxDeposit`]. + /// Returns an error if the envelope's variant is incompatible with the ethereum pooled + /// format: [`TxDeposit`] (not pooled at all) or [`Eip8130Signed`] (pooled, but has no + /// ethereum-format representation since the alloy `PooledTransaction` enum has no + /// EIP-8130 variant). Rejecting [`Eip8130Signed`] here prevents + /// `From for alloy_consensus::PooledTransaction` from panicking. pub fn try_into_eth_pooled( self, ) -> Result> { - self.try_into_pooled().map(Into::into) + match self { + tx @ Self::Eip8130(_) => Err(ValueError::new( + tx, + "EIP-8130 transactions cannot be converted to ethereum PooledTransaction", + )), + other => other.try_into_pooled().map(Into::into), + } } /// Attempts to convert the L2 variant into an ethereum [`TxEnvelope`]. @@ -344,6 +376,10 @@ impl BaseTxEnvelope { Self::Eip2930(tx) => Ok(tx.into()), Self::Eip1559(tx) => Ok(tx.into()), Self::Eip7702(tx) => Ok(tx.into()), + tx @ Self::Eip8130(_) => Err(ValueError::new( + tx, + "EIP-8130 transactions cannot be converted to ethereum transaction", + )), tx @ Self::Deposit(_) => Err(ValueError::new( tx, "Deposit transactions cannot be converted to ethereum transaction", @@ -385,13 +421,19 @@ impl BaseTxEnvelope { /// Returns mutable access to the input bytes. /// /// Caution: modifying this will cause side-effects on the hash. + /// + /// Panics for [`Self::Eip8130`] since EIP-8130 transactions have no single + /// input field; their payload is a list of calls. #[doc(hidden)] - pub const fn input_mut(&mut self) -> &mut Bytes { + pub fn input_mut(&mut self) -> &mut Bytes { match self { Self::Eip1559(tx) => &mut tx.tx_mut().input, Self::Eip2930(tx) => &mut tx.tx_mut().input, Self::Legacy(tx) => &mut tx.tx_mut().input, Self::Eip7702(tx) => &mut tx.tx_mut().input, + Self::Eip8130(_) => { + unimplemented!("EIP-8130 transactions have no single input field") + } Self::Deposit(tx) => &mut tx.inner_mut().input, } } @@ -427,6 +469,12 @@ impl BaseTxEnvelope { matches!(self, Self::Deposit(_)) } + /// Returns true if the transaction is an EIP-8130 AA transaction. + #[inline] + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130(_)) + } + /// Returns the [`TxLegacy`] variant if the transaction is a legacy transaction. pub const fn as_legacy(&self) -> Option<&Signed> { match self { @@ -459,16 +507,24 @@ impl BaseTxEnvelope { } } + /// Returns the [`Eip8130Signed`] variant if the transaction is an EIP-8130 AA transaction. + pub const fn as_eip8130(&self) -> Option<&Eip8130Signed> { + match self { + Self::Eip8130(tx) => Some(tx), + _ => None, + } + } + /// Return the reference to signature. /// - /// Returns `None` if this is a deposit variant. + /// Returns `None` if this is a deposit or EIP-8130 variant. pub const fn signature(&self) -> Option<&Signature> { match self { Self::Legacy(tx) => Some(tx.signature()), Self::Eip2930(tx) => Some(tx.signature()), Self::Eip1559(tx) => Some(tx.signature()), Self::Eip7702(tx) => Some(tx.signature()), - Self::Deposit(_) => None, + Self::Eip8130(_) | Self::Deposit(_) => None, } } @@ -479,6 +535,7 @@ impl BaseTxEnvelope { Self::Eip2930(_) => OpTxType::Eip2930, Self::Eip1559(_) => OpTxType::Eip1559, Self::Eip7702(_) => OpTxType::Eip7702, + Self::Eip8130(_) => OpTxType::Eip8130, Self::Deposit(_) => OpTxType::Deposit, } } @@ -490,6 +547,7 @@ impl BaseTxEnvelope { Self::Eip1559(tx) => tx.hash(), Self::Eip2930(tx) => tx.hash(), Self::Eip7702(tx) => tx.hash(), + Self::Eip8130(tx) => tx.hash(), Self::Deposit(tx) => tx.hash_ref(), } } @@ -506,6 +564,7 @@ impl BaseTxEnvelope { Self::Eip2930(t) => t.eip2718_encoded_length(), Self::Eip1559(t) => t.eip2718_encoded_length(), Self::Eip7702(t) => t.eip2718_encoded_length(), + Self::Eip8130(t) => t.encode_2718_len(), Self::Deposit(t) => t.eip2718_encoded_length(), } } @@ -527,6 +586,10 @@ impl alloy_consensus::transaction::SignerRecoverable for BaseTxEnvelope { Self::Eip2930(tx) => tx.signature_hash(), Self::Eip1559(tx) => tx.signature_hash(), Self::Eip7702(tx) => tx.signature_hash(), + Self::Eip8130(tx) => match tx.explicit_sender() { + Some(sender) => return Ok(sender), + None => return Err(alloy_consensus::crypto::RecoveryError::new()), + }, // The Deposit transaction does not have a signature. Directly return the // `from` address. Self::Deposit(tx) => return Ok(tx.from), @@ -536,7 +599,9 @@ impl alloy_consensus::transaction::SignerRecoverable for BaseTxEnvelope { Self::Eip2930(tx) => tx.signature(), Self::Eip1559(tx) => tx.signature(), Self::Eip7702(tx) => tx.signature(), - Self::Deposit(_) => unreachable!("Deposit transactions should not be handled here"), + Self::Eip8130(_) | Self::Deposit(_) => { + unreachable!("non-ECDSA variants short-circuit above") + } }; alloy_consensus::crypto::secp256k1::recover_signer(signature, signature_hash) } @@ -549,6 +614,10 @@ impl alloy_consensus::transaction::SignerRecoverable for BaseTxEnvelope { Self::Eip2930(tx) => tx.signature_hash(), Self::Eip1559(tx) => tx.signature_hash(), Self::Eip7702(tx) => tx.signature_hash(), + Self::Eip8130(tx) => match tx.explicit_sender() { + Some(sender) => return Ok(sender), + None => return Err(alloy_consensus::crypto::RecoveryError::new()), + }, // The Deposit transaction does not have a signature. Directly return the // `from` address. Self::Deposit(tx) => return Ok(tx.from), @@ -558,7 +627,9 @@ impl alloy_consensus::transaction::SignerRecoverable for BaseTxEnvelope { Self::Eip2930(tx) => tx.signature(), Self::Eip1559(tx) => tx.signature(), Self::Eip7702(tx) => tx.signature(), - Self::Deposit(_) => unreachable!("Deposit transactions should not be handled here"), + Self::Eip8130(_) | Self::Deposit(_) => { + unreachable!("non-ECDSA variants short-circuit above") + } }; alloy_consensus::crypto::secp256k1::recover_signer_unchecked(signature, signature_hash) } @@ -580,6 +651,9 @@ impl alloy_consensus::transaction::SignerRecoverable for BaseTxEnvelope { Self::Eip7702(tx) => { alloy_consensus::transaction::SignerRecoverable::recover_unchecked_with_buf(tx, buf) } + Self::Eip8130(tx) => { + tx.explicit_sender().ok_or_else(alloy_consensus::crypto::RecoveryError::new) + } Self::Deposit(tx) => Ok(tx.from), } } @@ -596,7 +670,7 @@ pub(super) mod serde_bincode_compat { use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_with::{DeserializeAs, SerializeAs}; - use crate::serde_bincode_compat::TxDeposit; + use crate::{serde_bincode_compat::TxDeposit, transaction::Eip8130Signed}; /// Bincode-compatible representation of an [`BaseTxEnvelope`]. #[derive(Debug, Serialize, Deserialize)] @@ -636,6 +710,16 @@ pub(super) mod serde_bincode_compat { /// Borrowed deposit transaction data. transaction: TxDeposit<'a>, }, + /// EIP-8130 Account Abstraction variant. + Eip8130 { + /// Owned [`Eip8130Signed`] envelope. + /// + /// The [`Eip8130Signed`] payload includes variable-length `calls`, + /// `account_changes`, and authentication buffers, so we serialize + /// it directly instead of borrowing a flattened bincode-friendly + /// projection. + transaction: Eip8130Signed, + }, } impl<'a> From<&'a super::BaseTxEnvelope> for BaseTxEnvelope<'a> { @@ -657,6 +741,9 @@ pub(super) mod serde_bincode_compat { signature: *signed_7702.signature(), transaction: signed_7702.tx().into(), }, + super::BaseTxEnvelope::Eip8130(eip8130_signed) => { + Self::Eip8130 { transaction: eip8130_signed.clone() } + } super::BaseTxEnvelope::Deposit(sealed_deposit) => Self::Deposit { hash: sealed_deposit.seal(), transaction: sealed_deposit.inner().into(), @@ -680,6 +767,7 @@ pub(super) mod serde_bincode_compat { BaseTxEnvelope::Eip7702 { signature, transaction } => { Self::Eip7702(Signed::new_unhashed(transaction.into(), signature)) } + BaseTxEnvelope::Eip8130 { transaction } => Self::Eip8130(transaction), BaseTxEnvelope::Deposit { hash, transaction } => { Self::Deposit(Sealed::new_unchecked(transaction.into(), hash)) } @@ -777,6 +865,7 @@ impl InMemorySize for BaseTxEnvelope { Self::Eip2930(tx) => tx.size(), Self::Eip1559(tx) => tx.size(), Self::Eip7702(tx) => tx.size(), + Self::Eip8130(tx) => tx.size(), Self::Deposit(tx) => tx.size(), } } diff --git a/crates/common/consensus/src/transaction/mod.rs b/crates/common/consensus/src/transaction/mod.rs index 363e47204e..d3c3e0308e 100644 --- a/crates/common/consensus/src/transaction/mod.rs +++ b/crates/common/consensus/src/transaction/mod.rs @@ -3,8 +3,14 @@ mod deposit; pub use deposit::{DepositTransaction, TxDeposit}; +mod eip8130; +pub use eip8130::{ + AccountChange, Call, ConfigChange, CreateEntry, Delegation, Eip8130Constants, Eip8130Signed, + InitialOwner, OwnerChange, OwnerChangeType, Scope, TxEip8130, +}; + mod tx_type; -pub use tx_type::DEPOSIT_TX_TYPE_ID; +pub use tx_type::{DEPOSIT_TX_TYPE_ID, EIP8130_REJECTION_MSG, EIP8130_TX_TYPE_ID}; mod envelope; pub use envelope::{BaseTransaction, BaseTxEnvelope, OpTxType}; diff --git a/crates/common/consensus/src/transaction/pooled.rs b/crates/common/consensus/src/transaction/pooled.rs index b3553f9442..6de60c94d0 100644 --- a/crates/common/consensus/src/transaction/pooled.rs +++ b/crates/common/consensus/src/transaction/pooled.rs @@ -12,7 +12,7 @@ use alloy_consensus::{ use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{B256, Signature, TxHash, bytes}; -use crate::BaseTxEnvelope; +use crate::{BaseTxEnvelope, transaction::Eip8130Signed}; /// All possible transactions that can be included in a response to `GetPooledTransactions`. /// A response to `GetPooledTransactions`. This can include a typed signed transaction, but cannot @@ -35,17 +35,28 @@ pub enum BasePooledTransaction { /// A [`TxEip7702`] transaction tagged with type 4. #[envelope(ty = 4)] Eip7702(Signed), + /// An [EIP-8130] Account Abstraction transaction tagged with type 0x7D. + /// + /// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 + #[envelope(ty = 125, typed = TxEip8130)] + Eip8130(Eip8130Signed), } impl BasePooledTransaction { /// Heavy operation that returns the signature hash over rlp encoded transaction. It is only /// for signature signing or signer recovery. + /// + /// Panics on the [`Self::Eip8130`] variant: EIP-8130 transactions do not + /// have a single ECDSA signature. pub fn signature_hash(&self) -> B256 { match self { Self::Legacy(tx) => tx.signature_hash(), Self::Eip2930(tx) => tx.signature_hash(), Self::Eip1559(tx) => tx.signature_hash(), Self::Eip7702(tx) => tx.signature_hash(), + Self::Eip8130(_) => { + unimplemented!("BasePooledTransaction::signature_hash invoked on EIP-8130 variant") + } } } @@ -56,16 +67,23 @@ impl BasePooledTransaction { Self::Eip2930(tx) => tx.hash(), Self::Eip1559(tx) => tx.hash(), Self::Eip7702(tx) => tx.hash(), + Self::Eip8130(tx) => tx.hash(), } } /// Returns the signature of the transaction. - pub const fn signature(&self) -> &Signature { + /// + /// Panics on the [`Self::Eip8130`] variant: EIP-8130 transactions do not + /// have a single ECDSA signature. + pub fn signature(&self) -> &Signature { match self { Self::Legacy(tx) => tx.signature(), Self::Eip2930(tx) => tx.signature(), Self::Eip1559(tx) => tx.signature(), Self::Eip7702(tx) => tx.signature(), + Self::Eip8130(_) => { + unimplemented!("BasePooledTransaction::signature invoked on EIP-8130 variant") + } } } @@ -77,16 +95,23 @@ impl BasePooledTransaction { Self::Eip2930(tx) => tx.tx().encode_for_signing(out), Self::Eip1559(tx) => tx.tx().encode_for_signing(out), Self::Eip7702(tx) => tx.tx().encode_for_signing(out), + Self::Eip8130(tx) => tx.tx().encode_for_signing(out), } } /// Converts the transaction into the ethereum [`TxEnvelope`]. + /// + /// Panics on the [`Self::Eip8130`] variant: EIP-8130 is Base-specific and + /// has no corresponding ethereum envelope variant. pub fn into_envelope(self) -> TxEnvelope { match self { Self::Legacy(tx) => tx.into(), Self::Eip2930(tx) => tx.into(), Self::Eip1559(tx) => tx.into(), Self::Eip7702(tx) => tx.into(), + Self::Eip8130(_) => { + unimplemented!("BasePooledTransaction::into_envelope invoked on EIP-8130 variant") + } } } @@ -97,9 +122,23 @@ impl BasePooledTransaction { Self::Eip2930(tx) => tx.into(), Self::Eip1559(tx) => tx.into(), Self::Eip7702(tx) => tx.into(), + Self::Eip8130(tx) => BaseTxEnvelope::Eip8130(tx), + } + } + + /// Returns the [`Eip8130Signed`] variant if the transaction is an EIP-8130 transaction. + pub const fn as_eip8130(&self) -> Option<&Eip8130Signed> { + match self { + Self::Eip8130(tx) => Some(tx), + _ => None, } } + /// Returns `true` if the transaction is an EIP-8130 (account-abstraction) transaction. + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130(_)) + } + /// Returns the [`TxLegacy`] variant if the transaction is a legacy transaction. pub const fn as_legacy(&self) -> Option<&TxLegacy> { match self { @@ -157,6 +196,12 @@ impl From> for BasePooledTransaction { } } +impl From for BasePooledTransaction { + fn from(v: Eip8130Signed) -> Self { + Self::Eip8130(v) + } +} + impl From for alloy_consensus::transaction::PooledTransaction { fn from(value: BasePooledTransaction) -> Self { match value { @@ -164,6 +209,9 @@ impl From for alloy_consensus::transaction::PooledTransac BasePooledTransaction::Eip2930(tx) => tx.into(), BasePooledTransaction::Eip1559(tx) => tx.into(), BasePooledTransaction::Eip7702(tx) => tx.into(), + BasePooledTransaction::Eip8130(_) => unimplemented!( + "EIP-8130 transactions cannot be converted to ethereum PooledTransaction" + ), } } } @@ -179,6 +227,9 @@ impl alloy_consensus::transaction::SignerRecoverable for BasePooledTransaction { fn recover_signer( &self, ) -> Result { + if let Self::Eip8130(tx) = self { + return tx.explicit_sender().ok_or_else(alloy_consensus::crypto::RecoveryError::new); + } let signature_hash = self.signature_hash(); alloy_consensus::crypto::secp256k1::recover_signer(self.signature(), signature_hash) } @@ -186,6 +237,9 @@ impl alloy_consensus::transaction::SignerRecoverable for BasePooledTransaction { fn recover_signer_unchecked( &self, ) -> Result { + if let Self::Eip8130(tx) = self { + return tx.explicit_sender().ok_or_else(alloy_consensus::crypto::RecoveryError::new); + } let signature_hash = self.signature_hash(); alloy_consensus::crypto::secp256k1::recover_signer_unchecked( self.signature(), @@ -210,6 +264,9 @@ impl alloy_consensus::transaction::SignerRecoverable for BasePooledTransaction { Self::Eip7702(tx) => { alloy_consensus::transaction::SignerRecoverable::recover_unchecked_with_buf(tx, buf) } + Self::Eip8130(tx) => { + tx.explicit_sender().ok_or_else(alloy_consensus::crypto::RecoveryError::new) + } } } } @@ -258,6 +315,7 @@ impl InMemorySize for BasePooledTransaction { Self::Eip2930(tx) => tx.size(), Self::Eip1559(tx) => tx.size(), Self::Eip7702(tx) => tx.size(), + Self::Eip8130(tx) => tx.size(), } } } diff --git a/crates/common/consensus/src/transaction/tx_type.rs b/crates/common/consensus/src/transaction/tx_type.rs index e527fe6d5f..c208f04bb9 100644 --- a/crates/common/consensus/src/transaction/tx_type.rs +++ b/crates/common/consensus/src/transaction/tx_type.rs @@ -9,6 +9,20 @@ use crate::transaction::envelope::OpTxType; /// Identifier for a deposit transaction pub const DEPOSIT_TX_TYPE_ID: u8 = 126; // 0x7E +/// Identifier for an [EIP-8130] Account Abstraction transaction. +/// +/// [EIP-8130]: https://eips.ethereum.org/EIPS/eip-8130 +pub const EIP8130_TX_TYPE_ID: u8 = 125; // 0x7D + +/// Canonical user-facing rejection message for EIP-8130 transactions submitted via RPC. +/// +/// Shared between `base-execution-rpc` (the `BaseInvalidTransactionError::Eip8130NotAccepted` +/// variant) and `ingress-rpc-lib` so both ingress surfaces return identical wording. +/// Centralising this prevents silent drift when the rejection becomes conditional +/// (e.g. fork-gated) in a future PR. +pub const EIP8130_REJECTION_MSG: &str = "EIP-8130 (account abstraction) transactions are not currently accepted via RPC; \ + eth_sendRawTransaction does not accept transaction type 0x7D"; + #[allow(clippy::derivable_impls)] impl Default for OpTxType { fn default() -> Self { @@ -24,19 +38,25 @@ impl Display for OpTxType { Self::Eip1559 => write!(f, "eip1559"), Self::Eip7702 => write!(f, "eip7702"), Self::Deposit => write!(f, "deposit"), + Self::Eip8130 => write!(f, "eip8130"), } } } impl OpTxType { /// List of all variants. - pub const ALL: [Self; 5] = - [Self::Legacy, Self::Eip2930, Self::Eip1559, Self::Eip7702, Self::Deposit]; + pub const ALL: [Self; 6] = + [Self::Legacy, Self::Eip2930, Self::Eip1559, Self::Eip7702, Self::Eip8130, Self::Deposit]; /// Returns `true` if the type is [`OpTxType::Deposit`]. pub const fn is_deposit(&self) -> bool { matches!(self, Self::Deposit) } + + /// Returns `true` if the type is [`OpTxType::Eip8130`]. + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130) + } } impl InMemorySize for OpTxType { @@ -56,12 +76,13 @@ mod tests { #[test] fn test_all_tx_types() { - assert_eq!(OpTxType::ALL.len(), 5); + assert_eq!(OpTxType::ALL.len(), 6); let all = vec![ OpTxType::Legacy, OpTxType::Eip2930, OpTxType::Eip1559, OpTxType::Eip7702, + OpTxType::Eip8130, OpTxType::Deposit, ]; assert_eq!(OpTxType::ALL.to_vec(), all); diff --git a/crates/common/consensus/src/transaction/typed.rs b/crates/common/consensus/src/transaction/typed.rs index 020b9c7fbb..7f8002d812 100644 --- a/crates/common/consensus/src/transaction/typed.rs +++ b/crates/common/consensus/src/transaction/typed.rs @@ -6,7 +6,7 @@ use alloy_eips::Encodable2718; use alloy_primitives::{B256, ChainId, Signature, TxHash, bytes::BufMut}; pub use crate::transaction::envelope::BaseTypedTransaction; -use crate::{BaseTxEnvelope, OpTxType, TxDeposit}; +use crate::{BaseTxEnvelope, OpTxType, TxDeposit, TxEip8130, transaction::Eip8130Signed}; impl From for BaseTypedTransaction { fn from(tx: TxLegacy) -> Self { @@ -38,6 +38,12 @@ impl From for BaseTypedTransaction { } } +impl From for BaseTypedTransaction { + fn from(tx: TxEip8130) -> Self { + Self::Eip8130(tx) + } +} + impl From for BaseTypedTransaction { fn from(envelope: BaseTxEnvelope) -> Self { match envelope { @@ -45,6 +51,7 @@ impl From for BaseTypedTransaction { BaseTxEnvelope::Eip2930(tx) => Self::Eip2930(tx.strip_signature()), BaseTxEnvelope::Eip1559(tx) => Self::Eip1559(tx.strip_signature()), BaseTxEnvelope::Eip7702(tx) => Self::Eip7702(tx.strip_signature()), + BaseTxEnvelope::Eip8130(tx) => Self::Eip8130(tx.into_tx()), BaseTxEnvelope::Deposit(tx) => Self::Deposit(tx.into_inner()), } } @@ -58,6 +65,9 @@ impl From for alloy_rpc_types_eth::TransactionRequest { BaseTypedTransaction::Eip2930(tx) => tx.into(), BaseTypedTransaction::Eip1559(tx) => tx.into(), BaseTypedTransaction::Eip7702(tx) => tx.into(), + BaseTypedTransaction::Eip8130(_) => unimplemented!( + "BaseTypedTransaction::Eip8130 cannot be converted to an alloy TransactionRequest; AA transactions have no single sender/recipient/value to project into the legacy request shape" + ), BaseTypedTransaction::Deposit(tx) => tx.into(), } } @@ -71,20 +81,22 @@ impl BaseTypedTransaction { Self::Eip2930(_) => OpTxType::Eip2930, Self::Eip1559(_) => OpTxType::Eip1559, Self::Eip7702(_) => OpTxType::Eip7702, + Self::Eip8130(_) => OpTxType::Eip8130, Self::Deposit(_) => OpTxType::Deposit, } } /// Calculates the signing hash for the transaction. /// - /// Returns `None` if the tx is a deposit transaction. + /// Returns `None` if the tx is a deposit or EIP-8130 transaction (those + /// do not use a standard ECDSA single-signature path). pub fn checked_signature_hash(&self) -> Option { match self { Self::Legacy(tx) => Some(tx.signature_hash()), Self::Eip2930(tx) => Some(tx.signature_hash()), Self::Eip1559(tx) => Some(tx.signature_hash()), Self::Eip7702(tx) => Some(tx.signature_hash()), - Self::Deposit(_) => None, + Self::Eip8130(_) | Self::Deposit(_) => None, } } @@ -125,15 +137,33 @@ impl BaseTypedTransaction { matches!(self, Self::Deposit(_)) } + /// Return the inner EIP-8130 transaction if it exists. + pub const fn eip8130(&self) -> Option<&TxEip8130> { + match self { + Self::Eip8130(tx) => Some(tx), + _ => None, + } + } + + /// Returns `true` if transaction is an EIP-8130 transaction. + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130(_)) + } + /// Calculate the transaction hash for the given signature. /// - /// Note: Returns the regular tx hash if this is a deposit variant + /// For a deposit variant the regular tx hash is returned (signature ignored). + /// Panics on an EIP-8130 variant: that variant has no ECDSA signature and + /// callers must hash through the [`BaseTxEnvelope`] path instead. pub fn tx_hash(&self, signature: &Signature) -> TxHash { match self { Self::Legacy(tx) => tx.tx_hash(signature), Self::Eip2930(tx) => tx.tx_hash(signature), Self::Eip1559(tx) => tx.tx_hash(signature), Self::Eip7702(tx) => tx.tx_hash(signature), + Self::Eip8130(_) => unimplemented!( + "BaseTypedTransaction::tx_hash invoked on an EIP-8130 variant; use Eip8130Signed::hash via the envelope path" + ), Self::Deposit(tx) => tx.tx_hash(), } } @@ -155,6 +185,10 @@ impl BaseTypedTransaction { Self::Eip2930(tx) => Ok(tx.into()), Self::Eip1559(tx) => Ok(tx.into()), Self::Eip7702(tx) => Ok(tx.into()), + tx @ Self::Eip8130(_) => Err(ValueError::new( + tx, + "EIP-8130 transactions cannot be converted to ethereum transaction", + )), tx @ Self::Deposit(_) => Err(ValueError::new( tx, "Deposit transactions cannot be converted to ethereum transaction", @@ -170,6 +204,7 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.rlp_encoded_fields_length(), Self::Eip1559(tx) => tx.rlp_encoded_fields_length(), Self::Eip7702(tx) => tx.rlp_encoded_fields_length(), + Self::Eip8130(tx) => tx.rlp_encoded_fields_length(), Self::Deposit(tx) => tx.rlp_encoded_fields_length(), } } @@ -180,6 +215,7 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.rlp_encode_fields(out), Self::Eip1559(tx) => tx.rlp_encode_fields(out), Self::Eip7702(tx) => tx.rlp_encode_fields(out), + Self::Eip8130(tx) => tx.rlp_encode_fields(out), Self::Deposit(tx) => tx.rlp_encode_fields(out), } } @@ -190,6 +226,9 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.eip2718_encode_with_type(signature, tx.ty(), out), Self::Eip1559(tx) => tx.eip2718_encode_with_type(signature, tx.ty(), out), Self::Eip7702(tx) => tx.eip2718_encode_with_type(signature, tx.ty(), out), + Self::Eip8130(_) => unimplemented!( + "BaseTypedTransaction::eip2718_encode_with_type invoked on EIP-8130 variant; use Eip8130Signed::encode_2718" + ), Self::Deposit(tx) => tx.encode_2718(out), } } @@ -200,6 +239,9 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.eip2718_encode(signature, out), Self::Eip1559(tx) => tx.eip2718_encode(signature, out), Self::Eip7702(tx) => tx.eip2718_encode(signature, out), + Self::Eip8130(_) => unimplemented!( + "BaseTypedTransaction::eip2718_encode invoked on EIP-8130 variant; use Eip8130Signed::encode_2718" + ), Self::Deposit(tx) => tx.encode_2718(out), } } @@ -210,6 +252,9 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.network_encode_with_type(signature, tx.ty(), out), Self::Eip1559(tx) => tx.network_encode_with_type(signature, tx.ty(), out), Self::Eip7702(tx) => tx.network_encode_with_type(signature, tx.ty(), out), + Self::Eip8130(_) => unimplemented!( + "BaseTypedTransaction::network_encode_with_type invoked on EIP-8130 variant" + ), Self::Deposit(tx) => tx.network_encode(out), } } @@ -220,6 +265,9 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.network_encode(signature, out), Self::Eip1559(tx) => tx.network_encode(signature, out), Self::Eip7702(tx) => tx.network_encode(signature, out), + Self::Eip8130(_) => { + unimplemented!("BaseTypedTransaction::network_encode invoked on EIP-8130 variant") + } Self::Deposit(tx) => tx.network_encode(out), } } @@ -230,6 +278,11 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.tx_hash_with_type(signature, tx.ty()), Self::Eip1559(tx) => tx.tx_hash_with_type(signature, tx.ty()), Self::Eip7702(tx) => tx.tx_hash_with_type(signature, tx.ty()), + Self::Eip8130(_) => { + unimplemented!( + "BaseTypedTransaction::tx_hash_with_type invoked on EIP-8130 variant" + ) + } Self::Deposit(tx) => tx.tx_hash(), } } @@ -240,6 +293,9 @@ impl RlpEcdsaEncodableTx for BaseTypedTransaction { Self::Eip2930(tx) => tx.tx_hash(signature), Self::Eip1559(tx) => tx.tx_hash(signature), Self::Eip7702(tx) => tx.tx_hash(signature), + Self::Eip8130(_) => { + unimplemented!("BaseTypedTransaction::tx_hash invoked on EIP-8130 variant") + } Self::Deposit(tx) => tx.tx_hash(), } } @@ -252,6 +308,7 @@ impl SignableTransaction for BaseTypedTransaction { Self::Eip2930(tx) => tx.set_chain_id(chain_id), Self::Eip1559(tx) => tx.set_chain_id(chain_id), Self::Eip7702(tx) => tx.set_chain_id(chain_id), + Self::Eip8130(tx) => tx.set_chain_id(chain_id), Self::Deposit(_) => {} } } @@ -262,6 +319,7 @@ impl SignableTransaction for BaseTypedTransaction { Self::Eip2930(tx) => tx.encode_for_signing(out), Self::Eip1559(tx) => tx.encode_for_signing(out), Self::Eip7702(tx) => tx.encode_for_signing(out), + Self::Eip8130(tx) => tx.encode_for_signing(out), Self::Deposit(_) => {} } } @@ -272,6 +330,7 @@ impl SignableTransaction for BaseTypedTransaction { Self::Eip2930(tx) => tx.payload_len_for_signature(), Self::Eip1559(tx) => tx.payload_len_for_signature(), Self::Eip7702(tx) => tx.payload_len_for_signature(), + Self::Eip8130(tx) => tx.payload_len_for_signature(), Self::Deposit(_) => 0, } } @@ -292,7 +351,14 @@ impl InMemorySize for BaseTypedTransaction { Self::Eip2930(tx) => tx.size(), Self::Eip1559(tx) => tx.size(), Self::Eip7702(tx) => tx.size(), + Self::Eip8130(tx) => tx.size(), Self::Deposit(tx) => tx.size(), } } } + +impl From for BaseTxEnvelope { + fn from(signed: Eip8130Signed) -> Self { + Self::Eip8130(signed) + } +} diff --git a/crates/common/evm/src/evm.rs b/crates/common/evm/src/evm.rs index 4a672b9bb7..96a05d0505 100644 --- a/crates/common/evm/src/evm.rs +++ b/crates/common/evm/src/evm.rs @@ -6,7 +6,7 @@ use revm::{ DatabaseCommit, ExecuteCommitEvm, ExecuteEvm, InspectCommitEvm, InspectEvm, InspectSystemCallEvm, Inspector, SystemCallEvm, context::{ - BlockEnv, ContextError, ContextSetters, Evm as RevmEvm, FrameStack, TxEnv, + BlockEnv, CfgEnv, ContextError, ContextSetters, Evm as RevmEvm, FrameStack, TxEnv, result::ExecResultAndState, }, context_interface::{ @@ -371,6 +371,10 @@ where self.cfg.chain_id } + fn cfg_env(&self) -> &CfgEnv { + &self.cfg + } + /// Executes `tx`, invoking the [`Inspector`] iff `self.inspect` is `true`. /// Uses [`InspectEvm::inspect_tx`] for the instrumented path and [`ExecuteEvm::transact`] /// for the uninstrumented path; both finalize the journal and return [`ResultAndState`]. @@ -416,3 +420,85 @@ where ) } } + +#[cfg(test)] +mod tests { + use alloc::vec; + + use alloy_evm::{ + EvmFactory, EvmInternals, + precompiles::{Precompile, PrecompileInput}, + }; + use alloy_primitives::{Address, U256}; + use base_common_precompiles::{ + JOVIAN, JOVIAN_G1_MSM, JOVIAN_G1_MSM_MAX_INPUT_SIZE, JOVIAN_G2_MSM, + JOVIAN_G2_MSM_MAX_INPUT_SIZE, JOVIAN_MAX_INPUT_SIZE, JOVIAN_PAIRING, + JOVIAN_PAIRING_MAX_INPUT_SIZE, + }; + use revm::{context::CfgEnv, database::EmptyDB}; + use rstest::rstest; + + use super::*; + use crate::{BaseEvmFactory, BaseSpecId, BaseUpgrade}; + + #[rstest] + #[case::bn254_pair(*JOVIAN.address(), JOVIAN_MAX_INPUT_SIZE)] + #[case::bls12_g1_msm(*JOVIAN_G1_MSM.address(), JOVIAN_G1_MSM_MAX_INPUT_SIZE)] + #[case::bls12_g2_msm(*JOVIAN_G2_MSM.address(), JOVIAN_G2_MSM_MAX_INPUT_SIZE)] + #[case::bls12_pairing(*JOVIAN_PAIRING.address(), JOVIAN_PAIRING_MAX_INPUT_SIZE)] + fn precompile_jovian_at_max_input(#[case] address: Address, #[case] max_size: usize) { + let mut evm = BaseEvmFactory::default().create_evm( + EmptyDB::default(), + EvmEnv::new( + CfgEnv::new_with_spec(BaseSpecId::new(BaseUpgrade::Jovian)), + BlockEnv::default(), + ), + ); + let (precompiles, ctx) = (&mut evm.inner.precompiles, &mut evm.inner.ctx); + let precompile = precompiles.get(&address).unwrap(); + let result = precompile.call(PrecompileInput { + data: &vec![0; max_size], + gas: u64::MAX, + caller: Address::ZERO, + value: U256::ZERO, + is_static: false, + target_address: Address::ZERO, + bytecode_address: Address::ZERO, + reservoir: 0, + internals: EvmInternals::from_context(ctx), + }); + assert!(result.is_ok(), "precompile {address} should succeed at max input size"); + } + + #[rstest] + #[case::bn254_pair(*JOVIAN.address(), JOVIAN_MAX_INPUT_SIZE)] + #[case::bls12_g1_msm(*JOVIAN_G1_MSM.address(), JOVIAN_G1_MSM_MAX_INPUT_SIZE)] + #[case::bls12_g2_msm(*JOVIAN_G2_MSM.address(), JOVIAN_G2_MSM_MAX_INPUT_SIZE)] + #[case::bls12_pairing(*JOVIAN_PAIRING.address(), JOVIAN_PAIRING_MAX_INPUT_SIZE)] + fn precompile_jovian_over_max_input(#[case] address: Address, #[case] max_size: usize) { + let mut evm = BaseEvmFactory::default().create_evm( + EmptyDB::default(), + EvmEnv::new( + CfgEnv::new_with_spec(BaseSpecId::new(BaseUpgrade::Jovian)), + BlockEnv::default(), + ), + ); + let (precompiles, ctx) = (&mut evm.inner.precompiles, &mut evm.inner.ctx); + let precompile = precompiles.get(&address).unwrap(); + let result = precompile.call(PrecompileInput { + data: &vec![0; max_size + 1], + gas: u64::MAX, + caller: Address::ZERO, + value: U256::ZERO, + is_static: false, + target_address: Address::ZERO, + bytecode_address: Address::ZERO, + reservoir: 0, + internals: EvmInternals::from_context(ctx), + }); + assert!( + result.is_err(), + "precompile {address} should fail over max input size, got {result:?}" + ); + } +} diff --git a/crates/common/evm/src/executor/block_executor.rs b/crates/common/evm/src/executor/block_executor.rs index f1edd61a9d..023e278d44 100644 --- a/crates/common/evm/src/executor/block_executor.rs +++ b/crates/common/evm/src/executor/block_executor.rs @@ -8,8 +8,8 @@ use alloy_evm::{ Database, Evm, FromRecoveredTx, FromTxWithEncoded, RecoveredTx, block::{ BlockExecutionError, BlockExecutionResult, BlockExecutor, BlockValidationError, - ExecutableTx, OnStateHook, StateChangePostBlockSource, StateChangeSource, StateDB, - SystemCaller, + ExecutableTx, GasOutput, OnStateHook, StateChangePostBlockSource, StateChangeSource, + StateDB, SystemCaller, state_changes::{balance_increment_state, post_block_balance_increments}, }, eth::{EthTxResult, receipt_builder::ReceiptBuilderCtx}, @@ -118,7 +118,10 @@ where DB: Database + DatabaseCommit + StateDB, Tx: FromRecoveredTx + FromTxWithEncoded + BaseTxEnv, >, - R: BaseReceiptBuilder, + R: BaseReceiptBuilder< + Transaction: Transaction + Encodable2718 + TransactionEnvelope, + Receipt: TxReceipt, + >, Spec: Upgrades, { type Transaction = R::Transaction; @@ -127,11 +130,6 @@ where type Result = BaseTxResult::TxType>; fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { - // Set state clear flag if the block is after the Spurious Dragon hardfork. - let state_clear_flag = - self.spec.is_spurious_dragon_active_at_block(self.evm.block().number().saturating_to()); - self.evm.db_mut().set_state_clear_flag(state_clear_flag); - self.system_caller.apply_blockhashes_contract_call(self.ctx.parent_hash, &mut self.evm)?; self.system_caller .apply_beacon_root_contract_call(self.ctx.parent_beacon_block_root, &mut self.evm)?; @@ -198,6 +196,13 @@ where BlockExecutionError::evm(err, hash) })?; + // Fetch the depositor account from the database for the deposit nonce. + // This *only* needs to be done post-Regolith for deposit transactions. + let depositor = (self.is_regolith && is_deposit) + .then(|| self.evm.db_mut().basic(*tx.signer()).map(|acc| acc.unwrap_or_default())) + .transpose() + .map_err(BlockExecutionError::other)?; + Ok(BaseTxResult { inner: EthTxResult { result, @@ -206,33 +211,25 @@ where }, is_deposit, sender: *tx.signer(), + depositor, }) } - fn commit_transaction(&mut self, output: Self::Result) -> Result { + fn commit_transaction(&mut self, output: Self::Result) -> GasOutput { let BaseTxResult { inner: EthTxResult { result: ResultAndState { result, state }, blob_gas_used, tx_type }, is_deposit, - sender, + sender: _, + depositor, } = output; - // Fetch the depositor account from the database for the deposit nonce. - // Note that this *only* needs to be done post-regolith hardfork, as deposit nonces - // were not introduced in Bedrock. In addition, regular transactions don't have deposit - // nonces, so we don't need to touch the DB for those. - let depositor = (self.is_regolith && is_deposit) - .then(|| self.evm.db_mut().basic(sender).map(|acc| acc.unwrap_or_default())) - .transpose() - .map_err(BlockExecutionError::other)?; - self.system_caller.on_state(StateChangeSource::Transaction(self.receipts.len()), &state); - let gas_used = result.gas_used(); + let tx_gas_used = result.tx_gas_used(); + let state_gas_used = result.gas().block_state_gas_used(); - // append gas used - self.gas_used += gas_used; + self.gas_used += tx_gas_used; - // Update DA footprint if Jovian is active. if self.spec.is_jovian_active_at_timestamp(self.evm.block().timestamp().saturating_to()) && !is_deposit { @@ -250,21 +247,14 @@ where Ok(receipt) => receipt, Err(ctx) => { let receipt = alloy_consensus::Receipt { - // Success flag was added in `EIP-658: Embedding transaction status code - // in receipts`. status: Eip658Value::Eip658(ctx.result.is_success()), - cumulative_gas_used: self.gas_used, + cumulative_gas_used: ctx.cumulative_gas_used, logs: ctx.result.into_logs(), }; self.receipt_builder.build_deposit_receipt(DepositReceipt { inner: receipt, deposit_nonce: depositor.map(|account| account.nonce), - // The deposit receipt version was introduced in Canyon to indicate an - // update to how receipt hashes should be computed - // when set. The state transition process ensures - // this is only set for post-Canyon deposit - // transactions. deposit_receipt_version: (is_deposit && self.spec.is_canyon_active_at_timestamp( self.evm.block().timestamp().saturating_to(), @@ -277,7 +267,7 @@ where self.evm.db_mut().commit(state); - Ok(gas_used) + GasOutput::with_state_gas(tx_gas_used, state_gas_used) } fn finish( @@ -605,13 +595,13 @@ mod tests { let gas_used_tx = executor.execute_transaction(&tx).expect("failed to execute transaction"); // The gas used when executing the transaction should be the legacy value... - assert!(gas_used_tx < expected_da_footprint); + assert!(gas_used_tx.tx_gas_used() < expected_da_footprint); // The gas used when finishing the executor should be the DA footprint since this is higher // than the legacy gas used and jovian is active... let (_, result) = executor.finish().expect("failed to finish executor"); assert_eq!(result.blob_gas_used, expected_da_footprint); - assert_eq!(result.gas_used, gas_used_tx); + assert_eq!(result.gas_used, gas_used_tx.tx_gas_used()); assert!(result.blob_gas_used > result.gas_used); } } diff --git a/crates/common/evm/src/executor/factory.rs b/crates/common/evm/src/executor/factory.rs index 908a1df387..82785b8a13 100644 --- a/crates/common/evm/src/executor/factory.rs +++ b/crates/common/evm/src/executor/factory.rs @@ -1,17 +1,17 @@ //! Contains the factory. -use alloy_consensus::{Transaction, TxReceipt}; +use alloy_consensus::{Transaction, TransactionEnvelope, TxReceipt}; use alloy_eips::Encodable2718; use alloy_evm::{ - Database, EvmFactory, FromRecoveredTx, FromTxWithEncoded, - block::{BlockExecutorFactory, BlockExecutorFor}, + EvmFactory, FromRecoveredTx, FromTxWithEncoded, + block::{BlockExecutorFactory, StateDB}, }; use base_common_chains::{ChainUpgrades, Upgrades}; -use revm::{Inspector, database::State}; +use revm::Inspector; use crate::{ AlloyReceiptBuilder, BaseBlockExecutionCtx, BaseBlockExecutor, BaseEvmFactory, - BaseReceiptBuilder, BaseTxEnv, + BaseReceiptBuilder, BaseTxEnv, BaseTxResult, }; /// Ethereum block executor factory. @@ -54,8 +54,11 @@ impl BaseBlockExecutorFactory { impl BlockExecutorFactory for BaseBlockExecutorFactory where - R: BaseReceiptBuilder, - Spec: Upgrades, + R: BaseReceiptBuilder< + Transaction: Transaction + Encodable2718 + TransactionEnvelope, + Receipt: TxReceipt, + > + Clone, + Spec: Upgrades + Clone, EvmF: EvmFactory< Tx: FromRecoveredTx + FromTxWithEncoded + BaseTxEnv, >, @@ -65,6 +68,12 @@ where type ExecutionCtx<'a> = BaseBlockExecutionCtx; type Transaction = R::Transaction; type Receipt = R::Receipt; + type TxExecutionResult = BaseTxResult< + ::HaltReason, + ::TxType, + >; + type Executor<'a, DB: StateDB, I: Inspector>> = + BaseBlockExecutor, R, Spec>; fn evm_factory(&self) -> &Self::EvmFactory { &self.evm_factory @@ -72,13 +81,13 @@ where fn create_executor<'a, DB, I>( &'a self, - evm: EvmF::Evm<&'a mut State, I>, + evm: EvmF::Evm, ctx: Self::ExecutionCtx<'a>, - ) -> impl BlockExecutorFor<'a, Self, DB, I> + ) -> Self::Executor<'a, DB, I> where - DB: Database + 'a, - I: Inspector>> + 'a, + DB: StateDB, + I: Inspector>, { - BaseBlockExecutor::new(evm, ctx, &self.spec, &self.receipt_builder) + BaseBlockExecutor::new(evm, ctx, self.spec.clone(), self.receipt_builder.clone()) } } diff --git a/crates/common/evm/src/executor/result.rs b/crates/common/evm/src/executor/result.rs index 317d56f2ef..5234785369 100644 --- a/crates/common/evm/src/executor/result.rs +++ b/crates/common/evm/src/executor/result.rs @@ -2,7 +2,7 @@ use alloy_evm::{block::TxResult as TxResultTrait, eth::EthTxResult}; use alloy_primitives::Address; -use revm::context::result::ResultAndState; +use revm::{context::result::ResultAndState, state::AccountInfo}; /// The result of executing a Base transaction. #[derive(Debug)] @@ -13,12 +13,18 @@ pub struct BaseTxResult { pub is_deposit: bool, /// The sender of the transaction. pub sender: Address, + /// The depositor account info, fetched during execution for post-Regolith deposit nonce. + pub depositor: Option, } -impl TxResultTrait for BaseTxResult { +impl TxResultTrait for BaseTxResult { type HaltReason = H; fn result(&self) -> &ResultAndState { &self.inner.result } + + fn into_result(self) -> ResultAndState { + self.inner.result + } } diff --git a/crates/common/evm/src/factory.rs b/crates/common/evm/src/factory.rs index 7e63de6c82..018490d95b 100644 --- a/crates/common/evm/src/factory.rs +++ b/crates/common/evm/src/factory.rs @@ -1,4 +1,5 @@ use alloy_evm::{Database, EvmEnv, EvmFactory, precompiles::PrecompilesMap}; +use alloy_primitives::Address; use revm::{ Context, Inspector, context::{BlockEnv, TxEnv}, @@ -13,12 +14,50 @@ use crate::{ /// Factory that produces [`BaseEvm`] instances backed by a [`PrecompilesMap`]. /// -/// [`BasePrecompiles`] are eagerly flattened into a [`PrecompilesMap`] on construction -/// so that precompile dispatch is a single hash-map lookup rather than a spec-aware -/// branch on every call. -#[derive(Debug, Default, Clone, Copy)] +/// Base precompiles are eagerly flattened into a [`PrecompilesMap`] on construction so that +/// precompile dispatch is a single hash-map lookup rather than a spec-aware branch on every call. +#[derive(Debug, Clone, Copy)] #[non_exhaustive] -pub struct BaseEvmFactory; +pub struct BaseEvmFactory { + /// Activation registry admin address. + activation_admin_address: Option
, +} + +impl BaseEvmFactory { + /// Creates a new [`BaseEvmFactory`] with the given activation registry admin address. + pub const fn new(activation_admin_address: Option
) -> Self { + Self { activation_admin_address } + } + + /// Returns the activation registry admin address. + pub const fn activation_admin_address(&self) -> Option
{ + self.activation_admin_address + } + + /// Returns this factory with the activation registry admin address set. + #[must_use] + pub const fn with_activation_admin_address( + mut self, + activation_admin_address: Option
, + ) -> Self { + self.set_activation_admin_address(activation_admin_address); + self + } + + /// Sets the activation registry admin address. + pub const fn set_activation_admin_address( + &mut self, + activation_admin_address: Option
, + ) { + self.activation_admin_address = activation_admin_address; + } +} + +impl Default for BaseEvmFactory { + fn default() -> Self { + Self::new(None) + } +} impl EvmFactory for BaseEvmFactory { type Evm>> = BaseEvm; @@ -43,9 +82,11 @@ impl EvmFactory for BaseEvmFactory { .with_cfg(input.cfg_env) .build_base() .with_inspector(NoOpInspector {}) - .with_precompiles(PrecompilesMap::from_static( - BasePrecompiles::new_with_spec(spec_id).precompiles(), - )) + .with_precompiles( + BasePrecompiles::new_with_spec(spec_id) + .with_activation_admin_address(self.activation_admin_address) + .install(), + ) } fn create_evm_with_inspector>>( @@ -60,8 +101,10 @@ impl EvmFactory for BaseEvmFactory { .with_block(input.block_env) .with_cfg(input.cfg_env) .build_with_inspector(inspector) - .with_precompiles(PrecompilesMap::from_static( - BasePrecompiles::new_with_spec(spec_id).precompiles(), - )) + .with_precompiles( + BasePrecompiles::new_with_spec(spec_id) + .with_activation_admin_address(self.activation_admin_address) + .install(), + ) } } diff --git a/crates/common/evm/src/handler.rs b/crates/common/evm/src/handler.rs index 336726bd6b..cf1582063e 100644 --- a/crates/common/evm/src/handler.rs +++ b/crates/common/evm/src/handler.rs @@ -1,5 +1,5 @@ //! Handler related to Base chain -use alloc::boxed::Box; +use alloc::{boxed::Box, vec::Vec}; use base_common_chains::BaseUpgrade; use base_common_consensus::Predeploys; @@ -11,8 +11,9 @@ use revm::{ }, context_interface::{ Block, Cfg, ContextTr, JournalTr, Transaction, + cfg::gas::InitialAndFloorGas, context::ContextError, - result::{EVMError, ExecutionResult, FromStringError}, + result::{EVMError, ExecutionResult, FromStringError, ResultGas}, }, handler::{ EthFrame, EvmTr, FrameResult, Handler, MainnetHandler, @@ -102,6 +103,7 @@ where fn validate_against_state_and_deduct_caller( &self, evm: &mut Self::Evm, + _initial_and_floor_gas: &mut InitialAndFloorGas, ) -> Result<(), Self::Error> { let (block, tx, cfg, journal, chain, _) = evm.ctx().all_mut(); let spec = cfg.spec(); @@ -303,6 +305,7 @@ where &mut self, evm: &mut Self::Evm, frame_result: <::Frame as FrameTr>::FrameResult, + result_gas: ResultGas, ) -> Result, Self::Error> { match core::mem::replace(evm.ctx().error(), Ok(())) { Err(ContextError::Db(e)) => return Err(e.into()), @@ -310,8 +313,8 @@ where Ok(_) => (), } - let exec_result = - post_execution::output(evm.ctx(), frame_result).map_haltreason(BaseHaltReason::Base); + let exec_result = post_execution::output(evm.ctx(), frame_result, result_gas) + .map_haltreason(BaseHaltReason::Base); if exec_result.is_halt() { let is_deposit = evm.ctx().tx().tx_type() == DEPOSIT_TRANSACTION_TYPE; @@ -366,7 +369,11 @@ where 0 }; // clear the journal - output = Ok(ExecutionResult::Halt { reason: BaseHaltReason::FailedDeposit, gas_used }) + output = Ok(ExecutionResult::Halt { + reason: BaseHaltReason::FailedDeposit, + gas: ResultGas::new_with_state_gas(gas_used, 0, 0, 0), + logs: Vec::new(), + }) } // do the cleanup @@ -438,7 +445,7 @@ mod tests { let gas = call_last_frame_return(ctx, InstructionResult::Revert, Gas::new(90)); assert_eq!(gas.remaining(), 90); - assert_eq!(gas.spent(), 10); + assert_eq!(gas.total_gas_spent(), 10); assert_eq!(gas.refunded(), 0); } @@ -450,7 +457,7 @@ mod tests { let gas = call_last_frame_return(ctx, InstructionResult::Stop, Gas::new(90)); assert_eq!(gas.remaining(), 90); - assert_eq!(gas.spent(), 10); + assert_eq!(gas.total_gas_spent(), 10); assert_eq!(gas.refunded(), 0); } @@ -470,12 +477,12 @@ mod tests { let gas = call_last_frame_return(ctx.clone(), InstructionResult::Stop, ret_gas); assert_eq!(gas.remaining(), 90); - assert_eq!(gas.spent(), 10); + assert_eq!(gas.total_gas_spent(), 10); assert_eq!(gas.refunded(), 2); // min(20, 10/5) let gas = call_last_frame_return(ctx, InstructionResult::Revert, ret_gas); assert_eq!(gas.remaining(), 90); - assert_eq!(gas.spent(), 10); + assert_eq!(gas.total_gas_spent(), 10); assert_eq!(gas.refunded(), 0); } @@ -491,7 +498,7 @@ mod tests { .with_cfg(CfgEnv::new_with_spec(BaseSpecId::new(BaseUpgrade::Bedrock))); let gas = call_last_frame_return(ctx, InstructionResult::Stop, Gas::new(90)); assert_eq!(gas.remaining(), 0); - assert_eq!(gas.spent(), 100); + assert_eq!(gas.total_gas_spent(), 100); assert_eq!(gas.refunded(), 0); } @@ -508,7 +515,7 @@ mod tests { .with_cfg(CfgEnv::new_with_spec(BaseSpecId::new(BaseUpgrade::Bedrock))); let gas = call_last_frame_return(ctx, InstructionResult::Stop, Gas::new(90)); assert_eq!(gas.remaining(), 100); - assert_eq!(gas.spent(), 0); + assert_eq!(gas.total_gas_spent(), 0); assert_eq!(gas.refunded(), 0); } @@ -539,7 +546,10 @@ mod tests { let handler = BaseHandler::<_, EVMError<_, BaseTransactionError>, EthFrame>::new(); - handler.validate_against_state_and_deduct_caller(&mut evm).unwrap(); + let mut init_and_floor_gas = InitialAndFloorGas::new(0, 0); + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut init_and_floor_gas) + .unwrap(); // Check the account balance is updated. let account = evm.ctx_mut().journal_mut().load_account(caller).unwrap(); @@ -580,7 +590,10 @@ mod tests { let handler = BaseHandler::<_, EVMError<_, BaseTransactionError>, EthFrame>::new(); - handler.validate_against_state_and_deduct_caller(&mut evm).unwrap(); + let mut init_and_floor_gas = InitialAndFloorGas::new(0, 0); + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut init_and_floor_gas) + .unwrap(); // Check the account balance is updated. let account = evm.ctx_mut().journal_mut().load_account(caller).unwrap(); @@ -634,7 +647,10 @@ mod tests { let handler = BaseHandler::<_, EVMError<_, BaseTransactionError>, EthFrame>::new(); - handler.validate_against_state_and_deduct_caller(&mut evm).unwrap(); + let mut init_and_floor_gas = InitialAndFloorGas::new(0, 0); + handler + .validate_against_state_and_deduct_caller(&mut evm, &mut init_and_floor_gas) + .unwrap(); assert_eq!( *evm.ctx().chain(), diff --git a/crates/common/evm/src/precompiles.rs b/crates/common/evm/src/precompiles/mod.rs similarity index 69% rename from crates/common/evm/src/precompiles.rs rename to crates/common/evm/src/precompiles/mod.rs index c3cda9f24d..a93597dc22 100644 --- a/crates/common/evm/src/precompiles.rs +++ b/crates/common/evm/src/precompiles/mod.rs @@ -10,7 +10,7 @@ mod tests { use alloc::{vec, vec::Vec}; use revm::{ - precompile::{PrecompileError, bn254, modexp, secp256r1}, + precompile::{bn254, modexp, secp256r1}, primitives::eip7823, }; @@ -37,10 +37,8 @@ mod tests { let bn254_pair = precompiles.precompiles().get(&bn254::pair::ADDRESS).unwrap(); let input = vec![0u8; 81_984 + bn254::PAIR_ELEMENT_LEN]; - assert!(matches!( - bn254_pair.execute(&input, u64::MAX), - Err(PrecompileError::Bn254PairLength) - )); + let result = bn254_pair.execute(&input, u64::MAX, 0); + assert!(result.is_err(), "expected error for oversized bn254 pair input, got {result:?}"); } #[test] @@ -54,13 +52,18 @@ mod tests { let azul_p256 = azul_precompiles.precompiles().get(secp256r1::P256VERIFY_OSAKA.address()).unwrap(); - assert!(jovian_p256.execute(&[], 5_000).is_ok()); - assert!(matches!(azul_p256.execute(&[], 5_000), Err(PrecompileError::OutOfGas))); + assert!(jovian_p256.execute(&[], 5_000, 0).is_ok()); + let azul_result = azul_p256.execute(&[], 5_000, 0); + assert!( + matches!(&azul_result, Ok(output) if output.halt_reason().is_some()), + "expected halt for azul p256, got {azul_result:?}" + ); let azul_modexp = azul_precompiles.precompiles().get(modexp::OSAKA.address()).unwrap(); - assert!(matches!( - azul_modexp.execute(&oversized_modexp_input(), u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - )); + let modexp_result = azul_modexp.execute(&oversized_modexp_input(), u64::MAX, 0); + assert!( + matches!(&modexp_result, Ok(output) if output.halt_reason().is_some()), + "expected halt for oversized modexp, got {modexp_result:?}" + ); } } diff --git a/crates/common/evm/src/receipt_builder.rs b/crates/common/evm/src/receipt_builder.rs index 7bedc7ca8e..cb26d7e33f 100644 --- a/crates/common/evm/src/receipt_builder.rs +++ b/crates/common/evm/src/receipt_builder.rs @@ -1,17 +1,21 @@ //! Abstraction over receipt building logic to allow plugging different primitive types into //! [`super::BaseBlockExecutor`]. +use alloc::boxed::Box; use core::fmt::Debug; use alloy_consensus::{Eip658Value, TransactionEnvelope}; use alloy_evm::{Evm, eth::receipt_builder::ReceiptBuilderCtx}; use base_common_consensus::{BaseReceiptEnvelope, BaseTxEnvelope, DepositReceipt, OpTxType}; +/// Boxed receipt-builder context returned for deposit transactions. +pub(crate) type ReceiptBuilderError<'a, Tx, E> = Box>; + /// Type that knows how to build a receipt based on execution result. #[auto_impl::auto_impl(&, Arc)] pub trait BaseReceiptBuilder: Debug { /// Transaction type. - type Transaction: TransactionEnvelope; + type Transaction: TransactionEnvelope; /// Receipt type. type Receipt; @@ -24,7 +28,7 @@ pub trait BaseReceiptBuilder: Debug { ctx: ReceiptBuilderCtx<'a, ::TxType, E>, ) -> Result< Self::Receipt, - ReceiptBuilderCtx<'a, ::TxType, E>, + ReceiptBuilderError<'a, ::TxType, E>, >; /// Builds receipt for a deposit transaction. @@ -43,9 +47,9 @@ impl BaseReceiptBuilder for AlloyReceiptBuilder { fn build_receipt<'a, E: Evm>( &self, ctx: ReceiptBuilderCtx<'a, OpTxType, E>, - ) -> Result> { + ) -> Result> { match ctx.tx_type { - OpTxType::Deposit => Err(ctx), + OpTxType::Deposit => Err(Box::new(ctx)), ty => { let receipt = alloy_consensus::Receipt { status: Eip658Value::Eip658(ctx.result.is_success()), @@ -60,6 +64,7 @@ impl BaseReceiptBuilder for AlloyReceiptBuilder { OpTxType::Eip1559 => BaseReceiptEnvelope::Eip1559(receipt), OpTxType::Eip7702 => BaseReceiptEnvelope::Eip7702(receipt), OpTxType::Deposit => unreachable!(), + OpTxType::Eip8130 => BaseReceiptEnvelope::Eip8130(receipt), }) } } diff --git a/crates/common/evm/src/transaction/core.rs b/crates/common/evm/src/transaction/core.rs index 1dbee6672c..6d195db205 100644 --- a/crates/common/evm/src/transaction/core.rs +++ b/crates/common/evm/src/transaction/core.rs @@ -194,15 +194,11 @@ where } #[cfg(feature = "reth")] -impl reth_evm::TransactionEnv for BaseTransaction { +impl reth_evm::TransactionEnvMut for BaseTransaction { fn set_gas_limit(&mut self, gas_limit: u64) { self.base.set_gas_limit(gas_limit); } - fn nonce(&self) -> u64 { - reth_evm::TransactionEnv::nonce(&self.base) - } - fn set_nonce(&mut self, nonce: u64) { self.base.set_nonce(nonce); } @@ -242,6 +238,9 @@ impl FromTxWithEncoded for BaseTransaction { enveloped_tx: Some(encoded), deposit: Default::default(), }, + BaseTxEnvelope::Eip8130(_) => { + unimplemented!("EVM execution for EIP-8130 BaseTxEnvelope is not yet implemented") + } BaseTxEnvelope::Deposit(tx) => Self::from_encoded_tx(tx.inner(), caller, encoded), } } diff --git a/crates/common/genesis/src/rollup.rs b/crates/common/genesis/src/rollup.rs index 8c97f33eef..75c62e08e1 100644 --- a/crates/common/genesis/src/rollup.rs +++ b/crates/common/genesis/src/rollup.rs @@ -158,145 +158,98 @@ impl EthereumHardforks for RollupConfig { } } -impl RollupConfig { - /// Returns true if Regolith is active at the given timestamp. - pub fn is_regolith_active(&self, timestamp: u64) -> bool { - self.hardforks.regolith_time.is_some_and(|t| timestamp >= t) - || self.is_canyon_active(timestamp) - } - - /// Returns true if the timestamp marks the first Regolith block. - pub fn is_first_regolith_block(&self, timestamp: u64) -> bool { - self.is_regolith_active(timestamp) - && !self.is_regolith_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Canyon is active at the given timestamp. - pub fn is_canyon_active(&self, timestamp: u64) -> bool { - self.hardforks.canyon_time.is_some_and(|t| timestamp >= t) - || self.is_delta_active(timestamp) - } - - /// Returns true if the timestamp marks the first Canyon block. - pub fn is_first_canyon_block(&self, timestamp: u64) -> bool { - self.is_canyon_active(timestamp) - && !self.is_canyon_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Delta is active at the given timestamp. - pub fn is_delta_active(&self, timestamp: u64) -> bool { - self.hardforks.delta_time.is_some_and(|t| timestamp >= t) - || self.is_ecotone_active(timestamp) - } - - /// Returns true if the timestamp marks the first Delta block. - pub fn is_first_delta_block(&self, timestamp: u64) -> bool { - self.is_delta_active(timestamp) - && !self.is_delta_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Ecotone is active at the given timestamp. - pub fn is_ecotone_active(&self, timestamp: u64) -> bool { - self.hardforks.ecotone_time.is_some_and(|t| timestamp >= t) - || self.is_fjord_active(timestamp) - } - - /// Returns true if the timestamp marks the first Ecotone block. - pub fn is_first_ecotone_block(&self, timestamp: u64) -> bool { - self.is_ecotone_active(timestamp) - && !self.is_ecotone_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Fjord is active at the given timestamp. - pub fn is_fjord_active(&self, timestamp: u64) -> bool { - self.hardforks.fjord_time.is_some_and(|t| timestamp >= t) - || self.is_granite_active(timestamp) - } - - /// Returns true if the timestamp marks the first Fjord block. - pub fn is_first_fjord_block(&self, timestamp: u64) -> bool { - self.is_fjord_active(timestamp) - && !self.is_fjord_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Granite is active at the given timestamp. - pub fn is_granite_active(&self, timestamp: u64) -> bool { - self.hardforks.granite_time.is_some_and(|t| timestamp >= t) - || self.is_holocene_active(timestamp) - } - - /// Returns true if the timestamp marks the first Granite block. - pub fn is_first_granite_block(&self, timestamp: u64) -> bool { - self.is_granite_active(timestamp) - && !self.is_granite_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Holocene is active at the given timestamp. - pub fn is_holocene_active(&self, timestamp: u64) -> bool { - self.hardforks.holocene_time.is_some_and(|t| timestamp >= t) - || self.is_isthmus_active(timestamp) - } - - /// Returns true if the timestamp marks the first Holocene block. - pub fn is_first_holocene_block(&self, timestamp: u64) -> bool { - self.is_holocene_active(timestamp) - && !self.is_holocene_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if the pectra blob schedule is active at the given timestamp. - pub fn is_pectra_blob_schedule_active(&self, timestamp: u64) -> bool { - self.hardforks.pectra_blob_schedule_time.is_some_and(|t| timestamp >= t) - } - - /// Returns true if the timestamp marks the first pectra blob schedule block. - pub fn is_first_pectra_blob_schedule_block(&self, timestamp: u64) -> bool { - self.is_pectra_blob_schedule_active(timestamp) - && !self.is_pectra_blob_schedule_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Isthmus is active at the given timestamp. - pub fn is_isthmus_active(&self, timestamp: u64) -> bool { - self.hardforks.isthmus_time.is_some_and(|t| timestamp >= t) - || self.is_jovian_active(timestamp) - } - - /// Returns true if the timestamp marks the first Isthmus block. - pub fn is_first_isthmus_block(&self, timestamp: u64) -> bool { - self.is_isthmus_active(timestamp) - && !self.is_isthmus_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Jovian is active at the given timestamp. - pub fn is_jovian_active(&self, timestamp: u64) -> bool { - self.hardforks.jovian_time.is_some_and(|t| timestamp >= t) - } - - /// Returns true if the timestamp marks the first Jovian block. - pub fn is_first_jovian_block(&self, timestamp: u64) -> bool { - self.is_jovian_active(timestamp) - && !self.is_jovian_active(timestamp.saturating_sub(self.block_time)) - } - - /// Returns true if Base Azul is active at the given timestamp. - pub fn is_base_azul_active(&self, timestamp: u64) -> bool { - self.hardforks.base.azul.is_some_and(|t| timestamp >= t) - } - - /// Returns true if the timestamp marks the first Base Azul block. - pub fn is_first_base_azul_block(&self, timestamp: u64) -> bool { - self.is_base_azul_active(timestamp) - && !self.is_base_azul_active(timestamp.saturating_sub(self.block_time)) - } +macro_rules! rollup_fork_methods { + ($( + $active:ident, + $first:ident, + [$($timestamp:tt)+], + $name:literal + $(, implies $next:ident)?; + )*) => { + $( + #[doc = concat!("Returns true if ", $name, " is active at the given timestamp.")] + pub fn $active(&self, timestamp: u64) -> bool { + self.$($timestamp)+.is_some_and(|t| timestamp >= t) $(|| self.$next(timestamp))? + } - /// Returns true if Beryl is active at the given timestamp. - pub fn is_beryl_active(&self, timestamp: u64) -> bool { - self.hardforks.base.beryl.is_some_and(|t| timestamp >= t) - } + #[doc = concat!("Returns true if the timestamp marks the first ", $name, " block.")] + pub fn $first(&self, timestamp: u64) -> bool { + self.$active(timestamp) + && !self.$active(timestamp.saturating_sub(self.block_time)) + } + )* + }; +} - /// Returns true if the timestamp marks the first Beryl block. - pub fn is_first_beryl_block(&self, timestamp: u64) -> bool { - self.is_beryl_active(timestamp) - && !self.is_beryl_active(timestamp.saturating_sub(self.block_time)) +impl RollupConfig { + rollup_fork_methods! { + is_regolith_active, + is_first_regolith_block, + [hardforks.regolith_time], + "Regolith", + implies is_canyon_active; + + is_canyon_active, + is_first_canyon_block, + [hardforks.canyon_time], + "Canyon", + implies is_delta_active; + + is_delta_active, + is_first_delta_block, + [hardforks.delta_time], + "Delta", + implies is_ecotone_active; + + is_ecotone_active, + is_first_ecotone_block, + [hardforks.ecotone_time], + "Ecotone", + implies is_fjord_active; + + is_fjord_active, + is_first_fjord_block, + [hardforks.fjord_time], + "Fjord", + implies is_granite_active; + + is_granite_active, + is_first_granite_block, + [hardforks.granite_time], + "Granite", + implies is_holocene_active; + + is_holocene_active, + is_first_holocene_block, + [hardforks.holocene_time], + "Holocene", + implies is_isthmus_active; + + is_pectra_blob_schedule_active, + is_first_pectra_blob_schedule_block, + [hardforks.pectra_blob_schedule_time], + "pectra blob schedule"; + + is_isthmus_active, + is_first_isthmus_block, + [hardforks.isthmus_time], + "Isthmus", + implies is_jovian_active; + + is_jovian_active, + is_first_jovian_block, + [hardforks.jovian_time], + "Jovian"; + + is_base_azul_active, + is_first_base_azul_block, + [hardforks.base.azul], + "Base Azul"; + + is_beryl_active, + is_first_beryl_block, + [hardforks.base.beryl], + "Beryl"; } /// Returns the max sequencer drift for the given timestamp. diff --git a/crates/common/network/Cargo.toml b/crates/common/network/Cargo.toml index e6f1bb39ca..292ec2ba3a 100644 --- a/crates/common/network/Cargo.toml +++ b/crates/common/network/Cargo.toml @@ -15,7 +15,7 @@ workspace = true [dependencies] # Workspace -base-common-rpc-types.workspace = true +base-common-rpc-types = { workspace = true, features = ["reth"] } base-common-rpc-types-engine = { workspace = true, features = ["serde"] } base-common-consensus = { workspace = true, features = ["alloy-compat", "std"] } @@ -28,12 +28,10 @@ alloy-rpc-types-engine = { workspace = true, features = ["serde", "std"] } alloy-consensus = { workspace = true, features = ["std"] } alloy-rpc-types-eth = { workspace = true, features = ["std"] } -# Reth (optional, behind "reth" feature) -reth-rpc-convert = { workspace = true, optional = true } - # misc async-trait.workspace = true + [dev-dependencies] rstest.workspace = true @@ -56,4 +54,4 @@ serde = [ "base-common-rpc-types-engine/serde", "base-common-rpc-types/serde", ] -reth = [ "dep:reth-rpc-convert", "std" ] +reth = [ "std" ] diff --git a/crates/common/network/src/builder.rs b/crates/common/network/src/builder.rs index 2177c2cf57..ab1894067f 100644 --- a/crates/common/network/src/builder.rs +++ b/crates/common/network/src/builder.rs @@ -1,109 +1,11 @@ use alloy_consensus::TxType; -use alloy_network::{BuildResult, TransactionBuilder, TransactionBuilderError}; -use alloy_primitives::{Address, Bytes, ChainId, TxKind, U256}; -use alloy_rpc_types_eth::AccessList; +use alloy_network::{BuildResult, NetworkTransactionBuilder, TransactionBuilderError}; use base_common_consensus::{BaseTypedTransaction, OpTxType}; use base_common_rpc_types::BaseTransactionRequest; use crate::Base; -impl TransactionBuilder for BaseTransactionRequest { - fn chain_id(&self) -> Option { - self.as_ref().chain_id() - } - - fn set_chain_id(&mut self, chain_id: ChainId) { - self.as_mut().set_chain_id(chain_id); - } - - fn nonce(&self) -> Option { - self.as_ref().nonce() - } - - fn set_nonce(&mut self, nonce: u64) { - self.as_mut().set_nonce(nonce); - } - - fn take_nonce(&mut self) -> Option { - self.as_mut().nonce.take() - } - - fn input(&self) -> Option<&Bytes> { - self.as_ref().input() - } - - fn set_input>(&mut self, input: T) { - self.as_mut().set_input(input); - } - - fn from(&self) -> Option
{ - self.as_ref().from() - } - - fn set_from(&mut self, from: Address) { - self.as_mut().set_from(from); - } - - fn kind(&self) -> Option { - self.as_ref().kind() - } - - fn clear_kind(&mut self) { - self.as_mut().clear_kind(); - } - - fn set_kind(&mut self, kind: TxKind) { - self.as_mut().set_kind(kind); - } - - fn value(&self) -> Option { - self.as_ref().value() - } - - fn set_value(&mut self, value: U256) { - self.as_mut().set_value(value); - } - - fn gas_price(&self) -> Option { - self.as_ref().gas_price() - } - - fn set_gas_price(&mut self, gas_price: u128) { - self.as_mut().set_gas_price(gas_price); - } - - fn max_fee_per_gas(&self) -> Option { - self.as_ref().max_fee_per_gas() - } - - fn set_max_fee_per_gas(&mut self, max_fee_per_gas: u128) { - self.as_mut().set_max_fee_per_gas(max_fee_per_gas); - } - - fn max_priority_fee_per_gas(&self) -> Option { - self.as_ref().max_priority_fee_per_gas() - } - - fn set_max_priority_fee_per_gas(&mut self, max_priority_fee_per_gas: u128) { - self.as_mut().set_max_priority_fee_per_gas(max_priority_fee_per_gas); - } - - fn gas_limit(&self) -> Option { - self.as_ref().gas_limit() - } - - fn set_gas_limit(&mut self, gas_limit: u64) { - self.as_mut().set_gas_limit(gas_limit); - } - - fn access_list(&self) -> Option<&AccessList> { - self.as_ref().access_list() - } - - fn set_access_list(&mut self, access_list: AccessList) { - self.as_mut().set_access_list(access_list); - } - +impl NetworkTransactionBuilder for BaseTransactionRequest { fn complete_type(&self, ty: OpTxType) -> Result<(), Vec<&'static str>> { match ty { OpTxType::Deposit => Err(vec!["not implemented for deposit tx"]), @@ -167,7 +69,8 @@ impl TransactionBuilder for BaseTransactionRequest { #[cfg(test)] mod tests { - use alloy_primitives::B256; + use alloy_network::TransactionBuilder; + use alloy_primitives::{B256, TxKind}; use rstest::rstest; use super::*; diff --git a/crates/common/network/src/lib.rs b/crates/common/network/src/lib.rs index c10b4363eb..627ee8cfa4 100644 --- a/crates/common/network/src/lib.rs +++ b/crates/common/network/src/lib.rs @@ -7,6 +7,11 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg))] +use alloy_primitives as _; + +#[allow(dead_code)] +const _ALLOY_PRIMITIVES_USED: alloy_primitives::Address = alloy_primitives::Address::ZERO; + mod base; pub use base::Base; diff --git a/crates/common/network/src/reth.rs b/crates/common/network/src/reth.rs index fac9d2efc4..26dd3cc203 100644 --- a/crates/common/network/src/reth.rs +++ b/crates/common/network/src/reth.rs @@ -1,26 +1 @@ -use core::convert::Infallible; - -use base_common_consensus::{BaseReceipt, BaseTxEnvelope}; -use reth_rpc_convert::{TryFromReceiptResponse, TryFromTransactionResponse}; - -use crate::Base; - -impl TryFromTransactionResponse for BaseTxEnvelope { - type Error = Infallible; - - fn from_transaction_response( - transaction_response: base_common_rpc_types::Transaction, - ) -> Result { - Ok(transaction_response.inner.into_inner()) - } -} - -impl TryFromReceiptResponse for BaseReceipt { - type Error = Infallible; - - fn from_receipt_response( - receipt_response: base_common_rpc_types::BaseTransactionReceipt, - ) -> Result { - Ok(receipt_response.inner.inner.into_components().0.map_logs(Into::into)) - } -} +//! Optional reth integration hooks for the Base network types. diff --git a/crates/common/precompile-macros/Cargo.toml b/crates/common/precompile-macros/Cargo.toml new file mode 100644 index 0000000000..edb93bdecd --- /dev/null +++ b/crates/common/precompile-macros/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "base-precompile-macros" +description = "Procedural macros for type-safe EVM storage abstractions for Base native precompiles" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +exclude.workspace = true + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +alloy-primitives.workspace = true +syn.workspace = true +quote.workspace = true +proc-macro2.workspace = true + +[dev-dependencies] +alloy-primitives.workspace = true diff --git a/crates/common/precompile-macros/LICENSE-APACHE b/crates/common/precompile-macros/LICENSE-APACHE new file mode 100644 index 0000000000..f6b4d8bf8f --- /dev/null +++ b/crates/common/precompile-macros/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Tempo Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/common/precompile-macros/LICENSE-MIT b/crates/common/precompile-macros/LICENSE-MIT new file mode 100644 index 0000000000..95865426b3 --- /dev/null +++ b/crates/common/precompile-macros/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Tempo Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/crates/common/precompile-macros/README.md b/crates/common/precompile-macros/README.md new file mode 100644 index 0000000000..54a3ed6ad1 --- /dev/null +++ b/crates/common/precompile-macros/README.md @@ -0,0 +1,35 @@ +# base-precompile-macros + +Procedural macros for type-safe EVM storage abstractions for Base native precompiles. + +## Macros + +- `#[contract]` — transforms a storage layout struct into a full contract +- `#[namespace("id")]` — starts a `#[contract]` field/layout or a `Storable` layout type at + an ERC-7201 namespace root +- `#[derive(Storable)]` — generates storage I/O for structs and `#[repr(u8)]` enums +- `storable_rust_ints!()`, `storable_alloy_ints!()`, `storable_alloy_bytes!()` — primitive impls +- `storable_arrays!()`, `storable_nested_arrays!()` — fixed-size array impls +- `gen_storable_tests!()` — proptest round-trip tests for all storage types + +For `Storable` layouts, place the derive before the namespace helper: + +```rust,ignore +#[derive(Debug, Clone, Storable)] +#[namespace("b20")] +pub struct B20Storage { + pub total_supply: U256, +} + +#[contract] +pub struct B20Security { + pub b20: B20Storage, +} +``` + +## Attribution + +This crate includes code adapted from Tempo's `precompiles-macros` crate in the +[`tempoxyz/tempo`](https://github.com/tempoxyz/tempo/tree/main/crates/precompiles-macros) +repository. The upstream license notices are retained in `LICENSE-MIT` and +`LICENSE-APACHE`. diff --git a/crates/common/precompile-macros/src/contract.rs b/crates/common/precompile-macros/src/contract.rs new file mode 100644 index 0000000000..ecf553715f --- /dev/null +++ b/crates/common/precompile-macros/src/contract.rs @@ -0,0 +1,140 @@ +//! Implementation of the `#[contract]` attribute macro. + +use alloy_primitives::U256; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Expr, Fields, Ident, Token, Type, Visibility, parse::ParseStream}; + +use crate::{ + layout, packing, + utils::{NamespaceInfo, extract_attributes, extract_namespace}, +}; + +pub(crate) struct ContractConfig { + pub(crate) address: Option, +} + +impl syn::parse::Parse for ContractConfig { + fn parse(input: ParseStream<'_>) -> syn::Result { + if input.is_empty() { + return Ok(Self { address: None }); + } + + let ident: Ident = input.parse()?; + if ident != "addr" && ident != "address" { + return Err(syn::Error::new(ident.span(), "only `addr` attribute is supported")); + } + + input.parse::()?; + let address: Expr = input.parse()?; + + Ok(Self { address: Some(address) }) + } +} + +pub(crate) const RESERVED: &[&str] = &["address", "storage", "msg_sender"]; + +#[derive(Debug)] +pub(crate) struct FieldInfo { + pub(crate) name: Ident, + pub(crate) ty: Type, + pub(crate) slot: Option, + pub(crate) base_slot: Option, + pub(crate) namespace: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum FieldKind<'a> { + Direct(&'a Type), + Mapping { key: &'a Type, value: &'a Type }, +} + +pub(crate) fn generate(input: DeriveInput, address: Option<&Expr>) -> proc_macro::TokenStream { + match gen_output(input, address) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn gen_output(input: DeriveInput, address: Option<&Expr>) -> syn::Result { + let (ident, vis) = (input.ident.clone(), input.vis.clone()); + let namespace = extract_namespace(&input.attrs)?; + let fields = parse_fields(input, namespace.is_some())?; + + let storage_output = gen_storage(&ident, &vis, &fields, address, namespace.as_ref())?; + Ok(quote! { #storage_output }) +} + +pub(crate) fn parse_fields( + input: DeriveInput, + namespace_enabled: bool, +) -> syn::Result> { + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "Contract structs cannot have generic parameters", + )); + } + + let named_fields = if let Data::Struct(data) = input.data + && let Fields::Named(fields) = data.fields + { + fields.named + } else { + return Err(syn::Error::new_spanned( + input.ident, + "Only structs with named fields are supported", + )); + }; + + named_fields + .into_iter() + .map(|field| { + let name = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(&field, "Fields must have names"))?; + + if RESERVED.contains(&name.to_string().as_str()) { + return Err(syn::Error::new_spanned( + name, + format!("Field name '{name}' is reserved"), + )); + } + + let (slot, base_slot, namespace) = extract_attributes(&field.attrs)?; + if namespace_enabled && (slot.is_some() || base_slot.is_some() || namespace.is_some()) { + return Err(syn::Error::new_spanned( + name, + "field-level `slot`, `base_slot`, and `namespace` attributes cannot be used with contract-level `namespace`", + )); + } + Ok(FieldInfo { name: name.to_owned(), ty: field.ty, slot, base_slot, namespace }) + }) + .collect() +} + +fn gen_storage( + ident: &Ident, + vis: &Visibility, + fields: &[FieldInfo], + address: Option<&Expr>, + namespace: Option<&NamespaceInfo>, +) -> syn::Result { + let allocated_fields = packing::allocate_slots_from( + fields, + namespace.map_or(U256::ZERO, |namespace| namespace.root), + namespace.is_none(), + )?; + let transformed_struct = layout::gen_struct(ident, vis, &allocated_fields); + let storage_trait = layout::gen_contract_storage_impl(ident); + let constructor = layout::gen_constructor(ident, &allocated_fields, address); + let slots_module = layout::gen_slots_module(&allocated_fields, namespace); + + Ok(quote! { + #slots_module + #transformed_struct + #constructor + #storage_trait + }) +} diff --git a/crates/common/precompile-macros/src/layout.rs b/crates/common/precompile-macros/src/layout.rs new file mode 100644 index 0000000000..53da71f0a7 --- /dev/null +++ b/crates/common/precompile-macros/src/layout.rs @@ -0,0 +1,287 @@ +use quote::{format_ident, quote}; +use syn::{Expr, Ident, Visibility}; + +use crate::{ + FieldKind, + packing::{self, LayoutField, PackingConstants, SlotAssignment}, + utils::NamespaceInfo, +}; + +pub(crate) fn gen_handler_field_decl(field: &LayoutField<'_>) -> proc_macro2::TokenStream { + let field_name = field.name; + let doc_str = format!("Storage handler for the `{field_name}` slot."); + let handler_type = match &field.kind { + FieldKind::Direct(ty) => { + quote! { <#ty as ::base_precompile_storage::StorableType>::Handler<'a> } + } + FieldKind::Mapping { key, value } => { + quote! { <::base_precompile_storage::Mapping<#key, #value> as ::base_precompile_storage::StorableType>::Handler<'a> } + } + }; + + quote! { + #[doc = #doc_str] + pub #field_name: #handler_type + } +} + +pub(crate) fn gen_handler_field_init( + field: &LayoutField<'_>, + field_idx: usize, + all_fields: &[LayoutField<'_>], + packing_mod: Option<&Ident>, +) -> proc_macro2::TokenStream { + let field_name = field.name; + let consts = PackingConstants::new(field_name); + let (loc_const, (slot_const, offset_const)) = (consts.location(), consts.into_tuple()); + + let is_contract = packing_mod.is_none(); + let slots_mod = format_ident!("slots"); + let const_mod = packing_mod.unwrap_or(&slots_mod); + + let slot_expr = if is_contract { + quote! { #const_mod::#slot_const } + } else { + quote! { base_slot.saturating_add(::alloy_primitives::U256::from_limbs([#const_mod::#loc_const.offset_slots as u64, 0, 0, 0])) } + }; + + let shares_slot_check = + gen_shares_slot_check(field, field_idx, all_fields, const_mod, is_contract); + + match &field.kind { + FieldKind::Direct(ty) => { + let layout_ctx = if is_contract { + packing::gen_layout_ctx_expr( + ty, + matches!(field.assigned_slot, SlotAssignment::Manual(_)), + quote! { #const_mod::#offset_const }, + shares_slot_check, + ) + } else { + packing::gen_layout_ctx_expr( + ty, + false, + quote! { #const_mod::#loc_const.offset_bytes }, + shares_slot_check, + ) + }; + + quote! { + #field_name: <#ty as ::base_precompile_storage::StorableType>::handle( + #slot_expr, #layout_ctx, address, storage + ) + } + } + FieldKind::Mapping { key, value } => { + quote! { + #field_name: <::base_precompile_storage::Mapping<#key, #value> as ::base_precompile_storage::StorableType>::handle( + #slot_expr, ::base_precompile_storage::LayoutCtx::FULL, address, storage + ) + } + } + } +} + +fn gen_shares_slot_check( + field: &LayoutField<'_>, + field_idx: usize, + all_fields: &[LayoutField<'_>], + const_mod: &Ident, + is_contract: bool, +) -> Option { + let current_consts = PackingConstants::new(field.name); + let current_slot = if is_contract { + let current_slot = current_consts.slot(); + quote! { #const_mod::#current_slot } + } else { + let current_loc = current_consts.location(); + quote! { #const_mod::#current_loc.offset_slots } + }; + + let checks: Vec<_> = all_fields + .iter() + .enumerate() + .filter(|(idx, _)| *idx != field_idx) + .map(|(_, other)| { + let other_consts = PackingConstants::new(other.name); + if is_contract { + let other_slot = other_consts.slot(); + quote! { #current_slot == #const_mod::#other_slot } + } else { + let other_loc = other_consts.location(); + quote! { #current_slot == #const_mod::#other_loc.offset_slots } + } + }) + .collect(); + + if checks.is_empty() { None } else { Some(quote! { false #(|| #checks)* }) } +} + +pub(crate) fn gen_struct( + name: &Ident, + vis: &Visibility, + allocated_fields: &[LayoutField<'_>], +) -> proc_macro2::TokenStream { + let handler_fields = allocated_fields.iter().map(gen_handler_field_decl); + let doc_str = format!("Storage layout for the [`{name}`] precompile."); + + quote! { + #[doc = #doc_str] + #vis struct #name<'a> { + #(#handler_fields,)* + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + } + } +} + +pub(crate) fn gen_constructor( + name: &Ident, + allocated_fields: &[LayoutField<'_>], + address: Option<&Expr>, +) -> proc_macro2::TokenStream { + let field_inits = allocated_fields + .iter() + .enumerate() + .map(|(idx, field)| gen_handler_field_init(field, idx, allocated_fields, None)); + + let new_fn = address.map(|addr| { + quote! { + /// Creates an instance of the precompile. + /// + /// Caution: This does not initialize the account, see [`Self::initialize`]. + pub fn new(storage: ::base_precompile_storage::StorageCtx<'a>) -> Self { + Self::__new(#addr, storage) + } + } + }); + + quote! { + impl<'a> #name<'a> { + #new_fn + + #[inline(always)] + fn __new( + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self { + #[cfg(debug_assertions)] + { + slots::__check_all_collisions(); + } + + Self { + #(#field_inits,)* + address, + storage, + } + } + + #[inline(always)] + fn __initialize(&mut self) -> ::base_precompile_storage::Result<()> { + let bytecode = ::revm::state::Bytecode::new_legacy(::alloy_primitives::Bytes::from_static(&[0xef])); + self.storage.set_code(self.address, bytecode)?; + Ok(()) + } + + #[inline(always)] + fn emit_event(&mut self, event: impl ::alloy_primitives::IntoLogData) -> ::base_precompile_storage::Result<()> { + self.storage.emit_event(self.address, event.into_log_data()) + } + + #[cfg(feature = "test-utils")] + /// Returns all events emitted by this contract (test-utils only). + pub fn emitted_events(&self) -> ::std::vec::Vec<::alloy_primitives::LogData> { + self.storage.get_events(self.address) + } + + #[cfg(feature = "test-utils")] + /// Clears all events emitted by this contract (test-utils only). + pub fn clear_emitted_events(&mut self) { + self.storage.clear_events(self.address); + } + + #[cfg(feature = "test-utils")] + /// Asserts that emitted events match the expected list (test-utils only). + pub fn assert_emitted_events(&self, expected: ::std::vec::Vec) { + let emitted = self.storage.get_events(self.address); + assert_eq!(emitted.len(), expected.len()); + for (i, event) in expected.into_iter().enumerate() { + assert_eq!(emitted[i], event.into_log_data()); + } + } + } + } +} + +pub(crate) fn gen_contract_storage_impl(name: &Ident) -> proc_macro2::TokenStream { + quote! { + impl<'a> ::base_precompile_storage::ContractStorage<'a> for #name<'a> { + #[inline(always)] + fn address(&self) -> ::alloy_primitives::Address { + self.address + } + + #[inline(always)] + fn storage(&self) -> ::base_precompile_storage::StorageCtx<'a> { + self.storage + } + } + } +} + +pub(crate) fn gen_slots_module( + allocated_fields: &[LayoutField<'_>], + namespace: Option<&NamespaceInfo>, +) -> proc_macro2::TokenStream { + let namespace_constants = namespace.map(gen_namespace_constants); + let constants = packing::gen_constants_from_ir(allocated_fields, false); + let collision_checks = gen_collision_checks(allocated_fields); + + quote! { + /// Storage slot indices and packing constants for this contract. + pub mod slots { + use super::*; + + #namespace_constants + #constants + #collision_checks + } + } +} + +fn gen_namespace_constants(namespace: &NamespaceInfo) -> proc_macro2::TokenStream { + let id = &namespace.id; + let limbs = *namespace.root.as_limbs(); + + quote! { + /// ERC-7201 namespace identifier for this contract storage layout. + pub const NAMESPACE_ID: &str = #id; + + /// ERC-7201 namespace root slot for this contract storage layout. + pub const NAMESPACE_ROOT: ::alloy_primitives::U256 = + ::alloy_primitives::U256::from_limbs([#(#limbs),*]); + } +} + +fn gen_collision_checks(allocated_fields: &[LayoutField<'_>]) -> proc_macro2::TokenStream { + let mut generated = proc_macro2::TokenStream::new(); + let mut check_fn_calls = Vec::new(); + + for (idx, allocated) in allocated_fields.iter().enumerate() { + let (check_fn_name, check_fn) = + packing::gen_collision_check_fn(idx, allocated, allocated_fields); + generated.extend(check_fn); + check_fn_calls.push(check_fn_name); + } + + generated.extend(quote! { + #[cfg(debug_assertions)] + #[inline(always)] + pub(super) fn __check_all_collisions() { + #(#check_fn_calls();)* + } + }); + + generated +} diff --git a/crates/common/precompile-macros/src/lib.rs b/crates/common/precompile-macros/src/lib.rs new file mode 100644 index 0000000000..424136decd --- /dev/null +++ b/crates/common/precompile-macros/src/lib.rs @@ -0,0 +1,97 @@ +#![doc = include_str!("../README.md")] + +mod contract; +pub(crate) use contract::{FieldInfo, FieldKind}; + +mod layout; +mod namespace; +mod packing; +mod precompile; +mod storable; +mod storable_primitives; +mod storable_tests; +mod test_fields; +mod utils; + +use proc_macro::TokenStream; +use syn::{DeriveInput, parse_macro_input}; + +/// Transforms a struct that represents a storage layout into a contract with helper methods to +/// easily interact with the EVM storage. +/// Its packing and encoding schemes aim to be an exact representation of the storage model used by Solidity. +#[proc_macro_attribute] +pub fn contract(attr: TokenStream, item: TokenStream) -> TokenStream { + let config = parse_macro_input!(attr as contract::ContractConfig); + let input = parse_macro_input!(item as DeriveInput); + contract::generate(input, config.address.as_ref()) +} + +/// Namespaces a `#[contract]` storage struct or `Storable` layout using an ERC-7201 storage root. +#[proc_macro_attribute] +pub fn namespace(attr: TokenStream, item: TokenStream) -> TokenStream { + namespace::expand(attr, item) +} + +/// Generates EVM precompile constructor and optional singleton installation methods. +/// +/// By default this expands through `crate::macros::base_precompile!` in the invoking crate. Callers +/// outside `base-common-precompiles` can pass `macro_path = path::to::wrapper_macro` to override the +/// runtime wrapper macro. +#[proc_macro_attribute] +pub fn precompile(attr: TokenStream, item: TokenStream) -> TokenStream { + precompile::expand(attr, item) +} + +/// Derives the `Storable` trait for structs with named fields and `#[repr(u8)]` unit enums. +#[proc_macro_derive(Storable, attributes(storable_arrays, namespace, storage_namespace))] +pub fn derive_storage_block(input: TokenStream) -> TokenStream { + storable::derive(parse_macro_input!(input as DeriveInput)) +} + +/// Generate `StorableType` and `Storable` implementations for all standard integer types. +#[proc_macro] +pub fn storable_rust_ints(_input: TokenStream) -> TokenStream { + storable_primitives::gen_storable_rust_ints().into() +} + +/// Generate `StorableType` and `Storable` implementations for alloy integer types. +#[proc_macro] +pub fn storable_alloy_ints(_input: TokenStream) -> TokenStream { + storable_primitives::gen_storable_alloy_ints().into() +} + +/// Generate `StorableType` and `Storable` implementations for alloy `FixedBytes` types. +#[proc_macro] +pub fn storable_alloy_bytes(_input: TokenStream) -> TokenStream { + storable_primitives::gen_storable_alloy_bytes().into() +} + +/// Generate comprehensive property tests for all storage types. +#[proc_macro] +pub fn gen_storable_tests(_input: TokenStream) -> TokenStream { + storable_tests::gen_storable_tests().into() +} + +/// Generate `Storable` implementations for fixed-size arrays of primitive types. +#[proc_macro] +pub fn storable_arrays(_input: TokenStream) -> TokenStream { + storable_primitives::gen_storable_arrays().into() +} + +/// Generate `Storable` implementations for nested arrays of small primitive types. +#[proc_macro] +pub fn storable_nested_arrays(_input: TokenStream) -> TokenStream { + storable_primitives::gen_nested_arrays().into() +} + +/// Test helper macro for validating slots. +#[proc_macro] +pub fn gen_test_fields_layout(input: TokenStream) -> TokenStream { + test_fields::gen_layout(proc_macro2::TokenStream::from(input)) +} + +/// Test helper macro for validating struct field slots. +#[proc_macro] +pub fn gen_test_fields_struct(input: TokenStream) -> TokenStream { + test_fields::gen_struct_fields(proc_macro2::TokenStream::from(input)) +} diff --git a/crates/common/precompile-macros/src/namespace.rs b/crates/common/precompile-macros/src/namespace.rs new file mode 100644 index 0000000000..c12f02c97f --- /dev/null +++ b/crates/common/precompile-macros/src/namespace.rs @@ -0,0 +1,65 @@ +//! Implementation of the `#[namespace]` attribute macro. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{DeriveInput, LitStr}; + +use crate::utils::{attr_path_is, parse_namespace_id}; + +pub(crate) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { + match expand_impl(attr.into(), item.into()) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand_impl( + attr: proc_macro2::TokenStream, + item: proc_macro2::TokenStream, +) -> syn::Result { + let namespace_id: LitStr = syn::parse2(attr)?; + let mut input: DeriveInput = syn::parse2(item)?; + + parse_namespace_id(namespace_id.clone())?; + + if input.attrs.iter().any(|attr| { + attr_path_is(attr.path(), "namespace") || attr_path_is(attr.path(), "storage_namespace") + }) { + return Err(syn::Error::new_spanned(&input.ident, "duplicate `namespace` attribute")); + } + + if let Some(contract_index) = + input.attrs.iter().position(|attr| attr_path_is(attr.path(), "contract")) + { + input.attrs.insert(contract_index + 1, syn::parse_quote!(#[namespace(#namespace_id)])); + return Ok(quote! { #input }); + } + + if has_storable_derive(&input)? { + input.attrs.push(syn::parse_quote!(#[storage_namespace(#namespace_id)])); + return Ok(quote! { #input }); + } + + Err(syn::Error::new_spanned( + &input.ident, + "`#[namespace]` must be paired with `#[contract]` or `#[derive(Storable)]`", + )) +} + +fn has_storable_derive(input: &DeriveInput) -> syn::Result { + let mut found = false; + for attr in &input.attrs { + if !attr.path().is_ident("derive") { + continue; + } + + attr.parse_nested_meta(|meta| { + if attr_path_is(&meta.path, "Storable") { + found = true; + } + Ok(()) + })?; + } + + Ok(found) +} diff --git a/crates/common/precompile-macros/src/packing.rs b/crates/common/precompile-macros/src/packing.rs new file mode 100644 index 0000000000..5abe0abbcc --- /dev/null +++ b/crates/common/precompile-macros/src/packing.rs @@ -0,0 +1,436 @@ +//! Shared code generation utilities for storage slot packing. +//! +//! This module provides common logic for computing slot and offset assignments +//! used by both the `#[derive(Storable)]` and `#[contract]` macros. + +use alloy_primitives::U256; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Ident, Type}; + +use crate::{FieldInfo, FieldKind}; + +/// Helper for generating packing constant identifiers +pub(crate) struct PackingConstants(String); + +impl PackingConstants { + pub(crate) fn new(name: &Ident) -> Self { + Self(const_name(name)) + } + + pub(crate) fn slot(&self) -> Ident { + format_ident!("{}", &self.0) + } + + pub(crate) fn location(&self) -> Ident { + let span = proc_macro2::Span::call_site(); + Ident::new(&format!("{}_LOC", self.0), span) + } + + pub(crate) fn offset(&self) -> Ident { + let span = proc_macro2::Span::call_site(); + Ident::new(&format!("{}_OFFSET", self.0), span) + } + + pub(crate) fn into_tuple(self) -> (Ident, Ident) { + (self.slot(), self.offset()) + } +} + +pub(crate) fn const_name(name: &Ident) -> String { + name.to_string().to_uppercase() +} + +#[derive(Debug, Clone)] +pub(crate) enum SlotAssignment { + Manual(U256), + Auto { base_slot: U256, allow_type_namespace: bool }, +} + +impl SlotAssignment { + pub(crate) const fn ref_slot(&self) -> &U256 { + match self { + Self::Manual(slot) => slot, + Self::Auto { base_slot, .. } => base_slot, + } + } + + pub(crate) const fn allows_type_namespace(&self) -> bool { + match self { + Self::Manual(_) => false, + Self::Auto { allow_type_namespace, .. } => *allow_type_namespace, + } + } +} + +#[derive(Debug)] +pub(crate) struct LayoutField<'a> { + pub name: &'a Ident, + pub ty: &'a Type, + pub kind: FieldKind<'a>, + pub assigned_slot: SlotAssignment, +} + +/// Build layout IR from field information. +pub(crate) fn allocate_slots(fields: &[FieldInfo]) -> syn::Result>> { + allocate_slots_from(fields, U256::ZERO, false) +} + +/// Build layout IR from field information, starting auto-allocation at `initial_base_slot`. +pub(crate) fn allocate_slots_from( + fields: &[FieldInfo], + initial_base_slot: U256, + allow_type_namespaces: bool, +) -> syn::Result>> { + let mut result = Vec::with_capacity(fields.len()); + let mut current_base_slot = initial_base_slot; + + for field in fields { + let kind = classify_field_type(&field.ty)?; + + let assigned_slot = match (field.slot, field.base_slot, field.namespace.as_ref()) { + (Some(explicit), _, _) => SlotAssignment::Manual(explicit), + (None, Some(new_base), _) => { + current_base_slot = new_base; + SlotAssignment::Auto { base_slot: new_base, allow_type_namespace: false } + } + (None, None, Some(namespace)) => { + SlotAssignment::Auto { base_slot: namespace.root, allow_type_namespace: false } + } + (None, None, None) => SlotAssignment::Auto { + base_slot: current_base_slot, + allow_type_namespace: allow_type_namespaces, + }, + }; + + result.push(LayoutField { name: &field.name, ty: &field.ty, kind, assigned_slot }); + } + + Ok(result) +} + +/// Generate packing constants from layout IR. +pub(crate) fn gen_constants_from_ir(fields: &[LayoutField<'_>], gen_location: bool) -> TokenStream { + let mut constants = TokenStream::new(); + let mut last_auto_fields = Vec::<&LayoutField<'_>>::new(); + + for field in fields { + let ty = field.ty; + let consts = PackingConstants::new(field.name); + let (loc_const, (slot_const, offset_const)) = (consts.location(), consts.into_tuple()); + let slots_to_end = quote! { + ::alloy_primitives::U256::from_limbs([<#ty as ::base_precompile_storage::StorableType>::SLOTS as u64, 0, 0, 0]) + .saturating_sub(::alloy_primitives::U256::ONE) + }; + + let bytes_expr = quote! { <#ty as ::base_precompile_storage::StorableType>::BYTES }; + + let (slot_expr, offset_expr) = match &field.assigned_slot { + SlotAssignment::Manual(manual_slot) => { + let hex_value = format!("{manual_slot}_U256"); + let slot_lit = syn::LitInt::new(&hex_value, proc_macro2::Span::call_site()); + let slot_expr = quote! { + ::alloy_primitives::uint!(#slot_lit) + .checked_add(#slots_to_end).expect("slot overflow") + .saturating_sub(#slots_to_end) + }; + (slot_expr, quote! { 0 }) + } + SlotAssignment::Auto { base_slot, .. } => { + let output = gen_auto_slot_expr(field, base_slot, &last_auto_fields, slots_to_end); + last_auto_fields.push(field); + output + } + }; + + let slot_doc = format!("Base storage slot for the `{}` field.", field.name); + let offset_doc = format!("Byte offset within the slot for the `{}` field.", field.name); + constants.extend(quote! { + #[doc = #slot_doc] + pub const #slot_const: ::alloy_primitives::U256 = #slot_expr; + #[doc = #offset_doc] + pub const #offset_const: usize = #offset_expr; + }); + + if gen_location { + let loc_doc = format!("Storage location descriptor for the `{}` field.", field.name); + constants.extend(quote! { + #[doc = #loc_doc] + pub const #loc_const: ::base_precompile_storage::FieldLocation = + ::base_precompile_storage::FieldLocation::new(#slot_const.as_limbs()[0] as usize, #offset_const, #bytes_expr); + }); + } + + #[cfg(debug_assertions)] + { + let bytes_const = format_ident!("{slot_const}_BYTES"); + let bytes_doc = format!("Size in bytes of the `{}` field.", field.name); + constants.extend(quote! { + #[doc = #bytes_doc] + pub const #bytes_const: usize = #bytes_expr; + }); + } + } + + constants +} + +fn gen_auto_slot_expr( + field: &LayoutField<'_>, + base_slot: &U256, + previous_auto_fields: &[&LayoutField<'_>], + slots_to_end: TokenStream, +) -> (TokenStream, TokenStream) { + let limbs = *base_slot.as_limbs(); + let initial_slot_expr = quote! { + ::alloy_primitives::U256::from_limbs([#(#limbs),*]) + .checked_add(#slots_to_end).expect("slot overflow") + .saturating_sub(#slots_to_end) + }; + let mut output = (initial_slot_expr, quote! { 0 }); + + for candidate in previous_auto_fields.iter().filter(|candidate| { + matches!(candidate.assigned_slot, SlotAssignment::Auto { .. }) + && candidate.assigned_slot.ref_slot() == base_slot + }) { + let (prev_slot, prev_offset) = PackingConstants::new(candidate.name).into_tuple(); + let candidate_output = gen_slot_packing_logic( + candidate.ty, + field.ty, + quote! { #prev_slot }, + quote! { #prev_offset }, + ); + + if candidate.assigned_slot.allows_type_namespace() { + let candidate_ty = candidate.ty; + let (fallback_slot, fallback_offset) = output; + let (candidate_slot, candidate_offset) = candidate_output; + output = ( + quote! { + if <#candidate_ty as ::base_precompile_storage::StorableType>::HAS_STORAGE_NAMESPACE { + #fallback_slot + } else { + #candidate_slot + } + }, + quote! { + if <#candidate_ty as ::base_precompile_storage::StorableType>::HAS_STORAGE_NAMESPACE { + #fallback_offset + } else { + #candidate_offset + } + }, + ); + } else { + output = candidate_output; + } + } + + if field.assigned_slot.allows_type_namespace() { + let field_ty = field.ty; + let (normal_slot, normal_offset) = output; + ( + quote! { + if <#field_ty as ::base_precompile_storage::StorableType>::HAS_STORAGE_NAMESPACE { + <#field_ty as ::base_precompile_storage::StorableType>::STORAGE_NAMESPACE_ROOT + .checked_add(#slots_to_end).expect("slot overflow") + .saturating_sub(#slots_to_end) + } else { + #normal_slot + } + }, + quote! { + if <#field_ty as ::base_precompile_storage::StorableType>::HAS_STORAGE_NAMESPACE { + 0 + } else { + #normal_offset + } + }, + ) + } else { + output + } +} + +/// Classify a field based on its type. +pub(crate) fn classify_field_type(ty: &Type) -> syn::Result> { + use crate::utils::extract_mapping_types; + + if let Some((key_ty, value_ty)) = extract_mapping_types(ty) { + return Ok(FieldKind::Mapping { key: key_ty, value: value_ty }); + } + + Ok(FieldKind::Direct(ty)) +} + +/// Helper to compute prev and next slot constant references for a field at a given index. +pub(crate) fn get_neighbor_slot_refs( + idx: usize, + fields: &[T], + packing: &Ident, + get_name: F, + use_full_slot: bool, +) -> (Option, Option) +where + F: Fn(&T) -> &Ident, +{ + let prev_slot_ref = if idx > 0 { + let prev_name = get_name(&fields[idx - 1]); + if use_full_slot { + let prev_slot = PackingConstants::new(prev_name).slot(); + Some(quote! { #packing::#prev_slot }) + } else { + let prev_loc = PackingConstants::new(prev_name).location(); + Some(quote! { #packing::#prev_loc.offset_slots }) + } + } else { + None + }; + + let next_slot_ref = if idx + 1 < fields.len() { + let next_name = get_name(&fields[idx + 1]); + if use_full_slot { + let next_slot = PackingConstants::new(next_name).slot(); + Some(quote! { #packing::#next_slot }) + } else { + let next_loc = PackingConstants::new(next_name).location(); + Some(quote! { #packing::#next_loc.offset_slots }) + } + } else { + None + }; + + (prev_slot_ref, next_slot_ref) +} + +/// Generate slot packing decision logic. +pub(crate) fn gen_slot_packing_logic( + prev_ty: &Type, + curr_ty: &Type, + prev_slot_expr: TokenStream, + prev_offset_expr: TokenStream, +) -> (TokenStream, TokenStream) { + let prev_layout_slots = quote! { + ::alloy_primitives::U256::from_limbs([<#prev_ty as ::base_precompile_storage::StorableType>::SLOTS as u64, 0, 0, 0]) + }; + let curr_slots_to_end = quote! { + ::alloy_primitives::U256::from_limbs([<#curr_ty as ::base_precompile_storage::StorableType>::SLOTS as u64, 0, 0, 0]) + .saturating_sub(::alloy_primitives::U256::ONE) + }; + + let can_pack_expr = quote! { + #prev_offset_expr + + <#prev_ty as ::base_precompile_storage::StorableType>::BYTES + + <#curr_ty as ::base_precompile_storage::StorableType>::BYTES <= 32 + }; + + let slot_expr = quote! {{ + if #can_pack_expr { + #prev_slot_expr + } else { + #prev_slot_expr + .checked_add(#prev_layout_slots).expect("slot overflow") + .checked_add(#curr_slots_to_end).expect("slot overflow") + .saturating_sub(#curr_slots_to_end) + } + }}; + + let offset_expr = quote! {{ + if #can_pack_expr { #prev_offset_expr + <#prev_ty as ::base_precompile_storage::StorableType>::BYTES } else { 0 } + }}; + + (slot_expr, offset_expr) +} + +/// Generate [`LayoutCtx`] expression for accessing a field. +pub(crate) fn gen_layout_ctx_expr( + ty: &Type, + is_manual_slot: bool, + offset_const_ref: TokenStream, + shares_slot_check: Option, +) -> TokenStream { + if !is_manual_slot && let Some(shares_slot_check) = shares_slot_check { + quote! { + { + if #shares_slot_check && <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE { + ::base_precompile_storage::LayoutCtx::packed(#offset_const_ref) + } else { + ::base_precompile_storage::LayoutCtx::FULL + } + } + } + } else { + quote! { ::base_precompile_storage::LayoutCtx::FULL } + } +} + +/// Generate collision detection debug assertions for a field against all other fields. +pub(crate) fn gen_collision_check_fn( + idx: usize, + field: &LayoutField<'_>, + all_fields: &[LayoutField<'_>], +) -> (Ident, TokenStream) { + fn gen_slot_count_expr(ty: &Type) -> TokenStream { + quote! { ::alloy_primitives::U256::from_limbs([<#ty as ::base_precompile_storage::StorableType>::SLOTS as u64, 0, 0, 0]) } + } + + let check_fn_name = format_ident!("__check_collision_{}", field.name); + let consts = PackingConstants::new(field.name); + let (slot_const, offset_const) = consts.into_tuple(); + let (field_name, field_ty) = (field.name, field.ty); + + let mut checks = TokenStream::new(); + + for (other_idx, other_field) in all_fields.iter().enumerate() { + if other_idx == idx { + continue; + } + + let other_consts = PackingConstants::new(other_field.name); + let (other_slot_const, other_offset_const) = other_consts.into_tuple(); + let other_name = other_field.name; + let other_ty = other_field.ty; + + let current_count_expr = gen_slot_count_expr(field.ty); + let other_count_expr = gen_slot_count_expr(other_field.ty); + + checks.extend(quote! { + { + let slot = #slot_const; + let slot_end = slot.checked_add(#current_count_expr).expect("slot range overflow"); + let other_slot = #other_slot_const; + let other_slot_end = other_slot.checked_add(#other_count_expr).expect("slot range overflow"); + + let no_overlap = if slot == other_slot { + let byte_end = #offset_const + <#field_ty as ::base_precompile_storage::StorableType>::BYTES; + let other_byte_end = #other_offset_const + <#other_ty as ::base_precompile_storage::StorableType>::BYTES; + byte_end <= #other_offset_const || other_byte_end <= #offset_const + } else { + slot_end.le(&other_slot) || other_slot_end.le(&slot) + }; + + debug_assert!( + no_overlap, + "Storage slot collision: field `{}` (slot {:?}, offset {}) overlaps with field `{}` (slot {:?}, offset {})", + stringify!(#field_name), + slot, + #offset_const, + stringify!(#other_name), + other_slot, + #other_offset_const + ); + } + }); + } + + let check_fn = quote! { + #[cfg(debug_assertions)] + #[inline(always)] + #[allow(non_snake_case)] + fn #check_fn_name() { + #checks + } + }; + + (check_fn_name, check_fn) +} diff --git a/crates/common/precompile-macros/src/precompile.rs b/crates/common/precompile-macros/src/precompile.rs new file mode 100644 index 0000000000..f142f8cf54 --- /dev/null +++ b/crates/common/precompile-macros/src/precompile.rs @@ -0,0 +1,256 @@ +//! Implementation of the `#[precompile]` attribute macro. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{ + Data, DeriveInput, Expr, Ident, LitStr, Path, Token, Type, parenthesized, + parse::{Parse, ParseStream}, +}; + +pub(crate) fn expand(attr: TokenStream, item: TokenStream) -> TokenStream { + match expand_impl(attr.into(), item.into()) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand_impl(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let config: PrecompileConfig = syn::parse2(attr)?; + let input: DeriveInput = syn::parse2(item)?; + let Data::Struct(_) = &input.data else { + return Err(syn::Error::new_spanned(input.ident, "`#[precompile]` supports structs only")); + }; + + let ident = input.ident.clone(); + let generics = input.generics.clone(); + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let base_name = precompile_name(&ident); + let id = config.id.unwrap_or_else(|| { + let id = LitStr::new(&base_name, ident.span()); + syn::parse_quote!(#id) + }); + let storage = config.storage.unwrap_or_else(|| { + let storage = format_ident!("{base_name}Storage", span = ident.span()); + syn::parse_quote!(#storage<'_>) + }); + let macro_path = + config.macro_path.unwrap_or_else(|| syn::parse_quote!(crate::macros::base_precompile)); + let args = config.args; + let arg_defs = args.iter().map(PrecompileArg::definition); + let install_arg_defs = args.iter().map(PrecompileArg::definition); + let install_arg_names = args.iter().map(|arg| &arg.ident); + let install = config.install.map(|install| { + let address = install + .address + .map_or_else(|| quote! { <#storage>::ADDRESS }, |address| quote! { #address }); + let doc = format!("Installs the `{ident}` precompile into `precompiles`."); + + quote! { + #[doc = #doc] + pub fn install( + precompiles: &mut ::alloy_evm::precompiles::PrecompilesMap, + #(#install_arg_defs),* + ) { + precompiles.extend_precompiles(::core::iter::once(( + #address, + Self::precompile(#(#install_arg_names),*), + ))); + } + } + }); + let precompile_doc = format!("Creates the EVM precompile wrapper for `{ident}`."); + let arg_names = args.iter().map(|arg| &arg.ident); + + Ok(quote! { + #input + + impl #impl_generics #ident #ty_generics #where_clause { + #install + + #[doc = #precompile_doc] + pub fn precompile(#(#arg_defs),*) -> ::alloy_evm::precompiles::DynPrecompile { + #macro_path!(#id, |ctx, calldata| { + <#storage>::new(ctx).dispatch(ctx, &calldata #(, #arg_names)*) + }) + } + } + }) +} + +struct PrecompileConfig { + id: Option, + storage: Option, + macro_path: Option, + args: Vec, + install: Option, +} + +impl Parse for PrecompileConfig { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut id = None; + let mut storage = None; + let mut macro_path = None; + let mut args = Vec::new(); + let mut install = None; + + while !input.is_empty() { + let key: Ident = input.parse()?; + match key.to_string().as_str() { + "id" => { + reject_duplicate(&id, &key)?; + input.parse::()?; + id = Some(input.parse()?); + } + "storage" => { + reject_duplicate(&storage, &key)?; + input.parse::()?; + storage = Some(input.parse()?); + } + "macro_path" => { + reject_duplicate(¯o_path, &key)?; + input.parse::()?; + macro_path = Some(input.parse()?); + } + "args" => { + if !args.is_empty() { + return Err(syn::Error::new_spanned(key, "duplicate `args` option")); + } + let content; + parenthesized!(content in input); + args = content + .parse_terminated(PrecompileArg::parse, Token![,])? + .into_iter() + .collect(); + } + "install" => { + reject_duplicate(&install, &key)?; + install = if input.peek(syn::token::Paren) { + let content; + parenthesized!(content in input); + Some(content.parse()?) + } else { + Some(InstallConfig { address: None }) + }; + } + _ => { + return Err(syn::Error::new_spanned( + key, + "expected `id`, `storage`, `macro_path`, `args`, or `install`", + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } + } + + Ok(Self { id, storage, macro_path, args, install }) + } +} + +struct PrecompileArg { + ident: Ident, + ty: Type, +} + +impl PrecompileArg { + fn definition(&self) -> TokenStream2 { + let ident = &self.ident; + let ty = &self.ty; + + quote! { #ident: #ty } + } +} + +impl Parse for PrecompileArg { + fn parse(input: ParseStream<'_>) -> syn::Result { + let ident = input.parse()?; + input.parse::()?; + let ty = input.parse()?; + + Ok(Self { ident, ty }) + } +} + +struct InstallConfig { + address: Option, +} + +impl Parse for InstallConfig { + fn parse(input: ParseStream<'_>) -> syn::Result { + let key: Ident = input.parse()?; + if key != "address" && key != "addr" { + return Err(syn::Error::new_spanned(key, "`install` supports only `address = ...`")); + } + + input.parse::()?; + let address = input.parse()?; + + if !input.is_empty() { + input.parse::()?; + } + if !input.is_empty() { + return Err(syn::Error::new(input.span(), "unexpected `install` option")); + } + + Ok(Self { address: Some(address) }) + } +} + +fn reject_duplicate(option: &Option, ident: &Ident) -> syn::Result<()> { + if option.is_some() { + return Err(syn::Error::new_spanned(ident, format!("duplicate `{ident}` option"))); + } + + Ok(()) +} + +fn precompile_name(ident: &Ident) -> String { + ident.to_string().trim_end_matches("Precompile").to_owned() +} + +#[cfg(test)] +mod tests { + use proc_macro2::TokenStream as TokenStream2; + use quote::quote; + + use super::PrecompileConfig; + + fn parse_config(tokens: TokenStream2) -> syn::Result { + syn::parse2(tokens) + } + + #[test] + fn config_rejects_unknown_options() { + let err = parse_config(quote! { instal }).err().unwrap(); + + assert!( + err.to_string() + .contains("expected `id`, `storage`, `macro_path`, `args`, or `install`") + ); + } + + #[test] + fn config_rejects_positional_storage() { + let err = parse_config(quote! { CustomStorage<'_> }).err().unwrap(); + + assert!( + err.to_string() + .contains("expected `id`, `storage`, `macro_path`, `args`, or `install`") + ); + } + + #[test] + fn config_accepts_explicit_storage_and_macro_path() { + let config = parse_config(quote! { + storage = CustomStorage<'_>, + macro_path = crate::macros::custom_precompile, + }) + .unwrap(); + + assert!(config.storage.is_some()); + assert!(config.macro_path.is_some()); + } +} diff --git a/crates/common/precompile-macros/src/storable.rs b/crates/common/precompile-macros/src/storable.rs new file mode 100644 index 0000000000..249d673238 --- /dev/null +++ b/crates/common/precompile-macros/src/storable.rs @@ -0,0 +1,622 @@ +//! Implementation of the `#[derive(Storable)]` macro. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; +use syn::{Attribute, Data, DataEnum, DataStruct, DeriveInput, Fields, Ident, Type}; + +use crate::{ + FieldInfo, + layout::{gen_handler_field_decl, gen_handler_field_init}, + packing::{self, LayoutField, PackingConstants}, + storable_primitives::gen_struct_arrays, + utils::{ + NamespaceInfo, extract_mapping_types, extract_storable_array_sizes, + extract_storage_namespace, to_snake_case, + }, +}; + +/// Entry point called from `lib.rs` — parses input and converts errors to compile errors. +pub(crate) fn derive(input: DeriveInput) -> proc_macro::TokenStream { + match derive_impl(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result { + match &input.data { + Data::Struct(data_struct) => derive_struct_impl(&input, data_struct), + Data::Enum(data_enum) => derive_unit_enum_impl(&input, data_enum), + _ => Err(syn::Error::new_spanned( + &input.ident, + "`Storable` can only be derived for structs with named fields or unit enums", + )), + } +} + +fn derive_struct_impl(input: &DeriveInput, data_struct: &DataStruct) -> syn::Result { + let strukt = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let namespace = extract_storage_namespace(&input.attrs)?; + + let fields = match &data_struct.fields { + Fields::Named(fields_named) => &fields_named.named, + _ => { + return Err(syn::Error::new_spanned( + &input.ident, + "`Storable` can only be derived for structs with named fields", + )); + } + }; + + if fields.is_empty() { + return Err(syn::Error::new_spanned( + &input.ident, + "`Storable` cannot be derived for empty structs", + )); + } + + let field_infos: Vec<_> = fields + .iter() + .map(|f| FieldInfo { + name: f.ident.as_ref().unwrap().clone(), + ty: f.ty.clone(), + slot: None, + base_slot: None, + namespace: None, + }) + .collect(); + + let layout_fields = packing::allocate_slots(&field_infos)?; + + let mod_ident = format_ident!("__packing_{}", to_snake_case(&strukt.to_string())); + let packing_module = gen_packing_module_from_ir(&layout_fields, &mod_ident); + + let len = fields.len(); + let (direct_fields, direct_names, mapping_names) = field_infos.iter().fold( + (Vec::with_capacity(len), Vec::with_capacity(len), Vec::new()), + |mut out, field_info| { + if extract_mapping_types(&field_info.ty).is_none() { + out.0.push((&field_info.name, &field_info.ty)); + out.1.push(&field_info.name); + } else { + out.2.push(&field_info.name); + } + out + }, + ); + + let direct_tys: Vec<_> = direct_fields.iter().map(|(_, ty)| *ty).collect(); + + let load_impl = gen_load_impl(&direct_fields, &mod_ident); + let store_impl = gen_store_impl(&direct_fields, &mod_ident); + let delete_impl = gen_delete_impl(&direct_fields, &mod_ident); + + let handler_struct = gen_handler_struct(strukt, &layout_fields, &mod_ident); + let handler_name = format_ident!("{}Handler", strukt); + let namespace_consts = gen_storage_namespace_consts(namespace.as_ref()); + + let expanded = quote! { + #packing_module + #handler_struct + + impl #impl_generics ::base_precompile_storage::StorableType for #strukt #ty_generics #where_clause { + const LAYOUT: ::base_precompile_storage::Layout = ::base_precompile_storage::Layout::Slots(#mod_ident::SLOT_COUNT); + #namespace_consts + + const IS_DYNAMIC: bool = #( + <#direct_tys as ::base_precompile_storage::StorableType>::IS_DYNAMIC + )||*; + + type Handler<'a> = #handler_name<'a>; + + fn handle<'a>( + slot: ::alloy_primitives::U256, + _ctx: ::base_precompile_storage::LayoutCtx, + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self::Handler<'a> { + #handler_name::new(slot, address, storage) + } + } + + impl #impl_generics ::base_precompile_storage::Storable for #strukt #ty_generics #where_clause { + fn load( + storage: &S, + base_slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result { + use ::base_precompile_storage::Storable; + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Struct types can only be loaded with LayoutCtx::FULL"); + + #load_impl + + Ok(Self { + #(#direct_names),*, + #(#mapping_names: Default::default()),* + }) + } + + fn store( + &self, + storage: &mut S, + base_slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result<()> { + use ::base_precompile_storage::Storable; + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Struct types can only be stored with LayoutCtx::FULL"); + + #store_impl + + Ok(()) + } + + fn delete( + storage: &mut S, + base_slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result<()> { + use ::base_precompile_storage::Storable; + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Struct types can only be deleted with LayoutCtx::FULL"); + + #delete_impl + + Ok(()) + } + } + }; + + let array_impls = extract_storable_array_sizes(&input.attrs)?.map_or_else( + || quote! {}, + |sizes| { + let struct_type = quote! { #strukt #ty_generics }; + gen_struct_arrays(struct_type, &sizes) + }, + ); + + Ok(quote! { + #expanded + #array_impls + }) +} + +fn derive_unit_enum_impl(input: &DeriveInput, data_enum: &DataEnum) -> syn::Result { + if extract_storage_namespace(&input.attrs)?.is_some() { + return Err(syn::Error::new_spanned( + &input.ident, + "`namespace` is only supported for `Storable` structs", + )); + } + + if extract_storable_array_sizes(&input.attrs)?.is_some() { + return Err(syn::Error::new_spanned( + &input.ident, + "`storable_arrays` is only supported for structs", + )); + } + + if !has_repr_u8(&input.attrs)? { + return Err(syn::Error::new_spanned( + &input.ident, + "`Storable` unit enums must be annotated with `#[repr(u8)]`", + )); + } + + if data_enum.variants.is_empty() { + return Err(syn::Error::new_spanned( + &input.ident, + "`Storable` cannot be derived for empty enums", + )); + } + + for variant in &data_enum.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + variant, + "`Storable` enums must use unit variants only", + )); + } + } + + validate_sequential_discriminants(data_enum)?; + + let enum_name = &input.ident; + let variant_names: Vec<_> = data_enum.variants.iter().map(|variant| &variant.ident).collect(); + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + Ok(quote! { + impl #impl_generics ::base_precompile_storage::StorableType for #enum_name #ty_generics #where_clause { + const LAYOUT: ::base_precompile_storage::Layout = ::base_precompile_storage::Layout::Bytes(1); + type Handler<'a> = ::base_precompile_storage::Slot<'a, Self>; + + fn handle<'a>( + slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx, + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self::Handler<'a> { + ::base_precompile_storage::Slot::new_with_ctx(slot, ctx, address, storage) + } + } + + impl #impl_generics ::base_precompile_storage::Storable for #enum_name #ty_generics #where_clause { + #[inline] + fn load( + storage: &S, + slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result { + let value = ::load(storage, slot, ctx)?; + match value { + #(discriminant if discriminant == Self::#variant_names as u8 => Ok(Self::#variant_names),)* + _ => Err(::base_precompile_storage::BasePrecompileError::enum_conversion_error()), + } + } + + #[inline] + fn store( + &self, + storage: &mut S, + slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result<()> { + let value = match self { + #(Self::#variant_names => Self::#variant_names as u8,)* + }; + ::store(&value, storage, slot, ctx) + } + } + }) +} + +fn has_repr_u8(attrs: &[Attribute]) -> syn::Result { + let mut repr_u8 = false; + for attr in attrs { + if !attr.path().is_ident("repr") { + continue; + } + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("u8") { + repr_u8 = true; + } + Ok(()) + })?; + } + Ok(repr_u8) +} + +fn validate_sequential_discriminants(data_enum: &DataEnum) -> syn::Result<()> { + if data_enum.variants.len() > usize::from(u8::MAX) + 1 { + return Err(syn::Error::new_spanned( + &data_enum.variants, + "`Storable` unit enums must have at most 256 variants", + )); + } + for variant in &data_enum.variants { + if variant.discriminant.is_some() { + return Err(syn::Error::new_spanned( + variant, + "`Storable` unit enums must not use explicit discriminants; \ + variants are assigned sequential values starting from 0, matching Solidity enum semantics", + )); + } + } + Ok(()) +} + +fn gen_packing_module_from_ir(fields: &[LayoutField<'_>], mod_ident: &Ident) -> TokenStream { + let last_field = &fields[fields.len() - 1]; + let last_slot_const = PackingConstants::new(last_field.name).slot(); + let packing_constants = packing::gen_constants_from_ir(fields, true); + let last_type = &last_field.ty; + + quote! { + pub mod #mod_ident { + use super::*; + + #packing_constants + pub const SLOT_COUNT: usize = (#last_slot_const.saturating_add( + ::alloy_primitives::U256::from_limbs([<#last_type as ::base_precompile_storage::StorableType>::SLOTS as u64, 0, 0, 0]) + )).as_limbs()[0] as usize; + } + } +} + +fn gen_handler_struct( + struct_name: &Ident, + fields: &[LayoutField<'_>], + mod_ident: &Ident, +) -> TokenStream { + let handler_name = format_ident!("{}Handler", struct_name); + let handler_fields = fields.iter().map(gen_handler_field_decl); + let field_inits = fields + .iter() + .enumerate() + .map(|(idx, field)| gen_handler_field_init(field, idx, fields, Some(mod_ident))); + + quote! { + /// Type-safe handler for accessing `#struct_name` in storage. + #[derive(Debug, Clone)] + pub struct #handler_name<'a> { + address: ::alloy_primitives::Address, + base_slot: ::alloy_primitives::U256, + storage: ::base_precompile_storage::StorageCtx<'a>, + #(#handler_fields,)* + } + + impl<'a> #handler_name<'a> { + #[inline] + pub fn new( + base_slot: ::alloy_primitives::U256, + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self { + Self { + base_slot, + storage, + #(#field_inits,)* + address, + } + } + + #[inline] + pub fn base_slot(&self) -> ::alloy_primitives::U256 { + self.base_slot + } + + #[inline] + fn as_slot(&self) -> ::base_precompile_storage::Slot<'a, #struct_name> { + ::base_precompile_storage::Slot::<#struct_name>::new( + self.base_slot, + self.address, + self.storage, + ) + } + } + + impl ::base_precompile_storage::Handler<#struct_name> for #handler_name<'_> { + #[inline] + fn read(&self) -> ::base_precompile_storage::Result<#struct_name> { + self.as_slot().read() + } + #[inline] + fn write(&mut self, value: #struct_name) -> ::base_precompile_storage::Result<()> { + self.as_slot().write(value) + } + #[inline] + fn delete(&mut self) -> ::base_precompile_storage::Result<()> { + self.as_slot().delete() + } + #[inline] + fn t_read(&self) -> ::base_precompile_storage::Result<#struct_name> { + self.as_slot().t_read() + } + #[inline] + fn t_write(&mut self, value: #struct_name) -> ::base_precompile_storage::Result<()> { + self.as_slot().t_write(value) + } + #[inline] + fn t_delete(&mut self) -> ::base_precompile_storage::Result<()> { + self.as_slot().t_delete() + } + } + } +} + +fn gen_storage_namespace_consts(namespace: Option<&NamespaceInfo>) -> TokenStream { + namespace.map_or_else( + || quote! {}, + |namespace| { + let id = &namespace.id; + let limbs = *namespace.root.as_limbs(); + quote! { + const HAS_STORAGE_NAMESPACE: bool = true; + const STORAGE_NAMESPACE_ID: &'static str = #id; + const STORAGE_NAMESPACE_ROOT: ::alloy_primitives::U256 = + ::alloy_primitives::U256::from_limbs([#(#limbs),*]); + } + }, + ) +} + +fn gen_load_impl(fields: &[(&Ident, &Type)], packing: &Ident) -> TokenStream { + if fields.is_empty() { + return quote! {}; + } + + let field_loads = fields.iter().enumerate().map(|(idx, (name, ty))| { + let loc_const = PackingConstants::new(name).location(); + + let (prev_slot_ref, _) = + packing::get_neighbor_slot_refs(idx, fields, packing, |(name, _)| name, false); + + let slot_addr = quote! { base_slot + ::alloy_primitives::U256::from(#packing::#loc_const.offset_slots) }; + let packed_ctx = quote! { ::base_precompile_storage::LayoutCtx::packed(#packing::#loc_const.offset_bytes) }; + + prev_slot_ref.map_or_else( + || quote! { + let #name = if <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE { + cached_slot = storage.load(#slot_addr)?; + let packed = ::base_precompile_storage::PackedSlot(cached_slot); + <#ty as ::base_precompile_storage::Storable>::load(&packed, ::alloy_primitives::U256::ZERO, #packed_ctx)? + } else { + <#ty as ::base_precompile_storage::Storable>::load(storage, #slot_addr, ::base_precompile_storage::LayoutCtx::FULL)? + }; + }, + |prev_slot_ref| quote! { + let #name = { + let curr_offset = #packing::#loc_const.offset_slots; + let prev_offset = #prev_slot_ref; + + if <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE && curr_offset == prev_offset { + let packed = ::base_precompile_storage::PackedSlot(cached_slot); + <#ty as ::base_precompile_storage::Storable>::load(&packed, ::alloy_primitives::U256::ZERO, #packed_ctx)? + } else if <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE { + cached_slot = storage.load(#slot_addr)?; + let packed = ::base_precompile_storage::PackedSlot(cached_slot); + <#ty as ::base_precompile_storage::Storable>::load(&packed, ::alloy_primitives::U256::ZERO, #packed_ctx)? + } else { + <#ty as ::base_precompile_storage::Storable>::load(storage, #slot_addr, ::base_precompile_storage::LayoutCtx::FULL)? + } + }; + }, + ) + }); + + quote! { + let mut cached_slot = ::alloy_primitives::U256::ZERO; + #(#field_loads)* + } +} + +fn gen_store_impl(fields: &[(&Ident, &Type)], packing: &Ident) -> TokenStream { + if fields.is_empty() { + return quote! {}; + } + + let field_stores = fields.iter().enumerate().map(|(idx, (name, ty))| { + let loc_const = PackingConstants::new(name).location(); + let next_ty = fields.get(idx + 1).map(|(_, ty)| *ty); + + let (prev_slot_ref, next_slot_ref) = + packing::get_neighbor_slot_refs(idx, fields, packing, |(name, _)| name, false); + + let slot_addr = quote! { base_slot + ::alloy_primitives::U256::from(#packing::#loc_const.offset_slots) }; + let packed_ctx = quote! { ::base_precompile_storage::LayoutCtx::packed(#packing::#loc_const.offset_bytes) }; + + let should_store = match (&next_slot_ref, next_ty) { + (Some(next_slot), Some(next_ty)) => { + quote! { + #packing::#loc_const.offset_slots != #next_slot + || !<#next_ty as ::base_precompile_storage::StorableType>::IS_PACKABLE + } + } + _ => quote! { true }, + }; + + prev_slot_ref.map_or_else( + || quote! {{ + if <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE { + // Always SLOAD first (Category 3: is_t4() optimization removed — correct but slightly less efficient) + pending_val = storage.load(#slot_addr)?; + pending_offset = Some(#packing::#loc_const.offset_slots); + let mut packed = ::base_precompile_storage::PackedSlot(pending_val); + <#ty as ::base_precompile_storage::Storable>::store(&self.#name, &mut packed, ::alloy_primitives::U256::ZERO, #packed_ctx)?; + pending_val = packed.0; + + if #should_store { + storage.store(#slot_addr, pending_val)?; + pending_offset = None; + } + } else { + <#ty as ::base_precompile_storage::Storable>::store(&self.#name, storage, #slot_addr, ::base_precompile_storage::LayoutCtx::FULL)?; + } + }}, + |prev_slot_ref| quote! {{ + let curr_offset = #packing::#loc_const.offset_slots; + let prev_offset = #prev_slot_ref; + + if <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE && curr_offset == prev_offset { + let mut packed = ::base_precompile_storage::PackedSlot(pending_val); + <#ty as ::base_precompile_storage::Storable>::store(&self.#name, &mut packed, ::alloy_primitives::U256::ZERO, #packed_ctx)?; + pending_val = packed.0; + } else if <#ty as ::base_precompile_storage::StorableType>::IS_PACKABLE { + if let Some(offset) = pending_offset { + storage.store(base_slot + ::alloy_primitives::U256::from(offset), pending_val)?; + } + // Always SLOAD first (Category 3: is_t4() optimization removed — correct but slightly less efficient) + pending_val = storage.load(#slot_addr)?; + pending_offset = Some(curr_offset); + let mut packed = ::base_precompile_storage::PackedSlot(pending_val); + <#ty as ::base_precompile_storage::Storable>::store(&self.#name, &mut packed, ::alloy_primitives::U256::ZERO, #packed_ctx)?; + pending_val = packed.0; + } else { + if let Some(offset) = pending_offset { + storage.store(base_slot + ::alloy_primitives::U256::from(offset), pending_val)?; + pending_offset = None; + } + <#ty as ::base_precompile_storage::Storable>::store(&self.#name, storage, #slot_addr, ::base_precompile_storage::LayoutCtx::FULL)?; + } + + if let Some(offset) = pending_offset && (#should_store) { + storage.store(base_slot + ::alloy_primitives::U256::from(offset), pending_val)?; + pending_offset = None; + } + }}, + ) + }); + + quote! { + let mut pending_val = ::alloy_primitives::U256::ZERO; + let mut pending_offset: Option = None; + #(#field_stores)* + } +} + +fn gen_delete_impl(fields: &[(&Ident, &Type)], packing: &Ident) -> TokenStream { + let dynamic_deletes = fields.iter().map(|(name, ty)| { + let loc_const = PackingConstants::new(name).location(); + quote! { + if <#ty as ::base_precompile_storage::StorableType>::IS_DYNAMIC { + <#ty as ::base_precompile_storage::Storable>::delete( + storage, + base_slot + ::alloy_primitives::U256::from(#packing::#loc_const.offset_slots), + ::base_precompile_storage::LayoutCtx::FULL + )?; + } + } + }); + + let is_static_slot = fields.iter().map(|(name, ty)| { + let loc_const = PackingConstants::new(name).location(); + quote! { + ((#packing::#loc_const.offset_slots..#packing::#loc_const.offset_slots + <#ty as ::base_precompile_storage::StorableType>::SLOTS) + .contains(&slot_offset) && + !<#ty as ::base_precompile_storage::StorableType>::IS_DYNAMIC) + } + }); + + quote! { + #(#dynamic_deletes)* + + for slot_offset in 0..#packing::SLOT_COUNT { + if #(#is_static_slot)||* { + storage.store( + base_slot + ::alloy_primitives::U256::from(slot_offset), + ::alloy_primitives::U256::ZERO + )?; + } + } + } +} + +#[cfg(test)] +mod tests { + use syn::parse_quote; + + use super::*; + + fn parse_enum(input: DeriveInput) -> DataEnum { + match input.data { + Data::Enum(data_enum) => data_enum, + _ => panic!("expected enum input"), + } + } + + #[test] + fn validate_sequential_discriminants_accepts_implicit_variants() { + let data_enum = parse_enum(parse_quote! { + enum PackedStatus { Pending, Active, Frozen, } + }); + validate_sequential_discriminants(&data_enum).unwrap(); + } + + #[test] + fn validate_sequential_discriminants_rejects_explicit_discriminants() { + let data_enum = parse_enum(parse_quote! { + enum PackedStatus { Pending = 0, Active = 1, Frozen = 2, } + }); + let err = validate_sequential_discriminants(&data_enum).unwrap_err(); + assert!(err.to_string().contains("explicit discriminants")); + } +} diff --git a/crates/common/precompile-macros/src/storable_primitives.rs b/crates/common/precompile-macros/src/storable_primitives.rs new file mode 100644 index 0000000000..d3064eed31 --- /dev/null +++ b/crates/common/precompile-macros/src/storable_primitives.rs @@ -0,0 +1,575 @@ +//! Code generation for primitive type storage implementations. + +use proc_macro2::TokenStream; +use quote::quote; + +pub(crate) const RUST_INT_SIZES: &[usize] = &[8, 16, 32, 64, 128]; +pub(crate) const ALLOY_INT_SIZES: &[usize] = &[8, 16, 32, 64, 96, 128, 256]; + +// -- CONFIGURATION TYPES ------------------------------------------------------ + +#[derive(Debug, Clone)] +enum StorableConversionStrategy { + UnsignedRust, + UnsignedAlloy(proc_macro2::Ident), + SignedRust(proc_macro2::Ident), + SignedAlloy(proc_macro2::Ident), + FixedBytes(usize), +} + +#[derive(Debug, Clone)] +enum StorageKeyStrategy { + Simple, + WithSize(usize), + SignedRaw(usize), + AsSlice, +} + +#[derive(Debug, Clone)] +struct TypeConfig { + type_path: TokenStream, + byte_count: usize, + storable_strategy: StorableConversionStrategy, + storage_key_strategy: StorageKeyStrategy, +} + +// -- IMPLEMENTATION GENERATORS ------------------------------------------------ + +fn gen_storable_layout_impl(type_path: &TokenStream, byte_count: usize) -> TokenStream { + quote! { + impl ::base_precompile_storage::StorableType for #type_path { + const LAYOUT: ::base_precompile_storage::Layout = ::base_precompile_storage::Layout::Bytes(#byte_count); + type Handler<'a> = ::base_precompile_storage::Slot<'a, Self>; + + fn handle<'a>( + slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx, + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self::Handler<'a> { + ::base_precompile_storage::Slot::new_with_ctx(slot, ctx, address, storage) + } + } + } +} + +fn gen_storage_key_impl(type_path: &TokenStream, strategy: &StorageKeyStrategy) -> TokenStream { + let conversion = match strategy { + StorageKeyStrategy::Simple => quote! { self.to_be_bytes() }, + StorageKeyStrategy::WithSize(size) => quote! { self.to_be_bytes::<#size>() }, + StorageKeyStrategy::SignedRaw(size) => quote! { self.into_raw().to_be_bytes::<#size>() }, + StorageKeyStrategy::AsSlice => quote! { self.as_slice() }, + }; + + quote! { + impl ::base_precompile_storage::StorageKey for #type_path { + #[inline] + fn as_storage_bytes(&self) -> impl AsRef<[u8]> { + #conversion + } + } + } +} + +fn gen_to_word_impl(type_path: &TokenStream, strategy: &StorableConversionStrategy) -> TokenStream { + match strategy { + StorableConversionStrategy::UnsignedRust => quote! { + impl ::base_precompile_storage::FromWord for #type_path { + #[inline] + fn to_word(&self) -> ::alloy_primitives::U256 { + ::alloy_primitives::U256::from(*self) + } + #[inline] + fn from_word(word: ::alloy_primitives::U256) -> ::base_precompile_storage::Result { + word.try_into().map_err(|_| ::base_precompile_storage::BasePrecompileError::under_overflow()) + } + } + }, + StorableConversionStrategy::UnsignedAlloy(ty) => quote! { + impl ::base_precompile_storage::FromWord for #type_path { + #[inline] + fn to_word(&self) -> ::alloy_primitives::U256 { + ::alloy_primitives::U256::from(*self) + } + #[inline] + fn from_word(word: ::alloy_primitives::U256) -> ::base_precompile_storage::Result { + if word > ::alloy_primitives::U256::from(::alloy_primitives::aliases::#ty::MAX) { + return Err(::base_precompile_storage::BasePrecompileError::under_overflow()); + } + Ok(word.to::()) + } + } + }, + StorableConversionStrategy::SignedRust(unsigned_type) => quote! { + impl ::base_precompile_storage::FromWord for #type_path { + #[inline] + fn to_word(&self) -> ::alloy_primitives::U256 { + ::alloy_primitives::U256::from(*self as #unsigned_type) + } + #[inline] + fn from_word(word: ::alloy_primitives::U256) -> ::base_precompile_storage::Result { + let unsigned: #unsigned_type = word.try_into() + .map_err(|_| ::base_precompile_storage::BasePrecompileError::under_overflow())?; + Ok(unsigned as Self) + } + } + }, + StorableConversionStrategy::SignedAlloy(unsigned_type) => quote! { + impl ::base_precompile_storage::FromWord for #type_path { + #[inline] + fn to_word(&self) -> ::alloy_primitives::U256 { + ::alloy_primitives::U256::from(self.into_raw()) + } + #[inline] + fn from_word(word: ::alloy_primitives::U256) -> ::base_precompile_storage::Result { + if word > ::alloy_primitives::U256::from(::alloy_primitives::aliases::#unsigned_type::MAX) { + return Err(::base_precompile_storage::BasePrecompileError::under_overflow()); + } + let unsigned_val = word.to::<::alloy_primitives::aliases::#unsigned_type>(); + Ok(Self::from_raw(unsigned_val)) + } + } + }, + StorableConversionStrategy::FixedBytes(size) => quote! { + impl ::base_precompile_storage::FromWord for #type_path { + #[inline] + fn to_word(&self) -> ::alloy_primitives::U256 { + let mut bytes = [0u8; 32]; + bytes[32 - #size..].copy_from_slice(&self[..]); + ::alloy_primitives::U256::from_be_bytes(bytes) + } + #[inline] + fn from_word(word: ::alloy_primitives::U256) -> ::base_precompile_storage::Result { + let bytes = word.to_be_bytes::<32>(); + let mut fixed_bytes = [0u8; #size]; + fixed_bytes.copy_from_slice(&bytes[32 - #size..]); + Ok(Self::from(fixed_bytes)) + } + } + }, + } +} + +fn gen_complete_impl_set(config: &TypeConfig) -> TokenStream { + let type_path = &config.type_path; + let storable_type_impl = gen_storable_layout_impl(type_path, config.byte_count); + let storage_key_impl = gen_storage_key_impl(type_path, &config.storage_key_strategy); + let to_word_impl = gen_to_word_impl(type_path, &config.storable_strategy); + + let full_word_storable_impl = if config.byte_count < 32 { + quote! { + impl ::base_precompile_storage::sealed::OnlyPrimitives for #type_path {} + impl ::base_precompile_storage::Packable for #type_path {} + } + } else { + quote! { + impl ::base_precompile_storage::sealed::OnlyPrimitives for #type_path {} + impl ::base_precompile_storage::Storable for #type_path { + #[inline] + fn load( + storage: &S, + slot: ::alloy_primitives::U256, + _ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result { + storage.load(slot).and_then(::from_word) + } + #[inline] + fn store( + &self, + storage: &mut S, + slot: ::alloy_primitives::U256, + _ctx: ::base_precompile_storage::LayoutCtx + ) -> ::base_precompile_storage::Result<()> { + storage.store(slot, ::to_word(self)) + } + } + } + }; + + quote! { + #storable_type_impl + #to_word_impl + #storage_key_impl + #full_word_storable_impl + } +} + +pub(crate) fn gen_storable_rust_ints() -> TokenStream { + let mut impls = Vec::with_capacity(RUST_INT_SIZES.len() * 2); + + for size in RUST_INT_SIZES { + let unsigned_type = quote::format_ident!("u{}", size); + let signed_type = quote::format_ident!("i{}", size); + let byte_count = size / 8; + + let unsigned_config = TypeConfig { + type_path: quote! { #unsigned_type }, + byte_count, + storable_strategy: StorableConversionStrategy::UnsignedRust, + storage_key_strategy: StorageKeyStrategy::Simple, + }; + impls.push(gen_complete_impl_set(&unsigned_config)); + + let signed_config = TypeConfig { + type_path: quote! { #signed_type }, + byte_count, + storable_strategy: StorableConversionStrategy::SignedRust(unsigned_type.clone()), + storage_key_strategy: StorageKeyStrategy::Simple, + }; + impls.push(gen_complete_impl_set(&signed_config)); + } + + quote! { #(#impls)* } +} + +fn gen_alloy_integers() -> Vec { + let mut impls = Vec::with_capacity(ALLOY_INT_SIZES.len() * 2); + + for &size in ALLOY_INT_SIZES { + let unsigned_type = quote::format_ident!("U{}", size); + let signed_type = quote::format_ident!("I{}", size); + let byte_count = size / 8; + + let unsigned_config = TypeConfig { + type_path: quote! { ::alloy_primitives::aliases::#unsigned_type }, + byte_count, + storable_strategy: StorableConversionStrategy::UnsignedAlloy(unsigned_type.clone()), + storage_key_strategy: StorageKeyStrategy::WithSize(byte_count), + }; + impls.push(gen_complete_impl_set(&unsigned_config)); + + let signed_config = TypeConfig { + type_path: quote! { ::alloy_primitives::aliases::#signed_type }, + byte_count, + storable_strategy: StorableConversionStrategy::SignedAlloy(unsigned_type.clone()), + storage_key_strategy: StorageKeyStrategy::SignedRaw(byte_count), + }; + impls.push(gen_complete_impl_set(&signed_config)); + } + + impls +} + +fn gen_fixed_bytes(sizes: &[usize]) -> Vec { + sizes + .iter() + .map(|&size| { + let config = TypeConfig { + type_path: quote! { ::alloy_primitives::FixedBytes<#size> }, + byte_count: size, + storable_strategy: StorableConversionStrategy::FixedBytes(size), + storage_key_strategy: StorageKeyStrategy::AsSlice, + }; + gen_complete_impl_set(&config) + }) + .collect() +} + +pub(crate) fn gen_storable_alloy_bytes() -> TokenStream { + let sizes: Vec = (1..=32).collect(); + let impls = gen_fixed_bytes(&sizes); + quote! { #(#impls)* } +} + +pub(crate) fn gen_storable_alloy_ints() -> TokenStream { + let impls = gen_alloy_integers(); + quote! { #(#impls)* } +} + +// -- ARRAY IMPLEMENTATIONS ---------------------------------------------------- + +#[derive(Debug, Clone)] +struct ArrayConfig { + elem_type: TokenStream, + array_size: usize, + elem_byte_count: usize, + elem_is_packable: bool, +} + +const fn is_packable(byte_count: usize) -> bool { + byte_count < 32 +} + +fn gen_array_impl(config: &ArrayConfig) -> TokenStream { + let ArrayConfig { elem_type, array_size, elem_byte_count, elem_is_packable } = config; + + let slot_count_expr = if *elem_is_packable { + quote! { ::base_precompile_storage::calc_packed_slot_count(#array_size, #elem_byte_count) } + } else { + quote! { #array_size } + }; + + let load_impl = if *elem_is_packable { + gen_packed_array_load(array_size, elem_byte_count) + } else { + gen_unpacked_array_load(array_size) + }; + + let store_impl = if *elem_is_packable { + gen_packed_array_store(array_size, elem_byte_count) + } else { + gen_unpacked_array_store() + }; + + quote! { + impl ::base_precompile_storage::StorableType for [#elem_type; #array_size] { + const LAYOUT: ::base_precompile_storage::Layout = ::base_precompile_storage::Layout::Slots(#slot_count_expr); + type Handler<'a> = ::base_precompile_storage::ArrayHandler<'a, #elem_type, #array_size>; + + fn handle<'a>( + slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx, + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self::Handler<'a> { + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Arrays cannot be packed"); + Self::Handler::new(slot, address, storage) + } + } + + impl ::base_precompile_storage::Storable for [#elem_type; #array_size] { + #[inline] + fn load(storage: &S, slot: ::alloy_primitives::U256, ctx: ::base_precompile_storage::LayoutCtx) -> ::base_precompile_storage::Result { + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Arrays can only be loaded with LayoutCtx::FULL"); + use ::base_precompile_storage::{calc_element_slot, calc_element_offset, extract_from_word}; + let base_slot = slot; + #load_impl + } + + #[inline] + fn store(&self, storage: &mut S, slot: ::alloy_primitives::U256, ctx: ::base_precompile_storage::LayoutCtx) -> ::base_precompile_storage::Result<()> { + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Arrays can only be stored with LayoutCtx::FULL"); + use ::base_precompile_storage::{calc_element_slot, calc_element_offset, insert_into_word}; + let base_slot = slot; + #store_impl + } + } + } +} + +fn gen_packed_array_load(array_size: &usize, elem_byte_count: &usize) -> TokenStream { + quote! { + let mut result = [Default::default(); #array_size]; + for i in 0..#array_size { + let slot_idx = calc_element_slot(i, #elem_byte_count); + let offset = calc_element_offset(i, #elem_byte_count); + let slot_addr = base_slot + ::alloy_primitives::U256::from(slot_idx); + let slot_value = storage.load(slot_addr)?; + result[i] = extract_from_word(slot_value, offset, #elem_byte_count)?; + } + Ok(result) + } +} + +fn gen_packed_array_store(array_size: &usize, elem_byte_count: &usize) -> TokenStream { + quote! { + let slot_count = ::base_precompile_storage::calc_packed_slot_count(#array_size, #elem_byte_count); + for slot_idx in 0..slot_count { + let slot_addr = base_slot + ::alloy_primitives::U256::from(slot_idx); + let mut slot_value = ::alloy_primitives::U256::ZERO; + for i in 0..#array_size { + let elem_slot = calc_element_slot(i, #elem_byte_count); + if elem_slot == slot_idx { + let offset = calc_element_offset(i, #elem_byte_count); + slot_value = insert_into_word(slot_value, &self[i], offset, #elem_byte_count)?; + } + } + storage.store(slot_addr, slot_value)?; + } + Ok(()) + } +} + +fn gen_unpacked_array_load(array_size: &usize) -> TokenStream { + quote! { + let mut result = [Default::default(); #array_size]; + for i in 0..#array_size { + let elem_slot = base_slot + ::alloy_primitives::U256::from(i); + result[i] = ::base_precompile_storage::Storable::load(storage, elem_slot, ::base_precompile_storage::LayoutCtx::FULL)?; + } + Ok(result) + } +} + +fn gen_unpacked_array_store() -> TokenStream { + quote! { + for (i, elem) in self.iter().enumerate() { + let elem_slot = base_slot + ::alloy_primitives::U256::from(i); + ::base_precompile_storage::Storable::store(elem, storage, elem_slot, ::base_precompile_storage::LayoutCtx::FULL)?; + } + Ok(()) + } +} + +fn gen_arrays_for_type( + elem_type: TokenStream, + elem_byte_count: usize, + sizes: &[usize], +) -> Vec { + let elem_is_packable = is_packable(elem_byte_count); + sizes + .iter() + .map(|&size| { + let config = ArrayConfig { + elem_type: elem_type.clone(), + array_size: size, + elem_byte_count, + elem_is_packable, + }; + gen_array_impl(&config) + }) + .collect() +} + +pub(crate) fn gen_storable_arrays() -> TokenStream { + let mut all_impls = Vec::new(); + let sizes: Vec = (1..=32).collect(); + + for &bit_size in RUST_INT_SIZES { + let type_ident = quote::format_ident!("u{}", bit_size); + all_impls.extend(gen_arrays_for_type(quote! { #type_ident }, bit_size / 8, &sizes)); + } + for &bit_size in RUST_INT_SIZES { + let type_ident = quote::format_ident!("i{}", bit_size); + all_impls.extend(gen_arrays_for_type(quote! { #type_ident }, bit_size / 8, &sizes)); + } + for &bit_size in ALLOY_INT_SIZES { + let type_ident = quote::format_ident!("U{}", bit_size); + all_impls.extend(gen_arrays_for_type( + quote! { ::alloy_primitives::aliases::#type_ident }, + bit_size / 8, + &sizes, + )); + } + for &bit_size in ALLOY_INT_SIZES { + let type_ident = quote::format_ident!("I{}", bit_size); + all_impls.extend(gen_arrays_for_type( + quote! { ::alloy_primitives::aliases::#type_ident }, + bit_size / 8, + &sizes, + )); + } + all_impls.extend(gen_arrays_for_type(quote! { ::alloy_primitives::Address }, 20, &sizes)); + for &byte_size in &[20usize, 32] { + all_impls.extend(gen_arrays_for_type( + quote! { ::alloy_primitives::FixedBytes<#byte_size> }, + byte_size, + &sizes, + )); + } + + quote! { #(#all_impls)* } +} + +pub(crate) fn gen_nested_arrays() -> TokenStream { + let mut all_impls = Vec::new(); + + for inner in &[2usize, 4, 8, 16] { + let inner_slots = inner.div_ceil(32); + let max_outer = 32 / inner_slots.max(1); + for outer in 1..=max_outer.min(32) { + all_impls.extend(gen_arrays_for_type( + quote! { [u8; #inner] }, + inner_slots * 32, + &[outer], + )); + } + } + for inner in &[2usize, 4, 8] { + let inner_slots = (inner * 2).div_ceil(32); + let max_outer = 32 / inner_slots.max(1); + for outer in 1..=max_outer.min(16) { + all_impls.extend(gen_arrays_for_type( + quote! { [u16; #inner] }, + inner_slots * 32, + &[outer], + )); + } + } + + quote! { #(#all_impls)* } +} + +// -- STRUCT ARRAY IMPLEMENTATIONS --------------------------------------------- + +pub(crate) fn gen_struct_arrays(struct_type: TokenStream, array_sizes: &[usize]) -> TokenStream { + let impls: Vec<_> = + array_sizes.iter().map(|&size| gen_struct_array_impl(&struct_type, size)).collect(); + quote! { #(#impls)* } +} + +fn gen_struct_array_impl(struct_type: &TokenStream, array_size: usize) -> TokenStream { + let struct_type_str = + struct_type.to_string().replace("::", "_").replace(['<', '>', ' ', '[', ']', ';'], "_"); + let mod_ident = quote::format_ident!("__array_{}_{}", struct_type_str, array_size); + + let load_impl = gen_struct_array_load(struct_type, array_size); + let store_impl = gen_struct_array_store(struct_type); + + quote! { + mod #mod_ident { + use super::*; + pub const ELEM_SLOTS: usize = <#struct_type as ::base_precompile_storage::StorableType>::SLOTS; + pub const ARRAY_LEN: usize = #array_size; + pub const SLOT_COUNT: usize = ARRAY_LEN * ELEM_SLOTS; + } + + impl ::base_precompile_storage::StorableType for [#struct_type; #array_size] { + const LAYOUT: ::base_precompile_storage::Layout = ::base_precompile_storage::Layout::Slots(#mod_ident::SLOT_COUNT); + type Handler<'a> = ::base_precompile_storage::Slot<'a, Self>; + fn handle<'a>( + slot: ::alloy_primitives::U256, + ctx: ::base_precompile_storage::LayoutCtx, + address: ::alloy_primitives::Address, + storage: ::base_precompile_storage::StorageCtx<'a>, + ) -> Self::Handler<'a> { + ::base_precompile_storage::Slot::new_with_ctx(slot, ctx, address, storage) + } + } + + impl ::base_precompile_storage::Storable for [#struct_type; #array_size] { + #[inline] + fn load(storage: &S, slot: ::alloy_primitives::U256, ctx: ::base_precompile_storage::LayoutCtx) -> ::base_precompile_storage::Result { + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Struct arrays can only be loaded with LayoutCtx::FULL"); + let base_slot = slot; + #load_impl + } + + #[inline] + fn store(&self, storage: &mut S, slot: ::alloy_primitives::U256, ctx: ::base_precompile_storage::LayoutCtx) -> ::base_precompile_storage::Result<()> { + debug_assert_eq!(ctx, ::base_precompile_storage::LayoutCtx::FULL, "Struct arrays can only be stored with LayoutCtx::FULL"); + let base_slot = slot; + #store_impl + } + } + } +} + +fn gen_struct_array_load(struct_type: &TokenStream, array_size: usize) -> TokenStream { + quote! { + let mut result = [Default::default(); #array_size]; + for i in 0..#array_size { + let elem_slot = base_slot.checked_add( + ::alloy_primitives::U256::from(i).checked_mul( + ::alloy_primitives::U256::from(<#struct_type as ::base_precompile_storage::StorableType>::SLOTS) + ).ok_or(::base_precompile_storage::BasePrecompileError::SlotOverflow)? + ).ok_or(::base_precompile_storage::BasePrecompileError::SlotOverflow)?; + result[i] = <#struct_type as ::base_precompile_storage::Storable>::load(storage, elem_slot, ::base_precompile_storage::LayoutCtx::FULL)?; + } + Ok(result) + } +} + +fn gen_struct_array_store(struct_type: &TokenStream) -> TokenStream { + quote! { + for (i, elem) in self.iter().enumerate() { + let elem_slot = base_slot.checked_add( + ::alloy_primitives::U256::from(i).checked_mul( + ::alloy_primitives::U256::from(<#struct_type as ::base_precompile_storage::StorableType>::SLOTS) + ).ok_or(::base_precompile_storage::BasePrecompileError::SlotOverflow)? + ).ok_or(::base_precompile_storage::BasePrecompileError::SlotOverflow)?; + <#struct_type as ::base_precompile_storage::Storable>::store(elem, storage, elem_slot, ::base_precompile_storage::LayoutCtx::FULL)?; + } + Ok(()) + } +} diff --git a/crates/common/precompile-macros/src/storable_tests.rs b/crates/common/precompile-macros/src/storable_tests.rs new file mode 100644 index 0000000000..bbdfcff8d4 --- /dev/null +++ b/crates/common/precompile-macros/src/storable_tests.rs @@ -0,0 +1,337 @@ +//! Code generation for storage trait property tests. + +use proc_macro2::TokenStream; +use quote::quote; + +use crate::storable_primitives::{ALLOY_INT_SIZES, RUST_INT_SIZES}; + +const FIXED_BYTES_SIZES: &[usize] = &[ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, 32, +]; + +pub(crate) fn gen_storable_tests() -> TokenStream { + let rust_unsigned_arb = gen_rust_unsigned_arbitrary(); + let rust_signed_arb = gen_rust_signed_arbitrary(); + let alloy_unsigned_arb = gen_alloy_unsigned_arbitrary(); + let alloy_signed_arb = gen_alloy_signed_arbitrary(); + let fixed_bytes_arb = gen_fixed_bytes_arbitrary(); + + let rust_unsigned_tests = gen_rust_unsigned_tests(); + let rust_signed_tests = gen_rust_signed_tests(); + let alloy_unsigned_tests = gen_alloy_unsigned_tests(); + let alloy_signed_tests = gen_alloy_signed_tests(); + let fixed_bytes_tests = gen_fixed_bytes_tests(); + + quote! { + #rust_unsigned_arb + #rust_signed_arb + #alloy_unsigned_arb + #alloy_signed_arb + #fixed_bytes_arb + + #rust_unsigned_tests + #rust_signed_tests + #alloy_unsigned_tests + #alloy_signed_tests + #fixed_bytes_tests + } +} + +fn gen_rust_unsigned_arbitrary() -> TokenStream { + quote! {} +} +fn gen_rust_signed_arbitrary() -> TokenStream { + quote! {} +} + +fn gen_alloy_unsigned_arbitrary() -> TokenStream { + let funcs: Vec<_> = ALLOY_INT_SIZES + .iter() + .map(|&size| { + let type_name = quote::format_ident!("U{size}"); + let fn_name = quote::format_ident!("arb_u{size}_alloy"); + quote! { + fn #fn_name() -> impl Strategy { + Just(()).prop_perturb(|_, _| ::alloy_primitives::aliases::#type_name::random()) + } + } + }) + .collect(); + quote! { #(#funcs)* } +} + +fn gen_alloy_signed_arbitrary() -> TokenStream { + let funcs: Vec<_> = ALLOY_INT_SIZES.iter().flat_map(|&size| { + let signed_type = quote::format_ident!("I{size}"); + let unsigned_type = quote::format_ident!("U{size}"); + let arb_any_fn = quote::format_ident!("arb_i{size}_alloy"); + let arb_pos_fn = quote::format_ident!("arb_positive_i{size}_alloy"); + let arb_neg_fn = quote::format_ident!("arb_negative_i{size}_alloy"); + let arb_unsigned_fn = quote::format_ident!("arb_u{size}_alloy"); + + vec![ + quote! { + fn #arb_any_fn() -> impl Strategy { + #arb_unsigned_fn().prop_map(|u| ::alloy_primitives::aliases::#signed_type::from_raw(u)) + } + }, + quote! { + fn #arb_pos_fn() -> impl Strategy { + #arb_unsigned_fn().prop_map(|u| { + ::alloy_primitives::aliases::#signed_type::from_raw( + u & (::alloy_primitives::aliases::#unsigned_type::MAX >> 1) + ) + }) + } + }, + quote! { + fn #arb_neg_fn() -> impl Strategy { + #arb_pos_fn().prop_map(|i| -i) + } + }, + ] + }).collect(); + quote! { #(#funcs)* } +} + +fn gen_fixed_bytes_arbitrary() -> TokenStream { + let funcs: Vec<_> = FIXED_BYTES_SIZES + .iter() + .map(|&size| { + let fn_name = quote::format_ident!("arb_fixed_bytes_{size}"); + quote! { + fn #fn_name() -> impl Strategy> { + Just(()).prop_perturb(|_, _| ::alloy_primitives::FixedBytes::<#size>::random()) + } + } + }) + .collect(); + quote! { #(#funcs)* } +} + +fn gen_rust_unsigned_tests() -> TokenStream { + let tests: Vec<_> = RUST_INT_SIZES.iter().map(|&size| { + let type_name = quote::format_ident!("u{size}"); + let test_name = quote::format_ident!("test_u{size}_storage_roundtrip"); + let label = format!("u{size}"); + quote! { + #[test] + fn #test_name(value in any::<#type_name>(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<#type_name>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, concat!(#label, " roundtrip failed")); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!(after_delete, 0, concat!(#label, " not zero after delete")); + let word = value.to_word(); + let recovered = #type_name::from_word(word).unwrap(); + assert_eq!(value, recovered, concat!(#label, " EVM word roundtrip failed")); + }); + } + } + }).collect(); + quote! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + #(#tests)* + } + } +} + +fn gen_rust_signed_tests() -> TokenStream { + let tests: Vec<_> = RUST_INT_SIZES.iter().flat_map(|&size| { + let type_name = quote::format_ident!("i{size}"); + let pos_test_name = quote::format_ident!("test_i{size}_positive_storage_roundtrip"); + let neg_test_name = quote::format_ident!("test_i{size}_negative_storage_roundtrip"); + let label = format!("i{size}"); + vec![ + quote! { + #[test] + fn #pos_test_name(value in 0 as #type_name..=#type_name::MAX, base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<#type_name>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, concat!(#label, " positive roundtrip failed")); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!(after_delete, 0, concat!(#label, " not zero after delete")); + let word = value.to_word(); + let recovered = #type_name::from_word(word).unwrap(); + assert_eq!(value, recovered, concat!(#label, " positive EVM word roundtrip failed")); + }); + } + }, + quote! { + #[test] + fn #neg_test_name(value in #type_name::MIN..0 as #type_name, base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<#type_name>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, concat!(#label, " negative roundtrip failed")); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!(after_delete, 0, concat!(#label, " not zero after delete")); + let word = value.to_word(); + let recovered = #type_name::from_word(word).unwrap(); + assert_eq!(value, recovered, concat!(#label, " negative EVM word roundtrip failed")); + }); + } + }, + ] + }).collect(); + quote! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + #(#tests)* + } + } +} + +fn gen_alloy_unsigned_tests() -> TokenStream { + let tests: Vec<_> = ALLOY_INT_SIZES.iter().map(|&size| { + let type_name = quote::format_ident!("U{size}"); + let test_name = quote::format_ident!("test_u{size}_alloy_storage_roundtrip"); + let arb_fn = quote::format_ident!("arb_u{size}_alloy"); + let label = format!("U{size}"); + quote! { + #[test] + fn #test_name(value in #arb_fn(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<::alloy_primitives::aliases::#type_name>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, concat!(#label, " roundtrip failed")); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!( + after_delete, + ::alloy_primitives::aliases::#type_name::ZERO, + concat!(#label, " not zero after delete") + ); + let word = value.to_word(); + let recovered = ::alloy_primitives::aliases::#type_name::from_word(word).unwrap(); + assert_eq!(value, recovered, concat!(#label, " EVM word roundtrip failed")); + }); + } + } + }).collect(); + quote! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + #(#tests)* + } + } +} + +fn gen_alloy_signed_tests() -> TokenStream { + let tests: Vec<_> = ALLOY_INT_SIZES.iter().flat_map(|&size| { + let type_name = quote::format_ident!("I{size}"); + let pos_test_name = quote::format_ident!("test_i{size}_alloy_positive_storage_roundtrip"); + let neg_test_name = quote::format_ident!("test_i{size}_alloy_negative_storage_roundtrip"); + let arb_pos_fn = quote::format_ident!("arb_positive_i{size}_alloy"); + let arb_neg_fn = quote::format_ident!("arb_negative_i{size}_alloy"); + let label = format!("I{size}"); + vec![ + quote! { + #[test] + fn #pos_test_name(value in #arb_pos_fn(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<::alloy_primitives::aliases::#type_name>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, concat!(#label, " positive roundtrip failed")); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!( + after_delete, + ::alloy_primitives::aliases::#type_name::ZERO, + concat!(#label, " not zero after delete") + ); + let word = value.to_word(); + let recovered = ::alloy_primitives::aliases::#type_name::from_word(word).unwrap(); + assert_eq!(value, recovered, concat!(#label, " positive EVM word roundtrip failed")); + }); + } + }, + quote! { + #[test] + fn #neg_test_name(value in #arb_neg_fn(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<::alloy_primitives::aliases::#type_name>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(value, loaded, concat!(#label, " negative roundtrip failed")); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!( + after_delete, + ::alloy_primitives::aliases::#type_name::ZERO, + concat!(#label, " not zero after delete") + ); + let word = value.to_word(); + let recovered = ::alloy_primitives::aliases::#type_name::from_word(word).unwrap(); + assert_eq!(value, recovered, concat!(#label, " negative EVM word roundtrip failed")); + }); + } + }, + ] + }).collect(); + quote! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + #(#tests)* + } + } +} + +fn gen_fixed_bytes_tests() -> TokenStream { + let tests: Vec<_> = FIXED_BYTES_SIZES.iter().map(|&size| { + let test_name = quote::format_ident!("test_fixed_bytes_{size}_storage_roundtrip"); + let arb_fn = quote::format_ident!("arb_fixed_bytes_{size}"); + quote! { + #[test] + fn #test_name(value in #arb_fn(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + ::base_precompile_storage::StorageCtx::enter(&mut storage, |ctx| { + let mut slot = ::base_precompile_storage::Slot::<::alloy_primitives::FixedBytes<#size>>::new(base_slot, address, ctx); + slot.write(value).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!( + value, loaded, + concat!("FixedBytes<", stringify!(#size), "> roundtrip failed") + ); + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!( + after_delete, + ::alloy_primitives::FixedBytes::<#size>::ZERO, + concat!("FixedBytes<", stringify!(#size), "> not zero after delete") + ); + let word = value.to_word(); + let recovered = ::alloy_primitives::FixedBytes::<#size>::from_word(word).unwrap(); + assert_eq!( + value, recovered, + concat!("FixedBytes<", stringify!(#size), "> EVM word roundtrip failed") + ); + }); + } + } + }).collect(); + quote! { + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + #(#tests)* + } + } +} diff --git a/crates/common/precompile-macros/src/test_fields.rs b/crates/common/precompile-macros/src/test_fields.rs new file mode 100644 index 0000000000..7353d9f354 --- /dev/null +++ b/crates/common/precompile-macros/src/test_fields.rs @@ -0,0 +1,69 @@ +//! Test helper macros for validating storage slot layouts. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{Expr, Ident, Token, parse::ParseStream, punctuated::Punctuated}; + +use crate::utils::to_camel_case; + +pub(crate) fn gen_layout(input: TokenStream2) -> TokenStream { + let parser = syn::punctuated::Punctuated::::parse_terminated; + let idents = match syn::parse::Parser::parse2(parser, input) { + Ok(idents) => idents, + Err(err) => return err.to_compile_error().into(), + }; + + let field_calls: Vec<_> = idents + .into_iter() + .map(|ident| { + let field_name = ident.to_string(); + let const_name = field_name.to_uppercase(); + let field_name = to_camel_case(&field_name); + let slot_ident = Ident::new(&const_name, ident.span()); + let offset_ident = Ident::new(&format!("{const_name}_OFFSET"), ident.span()); + let bytes_ident = Ident::new(&format!("{const_name}_BYTES"), ident.span()); + + quote! { + RustStorageField::new(#field_name, slots::#slot_ident, slots::#offset_ident, slots::#bytes_ident) + } + }) + .collect(); + + let output = quote! { vec![#(#field_calls),*] }; + output.into() +} + +pub(crate) fn gen_struct_fields(input: TokenStream2) -> TokenStream { + let parser = |input: ParseStream<'_>| { + let base_slot: Expr = input.parse()?; + input.parse::()?; + let fields = Punctuated::::parse_terminated(input)?; + Ok((base_slot, fields)) + }; + + let (base_slot, idents) = match syn::parse::Parser::parse2(parser, input) { + Ok(result) => result, + Err(err) => return err.to_compile_error().into(), + }; + + let field_calls: Vec<_> = idents + .into_iter() + .map(|ident| { + let field_name = ident.to_string(); + let const_name = field_name.to_uppercase(); + let field_name = to_camel_case(&field_name); + let slot_ident = Ident::new(&const_name, ident.span()); + let offset_ident = Ident::new(&format!("{const_name}_OFFSET"), ident.span()); + let loc_ident = Ident::new(&format!("{const_name}_LOC"), ident.span()); + let bytes_ident = quote! { #loc_ident.size }; + + quote! { + RustStorageField::new(#field_name, #base_slot + #slot_ident, #offset_ident, #bytes_ident) + } + }) + .collect(); + + let output = quote! { vec![#(#field_calls),*] }; + output.into() +} diff --git a/crates/common/precompile-macros/src/utils.rs b/crates/common/precompile-macros/src/utils.rs new file mode 100644 index 0000000000..64a85f012e --- /dev/null +++ b/crates/common/precompile-macros/src/utils.rs @@ -0,0 +1,352 @@ +//! Utility functions for the contract macro implementation. + +use alloy_primitives::{U256, keccak256}; +use syn::{Attribute, Lit, LitStr, Path, Type}; + +/// Parsed `#[namespace("...")]` metadata. +#[derive(Debug, Clone)] +pub(crate) struct NamespaceInfo { + pub(crate) id: LitStr, + pub(crate) root: U256, +} + +/// Return type for [`extract_attributes`]: (`slot`, `base_slot`, `namespace`) +type ExtractedAttributes = (Option, Option, Option); + +/// Parses a slot value from a literal. +/// +/// Supports: +/// - Integer literals: decimal (`42`) or hexadecimal (`0x2a`) +/// - String literals: computes keccak256 hash of the string +fn parse_slot_value(value: &Lit) -> syn::Result { + match value { + Lit::Int(int) => { + let lit_str = int.to_string(); + let slot = lit_str + .strip_prefix("0x") + .map_or_else( + || U256::from_str_radix(&lit_str, 10), + |hex| U256::from_str_radix(hex, 16), + ) + .map_err(|_| syn::Error::new_spanned(int, "Invalid slot number"))?; + Ok(slot) + } + Lit::Str(lit) => Ok(keccak256(lit.value().as_bytes()).into()), + _ => Err(syn::Error::new_spanned( + value, + "slot attribute must be an integer or a string literal", + )), + } +} + +/// Returns whether an attribute path ends with the provided identifier. +pub(crate) fn attr_path_is(path: &Path, ident: &str) -> bool { + path.segments.last().is_some_and(|segment| segment.ident == ident) +} + +/// Parses and validates a namespace id string. +pub(crate) fn parse_namespace_id(id: LitStr) -> syn::Result { + let value = id.value(); + if value.is_empty() { + return Err(syn::Error::new(id.span(), "namespace id cannot be empty")); + } + if value.chars().any(char::is_whitespace) { + return Err(syn::Error::new(id.span(), "namespace id must not contain whitespace")); + } + + Ok(NamespaceInfo { root: erc7201_root(&id)?, id }) +} + +/// Computes the ERC-7201 namespace root for `id`. +pub(crate) fn erc7201_root(id: &LitStr) -> syn::Result { + let id_hash = U256::from_be_bytes(keccak256(id.value().as_bytes()).0); + let shifted = id_hash.checked_sub(U256::ONE).ok_or_else(|| { + syn::Error::new(id.span(), "namespace root underflow while applying ERC-7201 formula") + })?; + let root = U256::from_be_bytes(keccak256(shifted.to_be_bytes::<32>()).0); + Ok(root & (U256::MAX - U256::from(0xffu64))) +} + +/// Converts a string from `CamelCase` or `snake_case` to `snake_case`. +pub(crate) fn to_snake_case(s: &str) -> String { + let constant = s.to_uppercase(); + if s == constant { + return constant; + } + + let mut result = String::with_capacity(s.len() + 4); + let mut chars = s.chars().peekable(); + let mut prev_upper = false; + + while let Some(c) = chars.next() { + if c.is_uppercase() { + if !result.is_empty() + && (!prev_upper || chars.peek().is_some_and(|&next| next.is_lowercase())) + { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + prev_upper = true; + } else { + result.push(c); + prev_upper = false; + } + } + + result +} + +/// Converts a string from `snake_case` to `camelCase`. +pub(crate) fn to_camel_case(s: &str) -> String { + let mut result = String::new(); + let mut first_word = true; + + for word in s.split('_') { + if word.is_empty() { + continue; + } + + if first_word { + result.push_str(word); + first_word = false; + } else { + let mut chars = word.chars(); + if let Some(first) = chars.next() { + result.push_str(&first.to_uppercase().collect::()); + result.push_str(chars.as_str()); + } + } + } + result +} + +/// Extracts `#[slot(N)]`, `#[base_slot(N)]` attributes from a field. +pub(crate) fn extract_attributes(attrs: &[Attribute]) -> syn::Result { + let mut slot_attr: Option = None; + let mut base_slot_attr: Option = None; + let mut namespace_attr: Option = None; + + for attr in attrs { + if attr.path().is_ident("slot") { + if slot_attr.is_some() { + return Err(syn::Error::new_spanned(attr, "duplicate `slot` attribute")); + } + if base_slot_attr.is_some() || namespace_attr.is_some() { + return Err(syn::Error::new_spanned( + attr, + "cannot combine `slot`, `base_slot`, and `namespace` attributes on the same field", + )); + } + let value: Lit = attr.parse_args()?; + slot_attr = Some(parse_slot_value(&value)?); + } else if attr.path().is_ident("base_slot") { + if base_slot_attr.is_some() { + return Err(syn::Error::new_spanned(attr, "duplicate `base_slot` attribute")); + } + if slot_attr.is_some() || namespace_attr.is_some() { + return Err(syn::Error::new_spanned( + attr, + "cannot combine `slot`, `base_slot`, and `namespace` attributes on the same field", + )); + } + let value: Lit = attr.parse_args()?; + base_slot_attr = Some(parse_slot_value(&value)?); + } else if attr_path_is(attr.path(), "namespace") { + if namespace_attr.is_some() { + return Err(syn::Error::new_spanned(attr, "duplicate `namespace` attribute")); + } + if slot_attr.is_some() || base_slot_attr.is_some() { + return Err(syn::Error::new_spanned( + attr, + "cannot combine `slot`, `base_slot`, and `namespace` attributes on the same field", + )); + } + namespace_attr = Some(parse_namespace_id(attr.parse_args()?)?); + } + } + + Ok((slot_attr, base_slot_attr, namespace_attr)) +} + +/// Extracts a contract-level `#[namespace("...")]` attribute. +pub(crate) fn extract_namespace(attrs: &[Attribute]) -> syn::Result> { + let mut namespace = None; + + for attr in attrs { + if !attr_path_is(attr.path(), "namespace") { + continue; + } + if namespace.is_some() { + return Err(syn::Error::new_spanned(attr, "duplicate `namespace` attribute")); + } + + namespace = Some(parse_namespace_id(attr.parse_args()?)?); + } + + Ok(namespace) +} + +/// Extracts a type-level `#[namespace("...")]` attribute from a `Storable` layout. +pub(crate) fn extract_storage_namespace(attrs: &[Attribute]) -> syn::Result> { + let mut namespace = None; + + for attr in attrs { + let is_namespace = attr_path_is(attr.path(), "namespace") + || attr_path_is(attr.path(), "storage_namespace"); + if !is_namespace { + continue; + } + if namespace.is_some() { + return Err(syn::Error::new_spanned(attr, "duplicate `namespace` attribute")); + } + + namespace = Some(parse_namespace_id(attr.parse_args()?)?); + } + + Ok(namespace) +} + +/// Extracts array sizes from the `#[storable_arrays(...)]` attribute. +pub(crate) fn extract_storable_array_sizes(attrs: &[Attribute]) -> syn::Result>> { + for attr in attrs { + if attr.path().is_ident("storable_arrays") { + let parsed = attr.parse_args_with( + syn::punctuated::Punctuated::::parse_terminated, + )?; + + let mut sizes = Vec::new(); + for lit in parsed { + if let Lit::Int(int) = lit { + let size = int.base10_parse::().map_err(|_| { + syn::Error::new_spanned( + &int, + "Invalid array size: must be a positive integer", + ) + })?; + + if size == 0 { + return Err(syn::Error::new_spanned( + &int, + "Array size must be greater than 0", + )); + } + if size > 256 { + return Err(syn::Error::new_spanned( + &int, + "Array size must not exceed 256", + )); + } + if sizes.contains(&size) { + return Err(syn::Error::new_spanned( + &int, + format!("Duplicate array size: {size}"), + )); + } + sizes.push(size); + } else { + return Err(syn::Error::new_spanned( + lit, + "Array sizes must be integer literals", + )); + } + } + + if sizes.is_empty() { + return Err(syn::Error::new_spanned( + attr, + "storable_arrays attribute requires at least one size", + )); + } + + return Ok(Some(sizes)); + } + } + + Ok(None) +} + +/// Extracts the type parameters from Mapping. +pub(crate) fn extract_mapping_types(ty: &Type) -> Option<(&Type, &Type)> { + if let Type::Path(type_path) = ty { + let last_segment = type_path.path.segments.last()?; + + if last_segment.ident != "Mapping" { + return None; + } + + if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments { + let mut iter = args.args.iter(); + + let key_type = if let Some(syn::GenericArgument::Type(ty)) = iter.next() { + ty + } else { + return None; + }; + let value_type = if let Some(syn::GenericArgument::Type(ty)) = iter.next() { + ty + } else { + return None; + }; + + return Some((key_type, value_type)); + } + } + None +} + +#[cfg(test)] +mod tests { + use alloy_primitives::uint; + use syn::parse_quote; + + use super::*; + + #[test] + fn test_to_snake_case() { + assert_eq!(to_snake_case("balanceOf"), "balance_of"); + assert_eq!(to_snake_case("transferFrom"), "transfer_from"); + assert_eq!(to_snake_case("name"), "name"); + assert_eq!(to_snake_case("already_snake"), "already_snake"); + assert_eq!(to_snake_case("updateQuoteToken"), "update_quote_token"); + assert_eq!(to_snake_case("DOMAIN_SEPARATOR"), "DOMAIN_SEPARATOR"); + assert_eq!(to_snake_case("ERC20Token"), "erc20_token"); + } + + #[test] + fn test_to_camel_case() { + assert_eq!(to_camel_case("balance_of"), "balanceOf"); + assert_eq!(to_camel_case("transfer_from"), "transferFrom"); + assert_eq!(to_camel_case("update_quote_token"), "updateQuoteToken"); + assert_eq!(to_camel_case("name"), "name"); + } + + #[test] + fn test_extract_mapping_types() { + let ty: Type = parse_quote!(Mapping); + assert!(extract_mapping_types(&ty).is_some()); + + let ty: Type = parse_quote!(Mapping>); + assert!(extract_mapping_types(&ty).is_some()); + + let ty: Type = parse_quote!(String); + assert!(extract_mapping_types(&ty).is_none()); + + let ty: Type = parse_quote!(Vec); + assert!(extract_mapping_types(&ty).is_none()); + } + + #[test] + fn test_erc7201_root() { + let id: LitStr = parse_quote!("b20.policy"); + assert_eq!( + erc7201_root(&id).unwrap(), + uint!(0x50861ae81a7f4392b927efbaeecf8f091f3bd39245aa45ea91499a137b8b3100_U256) + ); + } + + #[test] + fn test_parse_namespace_id_rejects_whitespace() { + let id: LitStr = parse_quote!("b20 policy"); + assert!(parse_namespace_id(id).is_err()); + } +} diff --git a/crates/common/precompile-storage/Cargo.toml b/crates/common/precompile-storage/Cargo.toml new file mode 100644 index 0000000000..e3b1e9e59b --- /dev/null +++ b/crates/common/precompile-storage/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "base-precompile-storage" +description = "EVM storage abstractions and runtime traits for Base native precompiles" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +exclude.workspace = true + +[lints] +workspace = true + +[dependencies] +# alloy +alloy-evm.workspace = true +alloy-primitives.workspace = true +alloy-sol-types.workspace = true + +# revm +revm.workspace = true + +# base +base-precompile-macros.workspace = true + +# misc +thiserror.workspace = true +derive_more = { workspace = true, features = ["from", "try_into"] } + +[dev-dependencies] +proptest.workspace = true +alloy-primitives = { workspace = true, features = ["rand"] } + +[[test]] +name = "contract" +required-features = ["test-utils"] + +[features] +default = [ "std" ] +std = [ + "alloy-evm/std", + "alloy-primitives/std", + "alloy-sol-types/std", + "derive_more/std", + "revm/std", + "thiserror/std", +] +test-utils = [ "std" ] diff --git a/crates/common/precompile-storage/LICENSE-APACHE b/crates/common/precompile-storage/LICENSE-APACHE new file mode 100644 index 0000000000..f6b4d8bf8f --- /dev/null +++ b/crates/common/precompile-storage/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Tempo Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/common/precompile-storage/LICENSE-MIT b/crates/common/precompile-storage/LICENSE-MIT new file mode 100644 index 0000000000..95865426b3 --- /dev/null +++ b/crates/common/precompile-storage/LICENSE-MIT @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Tempo Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/crates/common/precompile-storage/README.md b/crates/common/precompile-storage/README.md new file mode 100644 index 0000000000..52293079bc --- /dev/null +++ b/crates/common/precompile-storage/README.md @@ -0,0 +1,79 @@ +# base-precompile-storage + +EVM storage abstractions and runtime traits for Base native precompiles. + +## Slot Derivation Rules + +### Auto-allocation + +Fields in a `#[contract]` struct are allocated sequentially following Solidity's right-to-left +bin-packing rules. Fields smaller than 32 bytes are packed into the same slot when they fit. + +```rust,ignore +#[contract] +pub struct MyToken { + pub name: String, // slot 0 (full slot — dynamic) + pub symbol: String, // slot 1 (full slot — dynamic) + pub decimals: u8, // slot 2, offset 0 (1 byte) + pub paused: bool, // slot 2, offset 1 (packed with decimals) + pub total_supply: U256, // slot 3 (doesn't fit with the 30 remaining bytes) +} +``` + +### Manual slot override + +- `#[slot(N)]` — places the field at an explicit absolute slot with offset 0. +- `#[base_slot(N)]` — resets the auto-allocation chain starting from slot N. +- `#[slot("key")]` — computes `keccak256("key")` at macro expansion time. + +### Namespaced layouts + +- `#[namespace("id")]` — starts a `#[contract]` field at the ERC-7201 root for `id`. + +Multiple fields with the same namespace use normal Solidity offsets from that root without advancing +the surrounding contract layout. `#[slot]` and `#[base_slot]` overrides cannot be combined with +`#[namespace]` on the same field. + +The namespace can also be declared once on a reusable `Storable` layout type. A `#[contract]` +field with that type is automatically mounted at the type's namespace root: + +```rust,ignore +#[derive(Debug, Clone, Storable)] +#[namespace("b20.security")] +pub struct B20SecurityStorage { + pub shares_to_tokens_ratio: U256, +} + +#[contract] +pub struct B20Security { + pub security: B20SecurityStorage, +} +``` + +### Mapping slot derivation + +```text +slot(key, base) = keccak256(lpad32(key) ‖ to_be32(base)) +``` + +This matches Solidity's `keccak256(abi.encode(key, slot))` for: +- Unsigned integers, `Address`, `FixedBytes<32>` — identical encoding +- `String` — uses `keccak256(bytes(key) ‖ to_be32(base))`, matching Solidity's string-keyed + mapping derivation +- Signed integers — diverges (we zero-left-pad the two's complement bits; Solidity sign-extends) +- `FixedBytes` for N < 32 — diverges (we left-pad; Solidity right-pads) + +Use contract view functions rather than off-chain keccak reconstruction for the divergent types. + +### Append-only rule + +**Never reorder or reuse storage slots across hardforks.** Adding new fields is safe as long as +they append after existing ones. Changing slot assignments for existing fields corrupts state. + +## Attribution + +This crate includes code adapted from Tempo's `precompiles` crate, including its storage +abstractions, in the +[`tempoxyz/tempo`](https://github.com/tempoxyz/tempo/tree/main/crates/precompiles) +repository. The upstream license notices are retained in `LICENSE-MIT` and +`LICENSE-APACHE`. diff --git a/crates/common/precompile-storage/src/error.rs b/crates/common/precompile-storage/src/error.rs new file mode 100644 index 0000000000..bd6eb1a6a8 --- /dev/null +++ b/crates/common/precompile-storage/src/error.rs @@ -0,0 +1,171 @@ +use alloc::string::{String, ToString}; +use core::result; + +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::{Panic, PanicKind, SolError, sol}; + +sol! { + /// Precompile cannot be executed via delegatecall or callcode. + error DelegateCallNotAllowed(); +} +use revm::{ + context::journaled_state::JournalLoadError, + precompile::{PrecompileError, PrecompileHalt, PrecompileOutput, PrecompileResult}, +}; + +/// Top-level error type for all Base native precompile operations. +#[derive( + Debug, Clone, PartialEq, Eq, thiserror::Error, derive_more::From, derive_more::TryInto, +)] +pub enum BasePrecompileError { + /// EVM panic (arithmetic under/overflow, out-of-bounds access, enum conversion). + #[error("Panic({0:?})")] + Panic(PanicKind), + + /// Gas limit exceeded during precompile execution. + #[error("Gas limit exceeded")] + OutOfGas, + + /// The calldata's 4-byte selector does not match any known precompile function. + #[error("Unknown function selector: {0:?}")] + UnknownFunctionSelector([u8; 4]), + + /// The calldata selector is known, but its arguments failed ABI decoding. + #[error("ABI decode failed for selector {selector:?}: {error}")] + AbiDecodeFailed { + /// The matched calldata selector. + selector: [u8; 4], + /// The ABI decoder error. + error: String, + }, + + /// Storage slot arithmetic overflow. + #[error("Slot overflow")] + SlotOverflow, + + /// State mutation attempted inside a STATICCALL context. + /// + /// Reverts the current call frame without consuming all gas, matching the EVM's + /// `StateChangeDuringStaticCall` behaviour for SSTORE/LOG in static contexts. + #[error("State mutation in static call")] + StaticCallViolation, + + /// ABI-encoded revert from a contract-defined error (e.g. `InvalidSender`). + #[error("Revert")] + #[from(skip)] + Revert(Bytes), + + /// Unrecoverable internal error (e.g. database failure). + #[error("Fatal precompile error: {0:?}")] + #[from(skip)] + Fatal(String), +} + +impl From> for BasePrecompileError { + fn from(value: JournalLoadError) -> Self { + match value { + JournalLoadError::DBError(e) => Self::Fatal(e.to_string()), + JournalLoadError::ColdLoadSkipped => Self::OutOfGas, + } + } +} + +/// Result type alias for Base native precompile operations. +pub type Result = result::Result; + +impl BasePrecompileError { + /// Returns true if this error must be propagated rather than turned into a revert. + pub const fn is_system_error(&self) -> bool { + matches!(self, Self::OutOfGas | Self::Fatal(_) | Self::Panic(_) | Self::SlotOverflow) + } + + /// ABI-encodes a contract-defined error and wraps it as a [`Revert`](Self::Revert). + pub fn revert(error: impl SolError) -> Self { + Self::Revert(error.abi_encode().into()) + } + + /// Creates an arithmetic under/overflow panic error. + pub const fn under_overflow() -> Self { + Self::Panic(PanicKind::UnderOverflow) + } + + /// Creates an enum conversion error panic (Solidity Panic `0x21`). + pub const fn enum_conversion_error() -> Self { + Self::Panic(PanicKind::EnumConversionError) + } + + /// Creates an array out-of-bounds panic error. + pub const fn array_oob() -> Self { + Self::Panic(PanicKind::ArrayOutOfBounds) + } + + /// ABI-encodes this error and wraps it as a [`PrecompileResult`] (revert or fatal error). + /// + /// Internal dispatch diagnostics use compact, non-ABI revert data: unknown selectors return the + /// raw selector bytes, and decode failures return `selector || utf8_error_string`. + pub fn into_precompile_result(self, gas: u64, state_gas: u64) -> PrecompileResult { + let bytes: Bytes = match self { + Self::Revert(bytes) => bytes, + Self::Panic(kind) => Panic { code: U256::from(kind as u32) }.abi_encode().into(), + Self::OutOfGas => { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, 0)); + } + Self::SlotOverflow => { + return Err(PrecompileError::Fatal("slot overflow".into())); + } + Self::Fatal(msg) => { + return Err(PrecompileError::Fatal(msg)); + } + Self::StaticCallViolation => Bytes::new(), + Self::UnknownFunctionSelector(sel) => sel.to_vec().into(), + Self::AbiDecodeFailed { selector, error } => { + let mut bytes = selector.to_vec(); + bytes.extend_from_slice(error.as_bytes()); + bytes.into() + } + }; + Ok(PrecompileOutput::revert(gas, bytes, state_gas)) + } +} + +/// Extension trait to convert `Result` into a [`PrecompileResult`]. +pub trait IntoPrecompileResult { + /// Converts `self` into a [`PrecompileResult`] using `encode_ok` for the success path. + fn into_precompile_result( + self, + gas: u64, + state_gas: u64, + encode_ok: impl FnOnce(T) -> Bytes, + ) -> PrecompileResult; +} + +impl IntoPrecompileResult for Result { + fn into_precompile_result( + self, + gas: u64, + state_gas: u64, + encode_ok: impl FnOnce(T) -> Bytes, + ) -> PrecompileResult { + match self { + Ok(res) => Ok(PrecompileOutput::new(gas, encode_ok(res), state_gas)), + Err(err) => err.into_precompile_result(gas, state_gas), + } + } +} + +#[cfg(test)] +mod tests { + use alloy_sol_types::SolError; + + use super::*; + + #[test] + fn delegate_call_not_allowed_encodes_to_typed_revert() { + let expected: Bytes = DelegateCallNotAllowed {}.abi_encode().into(); + let result = + BasePrecompileError::revert(DelegateCallNotAllowed {}).into_precompile_result(0, 0); + let output = result.unwrap(); + assert!(output.is_revert()); + assert_eq!(output.bytes, expected); + } +} diff --git a/crates/common/precompile-storage/src/evm.rs b/crates/common/precompile-storage/src/evm.rs new file mode 100644 index 0000000000..75f998bebb --- /dev/null +++ b/crates/common/precompile-storage/src/evm.rs @@ -0,0 +1,343 @@ +//! Production EVM-backed [`PrecompileStorageProvider`]. +//! +//! [`EvmPrecompileStorageProvider`] wraps an alloy-evm [`PrecompileInput`] and implements +//! [`PrecompileStorageProvider`] by delegating to the live [`EvmInternals`] journal. +//! It is constructed inside each native precompile's `run()` function and passed to +//! [`StorageCtx::enter`] so that `#[contract]`-generated storage types read/write real EVM state. + +use alloc::string::ToString; + +use alloy_evm::precompiles::PrecompileInput; +use alloy_primitives::{Address, B256, Log, LogData, U256}; +use revm::{ + context::{Block, journaled_state::JournalCheckpoint}, + context_interface::cfg::GasParams, + interpreter::gas::{Gas, KECCAK256, KECCAK256WORD, LOG}, + primitives::keccak256, + state::{AccountInfo, Bytecode}, +}; + +use crate::{ + error::{BasePrecompileError, Result}, + provider::PrecompileStorageProvider, +}; + +/// Production [`PrecompileStorageProvider`] backed by a live EVM journal. +/// +/// Constructed from a [`PrecompileInput`] inside each native precompile's `run()` function. +/// Pass `&mut self` to [`StorageCtx::enter`] to give `#[contract]` storage types access to +/// the real EVM journal. +#[derive(Debug)] +pub struct EvmPrecompileStorageProvider<'a> { + internals: alloy_evm::EvmInternals<'a>, + caller: Address, + gas: Gas, + gas_params: GasParams, + is_static: bool, + block_number: u64, + timestamp: U256, + chain_id: u64, + beneficiary: Address, + state_gas_used: u64, +} + +impl<'a> EvmPrecompileStorageProvider<'a> { + /// Consume a [`PrecompileInput`] and build the provider. + /// + /// `gas_params` drives all EIP-2929/2200/3529 cost calculations. + /// Pass [`GasParams::default`] when the active spec is unknown at call site. + pub fn new(input: PrecompileInput<'a>, gas_params: GasParams) -> Self { + let PrecompileInput { gas, caller, is_static, internals, .. } = input; + + let block_number = internals.block_env().number().to::(); + let timestamp = internals.block_env().timestamp(); + let chain_id = internals.chain_id(); + let beneficiary = internals.block_env().beneficiary(); + + Self { + internals, + caller, + gas: Gas::new(gas), + gas_params, + is_static, + block_number, + timestamp, + chain_id, + beneficiary, + state_gas_used: 0, + } + } +} + +impl PrecompileStorageProvider for EvmPrecompileStorageProvider<'_> { + fn chain_id(&self) -> u64 { + self.chain_id + } + + fn timestamp(&self) -> U256 { + self.timestamp + } + + fn beneficiary(&self) -> Address { + self.beneficiary + } + + fn block_number(&self) -> u64 { + self.block_number + } + + fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()> { + let code_len = code.len(); + + // EIP-3541 / Yellow Paper G_codedeposit: 200 gas per byte of deployed bytecode. + self.deduct_gas(self.gas_params.code_deposit_cost(code_len))?; + + // For new (empty) accounts charge the CREATE equivalent costs (Yellow Paper G_create). + let is_new_account = { + let state_load = self + .internals + .load_account(address) + .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; + state_load.data.info.is_empty() + }; + + if is_new_account { + // Yellow Paper G_create: base cost for creating a new contract account. + self.deduct_gas(self.gas_params.create_cost())?; + // Yellow Paper G_sha3 + G_sha3word: cost of computing the stored code hash. + let num_words = code_len.div_ceil(32) as u64; + self.deduct_gas(KECCAK256.saturating_add(KECCAK256WORD.saturating_mul(num_words)))?; + // EIP-8037: both state gas charges are gated on is_new_account. + // create_state_gas covers the new account entry in the state trie. + // code_deposit_state_gas covers the new code object. Replacing code on an + // existing account is not a state-creating operation in the EIP-8037 model — + // the code slot already occupies a trie node — so it is intentionally excluded. + // In practice, precompile set_code is only called during factory token creation, + // where the target address is always a fresh account. + self.deduct_state_gas(self.gas_params.create_state_gas())?; + self.deduct_state_gas(self.gas_params.code_deposit_state_gas(code_len))?; + } + + self.internals + .set_code(address, code) + .map_err(|e| BasePrecompileError::Fatal(e.to_string())) + } + + fn with_account_info( + &mut self, + address: Address, + f: &mut dyn FnMut(&AccountInfo), + ) -> Result<()> { + // Extract is_cold and clone AccountInfo before releasing the internals borrow. + let (info, is_cold) = { + let state_load = self + .internals + .load_account(address) + .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; + (state_load.data.info.clone(), state_load.is_cold) + }; + + // EIP-2929: warm base cost always charged (100) + self.deduct_gas(self.gas_params.warm_storage_read_cost())?; + // dynamic cold penalty — total 2600 for a cold account access + if is_cold { + self.deduct_gas(self.gas_params.cold_account_additional_cost())?; + } + + f(&info); + Ok(()) + } + + fn sload(&mut self, address: Address, key: U256) -> Result { + let s = self + .internals + .sload(address, key) + .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; + + // EIP-2929: warm base cost always charged + self.deduct_gas(self.gas_params.warm_storage_read_cost())?; + // dynamic cold penalty + if s.is_cold { + self.deduct_gas(self.gas_params.cold_storage_additional_cost())?; + } + + Ok(s.data) + } + + fn tload(&mut self, address: Address, key: U256) -> Result { + self.deduct_gas(self.gas_params.warm_storage_read_cost())?; + Ok(self.internals.tload(address, key)) + } + + fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { + if self.is_static { + return Err(BasePrecompileError::StaticCallViolation); + } + let s = self + .internals + .sstore(address, key, value) + .map_err(|e| BasePrecompileError::Fatal(e.to_string()))?; + + // EIP-2929: static warm base cost + self.deduct_gas(self.gas_params.sstore_static_gas())?; + // EIP-2929 + EIP-2200: dynamic cost (cold penalty + net-metering) + self.deduct_gas(self.gas_params.sstore_dynamic_gas(true, &s.data, s.is_cold))?; + // EIP-3529: net-metering refund + self.refund_gas(self.gas_params.sstore_refund(true, &s.data)); + + Ok(()) + } + + fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()> { + if self.is_static { + return Err(BasePrecompileError::StaticCallViolation); + } + self.deduct_gas(self.gas_params.warm_storage_read_cost())?; + self.internals.tstore(address, key, value); + Ok(()) + } + + fn emit_event(&mut self, address: Address, event: LogData) -> Result<()> { + if self.is_static { + return Err(BasePrecompileError::StaticCallViolation); + } + let cost = + LOG + self.gas_params.log_cost(event.topics().len() as u8, event.data.len() as u64); + self.deduct_gas(cost)?; + self.internals.log(Log { address, data: event }); + Ok(()) + } + + fn deduct_gas(&mut self, gas: u64) -> Result<()> { + if !self.gas.record_regular_cost(gas) { + return Err(BasePrecompileError::OutOfGas); + } + Ok(()) + } + + fn deduct_state_gas(&mut self, gas: u64) -> Result<()> { + // No separate reservoir in the precompile context; state gas is drawn from regular gas. + self.deduct_gas(gas)?; + self.state_gas_used = self.state_gas_used.saturating_add(gas); + Ok(()) + } + + fn refund_gas(&mut self, gas: i64) { + self.gas.record_refund(gas); + } + + fn gas_limit(&self) -> u64 { + self.gas.limit() + } + + fn gas_used(&self) -> u64 { + self.gas.total_gas_spent() + } + + fn state_gas_used(&self) -> u64 { + self.state_gas_used + } + + fn gas_refunded(&self) -> i64 { + self.gas.refunded() + } + + fn reservoir(&self) -> u64 { + 0 + } + + fn is_static(&self) -> bool { + self.is_static + } + + fn caller(&self) -> Address { + self.caller + } + + fn replace_caller(&mut self, caller: Address) -> Address { + core::mem::replace(&mut self.caller, caller) + } + + fn checkpoint(&mut self) -> JournalCheckpoint { + self.internals.checkpoint() + } + + fn checkpoint_commit(&mut self, _checkpoint: JournalCheckpoint) { + // alloy-evm's checkpoint_commit pops the top checkpoint; the arg is unused. + self.internals.checkpoint_commit(); + } + + fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint) { + self.internals.checkpoint_revert(checkpoint); + } + + fn keccak256(&mut self, data: &[u8]) -> Result { + let num_words = + u64::try_from(data.len().div_ceil(32)).map_err(|_| BasePrecompileError::OutOfGas)?; + let price = KECCAK256WORD + .checked_mul(num_words) + .and_then(|w| w.checked_add(KECCAK256)) + .ok_or(BasePrecompileError::OutOfGas)?; + self.deduct_gas(price)?; + Ok(keccak256(data)) + } +} + +impl From for BasePrecompileError { + fn from(e: alloy_evm::EvmInternalsError) -> Self { + Self::Fatal(e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + use revm::{context_interface::cfg::GasParams, primitives::hardfork::SpecId, state::Bytecode}; + + use crate::{hashmap::HashMapStorageProvider, provider::PrecompileStorageProvider}; + + fn amsterdam_provider() -> HashMapStorageProvider { + let mut provider = HashMapStorageProvider::new(1); + provider.set_gas_params(GasParams::new_spec(SpecId::AMSTERDAM)); + provider + } + + /// `set_code` on a brand-new account must charge both `create_state_gas` and + /// `code_deposit_state_gas` against the state-gas counter. + #[test] + fn set_code_new_account_charges_create_and_deposit_state_gas() { + let mut provider = amsterdam_provider(); + let addr = Address::from([0x42u8; 20]); + let code = Bytecode::new_raw([0x60u8, 0x00].as_ref().into()); + let code_len = code.len(); + let gas_params = GasParams::new_spec(SpecId::AMSTERDAM); + + provider.set_code(addr, code).unwrap(); + + let expected = gas_params.create_state_gas() + gas_params.code_deposit_state_gas(code_len); + assert!(expected > 0, "AMSTERDAM state gas must be non-zero"); + assert_eq!(provider.state_gas_used(), expected); + } + + /// `set_code` on an already-initialised account must NOT charge any additional + /// state gas (the account and its metadata already exist in the trie). + #[test] + fn set_code_existing_account_skips_state_gas() { + let mut provider = amsterdam_provider(); + let addr = Address::from([0x42u8; 20]); + let code = Bytecode::new_raw([0x60u8, 0x00].as_ref().into()); + + // First call creates the account and charges state gas. + provider.set_code(addr, code.clone()).unwrap(); + let after_first = provider.state_gas_used(); + assert!(after_first > 0); + + // Second call updates an existing account; state gas must not increase. + provider.set_code(addr, code).unwrap(); + assert_eq!( + provider.state_gas_used(), + after_first, + "state_gas_used must not increase for an existing account" + ); + } +} diff --git a/crates/common/precompile-storage/src/hashmap.rs b/crates/common/precompile-storage/src/hashmap.rs new file mode 100644 index 0000000000..e2540c6108 --- /dev/null +++ b/crates/common/precompile-storage/src/hashmap.rs @@ -0,0 +1,328 @@ +use std::collections::HashMap; + +use alloy_primitives::{Address, LogData, U256}; +use revm::{ + context::journaled_state::JournalCheckpoint, + context_interface::cfg::GasParams, + state::{AccountInfo, Bytecode}, +}; + +use crate::{error::BasePrecompileError, provider::PrecompileStorageProvider}; + +/// In-memory [`PrecompileStorageProvider`] for unit tests. +/// +/// Stores all state in `HashMap`s, avoiding the need for a real EVM context. +#[derive(Debug)] +pub struct HashMapStorageProvider { + internals: HashMap<(Address, U256), U256>, + transient: HashMap<(Address, U256), U256>, + accounts: HashMap, + fail_on_sload: Option<(Address, U256)>, + chain_id: u64, + timestamp: U256, + beneficiary: Address, + block_number: u64, + caller: Address, + is_static: bool, + counter_sload: u64, + counter_sstore: u64, + snapshots: Vec, + gas_params: GasParams, + state_gas_used: u64, + /// Emitted events keyed by contract address. + pub events: HashMap>, +} + +#[derive(Debug)] +struct Snapshot { + internals: HashMap<(Address, U256), U256>, + events: HashMap>, +} + +impl HashMapStorageProvider { + /// Creates a new provider with the given chain ID. + pub fn new(chain_id: u64) -> Self { + Self { + internals: HashMap::new(), + transient: HashMap::new(), + accounts: HashMap::new(), + fail_on_sload: None, + events: HashMap::new(), + snapshots: Vec::new(), + chain_id, + #[allow(clippy::disallowed_methods)] + timestamp: U256::from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), + beneficiary: Address::ZERO, + block_number: 0, + caller: Address::ZERO, + is_static: false, + counter_sload: 0, + counter_sstore: 0, + gas_params: GasParams::default(), + state_gas_used: 0, + } + } +} + +impl PrecompileStorageProvider for HashMapStorageProvider { + fn chain_id(&self) -> u64 { + self.chain_id + } + + fn timestamp(&self) -> U256 { + self.timestamp + } + + fn beneficiary(&self) -> Address { + self.beneficiary + } + + fn block_number(&self) -> u64 { + self.block_number + } + + fn set_code(&mut self, address: Address, code: Bytecode) -> Result<(), BasePrecompileError> { + let code_len = code.len(); + // Mirror the production is_new_account check so state gas tracking is faithful. + let is_new_account = self.accounts.get(&address).is_none_or(AccountInfo::is_empty); + if is_new_account { + self.deduct_state_gas(self.gas_params.create_state_gas())?; + self.deduct_state_gas(self.gas_params.code_deposit_state_gas(code_len))?; + } + let account = self.accounts.entry(address).or_default(); + account.code_hash = code.hash_slow(); + account.code = Some(code); + Ok(()) + } + + fn with_account_info( + &mut self, + address: Address, + f: &mut dyn FnMut(&AccountInfo), + ) -> Result<(), BasePrecompileError> { + let account = self.accounts.entry(address).or_default(); + f(&*account); + Ok(()) + } + + fn sstore( + &mut self, + address: Address, + key: U256, + value: U256, + ) -> Result<(), BasePrecompileError> { + if self.is_static { + return Err(BasePrecompileError::StaticCallViolation); + } + self.counter_sstore += 1; + self.internals.insert((address, key), value); + Ok(()) + } + + fn tstore( + &mut self, + address: Address, + key: U256, + value: U256, + ) -> Result<(), BasePrecompileError> { + if self.is_static { + return Err(BasePrecompileError::StaticCallViolation); + } + self.transient.insert((address, key), value); + Ok(()) + } + + fn emit_event(&mut self, address: Address, event: LogData) -> Result<(), BasePrecompileError> { + if self.is_static { + return Err(BasePrecompileError::StaticCallViolation); + } + self.events.entry(address).or_default().push(event); + Ok(()) + } + + fn sload(&mut self, address: Address, key: U256) -> Result { + if self.fail_on_sload == Some((address, key)) { + return Err(BasePrecompileError::Fatal("injected sload failure".into())); + } + self.counter_sload += 1; + Ok(self.internals.get(&(address, key)).copied().unwrap_or(U256::ZERO)) + } + + fn tload(&mut self, address: Address, key: U256) -> Result { + Ok(self.transient.get(&(address, key)).copied().unwrap_or(U256::ZERO)) + } + + fn deduct_gas(&mut self, _gas: u64) -> Result<(), BasePrecompileError> { + Ok(()) + } + + fn deduct_state_gas(&mut self, gas: u64) -> Result<(), BasePrecompileError> { + // No gas limit in the test provider; just track the cumulative amount. + self.state_gas_used = self.state_gas_used.saturating_add(gas); + Ok(()) + } + + fn refund_gas(&mut self, _gas: i64) {} + + fn gas_limit(&self) -> u64 { + 0 + } + + fn gas_used(&self) -> u64 { + 0 + } + + fn state_gas_used(&self) -> u64 { + self.state_gas_used + } + + fn gas_refunded(&self) -> i64 { + 0 + } + + fn reservoir(&self) -> u64 { + 0 + } + + fn is_static(&self) -> bool { + self.is_static + } + + fn caller(&self) -> alloy_primitives::Address { + self.caller + } + + fn replace_caller(&mut self, caller: Address) -> Address { + core::mem::replace(&mut self.caller, caller) + } + + fn checkpoint(&mut self) -> JournalCheckpoint { + let idx = self.snapshots.len(); + self.snapshots + .push(Snapshot { internals: self.internals.clone(), events: self.events.clone() }); + JournalCheckpoint { log_i: 0, journal_i: idx, selfdestructed_i: 0 } + } + + fn checkpoint_commit(&mut self, checkpoint: JournalCheckpoint) { + assert_eq!( + checkpoint.journal_i, + self.snapshots.len() - 1, + "out-of-order checkpoint commit (expected top of stack)" + ); + self.snapshots.pop(); + } + + fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint) { + assert_eq!( + checkpoint.journal_i, + self.snapshots.len() - 1, + "out-of-order checkpoint revert (expected top of stack)" + ); + if let Some(snapshot) = self.snapshots.drain(checkpoint.journal_i..).next() { + self.internals = snapshot.internals; + self.events = snapshot.events; + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl HashMapStorageProvider { + /// Injects an SLOAD failure at the given address and slot (test-utils only). + pub const fn fail_next_sload_at(&mut self, address: Address, slot: U256) { + self.fail_on_sload = Some((address, slot)); + } + + /// Returns account info for the given address (test-utils only). + pub fn get_account_info(&self, address: Address) -> Option<&AccountInfo> { + self.accounts.get(&address) + } + + /// Returns emitted events for the given address (test-utils only). + pub fn get_events(&self, address: Address) -> &Vec { + static EMPTY: Vec = Vec::new(); + self.events.get(&address).unwrap_or(&EMPTY) + } + + /// Sets the nonce for the given address (test-utils only). + pub fn set_nonce(&mut self, address: Address, nonce: u64) { + let account = self.accounts.entry(address).or_default(); + account.nonce = nonce; + } + + /// Overrides the block timestamp (test-utils only). + pub const fn set_timestamp(&mut self, timestamp: U256) { + self.timestamp = timestamp; + } + + /// Overrides the block beneficiary (test-utils only). + pub const fn set_beneficiary(&mut self, beneficiary: Address) { + self.beneficiary = beneficiary; + } + + /// Overrides the block number (test-utils only). + pub const fn set_block_number(&mut self, block_number: u64) { + self.block_number = block_number; + } + + /// Sets the caller address (test-utils only). + pub const fn set_caller(&mut self, caller: Address) { + self.caller = caller; + } + + /// Sets whether the current call is static (test-utils only). + pub const fn set_static(&mut self, is_static: bool) { + self.is_static = is_static; + } + + /// Clears all transient storage (test-utils only). + pub fn clear_transient(&mut self) { + self.transient.clear(); + } + + /// Clears emitted events for the given address (test-utils only). + pub fn clear_events(&mut self, address: Address) { + let _ = self.events.entry(address).and_modify(|v| v.clear()).or_default(); + } + + /// Returns the SLOAD counter (test-utils only). + pub const fn counter_sload(&self) -> u64 { + self.counter_sload + } + + /// Returns the SSTORE counter (test-utils only). + pub const fn counter_sstore(&self) -> u64 { + self.counter_sstore + } + + /// Resets the SLOAD/SSTORE counters (test-utils only). + pub const fn reset_counters(&mut self) { + self.counter_sload = 0; + self.counter_sstore = 0; + } + + /// Returns an iterator over all stored (address, slot, value) triples (test-utils only). + pub fn into_storage(self) -> impl Iterator { + self.internals.into_iter().map(|((addr, slot), value)| (addr, slot, value)) + } + + /// Reads a storage slot directly without journal overhead (test-utils only). + pub fn sload_direct(&self, address: Address, key: U256) -> U256 { + self.internals.get(&(address, key)).copied().unwrap_or(U256::ZERO) + } + + /// Overrides the gas parameters used for state gas accounting (test-utils only). + pub fn set_gas_params(&mut self, gas_params: GasParams) { + self.gas_params = gas_params; + } +} + +/// Test helper: returns a fresh `(HashMapStorageProvider, precompile_address)` pair. +#[cfg(any(test, feature = "test-utils"))] +pub fn setup_storage() -> (HashMapStorageProvider, Address) { + (HashMapStorageProvider::new(1), Address::from([0x42u8; 20])) +} diff --git a/crates/common/precompile-storage/src/lib.rs b/crates/common/precompile-storage/src/lib.rs new file mode 100644 index 0000000000..9515470162 --- /dev/null +++ b/crates/common/precompile-storage/src/lib.rs @@ -0,0 +1,46 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; +// Allow macro-generated code inside this crate to use `::base_precompile_storage::` paths. +extern crate self as base_precompile_storage; + +mod error; +pub use error::{BasePrecompileError, DelegateCallNotAllowed, IntoPrecompileResult, Result}; + +mod packing; +pub use packing::{ + FieldLocation, PackedSlot, calc_element_loc, calc_element_offset, calc_element_slot, + calc_packed_slot_count, create_element_mask, delete_from_word, extract_from_word, + insert_into_word, +}; + +mod provider; +pub use provider::{ + ContractStorage, FromWord, Handler, Layout, LayoutCtx, Packable, PrecompileStorageProvider, + Storable, StorableType, StorageKey, StorageOps, sealed, +}; + +mod registration; +pub use registration::NativePrecompile; + +mod storage_ctx; +pub use storage_ctx::{CheckpointGuard, StorageCtx}; + +mod types; +pub use types::{ + ArrayHandler, BytesLikeHandler, HandlerCache, Mapping, MappingHandler, Set, SetHandler, Slot, + VecHandler, +}; + +mod evm; +pub use evm::EvmPrecompileStorageProvider; + +#[cfg(any(test, feature = "test-utils"))] +mod hashmap; +#[cfg(any(test, feature = "test-utils"))] +pub use hashmap::HashMapStorageProvider; +#[cfg(any(test, feature = "test-utils"))] +pub use hashmap::setup_storage; +#[cfg(any(test, feature = "test-utils"))] +pub use packing::gen_word_from; diff --git a/crates/common/precompile-storage/src/packing.rs b/crates/common/precompile-storage/src/packing.rs new file mode 100644 index 0000000000..b7515bf86d --- /dev/null +++ b/crates/common/precompile-storage/src/packing.rs @@ -0,0 +1,693 @@ +//! Shared utilities for packing and unpacking values in EVM storage slots. +//! +//! This module provides helper functions for bit-level manipulation of storage slots, +//! enabling efficient packing of multiple small values into single 32-byte slots. +//! +//! Packing only applies to primitive types where `LAYOUT::Bytes(count) && count < 32`. +//! Non-primitives (structs, fixed-size arrays, dynamic types) have `LAYOUT = Layout::Slot`. +//! +//! ## Solidity Compatibility +//! +//! This implementation matches Solidity's value packing convention: +//! - Values are right-aligned within their byte range +//! - Types smaller than 32 bytes can pack multiple per slot when dimensions align + +use alloc::format; + +use alloy_primitives::U256; + +use crate::{ + error::Result, + provider::{FromWord, Layout, StorableType, StorageOps}, +}; + +/// A helper struct to support packing elements into a single slot. Represents an +/// in-memory storage slot value. +/// +/// We used it when we operate on elements that are guaranteed to be packable. +/// To avoid doing multiple storage reads/writes when packing those elements, we +/// use this as an intermediate [`StorageOps`] implementation that can be passed to +/// `Storable::store` and `Storable::load`. +#[derive(Debug)] +pub struct PackedSlot(pub U256); + +impl StorageOps for PackedSlot { + fn load(&self, _slot: U256) -> Result { + Ok(self.0) + } + + fn store(&mut self, _slot: U256, value: U256) -> Result<()> { + self.0 = value; + Ok(()) + } +} + +/// Location information for a packed field within a storage slot. +#[derive(Debug, Clone, Copy)] +pub struct FieldLocation { + /// Offset in slots from the base slot + pub offset_slots: usize, + /// Offset in bytes within the target slot + pub offset_bytes: usize, + /// Size of the field in bytes + pub size: usize, +} + +impl FieldLocation { + /// Create a new field location + #[inline] + pub const fn new(offset_slots: usize, offset_bytes: usize, size: usize) -> Self { + Self { offset_slots, offset_bytes, size } + } +} + +/// Create a bit mask for a value of the given byte size. +/// +/// For values less than 32 bytes, returns a mask with the appropriate number of bits set. +/// For 32-byte values, returns `U256::MAX`. +#[inline] +pub fn create_element_mask(byte_count: usize) -> U256 { + if byte_count >= 32 { U256::MAX } else { (U256::ONE << (byte_count * 8)) - U256::ONE } +} + +/// Extract a packed value from a storage slot at a given byte offset. +#[inline] +pub fn extract_from_word( + slot_value: U256, + offset: usize, + bytes: usize, +) -> Result { + debug_assert!( + matches!(T::LAYOUT, Layout::Bytes(..)), + "Packing is only supported by primitive types" + ); + + if offset + bytes > 32 { + return Err(crate::error::BasePrecompileError::Fatal(format!( + "Value of {} bytes at offset {} would span slot boundary (max offset: {})", + bytes, + offset, + 32 - bytes + ))); + } + + let shift_bits = offset * 8; + let mask = create_element_mask(bytes); + + T::from_word((slot_value >> shift_bits) & mask) +} + +/// Insert a packed value into a storage slot at a given byte offset. +#[inline] +pub fn insert_into_word( + current: U256, + value: &T, + offset: usize, + bytes: usize, +) -> Result { + debug_assert!( + matches!(T::LAYOUT, Layout::Bytes(..)), + "Packing is only supported by primitive types" + ); + + if offset + bytes > 32 { + return Err(crate::error::BasePrecompileError::Fatal(format!( + "Value of {} bytes at offset {} would span slot boundary (max offset: {})", + bytes, + offset, + 32 - bytes + ))); + } + + let field_value = value.to_word(); + let shift_bits = offset * 8; + let mask = create_element_mask(bytes); + let clear_mask = !(mask << shift_bits); + let cleared = current & clear_mask; + let positioned = (field_value & mask) << shift_bits; + Ok(cleared | positioned) +} + +/// Zero out a packed value in a storage slot at a given byte offset. +#[inline] +pub fn delete_from_word(current: U256, offset: usize, bytes: usize) -> Result { + if offset + bytes > 32 { + return Err(crate::error::BasePrecompileError::Fatal(format!( + "Value of {} bytes at offset {} would span slot boundary (max offset: {})", + bytes, + offset, + 32 - bytes + ))); + } + + let mask = create_element_mask(bytes); + let shifted_mask = mask << (offset * 8); + Ok(current & !shifted_mask) +} + +/// Calculate which slot an array element at index `idx` starts in. +#[inline] +pub const fn calc_element_slot(idx: usize, elem_bytes: usize) -> usize { + let elems_per_slot = 32 / elem_bytes; + idx / elems_per_slot +} + +/// Calculate the byte offset within a slot for an array element at index `idx`. +#[inline] +pub const fn calc_element_offset(idx: usize, elem_bytes: usize) -> usize { + let elems_per_slot = 32 / elem_bytes; + (idx % elems_per_slot) * elem_bytes +} + +/// Calculate the element location within a slot for an array element at index `idx`. +#[inline] +pub const fn calc_element_loc(idx: usize, elem_bytes: usize) -> FieldLocation { + FieldLocation::new( + calc_element_slot(idx, elem_bytes), + calc_element_offset(idx, elem_bytes), + elem_bytes, + ) +} + +/// Calculate the total number of slots needed for an array. +#[inline] +pub const fn calc_packed_slot_count(n: usize, elem_bytes: usize) -> usize { + let elems_per_slot = 32 / elem_bytes; + n.div_ceil(elems_per_slot) +} + +/// Test helper: constructs a U256 slot from hex string literals, left-padded to 32 bytes. +/// +/// Takes an array of hex strings (with or without "0x" prefix), concatenates them +/// left-to-right, left-pads with zeros to 32 bytes, and returns a U256. +#[cfg(any(test, feature = "test-utils"))] +pub fn gen_word_from(values: &[&str]) -> U256 { + let mut bytes = Vec::new(); + + for value in values { + let hex_str = value.strip_prefix("0x").unwrap_or(value); + + assert!(hex_str.len() % 2 == 0, "Hex string '{value}' has odd length"); + + for i in (0..hex_str.len()).step_by(2) { + let byte_str = &hex_str[i..i + 2]; + let byte = u8::from_str_radix(byte_str, 16) + .unwrap_or_else(|e| panic!("Invalid hex in '{value}': {e}")); + bytes.push(byte); + } + } + + assert!(bytes.len() <= 32, "Total bytes ({}) exceed 32-byte slot limit", bytes.len()); + + let mut slot_bytes = [0u8; 32]; + let start_idx = 32 - bytes.len(); + slot_bytes[start_idx..].copy_from_slice(&bytes); + + U256::from_be_bytes(slot_bytes) +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + + use super::*; + use crate::{ + provider::{Handler, LayoutCtx}, + storage_ctx::StorageCtx, + types::Slot, + }; + + // -- HELPER FUNCTION TESTS ---------------------------------------------------- + + #[test] + fn test_calc_element_slot() { + assert_eq!(calc_element_slot(0, 1), 0); + assert_eq!(calc_element_slot(31, 1), 0); + assert_eq!(calc_element_slot(32, 1), 1); + assert_eq!(calc_element_slot(63, 1), 1); + assert_eq!(calc_element_slot(64, 1), 2); + + assert_eq!(calc_element_slot(0, 2), 0); + assert_eq!(calc_element_slot(15, 2), 0); + assert_eq!(calc_element_slot(16, 2), 1); + + assert_eq!(calc_element_slot(0, 20), 0); + assert_eq!(calc_element_slot(1, 20), 1); + assert_eq!(calc_element_slot(2, 20), 2); + } + + #[test] + fn test_calc_element_offset() { + assert_eq!(calc_element_offset(0, 1), 0); + assert_eq!(calc_element_offset(1, 1), 1); + assert_eq!(calc_element_offset(31, 1), 31); + assert_eq!(calc_element_offset(32, 1), 0); + + assert_eq!(calc_element_offset(0, 2), 0); + assert_eq!(calc_element_offset(1, 2), 2); + assert_eq!(calc_element_offset(15, 2), 30); + assert_eq!(calc_element_offset(16, 2), 0); + + assert_eq!(calc_element_offset(0, 20), 0); + assert_eq!(calc_element_offset(1, 20), 0); + assert_eq!(calc_element_offset(2, 20), 0); + } + + #[test] + fn test_calc_packed_slot_count() { + assert_eq!(calc_packed_slot_count(10, 1), 1); + assert_eq!(calc_packed_slot_count(32, 1), 1); + assert_eq!(calc_packed_slot_count(33, 1), 2); + assert_eq!(calc_packed_slot_count(100, 1), 4); + + assert_eq!(calc_packed_slot_count(16, 2), 1); + assert_eq!(calc_packed_slot_count(17, 2), 2); + + assert_eq!(calc_packed_slot_count(1, 20), 1); + assert_eq!(calc_packed_slot_count(2, 20), 2); + assert_eq!(calc_packed_slot_count(3, 20), 3); + } + + #[test] + fn test_calc_element_loc_non_divisor_sizes() { + assert_eq!(calc_element_slot(0, 11), 0); + assert_eq!(calc_element_slot(1, 11), 0); + assert_eq!(calc_element_slot(2, 11), 1); + assert_eq!(calc_element_slot(3, 11), 1); + assert_eq!(calc_element_slot(4, 11), 2); + + assert_eq!(calc_element_offset(0, 11), 0); + assert_eq!(calc_element_offset(1, 11), 11); + assert_eq!(calc_element_offset(2, 11), 0); + assert_eq!(calc_element_offset(3, 11), 11); + assert_eq!(calc_element_offset(4, 11), 0); + + assert_eq!(calc_packed_slot_count(1, 11), 1); + assert_eq!(calc_packed_slot_count(2, 11), 1); + assert_eq!(calc_packed_slot_count(3, 11), 2); + assert_eq!(calc_packed_slot_count(4, 11), 2); + assert_eq!(calc_packed_slot_count(5, 11), 3); + } + + #[test] + fn test_offset_never_exceeds_slot_boundary() { + for elem_bytes in 1..=32 { + for idx in 0..10 { + let offset = calc_element_offset(idx, elem_bytes); + assert!( + offset + elem_bytes <= 32, + "elem_bytes={elem_bytes}, idx={idx}, offset={offset} would cross slot boundary" + ); + } + } + } + + #[test] + fn test_create_element_mask() { + assert_eq!(create_element_mask(1), U256::from(0xff)); + assert_eq!(create_element_mask(2), U256::from(0xffff)); + assert_eq!(create_element_mask(4), U256::from(0xffffffffu32)); + assert_eq!(create_element_mask(8), U256::from(u64::MAX)); + assert_eq!(create_element_mask(16), U256::from(u128::MAX)); + assert_eq!(create_element_mask(32), U256::MAX); + assert_eq!(create_element_mask(64), U256::MAX); + } + + #[test] + fn test_delete_from_word() { + let slot = gen_word_from(&["0xff", "0x56", "0x34", "0x12"]); + + let cleared = delete_from_word(slot, 1, 1).unwrap(); + let expected = gen_word_from(&["0xff", "0x56", "0x00", "0x12"]); + assert_eq!(cleared, expected, "Should zero offset 1"); + + let slot = gen_word_from(&["0x5678", "0x1234"]); + let cleared = delete_from_word(slot, 0, 2).unwrap(); + let expected = gen_word_from(&["0x5678", "0x0000"]); + assert_eq!(cleared, expected, "Should zero u16 at offset 0"); + + let slot = gen_word_from(&["0xff"]); + let cleared = delete_from_word(slot, 0, 1).unwrap(); + assert_eq!(cleared, U256::ZERO, "Should zero entire slot"); + } + + #[test] + fn test_boundary_validation_rejects_spanning() { + let addr = Address::random(); + let result = insert_into_word(U256::ZERO, &addr, 13, 20); + assert!(result.is_err(), "Should reject address at offset 13"); + + let val: u16 = 42; + let result = insert_into_word(U256::ZERO, &val, 31, 2); + assert!(result.is_err(), "Should reject u16 at offset 31"); + + let val: u32 = 42; + let result = insert_into_word(U256::ZERO, &val, 29, 4); + assert!(result.is_err(), "Should reject u32 at offset 29"); + + let result = extract_from_word::
(U256::ZERO, 13, 20); + assert!(result.is_err(), "Should reject extracting address from offset 13"); + } + + #[test] + fn test_boundary_validation_accepts_valid() { + let addr = Address::random(); + assert!(insert_into_word(U256::ZERO, &addr, 12, 20).is_ok()); + + let val: u16 = 42; + assert!(insert_into_word(U256::ZERO, &val, 30, 2).is_ok()); + + let val: u8 = 42; + assert!(insert_into_word(U256::ZERO, &val, 31, 1).is_ok()); + + let val = U256::from(42); + assert!(insert_into_word(U256::ZERO, &val, 0, 32).is_ok()); + } + + #[test] + fn test_bool() { + let expected = gen_word_from(&["0x01"]); + let slot = insert_into_word(U256::ZERO, &true, 0, 1).unwrap(); + assert_eq!(slot, expected); + assert!(extract_from_word::(slot, 0, 1).unwrap()); + + let expected = gen_word_from(&["0x01", "0x01"]); + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &true, 0, 1).unwrap(); + slot = insert_into_word(slot, &true, 1, 1).unwrap(); + assert_eq!(slot, expected); + assert!(extract_from_word::(slot, 0, 1).unwrap()); + assert!(extract_from_word::(slot, 1, 1).unwrap()); + } + + #[test] + fn test_u8_packing() { + let v1: u8 = 0x12; + let v2: u8 = 0x34; + let v3: u8 = 0x56; + let v4: u8 = u8::MAX; + + let expected = gen_word_from(&["0xff", "0x56", "0x34", "0x12"]); + + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 1).unwrap(); + slot = insert_into_word(slot, &v2, 1, 1).unwrap(); + slot = insert_into_word(slot, &v3, 2, 1).unwrap(); + slot = insert_into_word(slot, &v4, 3, 1).unwrap(); + + assert_eq!(slot, expected); + assert_eq!(extract_from_word::(slot, 0, 1).unwrap(), v1); + assert_eq!(extract_from_word::(slot, 1, 1).unwrap(), v2); + assert_eq!(extract_from_word::(slot, 2, 1).unwrap(), v3); + assert_eq!(extract_from_word::(slot, 3, 1).unwrap(), v4); + } + + #[test] + fn test_u16_packing() { + let v1: u16 = 0x1234; + let v2: u16 = 0x5678; + let v3: u16 = u16::MAX; + + let expected = gen_word_from(&["0xffff", "0x5678", "0x1234"]); + + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 2).unwrap(); + slot = insert_into_word(slot, &v2, 2, 2).unwrap(); + slot = insert_into_word(slot, &v3, 4, 2).unwrap(); + + assert_eq!(slot, expected); + assert_eq!(extract_from_word::(slot, 0, 2).unwrap(), v1); + assert_eq!(extract_from_word::(slot, 2, 2).unwrap(), v2); + assert_eq!(extract_from_word::(slot, 4, 2).unwrap(), v3); + } + + #[test] + fn test_u32_packing() { + let v1: u32 = 0x12345678; + let v2: u32 = u32::MAX; + + let expected = gen_word_from(&["0xffffffff", "0x12345678"]); + + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 4).unwrap(); + slot = insert_into_word(slot, &v2, 4, 4).unwrap(); + + assert_eq!(slot, expected); + assert_eq!(extract_from_word::(slot, 0, 4).unwrap(), v1); + assert_eq!(extract_from_word::(slot, 4, 4).unwrap(), v2); + } + + #[test] + fn test_u64_packing() { + let v1: u64 = 0x123456789abcdef0; + let v2: u64 = u64::MAX; + + let expected = gen_word_from(&["0xffffffffffffffff", "0x123456789abcdef0"]); + + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 8).unwrap(); + slot = insert_into_word(slot, &v2, 8, 8).unwrap(); + + assert_eq!(slot, expected); + assert_eq!(extract_from_word::(slot, 0, 8).unwrap(), v1); + assert_eq!(extract_from_word::(slot, 8, 8).unwrap(), v2); + } + + #[test] + fn test_u128_packing() { + let v1: u128 = 0x123456789abcdef0fedcba9876543210; + let v2: u128 = u128::MAX; + + let expected = gen_word_from(&[ + "0xffffffffffffffffffffffffffffffff", + "0x123456789abcdef0fedcba9876543210", + ]); + + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 16).unwrap(); + slot = insert_into_word(slot, &v2, 16, 16).unwrap(); + + assert_eq!(slot, expected); + assert_eq!(extract_from_word::(slot, 0, 16).unwrap(), v1); + assert_eq!(extract_from_word::(slot, 16, 16).unwrap(), v2); + } + + #[test] + fn test_mixed_type_packing() { + let addr = Address::from([0x11; 20]); + let number: u8 = 0x2a; + + let expected = + gen_word_from(&["0x2a", "0x1111111111111111111111111111111111111111", "0x01"]); + + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &true, 0, 1).unwrap(); + slot = insert_into_word(slot, &addr, 1, 20).unwrap(); + slot = insert_into_word(slot, &number, 21, 1).unwrap(); + assert_eq!(slot, expected); + assert!(extract_from_word::(slot, 0, 1).unwrap()); + assert_eq!(extract_from_word::
(slot, 1, 20).unwrap(), addr); + assert_eq!(extract_from_word::(slot, 21, 1).unwrap(), number); + } + + #[test] + fn test_packed_at_multiple_types() -> Result<()> { + let (mut storage, address) = crate::hashmap::setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let struct_base = U256::from(0x2000); + + let flag = true; + let timestamp: u64 = 1234567890; + let amount: u128 = 999888777666; + + let mut flag_slot = + Slot::::new_with_ctx(struct_base, LayoutCtx::packed(0), address, ctx); + flag_slot.write(flag)?; + assert_eq!(flag_slot.read()?, flag); + + let mut ts_slot = + Slot::::new_with_ctx(struct_base, LayoutCtx::packed(1), address, ctx); + ts_slot.write(timestamp)?; + assert_eq!(ts_slot.read()?, timestamp); + + let mut amount_slot = + Slot::::new_with_ctx(struct_base, LayoutCtx::packed(9), address, ctx); + amount_slot.write(amount)?; + assert_eq!(amount_slot.read()?, amount); + + amount_slot.delete()?; + assert_eq!(flag_slot.read()?, flag); + assert_eq!(amount_slot.read()?, 0); + assert_eq!(ts_slot.read()?, timestamp); + + Ok(()) + }) + } + + use proptest::prelude::*; + + fn arb_address() -> impl Strategy { + any::<[u8; 20]>().prop_map(Address::from) + } + + fn arb_u256() -> impl Strategy { + any::<[u64; 4]>().prop_map(U256::from_limbs) + } + + fn arb_offset(bytes: usize) -> impl Strategy { + 0..=(32 - bytes) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + #[test] + fn proptest_roundtrip_u8(value: u8, offset in arb_offset(1)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 1)?; + let extracted: u8 = extract_from_word(slot, offset, 1)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_u16(value: u16, offset in arb_offset(2)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 2)?; + let extracted: u16 = extract_from_word(slot, offset, 2)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_u32(value: u32, offset in arb_offset(4)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 4)?; + let extracted: u32 = extract_from_word(slot, offset, 4)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_u64(value: u64, offset in arb_offset(8)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 8)?; + let extracted: u64 = extract_from_word(slot, offset, 8)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_u128(value: u128, offset in arb_offset(16)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 16)?; + let extracted: u128 = extract_from_word(slot, offset, 16)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_address(addr in arb_address(), offset in arb_offset(20)) { + let slot = insert_into_word(U256::ZERO, &addr, offset, 20)?; + let extracted: Address = extract_from_word(slot, offset, 20)?; + prop_assert_eq!(extracted, addr); + } + + #[test] + fn proptest_roundtrip_u256(value in arb_u256()) { + let slot = insert_into_word(U256::ZERO, &value, 0, 32)?; + let extracted: U256 = extract_from_word(slot, 0, 32)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_bool(value: bool, offset in arb_offset(1)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 1)?; + let extracted: bool = extract_from_word(slot, offset, 1)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_i8(value: i8, offset in arb_offset(1)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 1)?; + let extracted: i8 = extract_from_word(slot, offset, 1)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_i16(value: i16, offset in arb_offset(2)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 2)?; + let extracted: i16 = extract_from_word(slot, offset, 2)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_i32(value: i32, offset in arb_offset(4)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 4)?; + let extracted: i32 = extract_from_word(slot, offset, 4)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_i64(value: i64, offset in arb_offset(8)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 8)?; + let extracted: i64 = extract_from_word(slot, offset, 8)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_roundtrip_i128(value: i128, offset in arb_offset(16)) { + let slot = insert_into_word(U256::ZERO, &value, offset, 16)?; + let extracted: i128 = extract_from_word(slot, offset, 16)?; + prop_assert_eq!(extracted, value); + } + + #[test] + fn proptest_multiple_values_no_interference(v1: u8, v2: u16, v3: u32) { + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 1)?; + slot = insert_into_word(slot, &v2, 1, 2)?; + slot = insert_into_word(slot, &v3, 3, 4)?; + + let e1: u8 = extract_from_word(slot, 0, 1)?; + let e2: u16 = extract_from_word(slot, 1, 2)?; + let e3: u32 = extract_from_word(slot, 3, 4)?; + + prop_assert_eq!(e1, v1); + prop_assert_eq!(e2, v2); + prop_assert_eq!(e3, v3); + } + + #[test] + fn proptest_overwrite_preserves_others(v1: u8, v2: u16, v1_new: u8) { + let mut slot = U256::ZERO; + slot = insert_into_word(slot, &v1, 0, 1)?; + slot = insert_into_word(slot, &v2, 1, 2)?; + slot = insert_into_word(slot, &v1_new, 0, 1)?; + + let e1: u8 = extract_from_word(slot, 0, 1)?; + let e2: u16 = extract_from_word(slot, 1, 2)?; + + prop_assert_eq!(e1, v1_new); + prop_assert_eq!(e2, v2); + } + + #[test] + fn proptest_element_slot_offset_consistency_u8(idx in 0usize..1000) { + let slot = calc_element_slot(idx, 1); + let offset = calc_element_offset(idx, 1); + prop_assert_eq!(slot * 32 + offset, idx); + prop_assert!(offset < 32); + } + + #[test] + fn proptest_element_slot_offset_consistency_u16(idx in 0usize..1000) { + let slot = calc_element_slot(idx, 2); + let offset = calc_element_offset(idx, 2); + prop_assert_eq!(slot * 32 + offset, idx * 2); + prop_assert!(offset < 32); + } + + #[test] + fn proptest_packed_slot_count_sufficient(n in 1usize..100, elem_bytes in 1usize..=32) { + let slot_count = calc_packed_slot_count(n, elem_bytes); + let elems_per_slot = 32 / elem_bytes; + let expected = n.div_ceil(elems_per_slot); + prop_assert_eq!(slot_count, expected); + prop_assert!(slot_count * elems_per_slot >= n); + if slot_count > 0 { + prop_assert!(slot_count * elems_per_slot - n < elems_per_slot); + } + } + } +} diff --git a/crates/common/precompile-storage/src/provider.rs b/crates/common/precompile-storage/src/provider.rs new file mode 100644 index 0000000000..c7d7b09852 --- /dev/null +++ b/crates/common/precompile-storage/src/provider.rs @@ -0,0 +1,331 @@ +//! Core storage provider traits for Base native precompiles. +//! +//! Defines [`PrecompileStorageProvider`] (the EVM access boundary), +//! [`StorageOps`] (per-address slot read/write), and [`ContractStorage`] +//! (generated by the `#[contract]` macro). + +use alloy_primitives::{Address, B256, LogData, U256, keccak256}; +use revm::{ + context::journaled_state::JournalCheckpoint, + interpreter::gas::{KECCAK256, KECCAK256WORD}, + state::{AccountInfo, Bytecode}, +}; + +use crate::error::{BasePrecompileError, Result}; + +/// Low-level storage provider for interacting with the EVM. +/// +/// Abstracted over both production EVM journal and test `HashMap` backends. +pub trait PrecompileStorageProvider { + /// Returns the current chain ID. + fn chain_id(&self) -> u64; + /// Returns the current block timestamp. + fn timestamp(&self) -> U256; + /// Returns the current block beneficiary (coinbase). + fn beneficiary(&self) -> Address; + /// Returns the current block number. + fn block_number(&self) -> u64; + + /// Sets the bytecode at the given address. + fn set_code(&mut self, address: Address, code: Bytecode) -> Result<()>; + + /// Executes a closure with access to the account info for the given address. + fn with_account_info( + &mut self, + address: Address, + f: &mut dyn FnMut(&AccountInfo), + ) -> Result<()>; + + /// Performs an SLOAD operation (persistent storage read). + fn sload(&mut self, address: Address, key: U256) -> Result; + /// Performs a TLOAD operation (transient storage read). + fn tload(&mut self, address: Address, key: U256) -> Result; + /// Performs an SSTORE operation (persistent storage write). + fn sstore(&mut self, address: Address, key: U256, value: U256) -> Result<()>; + /// Performs a TSTORE operation (transient storage write). + fn tstore(&mut self, address: Address, key: U256, value: U256) -> Result<()>; + + /// Emits an event from the given contract address. + fn emit_event(&mut self, address: Address, event: LogData) -> Result<()>; + + /// Deducts gas from the remaining gas and returns an error if insufficient. + fn deduct_gas(&mut self, gas: u64) -> Result<()>; + /// Deducts state-creating gas (EIP-8037 reservoir model). + /// + /// Counts only state-creation operations: new account creation and code deposit. + /// State gas is tracked separately from regular gas and also deducted from the + /// regular gas counter when no reservoir is available. + fn deduct_state_gas(&mut self, gas: u64) -> Result<()>; + /// Adds a gas refund to the refund counter. + fn refund_gas(&mut self, gas: i64); + /// Returns the gas limit for this precompile call. + fn gas_limit(&self) -> u64; + /// Returns the gas used so far. + fn gas_used(&self) -> u64; + /// Returns the state-creating gas spent so far (EIP-8037). + /// + /// Counts only new account creation and code deposit operations. + fn state_gas_used(&self) -> u64; + /// Returns the gas refunded so far. + fn gas_refunded(&self) -> i64; + /// Returns the remaining EIP-8037 state-gas reservoir. + /// + /// State gas is first deducted from this reservoir before spilling into regular gas. + /// Returns zero when no reservoir was provided at construction time. + fn reservoir(&self) -> u64; + + /// Returns whether the current call context is static. + fn is_static(&self) -> bool; + + /// Returns the address that called this precompile. + fn caller(&self) -> Address; + /// Replaces the current caller address and returns the previous caller. + fn replace_caller(&mut self, caller: Address) -> Address; + + /// Creates a new journal checkpoint for atomic state management. + fn checkpoint(&mut self) -> JournalCheckpoint; + /// Commits all state changes since the given checkpoint. + fn checkpoint_commit(&mut self, checkpoint: JournalCheckpoint); + /// Reverts all state changes back to the given checkpoint. + fn checkpoint_revert(&mut self, checkpoint: JournalCheckpoint); + + /// Computes keccak256 and charges the appropriate gas. + fn keccak256(&mut self, data: &[u8]) -> Result { + let num_words = + u64::try_from(data.len().div_ceil(32)).map_err(|_| BasePrecompileError::OutOfGas)?; + let price = KECCAK256WORD + .checked_mul(num_words) + .and_then(|w| w.checked_add(KECCAK256)) + .ok_or(BasePrecompileError::OutOfGas)?; + self.deduct_gas(price)?; + Ok(keccak256(data)) + } +} + +/// Storage operations for a given (contract) address. +/// +/// Abstracts over persistent (SLOAD/SSTORE) and transient (TLOAD/TSTORE) storage. +pub trait StorageOps { + /// Stores a value at the provided slot. + fn store(&mut self, slot: U256, value: U256) -> Result<()>; + /// Loads a value from the provided slot. + fn load(&self, slot: U256) -> Result; +} + +/// Trait providing access to a contract's address and storage. +/// +/// Automatically implemented by the `#[contract]` macro. +pub trait ContractStorage<'a> { + /// Contract address. + fn address(&self) -> Address; + /// Contract storage accessor. + fn storage(&self) -> crate::storage_ctx::StorageCtx<'a>; + + /// Returns true if the contract has bytecode deployed at its address. + fn is_initialized(&self) -> Result { + self.storage().with_account_info(self.address(), |info| Ok(!info.is_empty_code_hash())) + } +} + +/// Describes how a type is laid out in EVM storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// Single slot, N bytes (1–32). Can be packed with other fields if N < 32. + Bytes(usize), + /// Occupies N full slots. Cannot be packed. + Slots(usize), +} + +impl Layout { + /// Returns true if this field can be packed with adjacent fields. + pub const fn is_packable(&self) -> bool { + match self { + Self::Bytes(n) => *n < 32, + Self::Slots(_) => false, + } + } + + /// Returns the number of storage slots this type occupies. + pub const fn slots(&self) -> usize { + match self { + Self::Bytes(_) => 1, + Self::Slots(n) => *n, + } + } + + /// Returns the number of bytes this type occupies. + pub const fn bytes(&self) -> usize { + match self { + Self::Bytes(n) => *n, + Self::Slots(n) => { + let (mut i, mut result) = (0, 0); + while i < *n { + result += 32; + i += 1; + } + result + } + } + } +} + +/// Describes the context in which a storable value is being loaded or stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(transparent)] +pub struct LayoutCtx(usize); + +impl LayoutCtx { + /// Load/store the entire value at a given slot. + pub const FULL: Self = Self(usize::MAX); + + /// Load/store a packed primitive at the given byte offset within a slot. + pub const fn packed(offset: usize) -> Self { + debug_assert!(offset < 32); + Self(offset) + } + + #[inline] + /// Returns the packed offset, or `None` for [`Self::FULL`]. + pub const fn packed_offset(&self) -> Option { + if self.0 == usize::MAX { None } else { Some(self.0) } + } +} + +/// Compile-time layout information and handler factory for storable types. +pub trait StorableType { + /// Storage layout descriptor. + const LAYOUT: Layout; + /// Whether this type declares its own ERC-7201 storage namespace. + const HAS_STORAGE_NAMESPACE: bool = false; + /// ERC-7201 namespace identifier for this type. + const STORAGE_NAMESPACE_ID: &'static str = ""; + /// ERC-7201 namespace root slot for this type. + const STORAGE_NAMESPACE_ROOT: U256 = U256::ZERO; + /// Number of storage slots this type occupies. + const SLOTS: usize = Self::LAYOUT.slots(); + /// Number of bytes this type occupies. + const BYTES: usize = Self::LAYOUT.bytes(); + /// Whether this type can be packed with adjacent fields. + const IS_PACKABLE: bool = Self::LAYOUT.is_packable(); + /// Whether this type stores data outside its base slot (e.g., `String`, `Vec`). + const IS_DYNAMIC: bool = false; + + /// The handler type that provides storage access for this type. + type Handler<'a>; + + /// Creates a handler for this type at the given storage location. + fn handle<'a>( + slot: U256, + ctx: LayoutCtx, + address: Address, + storage: crate::storage_ctx::StorageCtx<'a>, + ) -> Self::Handler<'a>; +} + +/// Handler trait for read/write/delete operations on a storable value. +pub trait Handler { + /// Reads the value from persistent storage. + fn read(&self) -> Result; + /// Writes the value to persistent storage. + fn write(&mut self, value: T) -> Result<()>; + /// Deletes the value from persistent storage (sets to zero). + fn delete(&mut self) -> Result<()>; + /// Reads the value from transient storage. + fn t_read(&self) -> Result; + /// Writes the value to transient storage. + fn t_write(&mut self, value: T) -> Result<()>; + /// Deletes the value from transient storage. + fn t_delete(&mut self) -> Result<()>; +} + +/// Storage I/O operations for storable types. +pub trait Storable: StorableType + Sized { + /// Load this type from storage at the given slot. + fn load(storage: &S, slot: U256, ctx: LayoutCtx) -> Result; + /// Store this type to storage at the given slot. + fn store(&self, storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()>; + + /// Delete this type from storage (set to zero). + fn delete(storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + match ctx.packed_offset() { + None => { + for offset in 0..Self::SLOTS { + storage.store(slot + U256::from(offset), U256::ZERO)?; + } + Ok(()) + } + Some(offset) => { + let bytes = Self::BYTES; + let current = storage.load(slot)?; + let cleared = crate::packing::delete_from_word(current, offset, bytes)?; + storage.store(slot, cleared) + } + } + } +} + +/// Sealed marker for primitive types that can be packed into EVM storage slots. +pub mod sealed { + /// Marker trait limiting [`Packable`](super::Packable) to primitive types. + pub trait OnlyPrimitives {} +} + +/// Trait for types that can be packed into EVM storage slots. +pub trait Packable: FromWord + StorableType {} + +/// Word-level encoding for primitive types that fit in a single EVM storage slot. +pub trait FromWord: sealed::OnlyPrimitives { + /// Encodes this value to a right-aligned `U256` word. + fn to_word(&self) -> U256; + /// Decodes a value from a right-aligned `U256` word. + fn from_word(word: U256) -> Result + where + Self: Sized; +} + +/// Blanket `Storable` implementation for all `Packable` types. +impl Storable for T { + #[inline] + fn load(storage: &S, slot: U256, ctx: LayoutCtx) -> Result { + const { assert!(T::IS_PACKABLE, "Packable requires IS_PACKABLE to be true") }; + match ctx.packed_offset() { + None => storage.load(slot).and_then(Self::from_word), + Some(offset) => { + let slot_value = storage.load(slot)?; + crate::packing::extract_from_word(slot_value, offset, Self::BYTES) + } + } + } + + #[inline] + fn store(&self, storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + const { assert!(T::IS_PACKABLE, "Packable requires IS_PACKABLE to be true") }; + match ctx.packed_offset() { + None => storage.store(slot, self.to_word()), + Some(offset) => { + let current = storage.load(slot)?; + let updated = crate::packing::insert_into_word(current, self, offset, Self::BYTES)?; + storage.store(slot, updated) + } + } + } +} + +/// Trait for types that can be used as storage mapping keys. +pub trait StorageKey: sealed::OnlyPrimitives { + /// Returns key bytes for storage slot computation (left-padded to 32 bytes). + fn as_storage_bytes(&self) -> impl AsRef<[u8]>; + + /// Computes `keccak256(lpad32(key) ‖ slot_be32)` — the Solidity mapping slot derivation. + fn mapping_slot(&self, slot: U256) -> U256 { + let key_bytes = self.as_storage_bytes(); + let key_bytes = key_bytes.as_ref(); + debug_assert!(key_bytes.len() <= 32); + + let mut buf = [0u8; 64]; + buf[32 - key_bytes.len()..32].copy_from_slice(key_bytes); + buf[32..].copy_from_slice(&slot.to_be_bytes::<32>()); + + U256::from_be_bytes(keccak256(buf).0) + } +} diff --git a/crates/common/precompile-storage/src/registration.rs b/crates/common/precompile-storage/src/registration.rs new file mode 100644 index 0000000000..95405268ad --- /dev/null +++ b/crates/common/precompile-storage/src/registration.rs @@ -0,0 +1,39 @@ +//! Precompile registration scaffold. +//! +//! Adding a new native precompile requires: +//! 1. One file implementing [`NativePrecompile`]. +//! 2. One registration line in the precompile registry. + +use alloy_primitives::Address; +use revm::precompile::PrecompileResult; + +use crate::provider::PrecompileStorageProvider; + +/// Trait that every native precompile must implement. +/// +/// # Example +/// +/// ```ignore +/// use base_precompile_storage::registration::NativePrecompile; +/// use base_precompile_macros::contract; +/// +/// #[contract(addr = MY_PRECOMPILE_ADDRESS)] +/// pub struct MyPrecompile { ... } +/// +/// impl NativePrecompile for MyPrecompile { +/// const ADDRESS: Address = MY_PRECOMPILE_ADDRESS; +/// fn execute(storage: &mut dyn PrecompileStorageProvider) -> PrecompileResult { +/// StorageCtx::enter(storage, |ctx| { +/// let pc = MyPrecompile::new(ctx); +/// // dispatch calldata ... +/// }) +/// } +/// } +/// ``` +pub trait NativePrecompile { + /// The precompile's canonical contract address. + const ADDRESS: Address; + + /// Executes the precompile with the given storage provider. + fn execute(storage: &mut dyn PrecompileStorageProvider) -> PrecompileResult; +} diff --git a/crates/common/precompile-storage/src/storage_ctx.rs b/crates/common/precompile-storage/src/storage_ctx.rs new file mode 100644 index 0000000000..50ac8fce4f --- /dev/null +++ b/crates/common/precompile-storage/src/storage_ctx.rs @@ -0,0 +1,420 @@ +//! Explicit storage context for Base native precompiles. +//! +//! [`StorageCtx`] is a zero-size token that provides access to the current +//! scoped [`PrecompileStorageProvider`]. All storage operations within a +//! precompile call receive a context from [`StorageCtx::enter`]. + +use alloc::string::ToString; +#[cfg(any(test, feature = "test-utils"))] +use alloc::vec::Vec; +use core::{cell::RefCell, fmt}; + +use alloy_primitives::{Address, B256, Bytes, LogData, U256}; +use alloy_sol_types::SolInterface; +use revm::{ + context::journaled_state::JournalCheckpoint, + precompile::{PrecompileOutput, PrecompileResult}, + state::{AccountInfo, Bytecode}, +}; + +use crate::{ + error::{BasePrecompileError, Result}, + provider::PrecompileStorageProvider, +}; + +type ScopedProvider<'a> = dyn PrecompileStorageProvider + 'a; + +/// Scoped handle providing access to the active [`PrecompileStorageProvider`]. +/// +/// Values of this type are created by [`StorageCtx::enter`] and cannot outlive +/// that closure. +#[derive(Clone, Copy)] +pub struct StorageCtx<'a> { + storage: &'a RefCell<&'a mut ScopedProvider<'a>>, +} + +impl fmt::Debug for StorageCtx<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StorageCtx").finish_non_exhaustive() + } +} + +impl StorageCtx<'_> { + /// Enter the storage context. All storage operations must happen within the closure. + pub fn enter(storage: &mut S, f: impl for<'ctx> FnOnce(StorageCtx<'ctx>) -> R) -> R + where + S: PrecompileStorageProvider, + { + let storage: &mut ScopedProvider<'_> = storage; + let cell = RefCell::new(storage); + f(StorageCtx { storage: &cell }) + } +} + +impl<'a> StorageCtx<'a> { + fn with_storage(&self, f: F) -> R + where + F: FnOnce(&mut dyn PrecompileStorageProvider) -> R, + { + let mut guard = self.storage.borrow_mut(); + f(&mut **guard) + } + + fn try_with_storage(&self, f: F) -> Result + where + F: FnOnce(&mut dyn PrecompileStorageProvider) -> Result, + { + let mut guard = self.storage.try_borrow_mut().map_err(|_| { + BasePrecompileError::Fatal("Storage context is already mutably borrowed".to_string()) + })?; + f(&mut **guard) + } + + // --- Provider method delegates --- + + /// Executes a closure with account info, returning the closure's result. + pub fn with_account_info( + &self, + address: Address, + mut f: impl FnMut(&AccountInfo) -> Result, + ) -> Result { + let mut result: Option> = None; + self.try_with_storage(|s| { + s.with_account_info(address, &mut |info| { + result = Some(f(info)); + }) + })?; + result.unwrap_or_else(|| { + Err(BasePrecompileError::Fatal( + "with_account_info callback was not invoked".to_string(), + )) + }) + } + + /// Returns the current chain ID. + pub fn chain_id(&self) -> u64 { + self.with_storage(|s| s.chain_id()) + } + /// Returns the current block timestamp. + pub fn timestamp(&self) -> U256 { + self.with_storage(|s| s.timestamp()) + } + /// Returns the block beneficiary (coinbase). + pub fn beneficiary(&self) -> Address { + self.with_storage(|s| s.beneficiary()) + } + /// Returns the current block number. + pub fn block_number(&self) -> u64 { + self.with_storage(|s| s.block_number()) + } + + /// Sets the bytecode at the given address. + pub fn set_code(&self, address: Address, code: Bytecode) -> Result<()> { + self.try_with_storage(|s| s.set_code(address, code)) + } + + /// Performs an SLOAD (persistent storage read). + pub fn sload(&self, address: Address, key: U256) -> Result { + self.try_with_storage(|s| s.sload(address, key)) + } + + /// Performs a TLOAD (transient storage read). + pub fn tload(&self, address: Address, key: U256) -> Result { + self.try_with_storage(|s| s.tload(address, key)) + } + + /// Performs an SSTORE (persistent storage write). + pub fn sstore(&self, address: Address, key: U256, value: U256) -> Result<()> { + self.try_with_storage(|s| s.sstore(address, key, value)) + } + + /// Performs a TSTORE (transient storage write). + pub fn tstore(&self, address: Address, key: U256, value: U256) -> Result<()> { + self.try_with_storage(|s| s.tstore(address, key, value)) + } + + /// Emits an event from the given contract address. + pub fn emit_event(&self, address: Address, event: LogData) -> Result<()> { + self.try_with_storage(|s| s.emit_event(address, event)) + } + + /// Adds gas to the refund counter. + pub fn refund_gas(&self, gas: i64) { + self.with_storage(|s| s.refund_gas(gas)) + } + /// Returns the gas limit for this precompile call. + pub fn gas_limit(&self) -> u64 { + self.with_storage(|s| s.gas_limit()) + } + /// Returns the gas used so far. + pub fn gas_used(&self) -> u64 { + self.with_storage(|s| s.gas_used()) + } + /// Returns the state-creating gas spent so far (EIP-8037). + pub fn state_gas_used(&self) -> u64 { + self.with_storage(|s| s.state_gas_used()) + } + /// Returns the gas refunded so far. + pub fn gas_refunded(&self) -> i64 { + self.with_storage(|s| s.gas_refunded()) + } + /// Returns the remaining EIP-8037 state-gas reservoir. + pub fn reservoir(&self) -> u64 { + self.with_storage(|s| s.reservoir()) + } + /// Returns whether the current call context is static. + pub fn is_static(&self) -> bool { + self.with_storage(|s| s.is_static()) + } + /// Returns the address that called this precompile. + pub fn caller(&self) -> Address { + self.with_storage(|s| s.caller()) + } + + /// Executes `f` with a temporary caller override, restoring the previous caller on exit. + pub fn with_caller(&self, caller: Address, f: impl FnOnce() -> R) -> R { + let previous = self.with_storage(|s| s.replace_caller(caller)); + let guard = CallerGuard { storage: *self, previous: Some(previous) }; + let result = f(); + drop(guard); + result + } + + /// Deducts gas from the remaining gas, returning `OutOfGas` if insufficient. + pub fn deduct_gas(&self, gas: u64) -> Result<()> { + self.try_with_storage(|s| s.deduct_gas(gas)) + } + + /// Computes keccak256 and charges the appropriate gas. + pub fn keccak256(&self, data: &[u8]) -> Result { + self.try_with_storage(|s| s.keccak256(data)) + } + + /// Creates a journal checkpoint and returns a RAII guard that auto-reverts on drop. + pub fn checkpoint(&self) -> CheckpointGuard<'a> { + let checkpoint = self.with_storage(|s| s.checkpoint()); + CheckpointGuard { storage: *self, checkpoint: Some(checkpoint) } + } + + /// Returns a success [`PrecompileOutput`] with the current gas used and accumulated refund. + /// + /// The `gas_refunded` field is populated so revm's frame handler can propagate it to the + /// transaction-level refund counter, where the EIP-3529 cap (`gas_used / 5`) is applied. + pub fn success_output(&self, output: Bytes) -> PrecompileOutput { + let mut out = PrecompileOutput::new(self.gas_used(), output, self.state_gas_used()); + out.gas_refunded = self.gas_refunded(); + out + } + + /// Returns an ABI-encoded success output. + pub fn abi_success(&self, output: impl SolInterface) -> PrecompileOutput { + self.success_output(output.abi_encode().into()) + } + + /// Returns a revert [`PrecompileOutput`] with the current gas used. + pub fn revert_output(&self, output: Bytes) -> PrecompileOutput { + PrecompileOutput::revert(self.gas_used(), output, self.state_gas_used()) + } + + /// Reverts with an ABI-encoded error. + pub fn abi_revert(&self, error: impl SolInterface) -> PrecompileOutput { + self.revert_output(error.abi_encode().into()) + } + + /// Returns a [`PrecompileResult`] constructed from the given error. + pub fn error_result(&self, error: impl Into) -> PrecompileResult { + error.into().into_precompile_result(self.gas_used(), self.state_gas_used()) + } +} + +/// RAII guard for temporary caller overrides. +#[derive(Debug)] +struct CallerGuard<'a> { + storage: StorageCtx<'a>, + previous: Option
, +} + +impl Drop for CallerGuard<'_> { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + self.storage.with_storage(|s| { + s.replace_caller(previous); + }); + } + } +} + +/// RAII guard for atomic state mutation batching. +/// +/// On drop, automatically reverts all state changes made since the checkpoint +/// unless [`commit`](CheckpointGuard::commit) is called. +#[derive(Debug)] +pub struct CheckpointGuard<'a> { + storage: StorageCtx<'a>, + checkpoint: Option, +} + +impl CheckpointGuard<'_> { + /// Commits all state changes since the checkpoint. + pub fn commit(mut self) { + if let Some(cp) = self.checkpoint.take() { + self.storage.with_storage(|s| s.checkpoint_commit(cp)); + } + } +} + +impl Drop for CheckpointGuard<'_> { + fn drop(&mut self) { + if let Some(cp) = self.checkpoint.take() { + self.storage.with_storage(|s| s.checkpoint_revert(cp)); + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +use crate::hashmap::HashMapStorageProvider; + +#[cfg(any(test, feature = "test-utils"))] +impl StorageCtx<'_> { + fn with_hashmap(&self, f: impl FnOnce(&mut HashMapStorageProvider) -> R) -> R { + let mut guard = self.storage.borrow_mut(); + // SAFETY: Test-utils code always uses `HashMapStorageProvider`. The borrow + // guard stays alive for the full callback, preserving `RefCell` borrow checks. + let provider = unsafe { + &mut *(&mut **guard as *mut dyn PrecompileStorageProvider + as *mut HashMapStorageProvider) + }; + f(provider) + } + + /// Executes a closure with account info from the test storage provider. + pub fn with_test_account_info( + &self, + address: Address, + f: impl FnOnce(Option<&AccountInfo>) -> T, + ) -> T { + self.with_hashmap(|storage| f(storage.get_account_info(address))) + } + + /// Executes a closure with emitted events from the test storage provider. + pub fn with_events(&self, address: Address, f: impl FnOnce(&[LogData]) -> T) -> T { + self.with_hashmap(|storage| { + let events = storage.get_events(address); + f(events) + }) + } + + /// Returns account info for the given address (test-utils only). + pub fn get_account_info(&self, address: Address) -> Option { + self.with_test_account_info(address, |account| account.cloned()) + } + + /// Returns emitted events for the given address (test-utils only). + pub fn get_events(&self, address: Address) -> Vec { + self.with_events(address, <[LogData]>::to_vec) + } + + /// Sets the nonce for the given address (test-utils only). + pub fn set_nonce(&self, address: Address, nonce: u64) { + self.with_hashmap(|storage| storage.set_nonce(address, nonce)) + } + + /// Overrides the block timestamp (test-utils only). + pub fn set_timestamp(&self, timestamp: U256) { + self.with_hashmap(|storage| storage.set_timestamp(timestamp)) + } + + /// Overrides the block beneficiary (test-utils only). + pub fn set_beneficiary(&self, beneficiary: Address) { + self.with_hashmap(|storage| storage.set_beneficiary(beneficiary)) + } + + /// Overrides the block number (test-utils only). + pub fn set_block_number(&self, block_number: u64) { + self.with_hashmap(|storage| storage.set_block_number(block_number)) + } + + /// Clears all transient storage (test-utils only). + pub fn clear_transient(&self) { + self.with_hashmap(HashMapStorageProvider::clear_transient) + } + /// Clears emitted events for the given address (test-utils only). + pub fn clear_events(&self, address: Address) { + self.with_hashmap(|storage| storage.clear_events(address)); + } + /// Returns the SLOAD counter (test-utils only). + pub fn counter_sload(&self) -> u64 { + self.with_hashmap(|storage| storage.counter_sload()) + } + /// Returns the SSTORE counter (test-utils only). + pub fn counter_sstore(&self) -> u64 { + self.with_hashmap(|storage| storage.counter_sstore()) + } + /// Resets the SLOAD/SSTORE counters (test-utils only). + pub fn reset_counters(&self) { + self.with_hashmap(HashMapStorageProvider::reset_counters) + } + + /// Returns true if the contract at the given address has non-empty bytecode (test-utils only). + pub fn has_bytecode(&self, address: Address) -> Result { + self.with_account_info(address, |info| Ok(!info.is_empty_code_hash())) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::U256; + + use super::*; + + #[test] + #[should_panic(expected = "already borrowed")] + fn test_reentrant_with_storage_panics() { + let mut storage = crate::hashmap::HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| ctx.with_storage(|_| ctx.with_storage(|_| ()))); + } + + #[test] + fn test_with_caller_restores_previous_caller() { + let mut storage = crate::hashmap::HashMapStorageProvider::new(1); + let original = Address::repeat_byte(0x11); + let outer = Address::repeat_byte(0x22); + let inner = Address::repeat_byte(0x33); + storage.set_caller(original); + + StorageCtx::enter(&mut storage, |ctx| { + assert_eq!(ctx.caller(), original); + let value = ctx.with_caller(outer, || { + assert_eq!(ctx.caller(), outer); + ctx.with_caller(inner, || { + assert_eq!(ctx.caller(), inner); + 7 + }) + }); + + assert_eq!(value, 7); + assert_eq!(ctx.caller(), original); + }); + } + + #[test] + fn test_checkpoint_commit_and_revert() { + let mut storage = crate::hashmap::HashMapStorageProvider::new(1); + let addr = Address::ZERO; + let key = U256::from(1); + + StorageCtx::enter(&mut storage, |ctx| { + ctx.sstore(addr, key, U256::from(42)).unwrap(); + let guard = ctx.checkpoint(); + ctx.sstore(addr, key, U256::from(99)).unwrap(); + guard.commit(); + assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(99)); + + { + let _guard = ctx.checkpoint(); + ctx.sstore(addr, key, U256::from(1)).unwrap(); + } + assert_eq!(ctx.sload(addr, key).unwrap(), U256::from(99)); + }); + } +} diff --git a/crates/common/precompile-storage/src/types/array.rs b/crates/common/precompile-storage/src/types/array.rs new file mode 100644 index 0000000000..8bbaaeaaec --- /dev/null +++ b/crates/common/precompile-storage/src/types/array.rs @@ -0,0 +1,149 @@ +//! Fixed-size array handler for the storage traits. +//! +//! Fixed-size arrays `[T; N]` use Solidity-compatible array storage: +//! - **Base slot**: Arrays start directly at `base_slot` (not at keccak256) +//! - Small elements (`T::BYTES` ≤ 16) are packed; larger elements use full slots. + +use core::ops::{Index, IndexMut}; + +use alloy_primitives::{Address, U256}; + +use crate::{ + error::Result, + packing, + provider::{Handler, LayoutCtx, Storable, StorableType}, + types::{HandlerCache, Slot}, +}; + +// fixed-size arrays: [T; N] for primitive types T and sizes 1-32 +base_precompile_macros::storable_arrays!(); +// nested arrays: [[T; M]; N] for small primitive types +base_precompile_macros::storable_nested_arrays!(); + +/// Type-safe handler for accessing fixed-size arrays `[T; N]` in storage. +#[derive(Debug, Clone)] +pub struct ArrayHandler<'a, T: StorableType, const N: usize> { + base_slot: U256, + address: Address, + storage: crate::StorageCtx<'a>, + cache: HandlerCache>, +} + +impl<'a, T: StorableType, const N: usize> ArrayHandler<'a, T, N> { + /// Creates a new handler for the array at the given base slot and address. + #[inline] + pub const fn new(base_slot: U256, address: Address, storage: crate::StorageCtx<'a>) -> Self { + Self { base_slot, address, storage, cache: HandlerCache::new() } + } + + #[inline] + const fn as_slot(&self) -> Slot<'a, [T; N]> { + Slot::new(self.base_slot, self.address, self.storage) + } + + /// Returns the base storage slot where this array's data is stored. + #[inline] + pub const fn base_slot(&self) -> U256 { + self.base_slot + } + + /// Returns the array size (compile-time constant `N`). + #[inline] + pub const fn len(&self) -> usize { + N + } + + /// Returns whether the array is empty (`N == 0`). + #[inline] + pub const fn is_empty(&self) -> bool { + N == 0 + } + + /// Returns a handler for the element at the given index, or `None` if out of bounds. + #[inline] + pub fn at(&mut self, index: usize) -> Option<&T::Handler<'a>> { + if index >= N { + return None; + } + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + Some( + self.cache.get_or_insert(&index, || { + Self::compute_handler(base_slot, address, storage, index) + }), + ) + } + + #[inline] + fn compute_handler( + base_slot: U256, + address: Address, + storage: crate::StorageCtx<'a>, + index: usize, + ) -> T::Handler<'a> { + let (slot, layout_ctx) = if T::BYTES <= 16 { + let location = packing::calc_element_loc(index, T::BYTES); + ( + base_slot + U256::from(location.offset_slots), + LayoutCtx::packed(location.offset_bytes), + ) + } else { + (base_slot + U256::from(index * T::SLOTS), LayoutCtx::FULL) + }; + T::handle(slot, layout_ctx, address, storage) + } +} + +impl<'a, T: StorableType, const N: usize> Index for ArrayHandler<'a, T, N> { + type Output = T::Handler<'a>; + + fn index(&self, index: usize) -> &Self::Output { + assert!(index < N, "index out of bounds: {index} >= {N}"); + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + self.cache + .get_or_insert(&index, || Self::compute_handler(base_slot, address, storage, index)) + } +} + +impl<'a, T: StorableType, const N: usize> IndexMut for ArrayHandler<'a, T, N> { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + assert!(index < N, "index out of bounds: {index} >= {N}"); + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + self.cache + .get_or_insert_mut(&index, || Self::compute_handler(base_slot, address, storage, index)) + } +} + +impl Handler<[T; N]> for ArrayHandler<'_, T, N> +where + [T; N]: Storable, +{ + #[inline] + fn read(&self) -> Result<[T; N]> { + self.as_slot().read() + } + + #[inline] + fn write(&mut self, value: [T; N]) -> Result<()> { + self.as_slot().write(value) + } + + #[inline] + fn delete(&mut self) -> Result<()> { + self.as_slot().delete() + } + + #[inline] + fn t_read(&self) -> Result<[T; N]> { + self.as_slot().t_read() + } + + #[inline] + fn t_write(&mut self, value: [T; N]) -> Result<()> { + self.as_slot().t_write(value) + } + + #[inline] + fn t_delete(&mut self) -> Result<()> { + self.as_slot().t_delete() + } +} diff --git a/crates/common/precompile-storage/src/types/bytes_like.rs b/crates/common/precompile-storage/src/types/bytes_like.rs new file mode 100644 index 0000000000..8e951943bd --- /dev/null +++ b/crates/common/precompile-storage/src/types/bytes_like.rs @@ -0,0 +1,456 @@ +//! Bytes-like (`Bytes`, `String`) implementation for the storage traits. +//! +//! # Storage Layout +//! +//! **Short strings (≤31 bytes)** are stored inline in a single slot: +//! - Bytes 0..len: data (left-aligned) +//! - Byte 31 (LSB): length * 2 (bit 0 = 0 indicates short string) +//! +//! **Long strings (≥32 bytes)** use keccak256-based storage: +//! - Base slot: stores `length * 2 + 1` (bit 0 = 1 indicates long string) +//! - Data slots: stored at `keccak256(main_slot) + i` for each 32-byte chunk + +use alloc::{format, string::String, vec::Vec}; +use core::marker::PhantomData; + +use alloy_primitives::{Address, Bytes, U256, keccak256}; + +use crate::{ + error::{BasePrecompileError, Result}, + provider::{ + Handler, Layout, LayoutCtx, Storable, StorableType, StorageKey, StorageOps, + sealed::OnlyPrimitives, + }, + types::Slot, +}; + +impl StorableType for Bytes { + const LAYOUT: Layout = Layout::Slots(1); + const IS_DYNAMIC: bool = true; + type Handler<'a> = BytesLikeHandler<'a, Self>; + fn handle<'a>( + slot: U256, + _ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + BytesLikeHandler::new(slot, address, storage) + } +} + +impl StorableType for String { + const LAYOUT: Layout = Layout::Slots(1); + const IS_DYNAMIC: bool = true; + type Handler<'a> = BytesLikeHandler<'a, Self>; + fn handle<'a>( + slot: U256, + _ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + BytesLikeHandler::new(slot, address, storage) + } +} + +/// Handler for bytes-like types providing efficient length queries. +#[derive(Debug, Clone)] +pub struct BytesLikeHandler<'a, T> { + base_slot: U256, + address: Address, + storage: crate::StorageCtx<'a>, + _ty: PhantomData, +} + +impl<'a, T: Storable> BytesLikeHandler<'a, T> { + /// Creates a new handler for the bytes-like value at the given base slot. + #[inline] + pub const fn new(base_slot: U256, address: Address, storage: crate::StorageCtx<'a>) -> Self { + Self { base_slot, address, storage, _ty: PhantomData } + } + + #[inline] + const fn as_slot(&self) -> Slot<'a, T> { + Slot::new(self.base_slot, self.address, self.storage) + } + + /// Returns the byte length without loading all data (reads only the base slot). + #[inline] + pub fn len(&self) -> Result { + let base_value = Slot::::new(self.base_slot, self.address, self.storage).read()?; + let is_long = is_long_string(base_value); + calc_string_length(base_value, is_long) + } + + /// Returns whether the stored value is empty. + #[inline] + pub fn is_empty(&self) -> Result { + Ok(self.len()? == 0) + } +} + +impl Handler for BytesLikeHandler<'_, T> { + #[inline] + fn read(&self) -> Result { + self.as_slot().read() + } + #[inline] + fn write(&mut self, value: T) -> Result<()> { + self.as_slot().write(value) + } + #[inline] + fn delete(&mut self) -> Result<()> { + self.as_slot().delete() + } + #[inline] + fn t_read(&self) -> Result { + self.as_slot().t_read() + } + #[inline] + fn t_write(&mut self, value: T) -> Result<()> { + self.as_slot().t_write(value) + } + #[inline] + fn t_delete(&mut self) -> Result<()> { + self.as_slot().t_delete() + } +} + +impl Storable for Bytes { + #[inline] + fn load(storage: &S, slot: U256, ctx: LayoutCtx) -> Result { + debug_assert_eq!(ctx, LayoutCtx::FULL, "Bytes cannot be packed"); + load_bytes_like(storage, slot, |data| Ok(Self::from(data))) + } + + #[inline] + fn store(&self, storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + debug_assert_eq!(ctx, LayoutCtx::FULL, "Bytes cannot be packed"); + store_bytes_like(self.as_ref(), storage, slot) + } + + #[inline] + fn delete(storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + debug_assert_eq!(ctx, LayoutCtx::FULL, "Bytes cannot be packed"); + delete_bytes_like(storage, slot) + } +} + +impl Storable for String { + #[inline] + fn load(storage: &S, slot: U256, ctx: LayoutCtx) -> Result { + debug_assert_eq!(ctx, LayoutCtx::FULL, "String cannot be packed"); + load_bytes_like(storage, slot, |data| { + Self::from_utf8(data).map_err(|e| { + BasePrecompileError::Fatal(format!("Invalid UTF-8 in stored string: {e}")) + }) + }) + } + + #[inline] + fn store(&self, storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + debug_assert_eq!(ctx, LayoutCtx::FULL, "String cannot be packed"); + store_bytes_like(self.as_bytes(), storage, slot) + } + + #[inline] + fn delete(storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + debug_assert_eq!(ctx, LayoutCtx::FULL, "String cannot be packed"); + delete_bytes_like(storage, slot) + } +} + +impl OnlyPrimitives for String {} + +impl StorageKey for String { + #[inline] + fn as_storage_bytes(&self) -> impl AsRef<[u8]> { + self.as_bytes() + } + + #[inline] + fn mapping_slot(&self, slot: U256) -> U256 { + let mut buf = Vec::with_capacity(self.len() + 32); + buf.extend_from_slice(self.as_bytes()); + buf.extend_from_slice(&slot.to_be_bytes::<32>()); + U256::from_be_bytes(keccak256(buf).0) + } +} + +// -- HELPER FUNCTIONS --------------------------------------------------------- + +#[inline] +fn load_bytes_like(storage: &S, base_slot: U256, into: F) -> Result +where + S: StorageOps, + F: FnOnce(Vec) -> Result, +{ + let base_value = storage.load(base_slot)?; + let is_long = is_long_string(base_value); + let length = calc_string_length(base_value, is_long)?; + + if is_long { + let slot_start = calc_data_slot(base_slot); + let chunks = calc_chunks(length); + let mut data = Vec::new(); + + for i in 0..chunks { + let slot = slot_start + U256::from(i); + let chunk_value = storage.load(slot)?; + let chunk_bytes = chunk_value.to_be_bytes::<32>(); + let bytes_to_take = if i == chunks - 1 { length - (i * 32) } else { 32 }; + data.extend_from_slice(&chunk_bytes[..bytes_to_take]); + } + + into(data) + } else { + let bytes = base_value.to_be_bytes::<32>(); + into(bytes[..length].to_vec()) + } +} + +#[inline] +fn store_bytes_like(bytes: &[u8], storage: &mut S, base_slot: U256) -> Result<()> { + let length = bytes.len(); + if length <= 31 { + storage.store(base_slot, encode_short_string(bytes)) + } else { + storage.store(base_slot, encode_long_string_length(length))?; + let slot_start = calc_data_slot(base_slot); + let chunks = calc_chunks(length); + + for i in 0..chunks { + let slot = slot_start + U256::from(i); + let chunk_start = i * 32; + let chunk_end = (chunk_start + 32).min(length); + let chunk = &bytes[chunk_start..chunk_end]; + let mut chunk_bytes = [0u8; 32]; + chunk_bytes[..chunk.len()].copy_from_slice(chunk); + storage.store(slot, U256::from_be_bytes(chunk_bytes))?; + } + + Ok(()) + } +} + +#[inline] +fn delete_bytes_like(storage: &mut S, base_slot: U256) -> Result<()> { + let base_value = storage.load(base_slot)?; + let is_long = is_long_string(base_value); + + if is_long { + let length = calc_string_length(base_value, true)?; + let slot_start = calc_data_slot(base_slot); + let chunks = calc_chunks(length); + for i in 0..chunks { + storage.store(slot_start + U256::from(i), U256::ZERO)?; + } + } + + storage.store(base_slot, U256::ZERO) +} + +#[inline] +fn calc_data_slot(base_slot: U256) -> U256 { + U256::from_be_bytes(keccak256(base_slot.to_be_bytes::<32>()).0) +} + +#[inline] +const fn is_long_string(slot_value: U256) -> bool { + (slot_value.as_limbs()[0] as u8 & 1) != 0 +} + +#[inline] +fn calc_string_length(slot_value: U256, is_long: bool) -> Result { + if is_long { + let length_times_two: U256 = slot_value - U256::ONE; + let length_u256: U256 = length_times_two >> 1; + if length_u256 > U256::from(u32::MAX) { + return Err(BasePrecompileError::under_overflow()); + } + Ok(length_u256.to::()) + } else { + let bytes = slot_value.to_be_bytes::<32>(); + let length = (bytes[31] / 2) as usize; + if length > 31 { + return Err(BasePrecompileError::Fatal(format!( + "short string length {length} exceeds maximum of 31 bytes" + ))); + } + Ok(length) + } +} + +#[inline] +const fn calc_chunks(byte_length: usize) -> usize { + byte_length.div_ceil(32) +} + +#[inline] +fn encode_short_string(bytes: &[u8]) -> U256 { + let mut storage_bytes = [0u8; 32]; + storage_bytes[..bytes.len()].copy_from_slice(bytes); + storage_bytes[31] = (bytes.len() * 2) as u8; + U256::from_be_bytes(storage_bytes) +} + +#[inline] +fn encode_long_string_length(byte_length: usize) -> U256 { + U256::from(byte_length * 2 + 1) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::{hashmap::setup_storage, provider::Handler, storage_ctx::StorageCtx}; + + fn arb_safe_slot() -> impl Strategy { + any::<[u64; 4]>() + .prop_map(|limbs| U256::from_limbs(limbs) % (U256::MAX - U256::from(10000u64))) + } + + fn arb_short_string() -> impl Strategy { + prop_oneof![ + Just(String::new()), + "[a-zA-Z0-9]{1,31}", + "[\u{0041}-\u{005A}\u{4E00}-\u{4E19}]{1,10}", + ] + } + + fn arb_32byte_string() -> impl Strategy { + "[a-zA-Z0-9]{32}" + } + + fn arb_long_string() -> impl Strategy { + prop_oneof!["[a-zA-Z0-9]{33,100}", "[\u{0041}-\u{005A}\u{4E00}-\u{4E19}]{11,30}",] + } + + fn arb_short_bytes() -> impl Strategy { + prop::collection::vec(any::(), 0..=31).prop_map(Bytes::from) + } + + fn arb_long_bytes() -> impl Strategy { + prop::collection::vec(any::(), 33..=100).prop_map(Bytes::from) + } + + #[test] + fn test_calc_data_slot_matches_manual_keccak() { + let base_slot = U256::from(42u64); + let data_slot = calc_data_slot(base_slot); + let expected = U256::from_be_bytes(keccak256(base_slot.to_be_bytes::<32>()).0); + assert_eq!(data_slot, expected); + } + + #[test] + fn test_is_long_string_boundaries() { + let short_31_bytes = encode_short_string(&[b'a'; 31]); + assert!(!is_long_string(short_31_bytes)); + + let long_32_bytes = encode_long_string_length(32); + assert!(is_long_string(long_32_bytes)); + + let empty = encode_short_string(&[]); + assert!(!is_long_string(empty)); + } + + #[test] + fn test_calc_chunks_boundaries() { + assert_eq!(calc_chunks(0), 0); + assert_eq!(calc_chunks(1), 1); + assert_eq!(calc_chunks(32), 1); + assert_eq!(calc_chunks(33), 2); + assert_eq!(calc_chunks(64), 2); + assert_eq!(calc_chunks(65), 3); + } + + #[test] + fn test_calc_string_length_tampered() { + let malicious_slot = U256::from(0x0008000000000001u64); + assert!(is_long_string(malicious_slot)); + assert_eq!( + calc_string_length(malicious_slot, true), + Err(BasePrecompileError::under_overflow()) + ); + + let at_max = U256::from(u32::MAX as u64 * 2 + 1); + assert_eq!(calc_string_length(at_max, true), Ok(u32::MAX as usize)); + + let above_max = U256::from((u32::MAX as u64 + 1) * 2 + 1); + assert_eq!(calc_string_length(above_max, true), Err(BasePrecompileError::under_overflow())); + + let malicious_short = U256::from(0xFEu64); + assert!(!is_long_string(malicious_short)); + assert!(calc_string_length(malicious_short, false).is_err()); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(200))] + + #[test] + fn test_short_strings(s in arb_short_string(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = BytesLikeHandler::::new(base_slot, address, ctx); + slot.write(s.clone()).unwrap(); + let loaded = slot.read().unwrap(); + prop_assert_eq!(&s, &loaded); + slot.delete().unwrap(); + let after = slot.read().unwrap(); + prop_assert_eq!(after, String::new()); + Ok(()) + }).unwrap(); + } + + #[test] + fn test_long_strings(s in arb_long_string(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = BytesLikeHandler::::new(base_slot, address, ctx); + slot.write(s.clone()).unwrap(); + let loaded = slot.read().unwrap(); + prop_assert_eq!(&s, &loaded); + slot.delete().unwrap(); + let after = slot.read().unwrap(); + prop_assert_eq!(after, String::new()); + Ok(()) + }).unwrap(); + } + + #[test] + fn test_short_bytes(b in arb_short_bytes(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = BytesLikeHandler::::new(base_slot, address, ctx); + slot.write(b.clone()).unwrap(); + let loaded = slot.read().unwrap(); + prop_assert_eq!(&b, &loaded); + Ok(()) + }).unwrap(); + } + + #[test] + fn test_long_bytes(b in arb_long_bytes(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = BytesLikeHandler::::new(base_slot, address, ctx); + slot.write(b.clone()).unwrap(); + let loaded = slot.read().unwrap(); + prop_assert_eq!(&b, &loaded); + Ok(()) + }).unwrap(); + } + + #[test] + fn test_32byte_strings(s in arb_32byte_string(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = BytesLikeHandler::::new(base_slot, address, ctx); + slot.write(s.clone()).unwrap(); + let loaded = slot.read().unwrap(); + prop_assert_eq!(&s, &loaded); + Ok(()) + }).unwrap(); + } + } +} diff --git a/crates/common/precompile-storage/src/types/mapping.rs b/crates/common/precompile-storage/src/types/mapping.rs new file mode 100644 index 0000000000..6f80a2b2fe --- /dev/null +++ b/crates/common/precompile-storage/src/types/mapping.rs @@ -0,0 +1,182 @@ +//! Type-safe wrapper for EVM storage mappings (hash-based key-value storage). + +use core::{ + marker::PhantomData, + ops::{Index, IndexMut}, +}; + +use alloy_primitives::{Address, U256}; + +use crate::{ + provider::{Layout, LayoutCtx, StorableType, StorageKey}, + types::HandlerCache, +}; + +/// Marker type for EVM storage mappings. +#[derive(Debug, Clone)] +pub struct Mapping { + _key: PhantomData, + _value: PhantomData, +} + +/// Type-safe access wrapper for EVM storage mappings. +#[derive(Debug, Clone)] +pub struct MappingHandler<'a, K, V: StorableType> { + base_slot: U256, + address: Address, + storage: crate::StorageCtx<'a>, + cache: HandlerCache>, +} + +impl Default for Mapping { + fn default() -> Self { + Self { _key: PhantomData, _value: PhantomData } + } +} + +impl<'a, K, V: StorableType> MappingHandler<'a, K, V> { + /// Creates a new mapping with the given base slot and contract address. + #[inline] + pub const fn new(base_slot: U256, address: Address, storage: crate::StorageCtx<'a>) -> Self { + Self { base_slot, address, storage, cache: HandlerCache::new() } + } + + /// Returns the base storage slot for this mapping. + #[inline] + pub const fn slot(&self) -> U256 { + self.base_slot + } + + /// Returns a handler for the given key (immutable access, cached). + pub fn at(&self, key: &K) -> &V::Handler<'a> + where + K: StorageKey + Eq + Clone + Ord, + { + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + self.cache.get_or_insert(key, || { + V::handle(key.mapping_slot(base_slot), LayoutCtx::FULL, address, storage) + }) + } + + /// Returns a mutable handler for the given key (mutable access, cached). + pub fn at_mut(&mut self, key: &K) -> &mut V::Handler<'a> + where + K: StorageKey + Eq + Clone + Ord, + { + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + self.cache.get_or_insert_mut(key, || { + V::handle(key.mapping_slot(base_slot), LayoutCtx::FULL, address, storage) + }) + } +} + +impl<'a, K, V: StorableType> Index for MappingHandler<'a, K, V> +where + K: StorageKey + Eq + Clone + Ord, +{ + type Output = V::Handler<'a>; + + fn index(&self, key: K) -> &Self::Output { + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + self.cache.get_or_insert(&key, || { + V::handle(key.mapping_slot(base_slot), LayoutCtx::FULL, address, storage) + }) + } +} + +impl<'a, K, V: StorableType> IndexMut for MappingHandler<'a, K, V> +where + K: StorageKey + Eq + Clone + Ord, +{ + fn index_mut(&mut self, key: K) -> &mut Self::Output { + let (base_slot, address, storage) = (self.base_slot, self.address, self.storage); + self.cache.get_or_insert_mut(&key, || { + V::handle(key.mapping_slot(base_slot), LayoutCtx::FULL, address, storage) + }) + } +} + +impl StorableType for Mapping +where + V: StorableType, +{ + const LAYOUT: Layout = Layout::Slots(1); + type Handler<'a> = MappingHandler<'a, K, V>; + + fn handle<'a>( + slot: U256, + _ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + MappingHandler::new(slot, address, storage) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, U256, keccak256}; + + use super::*; + + fn old_mapping_slot>(key: K, slot: U256) -> U256 { + let key = key.as_ref(); + let mut buf = [0u8; 64]; + buf[32 - key.len()..32].copy_from_slice(key); + buf[32..].copy_from_slice(&slot.to_be_bytes::<32>()); + U256::from_be_bytes(keccak256(buf).0) + } + + #[test] + fn test_mapping_slot_encoding() { + let key = Address::from([0x11; 20]); + let base_slot = U256::from(42u64); + + let mut buf = [0u8; 64]; + buf[12..32].copy_from_slice(key.as_ref()); + buf[32..].copy_from_slice(&base_slot.to_be_bytes::<32>()); + let expected = U256::from_be_bytes(keccak256(buf).0); + let computed = key.mapping_slot(base_slot); + + assert_eq!(computed, expected); + } + + #[test] + fn test_mapping_slot_matches_old_impl() { + let slot = U256::from(99u64); + let addr = Address::from([0x33; 20]); + assert_eq!(addr.mapping_slot(slot), old_mapping_slot(addr.as_slice(), slot)); + + let b256 = B256::from([0x44; 32]); + assert_eq!(b256.mapping_slot(slot), old_mapping_slot(b256.as_slice(), slot)); + } + + #[test] + fn test_string_mapping_slot_matches_solidity_packed_encoding() { + let slot = U256::from(123u64); + let key = "ISIN".to_owned(); + let mut buf = key.as_bytes().to_vec(); + buf.extend_from_slice(&slot.to_be_bytes::<32>()); + + assert_eq!(key.mapping_slot(slot), U256::from_be_bytes(keccak256(buf).0)); + } + + #[test] + fn test_mapping_basic_properties() { + let address = Address::from([0x10; 20]); + let base_slot = U256::from(1u64); + let (mut storage, _) = crate::hashmap::setup_storage(); + crate::StorageCtx::enter(&mut storage, |ctx| { + let mapping = MappingHandler::::new(base_slot, address, ctx); + + let key = Address::from([0x20; 20]); + let slot1 = &mapping[key]; + let slot2 = &mapping[key]; + assert_eq!(slot1.slot(), slot2.slot()); + + let key1 = Address::from([0x21; 20]); + let key2 = Address::from([0x22; 20]); + assert_ne!(mapping[key1].slot(), mapping[key2].slot()); + }); + } +} diff --git a/crates/common/precompile-storage/src/types/mod.rs b/crates/common/precompile-storage/src/types/mod.rs new file mode 100644 index 0000000000..4c956213e3 --- /dev/null +++ b/crates/common/precompile-storage/src/types/mod.rs @@ -0,0 +1,76 @@ +//! Storable type system for EVM storage. +//! +//! Re-exports core traits from [`crate::provider`] and defines `HandlerCache`. + +mod array; +mod bytes_like; +mod mapping; +mod primitives; +mod set; +mod slot; +mod vec; + +use alloc::{boxed::Box, collections::BTreeMap}; +use core::cell::RefCell; + +pub use array::ArrayHandler; +pub use bytes_like::BytesLikeHandler; +pub use mapping::{Mapping, MappingHandler}; +pub use set::{Set, SetHandler}; +pub use slot::Slot; +pub use vec::VecHandler; + +/// Cache for computed handlers with stable references. +/// +/// Enables `Index` implementations on handlers by storing child handlers and +/// returning references that remain valid across insertions. +/// +/// INVARIANT: Once an entry is pushed, it must never be removed or replaced. +/// `get_or_insert` returns references into heap-allocated handlers that would +/// dangle if entries were evicted. +#[derive(Debug, Default)] +pub struct HandlerCache { + inner: RefCell>>, +} + +impl HandlerCache { + /// Creates a new empty handler cache. + pub const fn new() -> Self { + Self { inner: RefCell::new(BTreeMap::new()) } + } +} + +impl Clone for HandlerCache { + fn clone(&self) -> Self { + Self::new() + } +} + +impl HandlerCache { + /// Returns a reference to a lazily initialized handler for the given key. + pub fn get_or_insert(&self, key: &K, f: impl FnOnce() -> H) -> &H { + let mut cache = self.inner.borrow_mut(); + if let Some(boxed) = cache.get(key) { + // SAFETY: The returned reference intentionally outlives this `RefMut` guard. + // `Box` gives `H` a stable heap address, this cache never removes or replaces + // entries, and later `BTreeMap` inserts may move the `Box` pointer value but + // not the boxed `H` allocation. + return unsafe { &*(boxed.as_ref() as *const H) }; + } + cache.insert(key.clone(), Box::new(f())); + let boxed = cache.get(key).expect("handler cache was just populated"); + // SAFETY: See the safety note above. The newly inserted handler is also stored in + // an append-only entry whose boxed allocation remains stable after this borrow ends. + unsafe { &*(boxed.as_ref() as *const H) } + } + + /// Returns a mutable reference to a lazily initialized handler for the given key. + pub fn get_or_insert_mut(&mut self, key: &K, f: impl FnOnce() -> H) -> &mut H { + // Using get_mut() requires &mut self (exclusive access) — no borrow guard needed. + let cache = self.inner.get_mut(); + if !cache.contains_key(key) { + cache.insert(key.clone(), Box::new(f())); + } + cache.get_mut(key).expect("handler cache was just populated").as_mut() + } +} diff --git a/crates/common/precompile-storage/src/types/primitives.rs b/crates/common/precompile-storage/src/types/primitives.rs new file mode 100644 index 0000000000..7117f6554a --- /dev/null +++ b/crates/common/precompile-storage/src/types/primitives.rs @@ -0,0 +1,170 @@ +//! `StorableType`, `FromWord`, and `StorageKey` implementations for single-word primitives. +//! +//! Covers Rust integers, Alloy integers, Alloy fixed bytes, `bool`, and `Address`. + +use alloy_primitives::{Address, U256}; + +use crate::{ + provider::{ + FromWord, Layout, LayoutCtx, Packable, StorableType, StorageKey, sealed::OnlyPrimitives, + }, + types::Slot, +}; + +// Rust integers: (u)int8, (u)int16, (u)int32, (u)int64, (u)int128 +base_precompile_macros::storable_rust_ints!(); +// Alloy integers: U8, I8, U16, I16, U32, I32, U64, I64, U128, I128, U256, I256 +base_precompile_macros::storable_alloy_ints!(); +// Alloy fixed bytes: FixedBytes<1> .. FixedBytes<32> +base_precompile_macros::storable_alloy_bytes!(); + +// -- BOOL --------------------------------------------------------------------- + +impl StorableType for bool { + const LAYOUT: Layout = Layout::Bytes(1); + type Handler<'a> = Slot<'a, Self>; + fn handle<'a>( + slot: U256, + ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + Slot::new_with_ctx(slot, ctx, address, storage) + } +} + +impl OnlyPrimitives for bool {} +impl Packable for bool {} + +impl FromWord for bool { + #[inline] + fn to_word(&self) -> U256 { + if *self { U256::ONE } else { U256::ZERO } + } + #[inline] + fn from_word(word: U256) -> crate::error::Result { + Ok(!word.is_zero()) + } +} + +impl StorageKey for bool { + #[inline] + fn as_storage_bytes(&self) -> impl AsRef<[u8]> { + if *self { [1u8] } else { [0u8] } + } +} + +// -- ADDRESS ------------------------------------------------------------------ + +impl StorableType for Address { + const LAYOUT: Layout = Layout::Bytes(20); + type Handler<'a> = Slot<'a, Self>; + fn handle<'a>( + slot: U256, + ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + Slot::new_with_ctx(slot, ctx, address, storage) + } +} + +impl OnlyPrimitives for Address {} +impl Packable for Address {} + +impl FromWord for Address { + #[inline] + fn to_word(&self) -> U256 { + // Left-pad 20-byte address to 32 bytes (right-aligned in the word) + let mut bytes = [0u8; 32]; + bytes[12..].copy_from_slice(self.as_slice()); + U256::from_be_bytes(bytes) + } + #[inline] + fn from_word(word: U256) -> crate::error::Result { + // Take the low 20 bytes (right-aligned) + Ok(Self::from_slice(&word.to_be_bytes::<32>()[12..])) + } +} + +impl StorageKey for Address { + #[inline] + fn as_storage_bytes(&self) -> impl AsRef<[u8]> { + // Return the raw bytes as a fixed-size array (no unsized dereference) + let mut arr = [0u8; 20]; + arr.copy_from_slice(self.as_slice()); + arr + } +} + +// B256 = FixedBytes<32>: StorageKey and OnlyPrimitives are generated by storable_alloy_bytes!() +// We only need to add the type alias mapping here so that B256 can be used as a mapping key. +// (storable_alloy_bytes!() generates impls for FixedBytes, and B256 = FixedBytes<32>) + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + use crate::{ + hashmap::setup_storage, + provider::{Handler, LayoutCtx}, + storage_ctx::StorageCtx, + }; + + fn arb_safe_slot() -> impl Strategy { + any::<[u64; 4]>() + .prop_map(|limbs| U256::from_limbs(limbs) % (U256::MAX - U256::from(10000u64))) + } + + fn arb_address() -> impl Strategy { + any::<[u8; 20]>().prop_map(Address::from) + } + + // Generate property tests for all primitive storage types + base_precompile_macros::gen_storable_tests!(); + + proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + #[test] + fn test_address(addr in arb_address(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = Address::handle(base_slot, LayoutCtx::FULL, address, ctx); + + slot.write(addr).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(addr, loaded, "Address roundtrip failed"); + + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert_eq!(after_delete, Address::ZERO, "Address not zero after delete"); + + let word = addr.to_word(); + let recovered =
::from_word(word).unwrap(); + assert_eq!(addr, recovered, "Address EVM word roundtrip failed"); + }); + } + + #[test] + fn test_bool_values(b in any::(), base_slot in arb_safe_slot()) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut slot = bool::handle(base_slot, LayoutCtx::FULL, address, ctx); + + slot.write(b).unwrap(); + let loaded = slot.read().unwrap(); + assert_eq!(b, loaded, "Bool roundtrip failed for value: {b}"); + + slot.delete().unwrap(); + let after_delete = slot.read().unwrap(); + assert!(!after_delete, "Bool not false after delete"); + + let word = b.to_word(); + let recovered = ::from_word(word).unwrap(); + assert_eq!(b, recovered, "Bool EVM word roundtrip failed"); + }); + } + } +} diff --git a/crates/common/precompile-storage/src/types/set.rs b/crates/common/precompile-storage/src/types/set.rs new file mode 100644 index 0000000000..c8d4cd6803 --- /dev/null +++ b/crates/common/precompile-storage/src/types/set.rs @@ -0,0 +1,378 @@ +//! [`OpenZeppelin`](https://github.com/OpenZeppelin/openzeppelin-contracts) `EnumerableSet` implementation for EVM storage using Rust primitives. +//! +//! +//! # Storage Layout +//! +//! - **Values Vec**: A `Vec` storing all set elements at `keccak256(base_slot)` +//! - **Positions Mapping**: A `Mapping` at `base_slot + 1` (1-indexed, 0 = not present) + +use alloc::{ + collections::BTreeSet, + vec::{IntoIter, Vec}, +}; +use core::{fmt, ops::Deref, slice}; + +use alloy_primitives::{Address, U256}; + +use crate::{ + error::{BasePrecompileError, Result}, + provider::{Handler, Layout, LayoutCtx, Storable, StorableType, StorageKey, StorageOps}, + types::{MappingHandler, Slot, vec::VecHandler}, +}; + +/// Read-only snapshot of a set stored via [`SetHandler`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Set(Vec); + +impl Set { + /// Creates a new empty set. + pub const fn new() -> Self { + Self(Vec::new()) + } + + /// Creates a set from a vector already known to contain no duplicates. + pub const fn new_unchecked(vec: Vec) -> Self { + Self(vec) + } +} + +impl Deref for Set { + type Target = [T]; + fn deref(&self) -> &[T] { + &self.0 + } +} + +impl From> for Vec { + fn from(set: Set) -> Self { + set.0 + } +} + +impl From> for Set { + fn from(vec: Vec) -> Self { + let mut seen = BTreeSet::new(); + let mut deduped = Vec::new(); + for item in vec { + if seen.insert(item.clone()) { + deduped.push(item); + } + } + Self(deduped) + } +} + +impl FromIterator for Set { + fn from_iter>(iter: I) -> Self { + Self::from(iter.into_iter().collect::>()) + } +} + +impl IntoIterator for Set { + type Item = T; + type IntoIter = IntoIter; + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a, T> IntoIterator for &'a Set { + type Item = &'a T; + type IntoIter = slice::Iter<'a, T>; + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +/// Type-safe handler for accessing `Set` in storage. +pub struct SetHandler<'a, T> +where + T: Storable + StorageKey + Eq + Clone + Ord, +{ + values: VecHandler<'a, T>, + positions: MappingHandler<'a, T, u32>, + base_slot: U256, + address: Address, + storage: crate::StorageCtx<'a>, +} + +/// Set occupies 2 slots: slot 0 = Vec length, slot 1 = positions mapping base. +impl StorableType for Set +where + T: Storable + StorageKey + Eq + Clone + Ord, +{ + const LAYOUT: Layout = Layout::Slots(2); + const IS_DYNAMIC: bool = true; + type Handler<'a> = SetHandler<'a, T>; + + fn handle<'a>( + slot: U256, + _ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + SetHandler::new(slot, address, storage) + } +} + +impl Storable for Set +where + T: Storable + StorageKey + Eq + Clone + Ord, + for<'a> T::Handler<'a>: Handler, +{ + fn load(storage: &S, slot: U256, _ctx: LayoutCtx) -> Result { + let values: Vec = Vec::load(storage, slot, LayoutCtx::FULL)?; + Ok(Self(values)) + } + + fn store(&self, _storage: &mut S, _slot: U256, _ctx: LayoutCtx) -> Result<()> { + Err(BasePrecompileError::Fatal( + "Set must be stored via SetHandler::write() to maintain position invariants".into(), + )) + } + + fn delete(storage: &mut S, slot: U256, ctx: LayoutCtx) -> Result<()> { + let values: Vec = Vec::load(storage, slot, LayoutCtx::FULL)?; + for value in values { + let pos_slot = value.mapping_slot(slot + U256::ONE); + ::delete(storage, pos_slot, LayoutCtx::FULL)?; + } + as Storable>::delete(storage, slot, ctx) + } +} + +#[inline] +fn checked_position(index: usize) -> Result { + u32::try_from(index) + .ok() + .and_then(|i| i.checked_add(1)) + .ok_or_else(BasePrecompileError::under_overflow) +} + +impl<'a, T> SetHandler<'a, T> +where + T: Storable + StorageKey + Eq + Clone + Ord, +{ + /// Creates a new handler for the set at the given base slot. + pub fn new(base_slot: U256, address: Address, storage: crate::StorageCtx<'a>) -> Self { + Self { + values: VecHandler::new(base_slot, address, storage), + positions: MappingHandler::new(base_slot + U256::ONE, address, storage), + base_slot, + address, + storage, + } + } + + /// Returns the base storage slot for this set. + pub const fn base_slot(&self) -> U256 { + self.base_slot + } + + /// Returns the number of elements in the set. + pub fn len(&self) -> Result { + self.values.len() + } + + /// Returns whether the set is empty. + pub fn is_empty(&self) -> Result { + self.values.is_empty() + } + + /// Returns true if the value is in the set. + pub fn contains(&self, value: &T) -> Result + where + T: StorageKey + Eq + Clone + Ord, + { + self.positions.at(value).read().map(|pos| pos != 0) + } + + /// Inserts a value into the set. Returns `true` if newly inserted, `false` if already present. + pub fn insert(&mut self, value: T) -> Result + where + T: StorageKey + Eq + Clone + Ord, + T::Handler<'a>: Handler, + { + if self.contains(&value)? { + return Ok(false); + } + let length = self.values.len()?; + self.positions.at_mut(&value).write(checked_position(length)?)?; + self.values.push(value)?; + Ok(true) + } + + /// Removes a value from the set using swap-and-pop. Returns `true` if found and removed. + pub fn remove(&mut self, value: &T) -> Result + where + T: StorageKey + Eq + Clone + Ord, + T::Handler<'a>: Handler, + { + let position = self.positions.at(value).read()?; + if position == 0 { + return Ok(false); + } + + let len = self.values.len()?; + let last_index = len - 1; + let index = (position - 1) as usize; + + if index != last_index { + let last_value = self.values[last_index].read()?; + self.positions.at_mut(&last_value).write(position)?; + self.values[index].write(last_value)?; + } + + self.values[last_index].delete()?; + Slot::::new(self.values.len_slot(), self.address, self.storage) + .write(U256::from(last_index))?; + self.positions.at_mut(value).delete()?; + Ok(true) + } + + /// Returns the value at the given index, or `None` if out of bounds. + pub fn at(&self, index: usize) -> Result> + where + T::Handler<'a>: Handler, + { + if index >= self.len()? { + return Ok(None); + } + Ok(Some(self.values[index].read()?)) + } + + /// Reads a contiguous range of elements from the set. + pub fn read_range(&self, start: usize, end: usize) -> Result> + where + T::Handler<'a>: Handler, + { + let len = self.len()?; + let end = end.min(len); + let start = start.min(end); + let mut result = Vec::new(); + for i in start..end { + result.push(self.values[i].read()?); + } + Ok(result) + } +} + +impl<'a, T> Handler> for SetHandler<'a, T> +where + T: Storable + StorageKey + Eq + Clone + Ord, + for<'ctx> T::Handler<'ctx>: Handler, +{ + fn read(&self) -> Result> { + let len = self.len()?; + let mut vec = Vec::new(); + for i in 0..len { + vec.push(self.values[i].read()?); + } + Ok(Set(vec)) + } + + fn write(&mut self, value: Set) -> Result<()> { + let old_len = self.values.len()?; + let new_len = value.0.len(); + + for i in 0..old_len { + let old_value = self.values[i].read()?; + self.positions.at_mut(&old_value).delete()?; + } + + for (index, new_value) in value.0.into_iter().enumerate() { + self.positions.at_mut(&new_value).write(checked_position(index)?)?; + self.values[index].write(new_value)?; + } + + Slot::::new(self.values.len_slot(), self.address, self.storage) + .write(U256::from(new_len))?; + + for i in new_len..old_len { + self.values[i].delete()?; + } + Ok(()) + } + + fn delete(&mut self) -> Result<()> { + let len = self.len()?; + for i in 0..len { + let value = self.values[i].read()?; + self.positions.at_mut(&value).delete()?; + } + self.values.delete() + } + + fn t_read(&self) -> Result> { + unimplemented!("Set does not support transient storage") + } + fn t_write(&mut self, _: Set) -> Result<()> { + unimplemented!("Set does not support transient storage") + } + fn t_delete(&mut self) -> Result<()> { + unimplemented!("Set does not support transient storage") + } +} + +impl fmt::Debug for SetHandler<'_, T> +where + T: Storable + StorageKey + Eq + Clone + Ord + fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SetHandler").field("base_slot", &self.base_slot).finish() + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + + use super::*; + use crate::{hashmap::setup_storage, storage_ctx::StorageCtx}; + + #[test] + fn test_set_insert_contains_remove() { + let (mut storage, contract_addr) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let base = U256::from(500u64); + let mut handler = SetHandler::
::new(base, contract_addr, ctx); + + let a = Address::from([0x11; 20]); + let b = Address::from([0x22; 20]); + + assert!(!handler.contains(&a).unwrap()); + assert!(handler.insert(a).unwrap()); + assert!(!handler.insert(a).unwrap()); // duplicate + assert!(handler.contains(&a).unwrap()); + assert_eq!(handler.len().unwrap(), 1); + + assert!(handler.insert(b).unwrap()); + assert_eq!(handler.len().unwrap(), 2); + + assert!(handler.remove(&a).unwrap()); + assert!(!handler.contains(&a).unwrap()); + assert_eq!(handler.len().unwrap(), 1); + + assert!(!handler.remove(&a).unwrap()); // already removed + }); + } + + #[test] + fn test_set_read_write() { + let (mut storage, contract_addr) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let base = U256::from(600u64); + let mut handler = SetHandler::
::new(base, contract_addr, ctx); + + let addrs: Vec
= (0..5u8).map(|i| Address::from([i; 20])).collect(); + let set = Set::from(addrs.clone()); + handler.write(set).unwrap(); + + let loaded = handler.read().unwrap(); + assert_eq!(loaded.len(), addrs.len()); + for addr in &addrs { + assert!(handler.contains(addr).unwrap()); + } + }); + } +} diff --git a/crates/common/precompile-storage/src/types/slot.rs b/crates/common/precompile-storage/src/types/slot.rs new file mode 100644 index 0000000000..7aa1b6c5bf --- /dev/null +++ b/crates/common/precompile-storage/src/types/slot.rs @@ -0,0 +1,260 @@ +//! Type-safe wrapper for a single EVM storage slot. + +use core::marker::PhantomData; + +use alloy_primitives::{Address, U256}; + +use crate::{ + error::Result, + packing::FieldLocation, + provider::{Handler, LayoutCtx, Storable, StorableType, StorageOps}, + storage_ctx::StorageCtx, +}; + +/// Type-safe wrapper for a single EVM storage slot. +#[derive(Debug, Clone)] +pub struct Slot<'a, T> { + slot: U256, + ctx: LayoutCtx, + address: Address, + storage: StorageCtx<'a>, + _ty: PhantomData, +} + +impl<'a, T> Slot<'a, T> { + /// Creates a full-slot accessor at the given slot number and contract address. + #[inline] + pub const fn new(slot: U256, address: Address, storage: StorageCtx<'a>) -> Self { + Self { slot, ctx: LayoutCtx::FULL, address, storage, _ty: PhantomData } + } + + /// Creates a slot with an explicit [`LayoutCtx`] (for packed fields). + #[inline] + pub const fn new_with_ctx( + slot: U256, + ctx: LayoutCtx, + address: Address, + storage: StorageCtx<'a>, + ) -> Self { + Self { slot, ctx, address, storage, _ty: PhantomData } + } + + /// Creates a full-slot accessor at `base_slot + offset_slots`. + #[inline] + pub const fn new_at_offset( + base_slot: U256, + offset_slots: usize, + address: Address, + storage: StorageCtx<'a>, + ) -> Self { + Self { + slot: base_slot.saturating_add(U256::from_limbs([offset_slots as u64, 0, 0, 0])), + ctx: LayoutCtx::FULL, + address, + storage, + _ty: PhantomData, + } + } + + /// Creates a packed-field accessor using a [`FieldLocation`] from `#[derive(Storable)]`. + #[inline] + pub fn new_at_loc( + base_slot: U256, + loc: FieldLocation, + address: Address, + storage: StorageCtx<'a>, + ) -> Self + where + T: StorableType, + { + debug_assert!(T::IS_PACKABLE, "`fn new_at_loc` can only be used with packable types"); + Self { + slot: base_slot.saturating_add(U256::from_limbs([loc.offset_slots as u64, 0, 0, 0])), + ctx: LayoutCtx::packed(loc.offset_bytes), + address, + storage, + _ty: PhantomData, + } + } + + /// Returns the storage slot number. + #[inline] + pub const fn slot(&self) -> U256 { + self.slot + } + + /// Returns the byte offset within the slot (for packed fields), or `None` for full-slot. + #[inline] + pub const fn offset(&self) -> Option { + self.ctx.packed_offset() + } +} + +impl StorageOps for Slot<'_, T> { + fn load(&self, slot: U256) -> Result { + self.storage.sload(self.address, slot) + } + + fn store(&mut self, slot: U256, value: U256) -> Result<()> { + self.storage.sstore(self.address, slot, value) + } +} + +struct TransientOps<'a> { + address: Address, + storage: StorageCtx<'a>, +} + +impl StorageOps for TransientOps<'_> { + fn load(&self, slot: U256) -> Result { + self.storage.tload(self.address, slot) + } + + fn store(&mut self, slot: U256, value: U256) -> Result<()> { + self.storage.tstore(self.address, slot, value) + } +} + +impl<'a, T: Storable> Slot<'a, T> { + const fn transient(&self) -> TransientOps<'a> { + TransientOps { address: self.address, storage: self.storage } + } +} + +impl Handler for Slot<'_, T> { + #[inline] + fn read(&self) -> Result { + T::load(self, self.slot, self.ctx) + } + + #[inline] + fn write(&mut self, value: T) -> Result<()> { + value.store(self, self.slot, self.ctx) + } + + #[inline] + fn delete(&mut self) -> Result<()> { + T::delete(self, self.slot, self.ctx) + } + + #[inline] + fn t_read(&self) -> Result { + T::load(&self.transient(), self.slot, self.ctx) + } + + #[inline] + fn t_write(&mut self, value: T) -> Result<()> { + value.store(&mut self.transient(), self.slot, self.ctx) + } + + #[inline] + fn t_delete(&mut self) -> Result<()> { + T::delete(&mut self.transient(), self.slot, self.ctx) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::B256; + use proptest::prelude::*; + + use super::*; + use crate::{hashmap::setup_storage, provider::StorageKey}; + + fn arb_u256() -> impl Strategy { + any::<[u64; 4]>().prop_map(U256::from_limbs) + } + + #[test] + fn test_slot_size() { + assert_eq!(size_of::>(), 72); + assert_eq!(size_of::>(), 72); + assert_eq!(size_of::>(), 72); + } + + #[test] + fn test_slot_read_write_types() -> crate::error::Result<()> { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut u256_slot = Slot::::new(U256::ZERO, address, ctx); + let val = U256::from(42u64); + u256_slot.write(val)?; + assert_eq!(u256_slot.read()?, val); + + let mut addr_slot = Slot::
::new(U256::ONE, address, ctx); + let test_addr = Address::from([0xab; 20]); + addr_slot.write(test_addr)?; + assert_eq!(addr_slot.read()?, test_addr); + + let mut bool_slot = Slot::::new(U256::from(2), address, ctx); + bool_slot.write(true)?; + assert!(bool_slot.read()?); + + Ok(()) + }) + } + + #[test] + fn test_transient_persistence_isolation() -> crate::error::Result<()> { + let (mut storage, address) = setup_storage(); + let slot_num = U256::from(7u64); + let t_value = U256::from(100u64); + let s_value = U256::from(200u64); + + StorageCtx::enter(&mut storage, |ctx| -> crate::error::Result<()> { + let mut slot = Slot::::new(slot_num, address, ctx); + slot.write(s_value)?; + slot.t_write(t_value)?; + assert_eq!(slot.read()?, s_value); + assert_eq!(slot.t_read()?, t_value); + Ok(()) + })?; + + storage.clear_transient(); + + StorageCtx::enter(&mut storage, |ctx| { + let slot = Slot::::new(slot_num, address, ctx); + assert_eq!(slot.read()?, s_value); + assert_eq!(slot.t_read()?, U256::ZERO); + Ok(()) + }) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(200))] + + #[test] + fn proptest_slot_isolation( + s1 in arb_u256(), s2 in arb_u256(), + v1 in arb_u256(), v2 in arb_u256() + ) { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| -> std::result::Result<(), TestCaseError> { + let mut slot1 = Slot::::new(s1, address, ctx); + let mut slot2 = Slot::::new(s2, address, ctx); + slot1.write(v1).unwrap(); + slot2.write(v2).unwrap(); + prop_assert_eq!(slot1.read().unwrap(), v1); + prop_assert_eq!(slot2.read().unwrap(), v2); + Ok(()) + })?; + } + } + + #[test] + fn test_slot_at_offset() -> crate::error::Result<()> { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let pair_key = B256::random(); + let base = pair_key.mapping_slot(U256::ZERO); + let test_addr = Address::from([0x22; 20]); + + let mut slot = Slot::
::new_at_offset(base, 0, address, ctx); + slot.write(test_addr)?; + assert_eq!(slot.read()?, test_addr); + slot.delete()?; + assert_eq!(slot.read()?, Address::ZERO); + Ok(()) + }) + } +} diff --git a/crates/common/precompile-storage/src/types/vec.rs b/crates/common/precompile-storage/src/types/vec.rs new file mode 100644 index 0000000000..9e8c833298 --- /dev/null +++ b/crates/common/precompile-storage/src/types/vec.rs @@ -0,0 +1,494 @@ +//! Dynamic array (`Vec`) implementation for the storage traits. +//! +//! # Storage Layout +//! +//! Vec uses Solidity-compatible dynamic array storage: +//! - **Base slot**: Stores the array length +//! - **Data slots**: Start at `keccak256(len_slot)`; elements packed where possible. + +use alloc::vec::Vec; +use core::ops::{Index, IndexMut}; + +use alloy_primitives::{Address, U256, keccak256}; + +use crate::{ + error::{BasePrecompileError, Result}, + packing::{PackedSlot, calc_element_loc, calc_packed_slot_count}, + provider::{Handler, Layout, LayoutCtx, Storable, StorableType, StorageOps}, + types::{HandlerCache, Slot}, +}; + +impl StorableType for Vec +where + T: Storable, +{ + const LAYOUT: Layout = Layout::Slots(1); + const IS_DYNAMIC: bool = true; + type Handler<'a> = VecHandler<'a, T>; + + fn handle<'a>( + slot: U256, + _ctx: LayoutCtx, + address: Address, + storage: crate::StorageCtx<'a>, + ) -> Self::Handler<'a> { + VecHandler::new(slot, address, storage) + } +} + +impl Storable for Vec +where + T: Storable, +{ + fn load(storage: &S, len_slot: U256, ctx: LayoutCtx) -> Result { + debug_assert_eq!(ctx, LayoutCtx::FULL, "Dynamic arrays cannot be packed"); + + let length = load_checked_len(storage, len_slot)?; + if length == 0 { + return Ok(Self::new()); + } + + let data_start = calc_data_slot(len_slot); + if T::BYTES <= 16 { + load_packed_elements(storage, data_start, length, T::BYTES) + } else { + load_unpacked_elements(storage, data_start, length) + } + } + + fn store(&self, storage: &mut S, len_slot: U256, ctx: LayoutCtx) -> Result<()> { + debug_assert_eq!(ctx, LayoutCtx::FULL, "Dynamic arrays cannot be packed"); + + storage.store(len_slot, U256::from(self.len()))?; + if self.is_empty() { + return Ok(()); + } + + let data_start = calc_data_slot(len_slot); + if T::BYTES <= 16 { + store_packed_elements(self, storage, data_start, T::BYTES) + } else { + store_unpacked_elements(self, storage, data_start) + } + } + + fn delete(storage: &mut S, len_slot: U256, ctx: LayoutCtx) -> Result<()> { + debug_assert_eq!(ctx, LayoutCtx::FULL, "Dynamic arrays cannot be packed"); + + let length = load_checked_len(storage, len_slot)?; + storage.store(len_slot, U256::ZERO)?; + + if length == 0 { + return Ok(()); + } + + let data_start = calc_data_slot(len_slot); + if T::BYTES <= 16 { + let slot_count = calc_packed_slot_count(length, T::BYTES); + for slot_idx in 0..slot_count { + storage.store(data_start + U256::from(slot_idx), U256::ZERO)?; + } + } else { + for elem_idx in 0..length { + let elem_slot = data_start + U256::from(elem_idx * T::SLOTS); + T::delete(storage, elem_slot, LayoutCtx::FULL)?; + } + } + + Ok(()) + } +} + +/// Type-safe handler for accessing `Vec` in storage. +#[derive(Debug, Clone)] +pub struct VecHandler<'a, T: Storable> { + len_slot: U256, + address: Address, + storage: crate::StorageCtx<'a>, + cache: HandlerCache>, +} + +impl Handler> for VecHandler<'_, T> +where + T: Storable, +{ + #[inline] + fn read(&self) -> Result> { + self.as_slot().read() + } + #[inline] + fn write(&mut self, value: Vec) -> Result<()> { + self.as_slot().write(value) + } + #[inline] + fn delete(&mut self) -> Result<()> { + self.as_slot().delete() + } + #[inline] + fn t_read(&self) -> Result> { + self.as_slot().t_read() + } + #[inline] + fn t_write(&mut self, value: Vec) -> Result<()> { + self.as_slot().t_write(value) + } + #[inline] + fn t_delete(&mut self) -> Result<()> { + self.as_slot().t_delete() + } +} + +impl<'a, T> VecHandler<'a, T> +where + T: Storable, +{ + /// Creates a new handler for the vector at the given length slot and contract address. + #[inline] + pub const fn new(len_slot: U256, address: Address, storage: crate::StorageCtx<'a>) -> Self { + Self { len_slot, address, storage, cache: HandlerCache::new() } + } + + const fn max_index() -> usize { + if T::BYTES <= 16 { u32::MAX as usize / T::BYTES } else { u32::MAX as usize / T::SLOTS } + } + + /// Returns the slot that stores the vector length. + #[inline] + pub const fn len_slot(&self) -> U256 { + self.len_slot + } + + /// Returns the slot where element data begins (`keccak256(len_slot)`). + #[inline] + pub fn data_slot(&self) -> U256 { + calc_data_slot(self.len_slot) + } + + #[inline] + const fn as_slot(&self) -> Slot<'a, Vec> { + Slot::new(self.len_slot, self.address, self.storage) + } + + /// Returns the number of elements in the vector. + #[inline] + pub fn len(&self) -> Result { + let slot = Slot::::new(self.len_slot, self.address, self.storage); + load_checked_len(&slot, self.len_slot) + } + + /// Returns whether the vector is empty. + #[inline] + pub fn is_empty(&self) -> Result { + Ok(self.len()? == 0) + } + + #[inline] + fn compute_handler( + data_start: U256, + address: Address, + storage: crate::StorageCtx<'a>, + index: usize, + ) -> T::Handler<'a> { + let (slot, layout_ctx) = if T::BYTES <= 16 { + let location = calc_element_loc(index, T::BYTES); + ( + data_start + U256::from(location.offset_slots), + LayoutCtx::packed(location.offset_bytes), + ) + } else { + (data_start + U256::from(index * T::SLOTS), LayoutCtx::FULL) + }; + T::handle(slot, layout_ctx, address, storage) + } + + /// Returns a handler for the element at the given index, or `None` if out of bounds. + pub fn at(&self, index: usize) -> Result>> { + if index >= self.len()? { + return Ok(None); + } + let (data_start, address, storage) = (self.data_slot(), self.address, self.storage); + Ok(Some( + self.cache.get_or_insert(&index, || { + Self::compute_handler(data_start, address, storage, index) + }), + )) + } + + /// Pushes a new element to the end of the vector. + #[inline] + pub fn push(&self, value: T) -> Result<()> + where + T: Storable, + T::Handler<'a>: Handler, + { + let length = self.len()?; + if length >= Self::max_index() { + return Err(BasePrecompileError::Fatal("Vec is at max capacity".into())); + } + let mut elem_slot = + Self::compute_handler(self.data_slot(), self.address, self.storage, length); + elem_slot.write(value)?; + let mut length_slot = Slot::::new(self.len_slot, self.address, self.storage); + length_slot.write(U256::from(length + 1)) + } + + /// Pops the last element from the vector. Returns `None` if empty. + #[inline] + pub fn pop(&self) -> Result> + where + T: Storable, + T::Handler<'a>: Handler, + { + let length = self.len()?; + if length == 0 { + return Ok(None); + } + let last_index = length - 1; + let mut elem_slot = + Self::compute_handler(self.data_slot(), self.address, self.storage, last_index); + let element = elem_slot.read()?; + elem_slot.delete()?; + let mut length_slot = Slot::::new(self.len_slot, self.address, self.storage); + length_slot.write(U256::from(last_index))?; + Ok(Some(element)) + } +} + +impl<'a, T> Index for VecHandler<'a, T> +where + T: Storable, +{ + type Output = T::Handler<'a>; + fn index(&self, index: usize) -> &Self::Output { + let (data_start, address, storage) = (self.data_slot(), self.address, self.storage); + self.cache + .get_or_insert(&index, || Self::compute_handler(data_start, address, storage, index)) + } +} + +impl<'a, T> IndexMut for VecHandler<'a, T> +where + T: Storable, +{ + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + let (data_start, address, storage) = (self.data_slot(), self.address, self.storage); + self.cache.get_or_insert_mut(&index, || { + Self::compute_handler(data_start, address, storage, index) + }) + } +} + +#[inline] +fn load_checked_len(storage: &S, slot: U256) -> Result { + let raw = storage.load(slot)?; + if raw > U256::from(u32::MAX) { + return Err(BasePrecompileError::under_overflow()); + } + Ok(raw.to::()) +} + +#[inline] +pub(crate) fn calc_data_slot(len_slot: U256) -> U256 { + U256::from_be_bytes(keccak256(len_slot.to_be_bytes::<32>()).0) +} + +fn load_packed_elements( + storage: &S, + data_start: U256, + length: usize, + byte_count: usize, +) -> Result> +where + T: Storable, + S: StorageOps, +{ + let elements_per_slot = 32 / byte_count; + let slot_count = calc_packed_slot_count(length, byte_count); + let mut result = Vec::new(); + let mut current_offset = 0; + + for slot_idx in 0..slot_count { + let slot_addr = data_start + U256::from(slot_idx); + let slot_value = storage.load(slot_addr)?; + let slot_packed = PackedSlot(slot_value); + + let elements_in_this_slot = if slot_idx == slot_count - 1 { + length - (slot_idx * elements_per_slot) + } else { + elements_per_slot + }; + + for _ in 0..elements_in_this_slot { + let elem = T::load(&slot_packed, slot_addr, LayoutCtx::packed(current_offset))?; + result.push(elem); + current_offset += byte_count; + if current_offset >= 32 { + current_offset = 0; + } + } + + current_offset = 0; + } + + Ok(result) +} + +fn store_packed_elements( + elements: &[T], + storage: &mut S, + data_start: U256, + byte_count: usize, +) -> Result<()> +where + T: Storable, + S: StorageOps, +{ + let elements_per_slot = 32 / byte_count; + let slot_count = calc_packed_slot_count(elements.len(), byte_count); + + for slot_idx in 0..slot_count { + let slot_addr = data_start + U256::from(slot_idx); + let start_elem = slot_idx * elements_per_slot; + let end_elem = (start_elem + elements_per_slot).min(elements.len()); + let slot_value = build_packed_slot(&elements[start_elem..end_elem], byte_count)?; + storage.store(slot_addr, slot_value)?; + } + + Ok(()) +} + +fn build_packed_slot(elements: &[T], byte_count: usize) -> Result +where + T: Storable, +{ + let mut slot_value = PackedSlot(U256::ZERO); + let mut current_offset = 0; + for elem in elements { + elem.store(&mut slot_value, U256::ZERO, LayoutCtx::packed(current_offset))?; + current_offset += byte_count; + } + Ok(slot_value.0) +} + +fn load_unpacked_elements(storage: &S, data_start: U256, length: usize) -> Result> +where + T: Storable, + S: StorageOps, +{ + let mut result = Vec::new(); + for index in 0..length { + let elem_slot = data_start + U256::from(index * T::SLOTS); + result.push(T::load(storage, elem_slot, LayoutCtx::FULL)?); + } + Ok(result) +} + +fn store_unpacked_elements(elements: &[T], storage: &mut S, data_start: U256) -> Result<()> +where + T: Storable, + S: StorageOps, +{ + for (idx, elem) in elements.iter().enumerate() { + let elem_slot = data_start + U256::from(idx * T::SLOTS); + elem.store(storage, elem_slot, LayoutCtx::FULL)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{hashmap::setup_storage, packing::gen_word_from, storage_ctx::StorageCtx}; + + #[test] + fn test_vec_empty_roundtrip() { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let len_slot = U256::from(100u64); + let mut slot = Slot::>::new(len_slot, address, ctx); + slot.write(vec![]).unwrap(); + let loaded: Vec = slot.read().unwrap(); + assert!(loaded.is_empty()); + }); + } + + #[test] + fn test_vec_u8_roundtrip() { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let len_slot = U256::from(200u64); + let data = vec![10u8, 20, 30, 40, 50]; + let mut slot = Slot::>::new(len_slot, address, ctx); + slot.write(data.clone()).unwrap(); + assert_eq!(slot.read().unwrap(), data); + slot.delete().unwrap(); + let loaded: Vec = slot.read().unwrap(); + assert!(loaded.is_empty()); + }); + } + + #[test] + fn test_vec_u8_explicit_slot_packing() { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let len_slot = U256::from(2000u64); + let data = vec![10u8, 20, 30, 40, 50]; + VecHandler::::new(len_slot, address, ctx).write(data).unwrap(); + + let length = U256::handle(len_slot, LayoutCtx::FULL, address, ctx).read().unwrap(); + assert_eq!(length, U256::from(5u64)); + + let data_start = calc_data_slot(len_slot); + let slot_data = U256::handle(data_start, LayoutCtx::FULL, address, ctx).read().unwrap(); + let expected = gen_word_from(&["0x32", "0x28", "0x1e", "0x14", "0x0a"]); + assert_eq!(slot_data, expected, "u8 packing should match Solidity layout"); + }); + } + + #[test] + fn test_vec_data_slot_derivation() { + let len_slot = U256::from(42u64); + let data_slot = calc_data_slot(len_slot); + let expected = U256::from_be_bytes(keccak256(len_slot.to_be_bytes::<32>()).0); + assert_eq!(data_slot, expected); + } + + #[test] + fn test_vec_handler_push_pop() { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let len_slot = U256::from(300u64); + let handler = VecHandler::::new(len_slot, address, ctx); + + let vals: Vec = (0..5).map(U256::from).collect(); + for &v in &vals { + handler.push(v).unwrap(); + } + assert_eq!(handler.len().unwrap(), 5); + + for &v in vals.iter().rev() { + assert_eq!(handler.pop().unwrap(), Some(v)); + } + assert_eq!(handler.len().unwrap(), 0); + assert_eq!(handler.pop().unwrap(), None); + }); + } + + #[test] + fn test_vec_length_overflow() { + let (mut storage, address) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut len_slot = Slot::::new(U256::ZERO, address, ctx); + let handler = VecHandler::::new(U256::ZERO, address, ctx); + + len_slot.write(U256::from(0x0004000000000000u64)).unwrap(); + assert_eq!(handler.len(), Err(BasePrecompileError::under_overflow())); + + len_slot.write(U256::from(u32::MAX)).unwrap(); + assert_eq!(handler.len().unwrap(), u32::MAX as usize); + + len_slot.write(U256::from(u32::MAX as u64 + 1)).unwrap(); + assert_eq!(handler.len(), Err(BasePrecompileError::under_overflow())); + }); + } +} diff --git a/crates/common/precompile-storage/tests/contract.rs b/crates/common/precompile-storage/tests/contract.rs new file mode 100644 index 0000000000..d5bc476be1 --- /dev/null +++ b/crates/common/precompile-storage/tests/contract.rs @@ -0,0 +1,522 @@ +//! End-to-end test: exercises the `#[contract]` macro with `HashMapStorageProvider`. +//! +//! Validates that the macro generates correct storage layout, +//! typed getter/setter fields work round-trip, and collision detection fires. +use alloy_primitives::{Address, U256, address, keccak256}; +use base_precompile_macros::contract; +use base_precompile_storage::{Handler, Mapping, StorageCtx, StorageKey, setup_storage}; + +const TEST_ADDR: Address = address!("0000000000000000000000000000000000001234"); + +fn data_slot(slot: U256) -> U256 { + U256::from_be_bytes(keccak256(slot.to_be_bytes::<32>()).0) +} + +fn erc7201_root(id: &str) -> U256 { + let id_hash = U256::from_be_bytes(keccak256(id.as_bytes()).0); + let shifted = id_hash.checked_sub(U256::ONE).unwrap(); + let root = U256::from_be_bytes(keccak256(shifted.to_be_bytes::<32>()).0); + root & (U256::MAX - U256::from(0xffu64)) +} + +fn word_from_chunk(data: &[u8], chunk_index: usize) -> U256 { + let mut word = [0u8; 32]; + let start = chunk_index * 32; + let end = (start + 32).min(data.len()); + word[..end - start].copy_from_slice(&data[start..end]); + U256::from_be_bytes(word) +} + +/// A minimal token storage layout for integration testing. +#[contract(addr = TEST_ADDR)] +pub struct TestToken { + pub owner: Address, + pub total_supply: U256, + pub balances: Mapping, + pub allowances: Mapping>, +} + +#[test] +fn test_contract_macro_basic_roundtrip() { + let (mut storage, _) = setup_storage(); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = TestToken::new(ctx); + + let alice = Address::from([0xaa; 20]); + let bob = Address::from([0xbb; 20]); + + // Write owner and total_supply + token.owner.write(alice).unwrap(); + token.total_supply.write(U256::from(1_000_000u64)).unwrap(); + + // Read back + assert_eq!(token.owner.read().unwrap(), alice); + assert_eq!(token.total_supply.read().unwrap(), U256::from(1_000_000u64)); + + // Write and read a mapping entry + token.balances.at_mut(&alice).write(U256::from(500u64)).unwrap(); + assert_eq!(token.balances.at(&alice).read().unwrap(), U256::from(500u64)); + assert_eq!(token.balances.at(&bob).read().unwrap(), U256::ZERO); + + // Nested mapping + token.allowances[alice][bob].write(U256::from(100u64)).unwrap(); + assert_eq!(token.allowances[alice][bob].read().unwrap(), U256::from(100u64)); + assert_eq!(token.allowances[bob][alice].read().unwrap(), U256::ZERO); + }); +} + +#[test] +fn test_contract_slots_are_deterministic() { + // Verify that the generated slot constants are stable across runs. + // owner is field 0 → slot 0, total_supply is field 1 → slot 1. + assert_eq!(slots::OWNER, U256::ZERO); + assert_eq!(slots::TOTAL_SUPPLY, U256::from(1u64)); + assert_eq!(slots::BALANCES, U256::from(2u64)); + assert_eq!(slots::ALLOWANCES, U256::from(3u64)); +} + +#[test] +fn test_contract_mapping_slot_derivation() { + // Verify that mapping slots match the Solidity keccak256 derivation. + let alice = Address::from([0xaa; 20]); + let expected = alice.mapping_slot(slots::BALANCES); + + let (mut storage, _) = setup_storage(); + StorageCtx::enter(&mut storage, |ctx| { + let mut token = TestToken::new(ctx); + let write_value = U256::from(42u64); + token.balances.at_mut(&alice).write(write_value).unwrap(); + + // Verify the raw storage slot matches the expected derivation. + let raw = ctx.sload(TEST_ADDR, expected).unwrap(); + assert_eq!(raw, write_value); + }); +} + +#[test] +fn test_contract_multiple_instances_independent() { + let (mut storage1, _) = setup_storage(); + let (mut storage2, _) = setup_storage(); + + let alice = Address::from([0xaa; 20]); + + StorageCtx::enter(&mut storage1, |ctx| { + let mut t1 = TestToken::new(ctx); + t1.balances.at_mut(&alice).write(U256::from(100u64)).unwrap(); + }); + + StorageCtx::enter(&mut storage2, |ctx| { + let t2 = TestToken::new(ctx); + // storage2 is independent — balance should be zero. + assert_eq!(t2.balances.at(&alice).read().unwrap(), U256::ZERO); + }); +} + +mod namespaced_layout { + use alloy_primitives::{Address, U256, address, uint}; + use base_precompile_macros::{Storable, contract}; + use base_precompile_storage::{Handler, Mapping, StorageCtx, StorageKey, setup_storage}; + + use super::{data_slot, word_from_chunk}; + + const NAMESPACED_ADDR: Address = address!("0000000000000000000000000000000000004321"); + const EXPECTED_ROOT: U256 = + uint!(0x50861ae81a7f4392b927efbaeecf8f091f3bd39245aa45ea91499a137b8b3100_U256); + + /// A storage section embedded into the token storage layout. + #[derive(Debug, Clone, Storable)] + struct PolicyNamespace { + label: String, + balances: Mapping, + checkpoints: [U256; 3], + packed_flags: [u16; 20], + amounts: Vec, + } + + /// Token storage with an embedded policy section rooted at the ERC-7201 namespace. + #[contract(addr = NAMESPACED_ADDR)] + pub struct NamespacedStorage { + pub admin: Address, + #[namespace("b20.policy")] + pub policy: PolicyNamespace, + pub total_supply: U256, + #[namespace("b20.policy")] + pub policy_owner: Address, + } + + #[test] + fn namespace_root_and_offsets_are_deterministic() { + assert_eq!(slots::ADMIN, U256::ZERO); + assert_eq!(slots::POLICY, EXPECTED_ROOT); + assert_eq!(slots::TOTAL_SUPPLY, U256::ONE); + assert_eq!( + slots::POLICY_OWNER, + EXPECTED_ROOT + U256::from(__packing_policy_namespace::SLOT_COUNT) + ); + } + + #[test] + fn namespaced_struct_field_handles_dynamic_mapping_and_array_storage() { + let (mut storage, _) = setup_storage(); + let owner = Address::from([0xaa; 20]); + let policy_owner = Address::from([0xcc; 20]); + let long_label = + "namespaced-string-storage-value-that-spans-more-than-one-word-for-layout".to_owned(); + assert!(long_label.len() > 64); + let amounts = vec![U256::from(11), U256::from(22), U256::from(33)]; + let policy_value = PolicyNamespace { + label: long_label.clone(), + balances: Mapping::default(), + checkpoints: [U256::from(1), U256::from(2), U256::from(3)], + packed_flags: [0; 20], + amounts: amounts.clone(), + }; + let _ = ( + &policy_value.label, + &policy_value.balances, + &policy_value.checkpoints, + &policy_value.packed_flags, + &policy_value.amounts, + ); + + StorageCtx::enter(&mut storage, |ctx| { + let mut layout = NamespacedStorage::new(ctx); + layout.admin.write(owner).unwrap(); + layout.policy.label.write(long_label.clone()).unwrap(); + layout.policy.balances.at_mut(&owner).write(U256::from(500)).unwrap(); + layout.policy.checkpoints.write([U256::from(1), U256::from(2), U256::from(3)]).unwrap(); + layout.policy.packed_flags[0].write(0x1111).unwrap(); + layout.policy.packed_flags[16].write(0x2222).unwrap(); + layout.policy.amounts.write(amounts.clone()).unwrap(); + layout.total_supply.write(U256::from(1_000)).unwrap(); + layout.policy_owner.write(policy_owner).unwrap(); + + assert_eq!(layout.admin.read().unwrap(), owner); + assert_eq!(layout.policy.label.read().unwrap(), long_label); + assert_eq!(layout.policy.balances.at(&owner).read().unwrap(), U256::from(500)); + assert_eq!(layout.policy.checkpoints[2].read().unwrap(), U256::from(3)); + assert_eq!(layout.policy.packed_flags[0].read().unwrap(), 0x1111); + assert_eq!(layout.policy.packed_flags[16].read().unwrap(), 0x2222); + assert_eq!(layout.policy.amounts.read().unwrap(), amounts); + assert_eq!(layout.total_supply.read().unwrap(), U256::from(1_000)); + assert_eq!(layout.policy_owner.read().unwrap(), policy_owner); + + let label_slot = + slots::POLICY + U256::from(__packing_policy_namespace::LABEL_LOC.offset_slots); + let balance_slot = owner.mapping_slot( + slots::POLICY + U256::from(__packing_policy_namespace::BALANCES_LOC.offset_slots), + ); + let checkpoints_slot = slots::POLICY + + U256::from(__packing_policy_namespace::CHECKPOINTS_LOC.offset_slots); + let packed_flags_slot = slots::POLICY + + U256::from(__packing_policy_namespace::PACKED_FLAGS_LOC.offset_slots); + let amounts_slot = + slots::POLICY + U256::from(__packing_policy_namespace::AMOUNTS_LOC.offset_slots); + + assert_eq!(ctx.sload(NAMESPACED_ADDR, balance_slot).unwrap(), U256::from(500)); + assert_eq!( + ctx.sload(NAMESPACED_ADDR, slots::ADMIN).unwrap(), + U256::from_be_bytes({ + let mut word = [0u8; 32]; + word[12..].copy_from_slice(owner.as_slice()); + word + }) + ); + assert_eq!(ctx.sload(NAMESPACED_ADDR, slots::TOTAL_SUPPLY).unwrap(), U256::from(1_000)); + assert_eq!( + ctx.sload(NAMESPACED_ADDR, slots::POLICY_OWNER).unwrap(), + U256::from_be_bytes({ + let mut word = [0u8; 32]; + word[12..].copy_from_slice(policy_owner.as_slice()); + word + }) + ); + + assert_eq!( + ctx.sload(NAMESPACED_ADDR, label_slot).unwrap(), + U256::from(long_label.len() * 2 + 1) + ); + let label_data_slot = data_slot(label_slot); + for chunk_index in 0..long_label.len().div_ceil(32) { + assert_eq!( + ctx.sload(NAMESPACED_ADDR, label_data_slot + U256::from(chunk_index)).unwrap(), + word_from_chunk(long_label.as_bytes(), chunk_index) + ); + } + + assert_eq!(ctx.sload(NAMESPACED_ADDR, checkpoints_slot).unwrap(), U256::from(1)); + assert_eq!( + ctx.sload(NAMESPACED_ADDR, checkpoints_slot + U256::ONE).unwrap(), + U256::from(2) + ); + assert_eq!( + ctx.sload(NAMESPACED_ADDR, checkpoints_slot + U256::from(2)).unwrap(), + U256::from(3) + ); + + let packed_first_slot = ctx.sload(NAMESPACED_ADDR, packed_flags_slot).unwrap(); + let packed_second_slot = + ctx.sload(NAMESPACED_ADDR, packed_flags_slot + U256::ONE).unwrap(); + assert_eq!(packed_first_slot & U256::from(0xffff), U256::from(0x1111)); + assert_eq!(packed_second_slot & U256::from(0xffff), U256::from(0x2222)); + + assert_eq!(ctx.sload(NAMESPACED_ADDR, amounts_slot).unwrap(), U256::from(3)); + let amounts_data_slot = data_slot(amounts_slot); + assert_eq!(ctx.sload(NAMESPACED_ADDR, amounts_data_slot).unwrap(), U256::from(11)); + assert_eq!( + ctx.sload(NAMESPACED_ADDR, amounts_data_slot + U256::ONE).unwrap(), + U256::from(22) + ); + assert_eq!( + ctx.sload(NAMESPACED_ADDR, amounts_data_slot + U256::from(2)).unwrap(), + U256::from(33) + ); + }); + } +} + +mod type_namespaced_layouts { + use alloy_primitives::{Address, U256, address}; + use base_precompile_macros::{Storable, contract}; + use base_precompile_storage::{ + Handler, Mapping, StorableType, StorageCtx, StorageKey, setup_storage, + }; + + use super::erc7201_root; + + const TYPE_NAMESPACE_ADDR: Address = address!("0000000000000000000000000000000000002468"); + + /// Core B-20 storage rooted at the canonical B-20 namespace. + #[derive(Debug, Clone, Storable)] + #[namespace("b20")] + struct B20Storage { + total_supply: U256, + balances: Mapping, + } + + /// Security-specific B-20 extension storage. + #[derive(Debug, Clone, Storable)] + #[namespace("b20.security")] + struct B20SecurityStorage { + shares_to_tokens_ratio: U256, + used_announcement_ids: Mapping, + security_identifiers: Mapping, + } + + /// Redeem-specific B-20 extension storage. + #[derive(Debug, Clone, Storable)] + #[namespace("b20.redeem")] + struct B20RedeemStorage { + minimum_redeemable: U256, + redeem_policy_ids: U256, + } + + /// Security token layout that composes canonical namespaced storage sections. + #[contract(addr = TYPE_NAMESPACE_ADDR)] + pub struct B20SecurityLayout { + pub local_head: u8, + pub b20: B20Storage, + pub security: B20SecurityStorage, + pub redeem: B20RedeemStorage, + pub local_tail: u16, + } + + #[test] + fn type_level_namespaces_mount_layouts_without_repeating_strings() { + let b20_value = B20Storage { total_supply: U256::ZERO, balances: Mapping::default() }; + let security_value = B20SecurityStorage { + shares_to_tokens_ratio: U256::ZERO, + used_announcement_ids: Mapping::default(), + security_identifiers: Mapping::default(), + }; + let redeem_value = + B20RedeemStorage { minimum_redeemable: U256::ZERO, redeem_policy_ids: U256::ZERO }; + let _ = ( + &b20_value.total_supply, + &b20_value.balances, + &security_value.shares_to_tokens_ratio, + &security_value.used_announcement_ids, + &security_value.security_identifiers, + &redeem_value.minimum_redeemable, + &redeem_value.redeem_policy_ids, + ); + + let b20_root = erc7201_root("b20"); + let security_root = erc7201_root("b20.security"); + let redeem_root = erc7201_root("b20.redeem"); + + assert_eq!(::STORAGE_NAMESPACE_ID, "b20"); + assert_eq!(::STORAGE_NAMESPACE_ROOT, b20_root); + assert_eq!(::STORAGE_NAMESPACE_ROOT, security_root); + assert_eq!(::STORAGE_NAMESPACE_ROOT, redeem_root); + + assert_eq!(slots::LOCAL_HEAD, U256::ZERO); + assert_eq!(slots::LOCAL_HEAD_OFFSET, 0); + assert_eq!(slots::B20, b20_root); + assert_eq!(slots::SECURITY, security_root); + assert_eq!(slots::REDEEM, redeem_root); + assert_eq!(slots::LOCAL_TAIL, U256::ZERO); + assert_eq!(slots::LOCAL_TAIL_OFFSET, 1); + } + + #[test] + fn type_level_namespaced_layouts_round_trip_through_handlers() { + let (mut storage, _) = setup_storage(); + let holder = Address::from([0xaa; 20]); + + StorageCtx::enter(&mut storage, |ctx| { + let mut layout = B20SecurityLayout::new(ctx); + + layout.local_head.write(0x11).unwrap(); + layout.b20.total_supply.write(U256::from(100)).unwrap(); + layout.b20.balances.at_mut(&holder).write(U256::from(25)).unwrap(); + layout.security.shares_to_tokens_ratio.write(U256::from(2)).unwrap(); + layout.redeem.minimum_redeemable.write(U256::from(10)).unwrap(); + layout.redeem.redeem_policy_ids.write(U256::from(3)).unwrap(); + layout.local_tail.write(0x2233).unwrap(); + + assert_eq!(layout.local_head.read().unwrap(), 0x11); + assert_eq!(layout.b20.total_supply.read().unwrap(), U256::from(100)); + assert_eq!(layout.b20.balances.at(&holder).read().unwrap(), U256::from(25)); + assert_eq!(layout.security.shares_to_tokens_ratio.read().unwrap(), U256::from(2)); + assert_eq!(layout.redeem.minimum_redeemable.read().unwrap(), U256::from(10)); + assert_eq!(layout.redeem.redeem_policy_ids.read().unwrap(), U256::from(3)); + assert_eq!(layout.local_tail.read().unwrap(), 0x2233); + + assert_eq!( + ctx.sload( + TYPE_NAMESPACE_ADDR, + slots::B20 + U256::from(__packing_b20_storage::TOTAL_SUPPLY_LOC.offset_slots), + ) + .unwrap(), + U256::from(100) + ); + assert_eq!( + ctx.sload( + TYPE_NAMESPACE_ADDR, + holder.mapping_slot( + slots::B20 + U256::from(__packing_b20_storage::BALANCES_LOC.offset_slots), + ), + ) + .unwrap(), + U256::from(25) + ); + assert_eq!( + ctx.sload( + TYPE_NAMESPACE_ADDR, + slots::SECURITY + + U256::from( + __packing_b20_security_storage::SHARES_TO_TOKENS_RATIO_LOC.offset_slots, + ), + ) + .unwrap(), + U256::from(2) + ); + assert_eq!( + ctx.sload( + TYPE_NAMESPACE_ADDR, + slots::REDEEM + + U256::from( + __packing_b20_redeem_storage::MINIMUM_REDEEMABLE_LOC.offset_slots + ), + ) + .unwrap(), + U256::from(10) + ); + let local_slot = ctx.sload(TYPE_NAMESPACE_ADDR, slots::LOCAL_HEAD).unwrap(); + assert_eq!(local_slot & U256::from(0xff), U256::from(0x11)); + assert_eq!((local_slot >> 8) & U256::from(0xffff), U256::from(0x2233)); + }); + } +} + +mod namespaced_fields { + use alloy_primitives::{Address, U256, address, uint}; + use base_precompile_macros::contract; + use base_precompile_storage::{Handler, Mapping, StorageCtx, StorageKey, setup_storage}; + + use super::{data_slot, word_from_chunk}; + + const FIELD_NAMESPACE_ADDR: Address = address!("0000000000000000000000000000000000008765"); + const EXPECTED_ROOT: U256 = + uint!(0x50861ae81a7f4392b927efbaeecf8f091f3bd39245aa45ea91499a137b8b3100_U256); + + /// Token storage with individual fields routed into a shared namespace-local layout. + #[contract(addr = FIELD_NAMESPACE_ADDR)] + pub struct FieldNamespacedStorage { + pub admin: Address, + #[namespace("b20.policy")] + pub policy_label: String, + pub total_supply: U256, + #[namespace("b20.policy")] + pub policy_balances: Mapping, + } + + #[test] + fn namespaced_fields_share_namespace_layout_without_advancing_contract_slots() { + assert_eq!(slots::ADMIN, U256::ZERO); + assert_eq!(slots::POLICY_LABEL, EXPECTED_ROOT); + assert_eq!(slots::TOTAL_SUPPLY, U256::ONE); + assert_eq!(slots::POLICY_BALANCES, EXPECTED_ROOT + U256::ONE); + + let (mut storage, _) = setup_storage(); + let owner = Address::from([0xbb; 20]); + let label = "field-level-namespaced-policy-label-that-spans-two-slots".to_owned(); + + StorageCtx::enter(&mut storage, |ctx| { + let mut layout = FieldNamespacedStorage::new(ctx); + layout.policy_label.write(label.clone()).unwrap(); + layout.policy_balances.at_mut(&owner).write(U256::from(700)).unwrap(); + layout.total_supply.write(U256::from(2_000)).unwrap(); + + assert_eq!(layout.policy_label.read().unwrap(), label); + assert_eq!(layout.policy_balances.at(&owner).read().unwrap(), U256::from(700)); + assert_eq!(layout.total_supply.read().unwrap(), U256::from(2_000)); + + assert_eq!( + ctx.sload(FIELD_NAMESPACE_ADDR, slots::POLICY_LABEL).unwrap(), + U256::from(label.len() * 2 + 1) + ); + let label_data_slot = data_slot(slots::POLICY_LABEL); + for chunk_index in 0..label.len().div_ceil(32) { + assert_eq!( + ctx.sload(FIELD_NAMESPACE_ADDR, label_data_slot + U256::from(chunk_index)) + .unwrap(), + word_from_chunk(label.as_bytes(), chunk_index) + ); + } + + let balance_slot = owner.mapping_slot(slots::POLICY_BALANCES); + assert_eq!(ctx.sload(FIELD_NAMESPACE_ADDR, balance_slot).unwrap(), U256::from(700)); + assert_eq!( + ctx.sload(FIELD_NAMESPACE_ADDR, slots::TOTAL_SUPPLY).unwrap(), + U256::from(2_000) + ); + }); + } +} + +mod namespace_outer_order { + use alloy_primitives::{Address, U256, address, uint}; + use base_precompile_macros::{contract, namespace}; + + const ORDER_ADDR: Address = address!("0000000000000000000000000000000000005678"); + + #[namespace("b20.outer-order")] + #[contract(addr = ORDER_ADDR)] + pub struct OuterOrderStorage { + pub value: U256, + } + + #[test] + fn namespace_macro_reorders_above_contract() { + assert_eq!(ORDER_ADDR, address!("0000000000000000000000000000000000005678")); + assert_eq!(slots::NAMESPACE_ID, "b20.outer-order"); + assert_eq!( + slots::NAMESPACE_ROOT, + uint!(0xf06e16fd945cfdfdb627e60cabea1fb8bb965382c21574655d1e8bb28bdfcf00_U256) + ); + assert_eq!(slots::VALUE, slots::NAMESPACE_ROOT); + } +} diff --git a/crates/common/precompiles/Cargo.toml b/crates/common/precompiles/Cargo.toml index e838b8befe..53e560886b 100644 --- a/crates/common/precompiles/Cargo.toml +++ b/crates/common/precompiles/Cargo.toml @@ -13,15 +13,40 @@ exclude.workspace = true workspace = true [dependencies] +# alloy +alloy-evm.workspace = true +alloy-sol-types.workspace = true +alloy-primitives.workspace = true + # base base-common-chains.workspace = true +base-precompile-macros.workspace = true +base-precompile-storage.workspace = true # revm revm.workspace = true +[dev-dependencies] +rstest.workspace = true +criterion.workspace = true +k256 = { workspace = true, features = ["ecdsa"] } +base-precompile-storage = { workspace = true, features = ["test-utils"] } + +[[bench]] +name = "base_precompiles" +harness = false +required-features = ["test-utils"] + [features] default = [ "blst", "c-kzg", "portable", "secp256k1", "std" ] -std = [ "base-common-chains/std", "revm/std" ] +std = [ + "alloy-evm/std", + "alloy-primitives/std", + "alloy-sol-types/std", + "base-common-chains/std", + "base-precompile-storage/std", + "revm/std", +] bn = [ "revm/bn" ] blst = [ "revm/blst" ] c-kzg = [ "revm/c-kzg" ] @@ -34,3 +59,4 @@ optional_eip3607 = [ "revm/optional_eip3607" ] optional_fee_charge = [ "revm/optional_fee_charge" ] optional_balance_check = [ "revm/optional_balance_check" ] optional_block_gas_limit = [ "revm/optional_block_gas_limit" ] +test-utils = [ "base-precompile-storage/test-utils" ] diff --git a/crates/common/precompiles/README.md b/crates/common/precompiles/README.md index 301d1be10d..f14d95c92d 100644 --- a/crates/common/precompiles/README.md +++ b/crates/common/precompiles/README.md @@ -22,6 +22,12 @@ Isthmus adds the Prague BLS12-381 precompiles with Base-specific limits, and Jov variable-input bn254 and BLS12-381 limits. Azul, Beryl, and newer Base upgrades inherit the latest known Base precompile set until they are explicitly mapped. +Starting in Beryl, `BasePrecompileInstaller` also installs the activation registry precompile at +`0x8453000000000000000000000000000000000001`. The registry stores runtime feature flags keyed by +`bytes32`, defaults every feature to inactive, and exposes `isActivated(bytes32)`, `admin()`, +`activate(bytes32)`, and `deactivate(bytes32)`. Only the configured activation admin can mutate +feature state, and repeated no-op transitions revert. + ## Usage Add the dependency to your `Cargo.toml`: diff --git a/crates/common/precompiles/benches/base_precompiles.rs b/crates/common/precompiles/benches/base_precompiles.rs new file mode 100644 index 0000000000..45e8c3b2b6 --- /dev/null +++ b/crates/common/precompiles/benches/base_precompiles.rs @@ -0,0 +1,502 @@ +//! Benchmarks for Base-native token and token-factory precompile logic. + +use std::hint::black_box; + +use alloy_primitives::{Address, B256, U256}; +use alloy_sol_types::SolValue; +use base_common_precompiles::{ + B20FactoryStorage, B20Token, B20TokenStorage, B20Variant, Burnable, Configurable, IB20, + IB20Factory, Mintable, Pausable, PolicyHandle, Token, TokenAccounting, Transferable, +}; +use base_precompile_storage::{HashMapStorageProvider, StorageCtx}; +use criterion::{Criterion, criterion_group, criterion_main}; + +struct BaseTokenBenchSetup; + +impl BaseTokenBenchSetup { + const fn admin() -> Address { + Address::repeat_byte(0xad) + } + + const fn caller() -> Address { + Address::repeat_byte(0xca) + } + + const fn initial_supply_recipient() -> Address { + Address::repeat_byte(0xcd) + } + + fn token_params(name: &str, symbol: &str) -> IB20Factory::B20CreateParams { + IB20Factory::B20CreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: name.to_string(), + symbol: symbol.to_string(), + initialAdmin: Self::admin(), + } + } + + fn create_b20( + ctx: StorageCtx<'_>, + caller: Address, + params: IB20Factory::B20CreateParams, + salt: B256, + _initial_supply: U256, + ) -> Address { + let call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::DEFAULT, + salt, + params: params.abi_encode().into(), + initCalls: Vec::new(), + }; + let mut factory = B20FactoryStorage::new(ctx); + factory.create_b20(caller, call).unwrap() + } + + fn create_token<'a>( + ctx: StorageCtx<'a>, + salt: B256, + initial_supply: U256, + ) -> B20Token, PolicyHandle<'a>> { + let params = Self::token_params("BaseToken", "BASE"); + + let token_address = Self::create_b20(ctx, Self::caller(), params, salt, initial_supply); + let mut token = Self::token_at(ctx, token_address); + if initial_supply > U256::ZERO { + token + .mint(Self::admin(), Self::initial_supply_recipient(), initial_supply, true) + .unwrap(); + } + token + } + + fn token_at<'a>( + ctx: StorageCtx<'a>, + token_address: Address, + ) -> B20Token, PolicyHandle<'a>> { + B20Token::with_storage_and_policy( + B20TokenStorage::from_address(token_address, ctx), + PolicyHandle::new(ctx), + ) + } +} + +fn base_token_metadata(c: &mut Criterion) { + c.bench_function("base_token_name", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x01), U256::ZERO); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().name().unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_symbol", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x02), U256::ZERO); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().symbol().unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_decimals", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x03), U256::ZERO); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().decimals().unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_contract_uri", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x04), U256::ZERO); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().contract_uri().unwrap(); + black_box(result); + }); + }); + }); +} + +fn base_token_view(c: &mut Criterion) { + c.bench_function("base_token_total_supply", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x05), + U256::from(1_000u64), + ); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().total_supply().unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_balance_of", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let account = BaseTokenBenchSetup::initial_supply_recipient(); + let token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x06), + U256::from(1_000u64), + ); + + b.iter(|| { + let token = black_box(&token); + let account = black_box(account); + let result = token.accounting().balance_of(account).unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_allowance", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let owner = Address::repeat_byte(0x01); + let spender = Address::repeat_byte(0x02); + let mut token = + BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x07), U256::ZERO); + token.approve(owner, spender, U256::from(500u64)).unwrap(); + + b.iter(|| { + let token = black_box(&token); + let owner = black_box(owner); + let spender = black_box(spender); + let result = token.accounting().allowance(owner, spender).unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_supply_cap", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x08), U256::ZERO); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().supply_cap().unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_token_paused", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let token = BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x09), U256::ZERO); + + b.iter(|| { + let token = black_box(&token); + let result = token.accounting().paused().unwrap(); + black_box(result); + }); + }); + }); +} + +fn base_token_mutate(c: &mut Criterion) { + c.bench_function("base_token_mint", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let user = Address::repeat_byte(0x01); + let mut token = + BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x0c), U256::ZERO); + + b.iter(|| { + let token = black_box(&mut token); + let user = black_box(user); + token.mint(user, user, U256::ONE, true).unwrap(); + }); + }); + }); + + c.bench_function("base_token_burn", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let holder = BaseTokenBenchSetup::initial_supply_recipient(); + let mut token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x0d), + U256::from(u128::MAX), + ); + + b.iter(|| { + let token = black_box(&mut token); + let holder = black_box(holder); + token.burn(holder, holder, U256::ONE, true).unwrap(); + }); + }); + }); + + c.bench_function("base_token_approve", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let owner = Address::repeat_byte(0x01); + let spender = Address::repeat_byte(0x02); + let mut token = + BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x0e), U256::ZERO); + + b.iter(|| { + let token = black_box(&mut token); + let owner = black_box(owner); + let spender = black_box(spender); + token.approve(owner, spender, U256::from(500u64)).unwrap(); + }); + }); + }); + + c.bench_function("base_token_transfer", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let from = BaseTokenBenchSetup::initial_supply_recipient(); + let to = Address::repeat_byte(0x02); + let mut token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x0f), + U256::from(u128::MAX), + ); + + b.iter(|| { + let token = black_box(&mut token); + let from = black_box(from); + token.transfer(from, to, U256::ONE, false).unwrap(); + }); + }); + }); + + c.bench_function("base_token_transfer_from", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let owner = BaseTokenBenchSetup::initial_supply_recipient(); + let spender = Address::repeat_byte(0x02); + let recipient = Address::repeat_byte(0x03); + let mut token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x10), + U256::from(u128::MAX), + ); + token.approve(owner, spender, U256::MAX).unwrap(); + + b.iter(|| { + let token = black_box(&mut token); + let spender = black_box(spender); + token.transfer_from(spender, owner, recipient, U256::ONE, false).unwrap(); + }); + }); + }); + + c.bench_function("base_token_transfer_with_memo", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let from = BaseTokenBenchSetup::initial_supply_recipient(); + let to = Address::repeat_byte(0x02); + let memo = B256::repeat_byte(0x42); + let mut token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x11), + U256::from(u128::MAX), + ); + + b.iter(|| { + let token = black_box(&mut token); + let from = black_box(from); + token.transfer_with_memo(from, to, U256::ONE, memo, false).unwrap(); + }); + }); + }); + + c.bench_function("base_token_transfer_from_with_memo", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let owner = BaseTokenBenchSetup::initial_supply_recipient(); + let spender = Address::repeat_byte(0x02); + let recipient = Address::repeat_byte(0x03); + let memo = B256::repeat_byte(0x43); + let mut token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x12), + U256::from(u128::MAX), + ); + token.approve(owner, spender, U256::MAX).unwrap(); + + b.iter(|| { + let token = black_box(&mut token); + let spender = black_box(spender); + token + .transfer_from_with_memo(spender, owner, recipient, U256::ONE, memo, false) + .unwrap(); + }); + }); + }); + + c.bench_function("base_token_pause", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let admin = BaseTokenBenchSetup::admin(); + let mut token = + BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x13), U256::ZERO); + + b.iter(|| { + let token = black_box(&mut token); + let admin = black_box(admin); + token.pause(admin, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + }); + }); + }); + + c.bench_function("base_token_unpause", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let admin = BaseTokenBenchSetup::admin(); + let mut token = + BaseTokenBenchSetup::create_token(ctx, B256::repeat_byte(0x14), U256::ZERO); + token.pause(admin, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + + b.iter(|| { + let token = black_box(&mut token); + let admin = black_box(admin); + token.unpause(admin, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + }); + }); + }); + + c.bench_function("base_token_update_supply_cap", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let admin = BaseTokenBenchSetup::admin(); + let mut token = BaseTokenBenchSetup::create_token( + ctx, + B256::repeat_byte(0x15), + U256::from(1_000u64), + ); + + b.iter(|| { + let token = black_box(&mut token); + let admin = black_box(admin); + token.update_supply_cap(admin, U256::from(10_000u64), true).unwrap(); + }); + }); + }); +} + +fn base_b20_factory_mutate(c: &mut Criterion) { + c.bench_function("base_b20_factory_create_b20", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let caller = BaseTokenBenchSetup::caller(); + let mut counter = 0u64; + + b.iter(|| { + counter += 1; + let salt = B256::from(U256::from(counter)); + let params = BaseTokenBenchSetup::token_params("FactoryToken", "FACT"); + let token = BaseTokenBenchSetup::create_b20(ctx, caller, params, salt, U256::ZERO); + black_box(token); + }); + }); + }); +} + +fn base_b20_factory_view(c: &mut Criterion) { + c.bench_function("base_b20_factory_predict_b20_address", |b| { + let caller = BaseTokenBenchSetup::caller(); + let salt = B256::repeat_byte(0x21); + + b.iter(|| { + let caller = black_box(caller); + let salt = black_box(salt); + let result = B20Variant::B20.compute_address(caller, salt); + black_box(result); + }); + }); + + c.bench_function("base_b20_factory_predict_stablecoin_address", |b| { + let caller = BaseTokenBenchSetup::caller(); + let salt = B256::repeat_byte(0x22); + + b.iter(|| { + let caller = black_box(caller); + let salt = black_box(salt); + let result = B20Variant::Stablecoin.compute_address(caller, salt); + black_box(result); + }); + }); + + c.bench_function("base_b20_factory_predict_security_address", |b| { + let caller = BaseTokenBenchSetup::caller(); + let salt = B256::repeat_byte(0x23); + + b.iter(|| { + let caller = black_box(caller); + let salt = black_box(salt); + let result = B20Variant::Security.compute_address(caller, salt); + black_box(result); + }); + }); + + c.bench_function("base_b20_factory_is_b20", |b| { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let params = BaseTokenBenchSetup::token_params("FactoryToken", "FACT"); + let token_address = BaseTokenBenchSetup::create_b20( + ctx, + BaseTokenBenchSetup::caller(), + params, + B256::repeat_byte(0x24), + U256::ZERO, + ); + let factory = B20FactoryStorage::new(ctx); + + b.iter(|| { + let factory = black_box(&factory); + let token_address = black_box(token_address); + let result = factory.is_b20(token_address).unwrap(); + black_box(result); + }); + }); + }); + + c.bench_function("base_b20_factory_get_token_variant", |b| { + let (token_address, _) = + B20Variant::B20.compute_address(BaseTokenBenchSetup::caller(), B256::repeat_byte(0x25)); + + b.iter(|| { + let token_address = black_box(token_address); + let result = B20Variant::from_address(token_address); + black_box(result); + }); + }); +} + +criterion_group!( + benches, + base_token_metadata, + base_token_view, + base_token_mutate, + base_b20_factory_mutate, + base_b20_factory_view, +); +criterion_main!(benches); diff --git a/crates/common/precompiles/src/activation/abi.rs b/crates/common/precompiles/src/activation/abi.rs new file mode 100644 index 0000000000..13242ef3d1 --- /dev/null +++ b/crates/common/precompiles/src/activation/abi.rs @@ -0,0 +1,44 @@ +//! ABI definitions for the activation registry precompile. + +use alloy_sol_types::sol; + +sol! { + /// Activation registry ABI. + interface IActivationRegistry { + /// Emitted when a feature is activated. + event FeatureActivated(bytes32 indexed feature, address indexed caller); + + /// Emitted when a feature is deactivated. + event FeatureDeactivated(bytes32 indexed feature, address indexed caller); + + /// Caller is not authorized to activate features. + error Unauthorized(address caller); + + /// Feature is already activated. + error AlreadyActivated(bytes32 feature); + + /// Feature is already deactivated. + error AlreadyDeactivated(bytes32 feature); + + /// Feature is not activated. + error FeatureNotActivated(bytes32 feature); + + /// Precompile cannot be executed via delegatecall or callcode. + error DelegateCallNotAllowed(); + + /// State-mutating call was attempted in a static context. + error StaticCallNotAllowed(); + + /// Returns true when `feature` is activated. + function isActivated(bytes32 feature) external view returns (bool); + + /// Returns the activation admin. + function admin() external view returns (address); + + /// Activates `feature`. + function activate(bytes32 feature) external; + + /// Deactivates `feature`. + function deactivate(bytes32 feature) external; + } +} diff --git a/crates/common/precompiles/src/activation/dispatch.rs b/crates/common/precompiles/src/activation/dispatch.rs new file mode 100644 index 0000000000..ab4381de53 --- /dev/null +++ b/crates/common/precompiles/src/activation/dispatch.rs @@ -0,0 +1,54 @@ +//! ABI dispatch for the activation registry. + +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolCall; +use base_precompile_storage::{IntoPrecompileResult, StorageCtx}; +use revm::precompile::PrecompileResult; + +use crate::{ + ActivationRegistryStorage, + IActivationRegistry::{self, IActivationRegistryCalls as C}, + macros::{decode_precompile_call, deduct_calldata_cost}, +}; + +impl ActivationRegistryStorage<'_> { + /// ABI-dispatches activation registry calldata. + pub fn dispatch( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + activation_admin_address: Option
, + ) -> PrecompileResult { + deduct_calldata_cost!(ctx, calldata); + self.inner(calldata, activation_admin_address).into_precompile_result( + ctx.gas_used(), + ctx.state_gas_used(), + |output| output, + ) + } + + fn inner( + &mut self, + calldata: &[u8], + activation_admin_address: Option
, + ) -> base_precompile_storage::Result { + match decode_precompile_call!(calldata, IActivationRegistry::IActivationRegistryCalls) { + C::isActivated(call) => { + let activated = self.is_activated(call.feature)?; + Ok(IActivationRegistry::isActivatedCall::abi_encode_returns(&activated).into()) + } + C::activate(call) => { + self.activate(call.feature, activation_admin_address)?; + Ok(Bytes::new()) + } + C::deactivate(call) => { + self.deactivate(call.feature, activation_admin_address)?; + Ok(Bytes::new()) + } + C::admin(_) => Ok(IActivationRegistry::adminCall::abi_encode_returns( + &self.admin(activation_admin_address), + ) + .into()), + } + } +} diff --git a/crates/common/precompiles/src/activation/mod.rs b/crates/common/precompiles/src/activation/mod.rs new file mode 100644 index 0000000000..fd76b18648 --- /dev/null +++ b/crates/common/precompiles/src/activation/mod.rs @@ -0,0 +1,12 @@ +//! Runtime activation registry native precompile. + +mod abi; +pub use abi::IActivationRegistry; + +mod storage; +pub use storage::{ActivationFeature, ActivationRegistryStorage}; + +mod dispatch; + +mod precompile; +pub use precompile::ActivationRegistry; diff --git a/crates/common/precompiles/src/activation/precompile.rs b/crates/common/precompiles/src/activation/precompile.rs new file mode 100644 index 0000000000..70fe9717ef --- /dev/null +++ b/crates/common/precompiles/src/activation/precompile.rs @@ -0,0 +1,11 @@ +//! Precompile entry point for the activation registry. + +use alloy_primitives::Address; +use base_precompile_macros::precompile; + +use crate::ActivationRegistryStorage; + +/// Entry point for the activation registry precompile. +#[precompile(install, args(activation_admin_address: Option
))] +#[derive(Debug, Default, Clone, Copy)] +pub struct ActivationRegistry; diff --git a/crates/common/precompiles/src/activation/storage.rs b/crates/common/precompiles/src/activation/storage.rs new file mode 100644 index 0000000000..af788524b3 --- /dev/null +++ b/crates/common/precompiles/src/activation/storage.rs @@ -0,0 +1,448 @@ +//! Storage layout and constants for the activation registry. + +use alloy_primitives::{Address, B256, Bytes, address, b256}; +use base_precompile_macros::contract; +use base_precompile_storage::{ + BasePrecompileError, Handler, IntoPrecompileResult, Mapping, Result, +}; +use revm::precompile::PrecompileResult; + +use crate::IActivationRegistry; + +/// Runtime activation registry for Base-native features. +#[contract(addr = Self::ADDRESS)] +#[namespace("base.activation_registry")] +pub struct ActivationRegistryStorage { + /// Runtime activation flags keyed by feature id. + pub features: Mapping, +} + +/// Identifies a Base-native precompile feature in the activation registry. +/// +/// Each variant maps to a stable `keccak256` hash of the feature's canonical name and is used as +/// the key when querying or mutating activation state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActivationFeature { + /// `keccak256("base.b20_token")` + B20Token, + /// `keccak256("base.b20_factory")` + B20Factory, + /// `keccak256("base.policy_registry")` + PolicyRegistry, + /// `keccak256("base.b20_stablecoin")` + B20Stablecoin, + /// `keccak256("base.b20_security")` + B20Security, +} + +impl ActivationFeature { + /// Returns the `keccak256` hash that identifies this feature in storage. + pub const fn id(self) -> B256 { + match self { + Self::B20Token => { + b256!("0x47a1afe8d3d691b87e090ee972d223a11f4da971ff5416c04985bb2393aca752") + } + Self::B20Factory => { + b256!("0x78751e29c8bcc0d609ab18e9fbc4158e73f7db25ae2ee095dad42e2578b1e800") + } + Self::PolicyRegistry => { + b256!("0xb582ebae03f16fee49a6763f78df482fb11ae73f103ed0d330bbe556aa90a43f") + } + Self::B20Stablecoin => { + b256!("0xecfa0def2c10020caaf65e6155aa69c84b24892aaef76eeac52e0e2b3a0b8601") + } + Self::B20Security => { + b256!("0x83d32fab502ae0e8bc4352a117767262cb5e47cc8d67a744008ed4ff03fcf5e6") + } + } + } +} + +impl From for B256 { + fn from(feature: ActivationFeature) -> Self { + feature.id() + } +} + +impl ActivationRegistryStorage<'_> { + /// Activation registry precompile address. + pub const ADDRESS: Address = address!("8453000000000000000000000000000000000001"); + + /// Returns the activation admin. + pub const fn admin(&self, activation_admin_address: Option
) -> Address { + match activation_admin_address { + Some(address) => address, + None => Address::ZERO, + } + } + + /// Returns true when the feature is activated. + pub fn is_activated(&self, feature: B256) -> Result { + self.features.at(&feature).read() + } + + /// Reverts unless the feature is activated. + /// + /// Both the activated and deactivated paths return `Ok`; callers must inspect + /// [`revm::precompile::PrecompileOutput::reverted`] to distinguish an activated feature from an + /// ABI revert. + pub fn assert_activated(&self, feature: B256) -> PrecompileResult { + self.ensure_activated(feature).into_precompile_result( + self.storage.gas_used(), + self.storage.state_gas_used(), + |()| Bytes::new(), + ) + } + + /// Returns `Ok(())` when the feature is activated. + pub fn ensure_activated(&self, feature: B256) -> Result<()> { + if self.is_activated(feature)? { + return Ok(()); + } + + Err(BasePrecompileError::revert(IActivationRegistry::FeatureNotActivated { feature })) + } + + /// Activates the feature. + pub fn activate( + &mut self, + feature: B256, + activation_admin_address: Option
, + ) -> Result<()> { + self.set_activated(feature, true, activation_admin_address) + } + + /// Deactivates the feature. + pub fn deactivate( + &mut self, + feature: B256, + activation_admin_address: Option
, + ) -> Result<()> { + self.set_activated(feature, false, activation_admin_address) + } + + /// Sets the feature activation state. + pub fn set_activated( + &mut self, + feature: B256, + activated: bool, + activation_admin_address: Option
, + ) -> Result<()> { + // Keep this guard at the shared mutation boundary so `activate`, `deactivate`, and direct + // `set_activated` callers all get the same static-call behavior after calldata validation. + if self.storage.is_static() { + return Err(BasePrecompileError::revert(IActivationRegistry::StaticCallNotAllowed {})); + } + + let caller = self.storage.caller(); + let Some(admin) = activation_admin_address else { + return Err(BasePrecompileError::revert(IActivationRegistry::Unauthorized { caller })); + }; + if caller != admin { + return Err(BasePrecompileError::revert(IActivationRegistry::Unauthorized { caller })); + } + + let current = self.features.at(&feature).read()?; + if current == activated { + if activated { + return Err(BasePrecompileError::revert(IActivationRegistry::AlreadyActivated { + feature, + })); + } + + return Err(BasePrecompileError::revert(IActivationRegistry::FeatureNotActivated { + feature, + })); + } + + if activated { + self.__initialize()?; + self.features.at_mut(&feature).write(true)?; + self.emit_event(IActivationRegistry::FeatureActivated { feature, caller })?; + } else { + self.features.at_mut(&feature).delete()?; + self.emit_event(IActivationRegistry::FeatureDeactivated { feature, caller })?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, U256, address, keccak256, uint}; + use base_precompile_storage::{ + BasePrecompileError, HashMapStorageProvider, Result, StorageCtx, StorageKey, + }; + use revm::precompile::PrecompileOutput; + use rstest::rstest; + + use crate::{ + ActivationFeature, ActivationRegistryStorage, IActivationRegistry, + activation::storage::slots, + }; + + const FEATURE: B256 = ActivationFeature::B20Security.id(); + const ADMIN: Address = address!("0xcb00000000000000000000000000000000000000"); + const ACTIVATION_REGISTRY_ROOT: U256 = + uint!(0x43ee1bbe25e988521cccd8b2c8fbd38c8287ebff8e074e825a70dfd3885cce00_U256); + + #[derive(Debug, Clone, Copy)] + enum Transition { + Activate, + Deactivate, + } + + #[derive(Debug, Clone, Copy)] + enum InvalidContext { + Static, + Unauthorized, + } + + fn apply_transition( + storage: &mut HashMapStorageProvider, + transition: Transition, + ) -> Result<()> { + match transition { + Transition::Activate => activate_feature(storage), + Transition::Deactivate => deactivate_feature(storage), + } + } + + fn apply_transition_with_current_context( + storage: &mut HashMapStorageProvider, + transition: Transition, + ) -> Result<()> { + StorageCtx::enter(storage, |ctx| { + let mut registry = ActivationRegistryStorage::new(ctx); + match transition { + Transition::Activate => registry.activate(FEATURE, Some(ADMIN)), + Transition::Deactivate => registry.deactivate(FEATURE, Some(ADMIN)), + } + }) + } + + fn set_active(storage: &mut HashMapStorageProvider, active: bool) { + if active { + activate_feature(storage).unwrap(); + } + } + + fn set_invalid_context(storage: &mut HashMapStorageProvider, context: InvalidContext) { + match context { + InvalidContext::Static => { + storage.set_caller(ADMIN); + storage.set_static(true); + } + InvalidContext::Unauthorized => { + storage.set_caller(address!("0x0000000000000000000000000000000000000001")); + } + } + } + + fn activate_feature(storage: &mut HashMapStorageProvider) -> Result<()> { + storage.set_caller(ADMIN); + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx).activate(FEATURE, Some(ADMIN)) + }) + } + + fn deactivate_feature(storage: &mut HashMapStorageProvider) -> Result<()> { + storage.set_caller(ADMIN); + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx).deactivate(FEATURE, Some(ADMIN)) + }) + } + + fn assert_activated(storage: &mut HashMapStorageProvider, expected: bool) { + StorageCtx::enter(storage, |ctx| { + assert_eq!( + ActivationRegistryStorage::new(ctx) + .is_activated(FEATURE) + .expect("storage read succeeds"), + expected + ); + }); + } + + fn assert_activated_output(storage: &mut HashMapStorageProvider) -> PrecompileOutput { + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx).assert_activated(FEATURE) + }) + .expect("activation assertion should not fail fatally") + } + + #[test] + fn feature_is_inactive_by_default() { + let mut storage = HashMapStorageProvider::new(1); + + assert_activated(&mut storage, false); + } + + #[test] + fn feature_id_constants_match_canonical_names() { + assert_eq!(ActivationFeature::B20Token.id(), keccak256("base.b20_token")); + assert_eq!(ActivationFeature::B20Factory.id(), keccak256("base.b20_factory")); + assert_eq!(ActivationFeature::PolicyRegistry.id(), keccak256("base.policy_registry")); + assert_eq!(ActivationFeature::B20Stablecoin.id(), keccak256("base.b20_stablecoin")); + assert_eq!(ActivationFeature::B20Security.id(), keccak256("base.b20_security")); + } + + #[test] + fn activation_registry_namespace_matches_base_std_root() { + assert_eq!(slots::NAMESPACE_ID, "base.activation_registry"); + assert_eq!(slots::NAMESPACE_ROOT, ACTIVATION_REGISTRY_ROOT); + assert_eq!(slots::FEATURES, ACTIVATION_REGISTRY_ROOT); + } + + #[test] + fn activation_registry_writes_use_base_std_namespace_slots() { + let mut storage = HashMapStorageProvider::new(1); + + activate_feature(&mut storage).unwrap(); + + StorageCtx::enter(&mut storage, |ctx| { + assert_eq!( + ctx.sload( + ActivationRegistryStorage::ADDRESS, + FEATURE.mapping_slot(slots::FEATURES) + ) + .unwrap(), + U256::ONE + ); + assert_eq!( + ctx.sload(ActivationRegistryStorage::ADDRESS, FEATURE.mapping_slot(U256::ZERO)) + .unwrap(), + U256::ZERO + ); + }); + } + + #[test] + fn admin_can_activate_deactivate_and_reactivate_feature() { + let mut storage = HashMapStorageProvider::new(1); + + activate_feature(&mut storage).unwrap(); + assert_activated(&mut storage, true); + assert_eq!(storage.get_events(ActivationRegistryStorage::ADDRESS).len(), 1); + + deactivate_feature(&mut storage).unwrap(); + assert_activated(&mut storage, false); + assert_eq!(storage.get_events(ActivationRegistryStorage::ADDRESS).len(), 2); + + activate_feature(&mut storage).unwrap(); + assert_activated(&mut storage, true); + assert_eq!(storage.get_events(ActivationRegistryStorage::ADDRESS).len(), 3); + } + + #[test] + fn configured_admin_can_activate_when_default_is_unset() { + let mut storage = HashMapStorageProvider::new(1); + let configured_admin = address!("0x0000000000000000000000000000000000000002"); + + storage.set_caller(ADMIN); + let err = StorageCtx::enter(&mut storage, |ctx| { + ActivationRegistryStorage::new(ctx).activate(FEATURE, Some(configured_admin)) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + assert_activated(&mut storage, false); + + storage.set_caller(configured_admin); + StorageCtx::enter(&mut storage, |ctx| { + ActivationRegistryStorage::new(ctx).activate(FEATURE, Some(configured_admin)) + }) + .unwrap(); + assert_activated(&mut storage, true); + } + + #[test] + fn unset_admin_cannot_change_activation() { + let mut storage = HashMapStorageProvider::new(1); + + storage.set_caller(ADMIN); + let err = StorageCtx::enter(&mut storage, |ctx| { + let mut registry = ActivationRegistryStorage::new(ctx); + assert_eq!(registry.admin(None), Address::ZERO); + registry.activate(FEATURE, None) + }) + .unwrap_err(); + + assert!(matches!(err, BasePrecompileError::Revert(_))); + assert_activated(&mut storage, false); + } + + #[rstest] + #[case::activate_when_active(Transition::Activate, true)] + #[case::deactivate_when_inactive(Transition::Deactivate, false)] + fn repeated_transition_reverts(#[case] transition: Transition, #[case] initially_active: bool) { + let mut storage = HashMapStorageProvider::new(1); + + set_active(&mut storage, initially_active); + let events_before = storage.get_events(ActivationRegistryStorage::ADDRESS).len(); + + let result = apply_transition(&mut storage, transition); + + assert_eq!( + result.unwrap_err(), + match transition { + Transition::Activate => { + BasePrecompileError::revert(IActivationRegistry::AlreadyActivated { + feature: FEATURE, + }) + } + Transition::Deactivate => { + BasePrecompileError::revert(IActivationRegistry::FeatureNotActivated { + feature: FEATURE, + }) + } + } + ); + assert_activated(&mut storage, initially_active); + // A failed transition must not emit any events — guard against emit-then-revert bugs. + assert_eq!(storage.get_events(ActivationRegistryStorage::ADDRESS).len(), events_before); + } + + #[rstest] + #[case::activate_unauthorized(Transition::Activate, InvalidContext::Unauthorized, false)] + #[case::deactivate_unauthorized(Transition::Deactivate, InvalidContext::Unauthorized, true)] + #[case::activate_static(Transition::Activate, InvalidContext::Static, false)] + #[case::deactivate_static(Transition::Deactivate, InvalidContext::Static, true)] + fn invalid_context_cannot_change_activation( + #[case] transition: Transition, + #[case] context: InvalidContext, + #[case] initially_active: bool, + ) { + let mut storage = HashMapStorageProvider::new(1); + + set_active(&mut storage, initially_active); + set_invalid_context(&mut storage, context); + let result = apply_transition_with_current_context(&mut storage, transition); + + assert!(result.is_err()); + assert_activated(&mut storage, initially_active); + } + + #[test] + fn assert_activated_reverts_when_feature_never_activated() { + let mut storage = HashMapStorageProvider::new(1); + + let output = assert_activated_output(&mut storage); + + assert!(output.is_revert()); + assert_eq!(storage.get_events(ActivationRegistryStorage::ADDRESS).len(), 0); + } + + #[test] + fn assert_activated_reverts_after_deactivate() { + let mut storage = HashMapStorageProvider::new(1); + + activate_feature(&mut storage).unwrap(); + let activated_output = assert_activated_output(&mut storage); + deactivate_feature(&mut storage).unwrap(); + let deactivated_output = assert_activated_output(&mut storage); + + assert!(!activated_output.is_revert()); + assert!(deactivated_output.is_revert()); + } +} diff --git a/crates/common/precompiles/src/b20/abi.rs b/crates/common/precompiles/src/b20/abi.rs new file mode 100644 index 0000000000..315aca192f --- /dev/null +++ b/crates/common/precompiles/src/b20/abi.rs @@ -0,0 +1,216 @@ +//! ABI definition for the `IB20` interface. + +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq, Eq)] + interface IB20 { + enum PausableFeature { + /// Transfer operations. + TRANSFER, + /// Mint operations. + MINT, + /// Burn operations. + BURN, + /// Redeem operations. + REDEEM + } + + // Errors + error AccessControlUnauthorizedAccount(address account, bytes32 neededRole); + error Unauthorized(); + error ContractPaused(PausableFeature feature); + error InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + error InsufficientBalance(address sender, uint256 balance, uint256 needed); + error InvalidSender(address sender); + error InvalidReceiver(address receiver); + error InvalidApprover(address approver); + error InvalidSpender(address spender); + error InvalidAmount(); + error EmptyFeatureSet(); + error InvalidSupplyCap(uint256 currentSupply, uint256 proposedCap); + error SupplyCapExceeded(uint256 cap, uint256 attempted); + error PolicyForbids(bytes32 policyScope, uint64 policyId); + error PolicyNotFound(uint64 policyId); + error UnsupportedPolicyType(bytes32 policyScope); + error AccountNotBlocked(address account); + error ExpiredSignature(uint256 deadline); + error InvalidSigner(address signer, address owner); + error LastAdminCannotRenounce(); + error NotSoleAdmin(); + error AccessControlBadConfirmation(); + + // Events + event Transfer(address indexed from, address indexed to, uint256 amount); + event Approval(address indexed owner, address indexed spender, uint256 amount); + event Memo(address indexed caller, bytes32 indexed memo); + event BurnedBlocked(address indexed caller, address indexed from, uint256 amount); + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + event LastAdminRenounced(address indexed previousAdmin); + event Paused(address indexed updater, PausableFeature[] features); + event Unpaused(address indexed updater, PausableFeature[] features); + event PolicyUpdated(bytes32 indexed policyScope, uint64 oldPolicyId, uint64 newPolicyId); + event SupplyCapUpdated(address indexed updater, uint256 oldSupplyCap, uint256 newSupplyCap); + event ContractURIUpdated(); + event NameUpdated(address indexed updater, string newName); + event SymbolUpdated(address indexed updater, string newSymbol); + event EIP712DomainChanged(); + + // Role identifiers + function DEFAULT_ADMIN_ROLE() external view returns (bytes32); + function MINT_ROLE() external view returns (bytes32); + function BURN_ROLE() external view returns (bytes32); + function BURN_BLOCKED_ROLE() external view returns (bytes32); + function PAUSE_ROLE() external view returns (bytes32); + function UNPAUSE_ROLE() external view returns (bytes32); + function METADATA_ROLE() external view returns (bytes32); + + // Policy type identifiers + function TRANSFER_SENDER_POLICY() external view returns (bytes32); + function TRANSFER_RECEIVER_POLICY() external view returns (bytes32); + function TRANSFER_EXECUTOR_POLICY() external view returns (bytes32); + function MINT_RECEIVER_POLICY() external view returns (bytes32); + + // ERC-20 + function name() external view returns (string); + function symbol() external view returns (string); + function decimals() external view returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function allowance(address owner, address spender) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); + + // Metadata updates + function updateName(string calldata newName) external; + function updateSymbol(string calldata newSymbol) external; + + // Memo transfer variants + function transferWithMemo(address to, uint256 amount, bytes32 memo) external returns (bool); + function transferFromWithMemo(address from, address to, uint256 amount, bytes32 memo) external returns (bool); + + // Mint / burn + function mint(address to, uint256 amount) external; + function mintWithMemo(address to, uint256 amount, bytes32 memo) external; + function burn(uint256 amount) external; + function burnWithMemo(uint256 amount, bytes32 memo) external; + function burnBlocked(address from, uint256 amount) external; + + // Roles + function hasRole(bytes32 role, address account) external view returns (bool); + function getRoleAdmin(bytes32 role) external view returns (bytes32); + function grantRole(bytes32 role, address account) external; + function revokeRole(bytes32 role, address account) external; + function renounceRole(bytes32 role, address callerConfirmation) external; + function renounceLastAdmin() external; + function setRoleAdmin(bytes32 role, bytes32 newAdminRole) external; + + // Pause + function pausedFeatures() external view returns (PausableFeature[] memory); + function isPaused(PausableFeature feature) external view returns (bool); + function pause(PausableFeature[] calldata features) external; + function unpause(PausableFeature[] calldata features) external; + + // Policy + function policyId(bytes32 policyScope) external view returns (uint64); + function updatePolicy(bytes32 policyScope, uint64 newPolicyId) external; + + // Supply cap + function supplyCap() external view returns (uint256); + function updateSupplyCap(uint256 newSupplyCap) external; + + // Permit (EIP-2612 + ERC-5267) + function DOMAIN_SEPARATOR() external view returns (bytes32); + function nonces(address owner) external view returns (uint256); + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; + function eip712Domain() external view returns (bytes1 fields, string memory name, string memory version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] memory extensions); + + // Contract URI (ERC-7572) + function contractURI() external view returns (string); + function updateContractURI(string calldata newURI) external; + } +} + +impl IB20::IB20Calls { + /// Returns the stable label for this decoded B-20 call. + pub const fn as_label(&self) -> &'static str { + match self { + Self::name(_) => "precompile-b20-name", + Self::symbol(_) => "precompile-b20-symbol", + Self::decimals(_) => "precompile-b20-decimals", + Self::totalSupply(_) => "precompile-b20-totalSupply", + Self::balanceOf(_) => "precompile-b20-balanceOf", + Self::allowance(_) => "precompile-b20-allowance", + Self::supplyCap(_) => "precompile-b20-supplyCap", + Self::nonces(_) => "precompile-b20-nonces", + Self::contractURI(_) => "precompile-b20-contractURI", + Self::DEFAULT_ADMIN_ROLE(_) => "precompile-b20-DEFAULT_ADMIN_ROLE", + Self::MINT_ROLE(_) => "precompile-b20-MINT_ROLE", + Self::BURN_ROLE(_) => "precompile-b20-BURN_ROLE", + Self::BURN_BLOCKED_ROLE(_) => "precompile-b20-BURN_BLOCKED_ROLE", + Self::PAUSE_ROLE(_) => "precompile-b20-PAUSE_ROLE", + Self::UNPAUSE_ROLE(_) => "precompile-b20-UNPAUSE_ROLE", + Self::METADATA_ROLE(_) => "precompile-b20-METADATA_ROLE", + Self::TRANSFER_SENDER_POLICY(_) => "precompile-b20-TRANSFER_SENDER_POLICY", + Self::TRANSFER_RECEIVER_POLICY(_) => "precompile-b20-TRANSFER_RECEIVER_POLICY", + Self::TRANSFER_EXECUTOR_POLICY(_) => "precompile-b20-TRANSFER_EXECUTOR_POLICY", + Self::MINT_RECEIVER_POLICY(_) => "precompile-b20-MINT_RECEIVER_POLICY", + Self::hasRole(_) => "precompile-b20-hasRole", + Self::getRoleAdmin(_) => "precompile-b20-getRoleAdmin", + Self::pausedFeatures(_) => "precompile-b20-pausedFeatures", + Self::policyId(_) => "precompile-b20-policyId", + Self::isPaused(_) => "precompile-b20-isPaused", + Self::DOMAIN_SEPARATOR(_) => "precompile-b20-DOMAIN_SEPARATOR", + Self::eip712Domain(_) => "precompile-b20-eip712Domain", + Self::transfer(_) => "precompile-b20-transfer", + Self::transferFrom(_) => "precompile-b20-transferFrom", + Self::approve(_) => "precompile-b20-approve", + Self::transferWithMemo(_) => "precompile-b20-transferWithMemo", + Self::transferFromWithMemo(_) => "precompile-b20-transferFromWithMemo", + Self::mint(_) => "precompile-b20-mint", + Self::mintWithMemo(_) => "precompile-b20-mintWithMemo", + Self::burn(_) => "precompile-b20-burn", + Self::burnWithMemo(_) => "precompile-b20-burnWithMemo", + Self::burnBlocked(_) => "precompile-b20-burnBlocked", + Self::pause(_) => "precompile-b20-pause", + Self::unpause(_) => "precompile-b20-unpause", + Self::updateSupplyCap(_) => "precompile-b20-updateSupplyCap", + Self::updateName(_) => "precompile-b20-updateName", + Self::updateSymbol(_) => "precompile-b20-updateSymbol", + Self::updateContractURI(_) => "precompile-b20-updateContractURI", + Self::grantRole(_) => "precompile-b20-grantRole", + Self::revokeRole(_) => "precompile-b20-revokeRole", + Self::renounceRole(_) => "precompile-b20-renounceRole", + Self::renounceLastAdmin(_) => "precompile-b20-renounceLastAdmin", + Self::setRoleAdmin(_) => "precompile-b20-setRoleAdmin", + Self::updatePolicy(_) => "precompile-b20-updatePolicy", + Self::permit(_) => "precompile-b20-permit", + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256}; + + use crate::IB20; + + #[test] + fn b20_call_labels_are_stable() { + assert_eq!( + IB20::IB20Calls::transfer(IB20::transferCall { to: Address::ZERO, amount: U256::ZERO }) + .as_label(), + "precompile-b20-transfer" + ); + assert_eq!( + IB20::IB20Calls::updateSupplyCap(IB20::updateSupplyCapCall { + newSupplyCap: U256::ZERO, + }) + .as_label(), + "precompile-b20-updateSupplyCap" + ); + } +} diff --git a/crates/common/precompiles/src/b20/dispatch.rs b/crates/common/precompiles/src/b20/dispatch.rs new file mode 100644 index 0000000000..12a115b15a --- /dev/null +++ b/crates/common/precompiles/src/b20/dispatch.rs @@ -0,0 +1,302 @@ +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::{SolCall, SolValue}; +use base_precompile_storage::{BasePrecompileError, IntoPrecompileResult, StorageCtx}; +use revm::precompile::PrecompileResult; + +use crate::{ + ActivationFeature, ActivationRegistryStorage, B20Token, B20TokenRole, Burnable, Configurable, + IB20::{self, IB20Calls as C}, + Mintable, NoopPrecompileCallObserver, Pausable, PermitArgs, Permittable, Policy, + PrecompileCallObserver, RoleManaged, Token, TokenAccounting, Transferable, + macros::{decode_precompile_call, deduct_calldata_cost}, +}; + +impl B20Token { + /// ABI-dispatches `calldata` to the appropriate `IB20` handler. + pub fn dispatch(&mut self, ctx: StorageCtx<'_>, calldata: &[u8]) -> PrecompileResult { + self.dispatch_with_observer(ctx, calldata, NoopPrecompileCallObserver) + } + + /// ABI-dispatches `calldata` and observes the decoded B-20 operation. + pub fn dispatch_with_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + observer: O, + ) -> PrecompileResult + where + O: PrecompileCallObserver, + { + deduct_calldata_cost!(ctx, calldata); + // Ensure the token has been deployed (has bytecode at its address). + match self.accounting().is_initialized() { + Ok(true) => {} + Ok(false) => { + return BasePrecompileError::Revert(Bytes::new()) + .into_precompile_result(ctx.gas_used(), ctx.state_gas_used()); + } + Err(e) => return e.into_precompile_result(ctx.gas_used(), ctx.state_gas_used()), + } + self.inner_with_observer(ctx, calldata, observer).into_precompile_result( + ctx.gas_used(), + ctx.state_gas_used(), + |b| b, + ) + } + + /// Decodes calldata and executes the matching `IB20` operation. + pub fn inner( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + ) -> base_precompile_storage::Result { + self.inner_with_observer(ctx, calldata, NoopPrecompileCallObserver) + } + + /// Decodes calldata, observes the decoded operation, and executes the matching `IB20` handler. + pub fn inner_with_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + observer: O, + ) -> base_precompile_storage::Result + where + O: PrecompileCallObserver, + { + self.inner_with_privilege_and_observer(ctx, calldata, false, observer) + } + + /// Decodes calldata and executes it with optional factory-init privilege. + pub fn inner_with_privilege( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + privileged: bool, + ) -> base_precompile_storage::Result { + self.inner_with_privilege_and_observer( + ctx, + calldata, + privileged, + NoopPrecompileCallObserver, + ) + } + + /// Decodes calldata, observes the decoded operation, and executes it with optional + /// factory-init privilege. + pub fn inner_with_privilege_and_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + privileged: bool, + observer: O, + ) -> base_precompile_storage::Result + where + O: PrecompileCallObserver, + { + ActivationRegistryStorage::new(ctx).ensure_activated(ActivationFeature::B20Token.id())?; + + let call = decode_precompile_call!(calldata, IB20::IB20Calls); + let label = call.as_label(); + + observer.observe(label, || { + let encoded: Bytes = match call { + // --- Pure reads: direct to accounting --- + C::name(_) => self.accounting().name()?.abi_encode().into(), + C::symbol(_) => self.accounting().symbol()?.abi_encode().into(), + C::decimals(_) => U256::from(self.accounting().decimals()?).abi_encode().into(), + C::totalSupply(_) => self.accounting().total_supply()?.abi_encode().into(), + C::balanceOf(c) => self.accounting().balance_of(c.account)?.abi_encode().into(), + C::allowance(c) => { + self.accounting().allowance(c.owner, c.spender)?.abi_encode().into() + } + C::supplyCap(_) => self.accounting().supply_cap()?.abi_encode().into(), + C::nonces(c) => self.accounting().nonce(c.owner)?.abi_encode().into(), + C::contractURI(_) => self.accounting().contract_uri()?.abi_encode().into(), + C::DEFAULT_ADMIN_ROLE(_) => B20TokenRole::DefaultAdmin.id().abi_encode().into(), + C::MINT_ROLE(_) => B20TokenRole::Mint.id().abi_encode().into(), + C::BURN_ROLE(_) => B20TokenRole::Burn.id().abi_encode().into(), + C::BURN_BLOCKED_ROLE(_) => B20TokenRole::BurnBlocked.id().abi_encode().into(), + C::PAUSE_ROLE(_) => B20TokenRole::Pause.id().abi_encode().into(), + C::UNPAUSE_ROLE(_) => B20TokenRole::Unpause.id().abi_encode().into(), + C::METADATA_ROLE(_) => B20TokenRole::Metadata.id().abi_encode().into(), + C::TRANSFER_SENDER_POLICY(_) => Self::transfer_sender_policy().abi_encode().into(), + C::TRANSFER_RECEIVER_POLICY(_) => { + Self::transfer_receiver_policy().abi_encode().into() + } + C::TRANSFER_EXECUTOR_POLICY(_) => { + Self::transfer_executor_policy().abi_encode().into() + } + C::MINT_RECEIVER_POLICY(_) => Self::mint_receiver_policy().abi_encode().into(), + C::hasRole(c) => self.has_role(c.role, c.account)?.abi_encode().into(), + C::getRoleAdmin(c) => self.role_admin(c.role)?.abi_encode().into(), + C::pausedFeatures(_) => self.paused_features()?.abi_encode().into(), + C::policyId(c) => self.policy_id(c.policyScope)?.abi_encode().into(), + + // --- Domain reads (light logic) --- + C::isPaused(c) => self.is_paused(c.feature)?.abi_encode().into(), + C::DOMAIN_SEPARATOR(_) => { + self.domain_separator(ctx.chain_id())?.abi_encode().into() + } + C::eip712Domain(_) => { + let (fields, name, version, chain_id, verifying_contract, salt, extensions) = + self.eip712_domain(ctx.chain_id())?; + IB20::eip712DomainCall::abi_encode_returns(&IB20::eip712DomainReturn { + fields, + name, + version, + chainId: chain_id, + verifyingContract: verifying_contract, + salt, + extensions, + }) + .into() + } + + // --- ERC-20 mutating --- + C::transfer(c) => { + let caller = ctx.caller(); + self.transfer(caller, c.to, c.amount, privileged)?; + true.abi_encode().into() + } + C::transferFrom(c) => { + let caller = ctx.caller(); + self.transfer_from(caller, c.from, c.to, c.amount, privileged)?; + true.abi_encode().into() + } + C::approve(c) => { + let caller = ctx.caller(); + self.approve(caller, c.spender, c.amount)?; + true.abi_encode().into() + } + C::transferWithMemo(c) => { + let caller = ctx.caller(); + self.transfer_with_memo(caller, c.to, c.amount, c.memo, privileged)?; + true.abi_encode().into() + } + C::transferFromWithMemo(c) => { + let caller = ctx.caller(); + self.transfer_from_with_memo( + caller, c.from, c.to, c.amount, c.memo, privileged, + )?; + true.abi_encode().into() + } + + // --- Mint --- + C::mint(c) => { + let caller = ctx.caller(); + self.mint(caller, c.to, c.amount, privileged)?; + Bytes::new() + } + C::mintWithMemo(c) => { + let caller = ctx.caller(); + self.mint_with_memo(caller, c.to, c.amount, c.memo, privileged)?; + Bytes::new() + } + + // --- Burn --- + C::burn(c) => { + let caller = ctx.caller(); + // Self-burn operations are never factory-privileged: during init the caller is the + // factory, not a token holder. + self.burn(caller, caller, c.amount, false)?; + Bytes::new() + } + C::burnWithMemo(c) => { + let caller = ctx.caller(); + self.burn_with_memo(caller, caller, c.amount, c.memo, false)?; + Bytes::new() + } + C::burnBlocked(c) => { + let caller = ctx.caller(); + self.burn_blocked(caller, c.from, c.amount, privileged)?; + Bytes::new() + } + + // --- Pause --- + C::pause(c) => { + let caller = ctx.caller(); + self.pause(caller, c.features, privileged)?; + Bytes::new() + } + C::unpause(c) => { + let caller = ctx.caller(); + self.unpause(caller, c.features, privileged)?; + Bytes::new() + } + + // --- Admin --- + C::updateSupplyCap(c) => { + let caller = ctx.caller(); + Configurable::update_supply_cap(self, caller, c.newSupplyCap, privileged)?; + Bytes::new() + } + C::updateName(c) => { + let caller = ctx.caller(); + Configurable::update_name(self, caller, c.newName, privileged)?; + Bytes::new() + } + C::updateSymbol(c) => { + let caller = ctx.caller(); + Configurable::update_symbol(self, caller, c.newSymbol, privileged)?; + Bytes::new() + } + C::updateContractURI(c) => { + let caller = ctx.caller(); + Configurable::update_contract_uri(self, caller, c.newURI, privileged)?; + Bytes::new() + } + C::grantRole(c) => { + let caller = ctx.caller(); + self.grant_role(caller, c.role, c.account, privileged)?; + Bytes::new() + } + C::revokeRole(c) => { + let caller = ctx.caller(); + self.revoke_role(caller, c.role, c.account, privileged)?; + Bytes::new() + } + // Renounce operations are never factory-privileged: they are only meaningful for the + // role holder making the call after token creation. + C::renounceRole(c) => { + let caller = ctx.caller(); + self.renounce_role(caller, c.role, c.callerConfirmation)?; + Bytes::new() + } + C::renounceLastAdmin(_) => { + let caller = ctx.caller(); + self.renounce_last_admin(caller)?; + Bytes::new() + } + C::setRoleAdmin(c) => { + let caller = ctx.caller(); + self.set_role_admin(caller, c.role, c.newAdminRole, privileged)?; + Bytes::new() + } + C::updatePolicy(c) => { + let caller = ctx.caller(); + self.update_policy(caller, c.policyScope, c.newPolicyId, privileged)?; + Bytes::new() + } + + // --- Permit --- + C::permit(c) => { + self.permit( + ctx.chain_id(), + ctx.timestamp(), + PermitArgs { + owner: c.owner, + spender: c.spender, + value: c.value, + deadline: c.deadline, + v: c.v, + r: c.r, + s: c.s, + }, + )?; + Bytes::new() + } + }; + Ok(encoded) + }) + } +} diff --git a/crates/common/precompiles/src/b20/mod.rs b/crates/common/precompiles/src/b20/mod.rs new file mode 100644 index 0000000000..aa62769ff5 --- /dev/null +++ b/crates/common/precompiles/src/b20/mod.rs @@ -0,0 +1,21 @@ +//! `B20Token` native precompile — the core B-20 token implementation. + +mod abi; +pub use abi::IB20; + +mod dispatch; + +mod pausable; +pub use pausable::B20PausableFeature; + +mod policies; +pub use policies::B20PolicyType; + +mod precompile; +pub use precompile::B20TokenPrecompile; + +mod storage; +pub use storage::{B20CoreStorage, B20TokenInit, B20TokenStorage}; + +mod token; +pub use token::B20Token; diff --git a/crates/common/precompiles/src/b20/pausable.rs b/crates/common/precompiles/src/b20/pausable.rs new file mode 100644 index 0000000000..a77b960d00 --- /dev/null +++ b/crates/common/precompiles/src/b20/pausable.rs @@ -0,0 +1,16 @@ +//! Pause-bit helpers for B-20 tokens. + +use alloy_primitives::U256; + +use crate::IB20; + +/// Helpers for mapping B-20 pausable features into storage bits. +#[derive(Debug, Clone, Copy)] +pub struct B20PausableFeature; + +impl B20PausableFeature { + /// Returns the storage bit for a pausable feature. + pub fn mask(feature: IB20::PausableFeature) -> U256 { + U256::ONE.checked_shl(usize::from(feature as u8)).unwrap_or(U256::ZERO) + } +} diff --git a/crates/common/precompiles/src/b20/policies.rs b/crates/common/precompiles/src/b20/policies.rs new file mode 100644 index 0000000000..d8e0a1ad1f --- /dev/null +++ b/crates/common/precompiles/src/b20/policies.rs @@ -0,0 +1,182 @@ +//! Policy helpers for B-20 tokens. + +use alloy_primitives::{Address, B256, b256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, B20Token, B20TokenRole, IB20, Policy, Token, TokenAccounting}; + +const TRANSFER_SENDER_POLICY: B256 = + b256!("b81736c875ab819dd97f59f2a6542cfb731ad52b4ae15a6f24df2fb02b0327f5"); +const TRANSFER_RECEIVER_POLICY: B256 = + b256!("8a4b3fa2d8b921852bc0089c6ef0958aa6961897be36fd731330fe2cd23f8363"); +const TRANSFER_EXECUTOR_POLICY: B256 = + b256!("10be5173aff2a44e748bd9acd8b19fe34689581398a9db7ba2fb671e786ff7d8"); +const MINT_RECEIVER_POLICY: B256 = + b256!("a0d5ae037e66a09119acf080a1d807abb9b6d03b6b9130eb19f7c1e6bdb8ffc8"); + +/// Built-in B-20 policy slots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum B20PolicyType { + /// Policy slot checked against transfer senders. + TransferSender, + /// Policy slot checked against transfer receivers. + TransferReceiver, + /// Policy slot checked against delegated transfer executors. + TransferExecutor, + /// Policy slot checked against mint receivers. + MintReceiver, +} + +impl B20PolicyType { + /// Returns the built-in policy type for `id`, if it is recognized. + pub fn from_id(id: B256) -> Option { + if id == TRANSFER_SENDER_POLICY { + Some(Self::TransferSender) + } else if id == TRANSFER_RECEIVER_POLICY { + Some(Self::TransferReceiver) + } else if id == TRANSFER_EXECUTOR_POLICY { + Some(Self::TransferExecutor) + } else if id == MINT_RECEIVER_POLICY { + Some(Self::MintReceiver) + } else { + None + } + } + + /// Returns the policy type identifier. + pub const fn id(self) -> B256 { + match self { + Self::TransferSender => TRANSFER_SENDER_POLICY, + Self::TransferReceiver => TRANSFER_RECEIVER_POLICY, + Self::TransferExecutor => TRANSFER_EXECUTOR_POLICY, + Self::MintReceiver => MINT_RECEIVER_POLICY, + } + } +} + +impl B20Token { + /// Policy slot checked against transfer senders. + pub const fn transfer_sender_policy() -> B256 { + B20PolicyType::TransferSender.id() + } + + /// Policy slot checked against transfer receivers. + pub const fn transfer_receiver_policy() -> B256 { + B20PolicyType::TransferReceiver.id() + } + + /// Policy slot checked against delegated transfer executors. + pub const fn transfer_executor_policy() -> B256 { + B20PolicyType::TransferExecutor.id() + } + + /// Policy slot checked against mint receivers. + pub const fn mint_receiver_policy() -> B256 { + B20PolicyType::MintReceiver.id() + } + + /// Returns the configured policy ID for `policy_scope`. + pub fn policy_id(&self, policy_scope: B256) -> Result { + Self::ensure_supported_policy_type(policy_scope)?; + self.accounting().policy_id(policy_scope) + } + + /// Updates the configured policy ID for `policy_scope`. + pub fn update_policy( + &mut self, + caller: Address, + policy_scope: B256, + new_policy_id: u64, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(self, caller, B20TokenRole::DefaultAdmin)?; + } + let old_policy_id = self.policy_id(policy_scope)?; + if !self.policy().policy_exists(new_policy_id)? { + return Err(BasePrecompileError::revert(IB20::PolicyNotFound { + policyId: new_policy_id, + })); + } + self.accounting_mut().set_policy_id(policy_scope, new_policy_id)?; + self.accounting_mut().emit_event( + IB20::PolicyUpdated { + policyScope: policy_scope, + oldPolicyId: old_policy_id, + newPolicyId: new_policy_id, + } + .encode_log_data(), + ) + } + + /// Ensures `policy_scope` names a B-20 policy slot. + pub fn ensure_supported_policy_type(policy_scope: B256) -> Result<()> { + if B20PolicyType::from_id(policy_scope).is_some() { + Ok(()) + } else { + Err(BasePrecompileError::revert(IB20::UnsupportedPolicyType { + policyScope: policy_scope, + })) + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256}; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20PolicyType, B20Token, B20TokenRole, IB20, InMemoryPolicy, InMemoryTokenAccounting, + Token, TokenAccounting, + }; + + const ADMIN: Address = Address::repeat_byte(0xaa); + const TOKEN_ADDR: Address = Address::repeat_byte(0x20); + const CUSTOM_POLICY_ID: u64 = 7; + + fn token() -> B20Token { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.roles.insert((B20TokenRole::DefaultAdmin.id(), ADMIN), true); + B20Token::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn policy_id_reverts_for_unsupported_policy_type() { + let token = token(); + let policy_scope = B256::repeat_byte(0x99); + + assert_eq!( + token.policy_id(policy_scope).unwrap_err(), + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: policy_scope }) + ); + } + + #[test] + fn update_policy_reverts_for_missing_policy_id() { + let mut token = token(); + + assert_eq!( + token + .update_policy(ADMIN, B20PolicyType::TransferSender.id(), CUSTOM_POLICY_ID, false) + .unwrap_err(), + BasePrecompileError::revert(IB20::PolicyNotFound { policyId: CUSTOM_POLICY_ID }) + ); + } + + #[test] + fn update_policy_accepts_existing_policy_id() { + let mut token = token(); + token.policy_mut().create_existing_policy(CUSTOM_POLICY_ID); + + token + .update_policy(ADMIN, B20PolicyType::TransferSender.id(), CUSTOM_POLICY_ID, false) + .unwrap(); + + assert_eq!( + token.accounting().policy_id(B20PolicyType::TransferSender.id()).unwrap(), + CUSTOM_POLICY_ID + ); + } +} diff --git a/crates/common/precompiles/src/b20/precompile.rs b/crates/common/precompiles/src/b20/precompile.rs new file mode 100644 index 0000000000..3c002e20d2 --- /dev/null +++ b/crates/common/precompiles/src/b20/precompile.rs @@ -0,0 +1,39 @@ +//! Precompile entry point for the `B20Token`. + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::Address; + +use crate::{ + B20Token, B20TokenStorage, NoopPrecompileCallObserver, PolicyHandle, PrecompileCallObserver, + macros::base_precompile, +}; + +/// Entry point for the `B20Token` precompile. +/// +/// Wraps [`B20Token`] dispatch behind a [`DynPrecompile`] suitable for +/// registration in a [`PrecompilesMap`]. +#[derive(Debug)] +pub struct B20TokenPrecompile; + +impl B20TokenPrecompile { + /// Returns a [`DynPrecompile`] that dispatches to the [`B20Token`] logic at `token_address`. + pub fn create_precompile(token_address: Address) -> DynPrecompile { + Self::create_precompile_with_observer(token_address, NoopPrecompileCallObserver) + } + + /// Returns a [`DynPrecompile`] that observes and dispatches to the [`B20Token`] logic at + /// `token_address`. + pub fn create_precompile_with_observer(token_address: Address, observer: O) -> DynPrecompile + where + O: PrecompileCallObserver, + { + base_precompile!(alloc::format!("B20Token@{token_address}"), |ctx, calldata| { + let observer = observer.clone(); + B20Token::with_storage_and_policy( + B20TokenStorage::from_address(token_address, ctx), + PolicyHandle::new(ctx), + ) + .dispatch_with_observer(ctx, &calldata, observer) + }) + } +} diff --git a/crates/common/precompiles/src/b20/storage.rs b/crates/common/precompiles/src/b20/storage.rs new file mode 100644 index 0000000000..d538116e51 --- /dev/null +++ b/crates/common/precompiles/src/b20/storage.rs @@ -0,0 +1,376 @@ +//! `B20TokenStorage` stores the EVM storage layout for B-20 tokens. + +use alloc::string::String; + +use alloy_primitives::{Address, B256, LogData, U256}; +use base_precompile_macros::{Storable, contract}; +use base_precompile_storage::{ + BasePrecompileError, ContractStorage, Handler, Mapping, Result, StorageCtx, +}; + +use crate::{B20PolicyType, B20TokenRole, B20Variant, IB20, TokenAccounting}; + +/// Creation-time parameters for a B-20 token. +/// +/// Passed to [`B20TokenStorage::initialize`] to write all fields atomically. +#[derive(Debug)] +pub struct B20TokenInit { + /// Token name. + pub name: String, + /// Token symbol. + pub symbol: String, + /// Maximum total supply allowed. + pub supply_cap: U256, +} + +/// Core B-20 storage rooted at the `base.b20` ERC-7201 namespace. +#[derive(Debug, Clone, Storable)] +#[namespace("base.b20")] +pub struct B20CoreStorage { + /// Mutable token name. + pub name: String, // offset 0 + /// Mutable token symbol. + pub symbol: String, // offset 1 + /// ERC-7572 contract metadata URI. + pub contract_uri: String, // offset 2 + /// Total token supply. + pub total_supply: U256, // offset 3 + /// Token balances by account. + pub balances: Mapping, // offset 4 + /// Spending allowances by owner and spender. + pub allowances: Mapping>, // offset 5 + /// Role membership flags by role and account. + pub roles: Mapping>, // offset 6 + /// Admin role configured for each role. + pub role_admins: Mapping, // offset 7 + /// Default-admin holder count. + pub admin_count: U256, // offset 8 + /// Packed transfer-side policy IDs. + pub transfer_policy_ids: U256, // offset 9: sender, receiver, executor, reserved + /// Packed mint-side policy IDs. + pub mint_policy_ids: U256, // offset 10: receiver, reserved, reserved, reserved + /// Paused feature bitmask. + pub paused: U256, // offset 11 + /// Maximum total supply. + pub supply_cap: U256, // offset 12 + /// EIP-2612 permit nonces by owner. + pub nonces: Mapping, // offset 13 +} + +/// EVM-backed storage for the default B-20 variant. +#[contract] +pub struct B20TokenStorage { + pub b20: B20CoreStorage, +} + +impl<'a> B20TokenStorage<'a> { + /// Creates a `B20TokenStorage` instance targeting `addr`. + /// + /// Used by the factory to initialize token storage at a dynamically computed address. + pub fn from_address(addr: Address, storage: StorageCtx<'a>) -> Self { + Self::__new(addr, storage) + } + + /// Writes all creation-time fields atomically. + pub fn initialize(&mut self, init: B20TokenInit) -> Result<()> { + self.b20.name.write(init.name)?; + self.b20.symbol.write(init.symbol)?; + self.b20.supply_cap.write(init.supply_cap)?; + Ok(()) + } +} + +impl TokenAccounting for B20TokenStorage<'_> { + fn token_address(&self) -> Address { + ContractStorage::address(self) + } + + fn is_initialized(&self) -> Result { + ContractStorage::is_initialized(self) + } + + fn balance_of(&self, account: Address) -> Result { + self.b20.balances.at(&account).read() + } + + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()> { + self.b20.balances.at_mut(&account).write(balance) + } + + fn allowance(&self, owner: Address, spender: Address) -> Result { + self.b20.allowances.at(&owner).at(&spender).read() + } + + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + self.b20.allowances.at_mut(&owner).at_mut(&spender).write(amount) + } + + fn total_supply(&self) -> Result { + self.b20.total_supply.read() + } + + fn set_total_supply(&mut self, supply: U256) -> Result<()> { + self.b20.total_supply.write(supply) + } + + fn supply_cap(&self) -> Result { + self.b20.supply_cap.read() + } + + fn set_supply_cap(&mut self, cap: U256) -> Result<()> { + self.b20.supply_cap.write(cap) + } + + fn name(&self) -> Result { + self.b20.name.read() + } + + fn set_name(&mut self, name: String) -> Result<()> { + self.b20.name.write(name) + } + + fn symbol(&self) -> Result { + self.b20.symbol.read() + } + + fn set_symbol(&mut self, symbol: String) -> Result<()> { + self.b20.symbol.write(symbol) + } + + fn decimals(&self) -> Result { + Ok(B20Variant::from_address(ContractStorage::address(self)).map_or(0, B20Variant::decimals)) + } + + fn paused(&self) -> Result { + self.b20.paused.read() + } + + fn set_paused(&mut self, vectors: U256) -> Result<()> { + self.b20.paused.write(vectors) + } + + fn nonce(&self, owner: Address) -> Result { + self.b20.nonces.at(&owner).read() + } + + fn increment_nonce(&mut self, owner: Address) -> Result<()> { + let current = self.b20.nonces.at(&owner).read()?; + let next = + current.checked_add(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + self.b20.nonces.at_mut(&owner).write(next) + } + + fn contract_uri(&self) -> Result { + self.b20.contract_uri.read() + } + + fn set_contract_uri(&mut self, uri: String) -> Result<()> { + self.b20.contract_uri.write(uri) + } + + fn has_role(&self, role: B256, account: Address) -> Result { + self.b20.roles.at(&role).at(&account).read() + } + + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()> { + self.b20.roles.at_mut(&role).at_mut(&account).write(enabled) + } + + fn role_member_count(&self, role: B256) -> Result { + if role == B20TokenRole::DefaultAdmin.id() { + self.b20.admin_count.read() + } else { + Ok(U256::ZERO) + } + } + + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()> { + if role == B20TokenRole::DefaultAdmin.id() { + self.b20.admin_count.write(count) + } else { + Ok(()) + } + } + + fn role_admin(&self, role: B256) -> Result { + let admin_role = self.b20.role_admins.at(&role).read()?; + if admin_role.is_zero() && role != B20TokenRole::DefaultAdmin.id() { + Ok(B20TokenRole::DefaultAdmin.id()) + } else { + Ok(admin_role) + } + } + + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()> { + self.b20.role_admins.at_mut(&role).write(admin_role) + } + + fn policy_id(&self, policy_scope: B256) -> Result { + let policy_type = Self::require_policy_type(policy_scope)?; + match policy_type { + B20PolicyType::TransferSender => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_SENDER_POLICY_LANE, + )), + B20PolicyType::TransferReceiver => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_RECEIVER_POLICY_LANE, + )), + B20PolicyType::TransferExecutor => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_EXECUTOR_POLICY_LANE, + )), + B20PolicyType::MintReceiver => Ok(Self::read_policy_lane( + self.b20.mint_policy_ids.read()?, + Self::MINT_RECEIVER_POLICY_LANE, + )), + } + } + + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()> { + let policy_type = Self::require_policy_type(policy_scope)?; + match policy_type { + B20PolicyType::TransferSender => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_SENDER_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::TransferReceiver => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_RECEIVER_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::TransferExecutor => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_EXECUTOR_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::MintReceiver => { + let packed = Self::write_policy_lane( + self.b20.mint_policy_ids.read()?, + Self::MINT_RECEIVER_POLICY_LANE, + policy_id, + ); + self.b20.mint_policy_ids.write(packed) + } + } + } + + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.emit_event(log) + } +} + +impl B20TokenStorage<'_> { + const TRANSFER_SENDER_POLICY_LANE: usize = 0; + const TRANSFER_RECEIVER_POLICY_LANE: usize = 1; + const TRANSFER_EXECUTOR_POLICY_LANE: usize = 2; + const MINT_RECEIVER_POLICY_LANE: usize = 0; + const POLICY_LANE_BITS: usize = 64; + + fn require_policy_type(policy_scope: B256) -> Result { + B20PolicyType::from_id(policy_scope).ok_or_else(|| { + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: policy_scope }) + }) + } + + fn read_policy_lane(packed: U256, lane: usize) -> u64 { + ((packed >> (lane * Self::POLICY_LANE_BITS)) & U256::from(u64::MAX)).to::() + } + + fn write_policy_lane(packed: U256, lane: usize, policy_id: u64) -> U256 { + let shift = lane * Self::POLICY_LANE_BITS; + let mask = U256::from(u64::MAX) << shift; + (packed & !mask) | (U256::from(policy_id) << shift) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256, address, uint}; + use base_precompile_storage::{Handler, StorableType, StorageCtx, StorageKey, setup_storage}; + + use crate::{ + B20CoreStorage, B20TokenRole, B20TokenStorage, TokenAccounting, + b20::storage::{__packing_b20_core_storage, slots}, + }; + + const TOKEN: Address = address!("000000000000000000000000000000000000b020"); + const B20_ROOT: U256 = + uint!(0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434000_U256); + + #[test] + fn b20_namespaces_match_base_std_roots() { + assert_eq!(::STORAGE_NAMESPACE_ID, "base.b20"); + assert_eq!(::STORAGE_NAMESPACE_ROOT, B20_ROOT); + + assert_eq!(slots::B20, B20_ROOT); + } + + #[test] + fn b20_core_offsets_match_mock_b20_storage() { + assert_eq!(__packing_b20_core_storage::NAME_LOC.offset_slots, 0); + assert_eq!(__packing_b20_core_storage::SYMBOL_LOC.offset_slots, 1); + assert_eq!(__packing_b20_core_storage::CONTRACT_URI_LOC.offset_slots, 2); + assert_eq!(__packing_b20_core_storage::TOTAL_SUPPLY_LOC.offset_slots, 3); + assert_eq!(__packing_b20_core_storage::BALANCES_LOC.offset_slots, 4); + assert_eq!(__packing_b20_core_storage::ALLOWANCES_LOC.offset_slots, 5); + assert_eq!(__packing_b20_core_storage::ROLES_LOC.offset_slots, 6); + assert_eq!(__packing_b20_core_storage::ROLE_ADMINS_LOC.offset_slots, 7); + assert_eq!(__packing_b20_core_storage::ADMIN_COUNT_LOC.offset_slots, 8); + assert_eq!(__packing_b20_core_storage::TRANSFER_POLICY_IDS_LOC.offset_slots, 9); + assert_eq!(__packing_b20_core_storage::MINT_POLICY_IDS_LOC.offset_slots, 10); + assert_eq!(__packing_b20_core_storage::PAUSED_LOC.offset_slots, 11); + assert_eq!(__packing_b20_core_storage::SUPPLY_CAP_LOC.offset_slots, 12); + assert_eq!(__packing_b20_core_storage::NONCES_LOC.offset_slots, 13); + } + + #[test] + fn b20_core_mapping_slots_are_rooted_at_namespace_offsets() { + let (mut storage, _) = setup_storage(); + let holder = Address::repeat_byte(0xaa); + let spender = Address::repeat_byte(0xbb); + let role = B20TokenRole::Mint.id(); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20TokenStorage::from_address(TOKEN, ctx); + token.b20.balances.at_mut(&holder).write(U256::from(100)).unwrap(); + token.b20.allowances.at_mut(&holder).at_mut(&spender).write(U256::from(25)).unwrap(); + token.b20.roles.at_mut(&role).at_mut(&holder).write(true).unwrap(); + token.set_role_member_count(B20TokenRole::DefaultAdmin.id(), U256::ONE).unwrap(); + + let balances_slot = + B20_ROOT + U256::from(__packing_b20_core_storage::BALANCES_LOC.offset_slots); + let allowances_slot = + B20_ROOT + U256::from(__packing_b20_core_storage::ALLOWANCES_LOC.offset_slots); + let roles_slot = + B20_ROOT + U256::from(__packing_b20_core_storage::ROLES_LOC.offset_slots); + let admin_count_slot = + B20_ROOT + U256::from(__packing_b20_core_storage::ADMIN_COUNT_LOC.offset_slots); + + assert_eq!( + ctx.sload(TOKEN, holder.mapping_slot(balances_slot)).unwrap(), + U256::from(100) + ); + assert_eq!( + ctx.sload(TOKEN, spender.mapping_slot(holder.mapping_slot(allowances_slot))) + .unwrap(), + U256::from(25) + ); + assert_eq!( + ctx.sload(TOKEN, holder.mapping_slot(role.mapping_slot(roles_slot))).unwrap(), + U256::ONE + ); + assert_eq!(ctx.sload(TOKEN, admin_count_slot).unwrap(), U256::ONE); + }); + } +} diff --git a/crates/common/precompiles/src/b20/token.rs b/crates/common/precompiles/src/b20/token.rs new file mode 100644 index 0000000000..71851e46e6 --- /dev/null +++ b/crates/common/precompiles/src/b20/token.rs @@ -0,0 +1,71 @@ +//! `B20Token` struct — the concrete B-20 token type. + +use alloy_primitives::Address; + +use crate::{ + Burnable, Configurable, Mintable, Pausable, Permittable, Policy, RoleManaged, Token, + TokenAccounting, Transferable, +}; + +/// EVM precompile for the Default B-20 token variant. +/// +/// The generic `S` lets callers swap in an in-memory [`TokenAccounting`] +/// implementation for unit tests without touching real EVM storage. The +/// generic `P` provides the [`Policy`] implementation consulted for policy +/// decisions. In production, the dynamic precompile lookup wires storage and +/// policy adapters from the same EVM context. +#[derive(Debug, Clone)] +pub struct B20Token { + accounting: S, + policy: P, +} + +impl B20Token { + /// Creates a `B20Token` backed by the provided storage and policy adapters. + /// + /// Use this in tests to inject in-memory [`TokenAccounting`] and [`Policy`] implementations. + pub const fn with_storage_and_policy(accounting: S, policy: P) -> Self { + Self { accounting, policy } + } +} + +// --------------------------------------------------------------------------- +// Token: wire the accounting field and dynamic token address +// --------------------------------------------------------------------------- + +impl Token for B20Token { + type Accounting = S; + type Policy = P; + + fn accounting(&self) -> &S { + &self.accounting + } + + fn accounting_mut(&mut self) -> &mut S { + &mut self.accounting + } + + fn policy(&self) -> &P { + &self.policy + } + + fn policy_mut(&mut self) -> &mut P { + &mut self.policy + } + + fn token_address(&self) -> Address { + self.accounting.token_address() + } +} + +// --------------------------------------------------------------------------- +// Capability selection — B20Token opts in to all capabilities +// --------------------------------------------------------------------------- + +impl Transferable for B20Token {} +impl Mintable for B20Token {} +impl Burnable for B20Token {} +impl Pausable for B20Token {} +impl Configurable for B20Token {} +impl Permittable for B20Token {} +impl RoleManaged for B20Token {} diff --git a/crates/common/precompiles/src/b20_factory/abi.rs b/crates/common/precompiles/src/b20_factory/abi.rs new file mode 100644 index 0000000000..179c90a2b7 --- /dev/null +++ b/crates/common/precompiles/src/b20_factory/abi.rs @@ -0,0 +1,97 @@ +//! ABI definition for the `IB20Factory` interface. + +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq, Eq)] + interface IB20Factory { + // ── Structs ───────────────────────────────────────────────────────── + + enum B20Variant { + /// Default B-20 token variant. + DEFAULT, + /// Stablecoin B-20 token variant. + STABLECOIN, + /// Security B-20 token variant. + SECURITY + } + + struct B20CreateParams { + uint8 version; + string name; + string symbol; + address initialAdmin; + } + + struct B20StablecoinCreateParams { + uint8 version; + string name; + string symbol; + address initialAdmin; + string currency; + } + + struct B20SecurityCreateParams { + uint8 version; + string name; + string symbol; + address initialAdmin; + string isin; + uint256 minimumRedeemable; + } + + // ── Errors ─────────────────────────────────────────────────────────── + + /// A token already exists at the address derived from `(variant, msg.sender, salt)`. + error TokenAlreadyExists(address token); + + /// `variant` is not recognized or is `NONE`. + error InvalidVariant(); + + /// `version` is not supported for the requested variant. + error UnsupportedVersion(uint8 version, B20Variant variant); + + /// A required string argument was empty. + error MissingRequiredField(); + + /// The stablecoin `currency` field was not on the ISO 4217 fiat allowlist. + error InvalidCurrency(string code); + + /// One of the post-creation init calls failed. + error InitCallFailed(uint256 index); + + // ── Events ─────────────────────────────────────────────────────────── + + event B20Created( + address indexed token, + B20Variant indexed variant, + string name, + string symbol, + uint8 decimals + ); + + // ── Functions ──────────────────────────────────────────────────────── + + /// Creates a B-20 token of the requested variant at a deterministic address. + /// + /// Default tokens start with an unbounded supply cap and the pausable plus mutable-cap + /// capability bits enabled. Callers configure optional launch state atomically through + /// `initCalls`, such as minting initial supply, lowering the supply cap, pausing, or setting + /// metadata. + function createB20( + B20Variant variant, + bytes32 salt, + bytes calldata params, + bytes[] calldata initCalls + ) external returns (address token); + + /// Returns the address a `createB20` call would produce. + function getB20Address(B20Variant variant, address sender, bytes32 salt) external view returns (address); + + /// Returns `true` if `token` has the B-20 address prefix. + function isB20(address token) external view returns (bool); + + /// Returns `true` if `token` has been initialized by this factory. + function isB20Initialized(address token) external view returns (bool); + } +} diff --git a/crates/common/precompiles/src/b20_factory/dispatch.rs b/crates/common/precompiles/src/b20_factory/dispatch.rs new file mode 100644 index 0000000000..924a8e7a6e --- /dev/null +++ b/crates/common/precompiles/src/b20_factory/dispatch.rs @@ -0,0 +1,51 @@ +//! ABI dispatch for the `B20Factory` precompile. + +use alloy_primitives::Bytes; +use alloy_sol_types::SolCall; +use base_precompile_storage::{BasePrecompileError, IntoPrecompileResult, StorageCtx}; +use revm::precompile::PrecompileResult; + +use crate::{ + ActivationFeature, ActivationRegistryStorage, B20FactoryStorage, B20Variant, IB20Factory, + macros::{decode_precompile_call, deduct_calldata_cost}, +}; + +impl<'a> B20FactoryStorage<'a> { + /// ABI-dispatches `calldata` to the appropriate `IB20Factory` handler. + pub fn dispatch(&mut self, ctx: StorageCtx<'_>, calldata: &[u8]) -> PrecompileResult { + deduct_calldata_cost!(ctx, calldata); + let result = self.inner(ctx, calldata); + let gas = ctx.gas_used(); + result.into_precompile_result(gas, ctx.state_gas_used(), |b| b) + } + + fn inner( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + ) -> base_precompile_storage::Result { + ActivationRegistryStorage::new(ctx).ensure_activated(ActivationFeature::B20Factory.id())?; + + match decode_precompile_call!(calldata, IB20Factory::IB20FactoryCalls) { + IB20Factory::IB20FactoryCalls::createB20(call) => { + let caller = ctx.caller(); + let token = self.create_b20(caller, call)?; + Ok(IB20Factory::createB20Call::abi_encode_returns(&token).into()) + } + IB20Factory::IB20FactoryCalls::getB20Address(call) => { + let variant = B20Variant::from_abi(call.variant) + .ok_or_else(|| BasePrecompileError::revert(IB20Factory::InvalidVariant {}))?; + let (addr, _) = variant.compute_address(call.sender, call.salt); + Ok(IB20Factory::getB20AddressCall::abi_encode_returns(&addr).into()) + } + IB20Factory::IB20FactoryCalls::isB20(call) => { + let result = self.is_b20(call.token)?; + Ok(IB20Factory::isB20Call::abi_encode_returns(&result).into()) + } + IB20Factory::IB20FactoryCalls::isB20Initialized(call) => { + let initialized = self.is_b20_initialized(call.token)?; + Ok(IB20Factory::isB20InitializedCall::abi_encode_returns(&initialized).into()) + } + } + } +} diff --git a/crates/common/precompiles/src/b20_factory/mod.rs b/crates/common/precompiles/src/b20_factory/mod.rs new file mode 100644 index 0000000000..31d3a5b2f0 --- /dev/null +++ b/crates/common/precompiles/src/b20_factory/mod.rs @@ -0,0 +1,15 @@ +//! `B20Factory` native precompile — creates B-20 tokens at deterministic prefix-encoded addresses. + +mod abi; +pub use abi::IB20Factory; + +mod dispatch; + +mod precompile; +pub use precompile::B20Factory; + +mod storage; +pub use storage::{B20FactoryStorage, CommonParams, TokenCreateParams}; + +mod variant; +pub use variant::B20Variant; diff --git a/crates/common/precompiles/src/b20_factory/precompile.rs b/crates/common/precompiles/src/b20_factory/precompile.rs new file mode 100644 index 0000000000..227866a131 --- /dev/null +++ b/crates/common/precompiles/src/b20_factory/precompile.rs @@ -0,0 +1,10 @@ +//! Precompile entry point for the `B20Factory`. + +use base_precompile_macros::precompile; + +use crate::B20FactoryStorage; + +/// Entry point for the `B20Factory` precompile. +#[precompile(install)] +#[derive(Debug, Default, Clone, Copy)] +pub struct B20Factory; diff --git a/crates/common/precompiles/src/b20_factory/storage.rs b/crates/common/precompiles/src/b20_factory/storage.rs new file mode 100644 index 0000000000..2865bd1046 --- /dev/null +++ b/crates/common/precompiles/src/b20_factory/storage.rs @@ -0,0 +1,1242 @@ +use alloc::{string::ToString, vec::Vec}; + +use alloy_primitives::{Address, Bytes, U256, address}; +use alloy_sol_types::{SolCall, SolValue}; +use base_precompile_macros::contract; +use base_precompile_storage::{BasePrecompileError, Result}; +use revm::state::Bytecode; + +use crate::{ + B20SecurityInit, B20SecurityStorage, B20SecurityToken, B20StablecoinInit, B20StablecoinStorage, + B20StablecoinToken, B20Token, B20TokenInit, B20TokenRole, B20TokenStorage, B20Variant, + IB20Factory, PolicyHandle, RoleManaged, Token, +}; + +/// Maximum total supply for all newly-created B-20 tokens. +const DEFAULT_SUPPLY_CAP: U256 = U256::MAX; + +/// Initial share-to-token ratio storage value. Reads treat zero as WAD precision (1:1). +const INITIAL_SHARES_TO_TOKENS_RATIO: U256 = U256::ZERO; + +/// The B-20 token factory precompile. +#[contract(addr = Self::ADDRESS)] +pub struct B20FactoryStorage {} + +impl<'a> B20FactoryStorage<'a> { + /// Singleton precompile address for the `B20Factory`. + pub const ADDRESS: Address = address!("B20F000000000000000000000000000000000000"); + + /// Current token creation parameter version. + pub const CREATE_TOKEN_VERSION: u8 = 1; + + /// Initial supply cap for newly created default B-20 tokens. + pub const DEFAULT_SUPPLY_CAP: U256 = DEFAULT_SUPPLY_CAP; + + /// Creates a token at a deterministic address derived from `(caller, variant, salt)`. + pub fn create_b20( + &mut self, + caller: Address, + call: IB20Factory::createB20Call, + ) -> Result
{ + let variant = B20Variant::from_abi(call.variant) + .ok_or_else(|| BasePrecompileError::revert(IB20Factory::InvalidVariant {}))?; + let params = TokenCreateParams::decode(variant, &call.params)?; + Self::check_version(params.version(), variant.abi())?; + params.validate()?; + let (token_address, _) = variant.compute_address(caller, call.salt); + + let already_deployed = + self.storage.with_account_info(token_address, |info| Ok(!info.is_empty_code_hash()))?; + if already_deployed { + return Err(BasePrecompileError::revert(IB20Factory::TokenAlreadyExists { + token: token_address, + })); + } + + let checkpoint = self.storage.checkpoint(); + let stub = Bytecode::new_legacy(Bytes::from_static(&[0xef])); + self.storage.set_code(token_address, stub)?; + + let init_calls = call.initCalls; + match params { + TokenCreateParams::B20 { common, init } => { + self.init_b20_token(token_address, common, init, init_calls)?; + } + TokenCreateParams::Stablecoin { common, init } => { + self.init_stablecoin(token_address, common, init, init_calls)?; + } + TokenCreateParams::Security { common, init } => { + self.init_security_token(token_address, common, init, init_calls)?; + } + } + + checkpoint.commit(); + Ok(token_address) + } + + /// Returns whether `token` has the structural B-20 prefix. + /// + /// This includes reserved or future variant discriminants in the B-20 address range. + pub fn is_b20(&self, token: Address) -> Result { + Ok(B20Variant::has_b20_prefix(token)) + } + + /// Returns whether `token` is a B-20 address that has been initialized by this factory. + /// + /// Returns `false` for addresses without the B-20 prefix, even if they have bytecode. + pub fn is_b20_initialized(&self, token: Address) -> Result { + if !B20Variant::has_b20_prefix(token) { + return Ok(false); + } + self.storage.with_account_info(token, |info| Ok(!info.is_empty_code_hash())) + } + + fn init_b20_token( + &mut self, + token_address: Address, + common: CommonParams, + init: B20TokenInit, + init_calls: Vec, + ) -> Result<()> { + let mut token = B20Token::with_storage_and_policy( + B20TokenStorage::from_address(token_address, self.storage), + PolicyHandle::new(self.storage), + ); + let (name, symbol) = (init.name.clone(), init.symbol.clone()); + token.accounting_mut().initialize(init)?; + + self.emit_event(IB20Factory::B20Created { + token: token_address, + variant: B20Variant::B20.abi(), + name, + symbol, + decimals: B20Variant::B20.decimals(), + })?; + + if !common.initial_admin.is_zero() { + token.grant_role_unchecked( + B20TokenRole::DefaultAdmin.id(), + common.initial_admin, + Self::ADDRESS, + )?; + } + + self.storage.with_caller(Self::ADDRESS, || { + for (index, calldata) in init_calls.into_iter().enumerate() { + token + .inner_with_privilege(self.storage, &calldata, true) + .map_err(|err| Self::map_init_call_error(index, err))?; + } + Ok::<(), BasePrecompileError>(()) + })?; + Ok(()) + } + + fn init_stablecoin( + &mut self, + token_address: Address, + common: CommonParams, + init: B20StablecoinInit, + init_calls: Vec, + ) -> Result<()> { + let mut token = B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(token_address, self.storage), + PolicyHandle::new(self.storage), + ); + let (name, symbol) = (init.name.clone(), init.symbol.clone()); + token.accounting_mut().initialize(init)?; + + self.emit_event(IB20Factory::B20Created { + token: token_address, + variant: B20Variant::Stablecoin.abi(), + name, + symbol, + decimals: B20Variant::Stablecoin.decimals(), + })?; + + if !common.initial_admin.is_zero() { + token.grant_role_unchecked( + B20TokenRole::DefaultAdmin.id(), + common.initial_admin, + Self::ADDRESS, + )?; + } + + self.storage.with_caller(Self::ADDRESS, || { + for (index, calldata) in init_calls.into_iter().enumerate() { + token + .inner_with_privilege(self.storage, &calldata, true) + .map_err(|err| Self::map_init_call_error(index, err))?; + } + Ok::<(), BasePrecompileError>(()) + })?; + Ok(()) + } + + fn init_security_token( + &mut self, + token_address: Address, + common: CommonParams, + init: B20SecurityInit, + init_calls: Vec, + ) -> Result<()> { + let mut storage = B20SecurityStorage::from_address(token_address, self.storage); + let (name, symbol) = (init.name.clone(), init.symbol.clone()); + storage.initialize(init)?; + + self.emit_event(IB20Factory::B20Created { + token: token_address, + variant: B20Variant::Security.abi(), + name, + symbol, + decimals: B20Variant::Security.decimals(), + })?; + + if !common.initial_admin.is_zero() { + let mut token = B20SecurityToken::with_storage_and_policy( + B20SecurityStorage::from_address(token_address, self.storage), + PolicyHandle::new(self.storage), + ); + token.grant_role_unchecked( + B20TokenRole::DefaultAdmin.id(), + common.initial_admin, + Self::ADDRESS, + )?; + } + + self.storage.with_caller(Self::ADDRESS, || { + for (index, calldata) in init_calls.into_iter().enumerate() { + B20SecurityToken::with_storage_and_policy( + B20SecurityStorage::from_address(token_address, self.storage), + PolicyHandle::new(self.storage), + ) + .inner_with_privilege(self.storage, &calldata, true) + .map_err(|err| Self::map_init_call_error(index, err))?; + } + Ok::<(), BasePrecompileError>(()) + })?; + Ok(()) + } + + fn check_version(version: u8, variant: IB20Factory::B20Variant) -> Result<()> { + if version != Self::CREATE_TOKEN_VERSION { + return Err(BasePrecompileError::revert(IB20Factory::UnsupportedVersion { + version, + variant, + })); + } + Ok(()) + } + + fn map_init_call_error(index: usize, err: BasePrecompileError) -> BasePrecompileError { + match err { + BasePrecompileError::Revert(bytes) if !bytes.is_empty() => { + BasePrecompileError::Revert(bytes) + } + err if err.is_system_error() => err, + _ => BasePrecompileError::revert(IB20Factory::InitCallFailed { + index: U256::from(index), + }), + } + } +} + +/// Control-flow fields shared by every token variant (not written to storage). +#[derive(Debug)] +pub struct CommonParams { + /// Token creation parameter version. + version: u8, + /// Initial default admin granted after token initialization. + initial_admin: Address, +} + +/// Decoded creation parameters typed per token variant. +/// +/// Each arm carries a typed `init` struct that maps 1-to-1 to its storage +/// `initialize()` call, plus the shared control-flow fields in `common`. +#[derive(Debug)] +pub enum TokenCreateParams { + /// Default B-20 token creation parameters. + B20 { + /// Shared control-flow fields. + common: CommonParams, + /// Default B-20 initialization fields. + init: B20TokenInit, + }, + /// Stablecoin B-20 token creation parameters. + Stablecoin { + /// Shared control-flow fields. + common: CommonParams, + /// Stablecoin initialization fields. + init: B20StablecoinInit, + }, + /// Security B-20 token creation parameters. + Security { + /// Shared control-flow fields. + common: CommonParams, + /// Security-token initialization fields. + init: B20SecurityInit, + }, +} + +impl TokenCreateParams { + /// Decodes ABI-encoded creation parameters for `variant`. + pub fn decode(variant: B20Variant, params: &Bytes) -> Result { + match variant { + B20Variant::B20 => { + let p = IB20Factory::B20CreateParams::abi_decode(params) + .map_err(Self::invalid_params)?; + Ok(Self::B20 { + common: CommonParams { version: p.version, initial_admin: p.initialAdmin }, + init: B20TokenInit { + name: p.name, + symbol: p.symbol, + supply_cap: DEFAULT_SUPPLY_CAP, + }, + }) + } + B20Variant::Stablecoin => { + let p = IB20Factory::B20StablecoinCreateParams::abi_decode(params) + .map_err(Self::invalid_params)?; + Ok(Self::Stablecoin { + common: CommonParams { version: p.version, initial_admin: p.initialAdmin }, + init: B20StablecoinInit { + name: p.name, + symbol: p.symbol, + supply_cap: DEFAULT_SUPPLY_CAP, + currency: p.currency, + }, + }) + } + B20Variant::Security => { + let p = IB20Factory::B20SecurityCreateParams::abi_decode(params) + .map_err(Self::invalid_params)?; + Ok(Self::Security { + common: CommonParams { version: p.version, initial_admin: p.initialAdmin }, + init: B20SecurityInit { + name: p.name, + symbol: p.symbol, + supply_cap: DEFAULT_SUPPLY_CAP, + shares_to_tokens_ratio: INITIAL_SHARES_TO_TOKENS_RATIO, + isin: p.isin, + minimum_redeemable: p.minimumRedeemable, + }, + }) + } + } + } + + /// Returns the shared token creation parameter version. + pub const fn version(&self) -> u8 { + match self { + Self::B20 { common, .. } + | Self::Stablecoin { common, .. } + | Self::Security { common, .. } => common.version, + } + } + + /// Validates variant-specific invariants after the shared version check. + /// + /// Each arm owns its own rules. Version is checked first by the caller (`check_version`) + /// so that version errors always take precedence over field-level errors. + pub fn validate(&self) -> Result<()> { + match self { + Self::B20 { init, .. } => Self::validate_b20(init), + Self::Stablecoin { init, .. } => Self::validate_stablecoin(init), + Self::Security { init, .. } => Self::validate_security(init), + } + } + + /// Validates default B-20 initialization fields. + pub const fn validate_b20(_init: &B20TokenInit) -> Result<()> { + Ok(()) + } + + /// Validates stablecoin initialization fields. + pub const fn validate_stablecoin(_init: &B20StablecoinInit) -> Result<()> { + // Currency validation is delegated to `B20StablecoinStorage::initialize`, which rejects + // all invalid values (including empty) with `InvalidCurrency`. + Ok(()) + } + + /// Validates security-token initialization fields. + pub fn validate_security(init: &B20SecurityInit) -> Result<()> { + if init.isin.is_empty() { + return Err(BasePrecompileError::revert(IB20Factory::MissingRequiredField {})); + } + Ok(()) + } + + /// Maps an ABI parameter decoding error into the factory error surface. + pub fn invalid_params(error: impl core::fmt::Display) -> BasePrecompileError { + BasePrecompileError::AbiDecodeFailed { + selector: IB20Factory::createB20Call::SELECTOR, + error: error.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use alloy_primitives::{Address, B256, Bytes, U256, address}; + use alloy_sol_types::{SolCall, SolError, SolValue}; + use base_precompile_storage::{Handler, HashMapStorageProvider, StorageCtx}; + + use crate::{ + ActivationFeature, ActivationRegistryStorage, B20FactoryStorage, B20SecurityStorage, + B20SecurityToken, B20StablecoinStorage, B20Token, B20TokenRole, B20TokenStorage, + B20Variant, IB20, IB20Factory, Mintable, Permittable, PolicyHandle, RoleManaged, Token, + TokenAccounting, Transferable, + }; + + const ACTIVATION_ADMIN: Address = address!("0xcb00000000000000000000000000000000000000"); + + #[test] + fn factory_address_matches_canonical_precompile_address() { + assert_eq!( + B20FactoryStorage::ADDRESS, + address!("B20F000000000000000000000000000000000000") + ); + } + + fn activate_precompiles(storage: &mut HashMapStorageProvider) { + storage.set_caller(ACTIVATION_ADMIN); + for key in [ + ActivationFeature::B20Factory.id(), + ActivationFeature::B20Token.id(), + ActivationFeature::B20Stablecoin.id(), + ActivationFeature::B20Security.id(), + ] { + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx).activate(key, Some(ACTIVATION_ADMIN)).unwrap() + }); + } + } + + fn token_params(name: &str, symbol: &str) -> IB20Factory::B20CreateParams { + IB20Factory::B20CreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: name.to_string(), + symbol: symbol.to_string(), + initialAdmin: Address::repeat_byte(0xAB), + } + } + + fn create_call( + variant: IB20Factory::B20Variant, + params: IB20Factory::B20CreateParams, + salt: B256, + ) -> IB20Factory::createB20Call { + IB20Factory::createB20Call { + variant, + salt, + params: params.abi_encode().into(), + initCalls: Vec::new(), + } + } + + fn b20_call(salt: B256) -> IB20Factory::createB20Call { + create_call(IB20Factory::B20Variant::DEFAULT, token_params("Test", "TST"), salt) + } + + fn token_at<'a>( + addr: Address, + ctx: StorageCtx<'a>, + ) -> B20Token, PolicyHandle<'a>> { + B20Token::with_storage_and_policy( + B20TokenStorage::from_address(addr, ctx), + PolicyHandle::new(ctx), + ) + } + + fn assert_output(output: Bytes, expected: impl AsRef<[u8]>) { + assert_eq!(output.as_ref(), expected.as_ref()); + } + + fn dispatch_factory_success(ctx: StorageCtx<'_>, call: impl SolCall) -> Bytes { + let mut factory = B20FactoryStorage::new(ctx); + let output = factory.dispatch(ctx, &call.abi_encode()).unwrap(); + assert!(!output.is_revert(), "factory call reverted: {:?}", output.bytes); + output.bytes + } + + fn dispatch_factory_revert(ctx: StorageCtx<'_>, call: impl SolCall) -> Bytes { + let mut factory = B20FactoryStorage::new(ctx); + let output = factory.dispatch(ctx, &call.abi_encode()).unwrap(); + assert!(output.is_revert(), "factory call unexpectedly succeeded"); + output.bytes + } + + fn dispatch_b20_success(ctx: StorageCtx<'_>, token_addr: Address, call: impl SolCall) -> Bytes { + let mut token = token_at(token_addr, ctx); + let output = token.dispatch(ctx, &call.abi_encode()).unwrap(); + assert!(!output.is_revert(), "token call reverted: {:?}", output.bytes); + output.bytes + } + + #[test] + fn test_token_variant_compute_address_encodes_variant_and_hash_tail() { + let creator = Address::repeat_byte(0x11); + let salt = B256::repeat_byte(0x22); + let (addr, tail) = B20Variant::B20.compute_address(creator, salt); + + assert_eq!(addr.as_slice()[11..], tail); + assert!(B20Variant::is_b20_address(addr)); + assert_eq!(B20Variant::from_address(addr), Some(B20Variant::B20)); + assert_eq!(B20Variant::decimals_of(addr), Some(18)); + } + + #[test] + fn test_address_derivation_ignores_decimals_and_uses_variant() { + let creator = Address::repeat_byte(0x11); + let salt = B256::repeat_byte(0x33); + let (default_token, _) = B20Variant::B20.compute_address(creator, salt); + let (stablecoin, _) = B20Variant::Stablecoin.compute_address(creator, salt); + + assert_ne!(default_token, stablecoin); + assert_eq!(B20Variant::decimals_of(default_token), Some(18)); + assert_eq!(B20Variant::decimals_of(stablecoin), Some(6)); + } + + #[test] + fn test_supported_variants_are_b20_prefixes() { + let creator = Address::repeat_byte(0x11); + let salt = B256::repeat_byte(0x44); + let (stablecoin, _) = B20Variant::compute_address_for_discriminant(creator, 1, salt); + let (security, _) = B20Variant::compute_address_for_discriminant(creator, 2, salt); + + assert!(B20Variant::is_supported_discriminant(1)); + assert!(B20Variant::is_supported_discriminant(2)); + assert!(!B20Variant::is_supported_discriminant(3)); + assert!(B20Variant::is_b20_address(stablecoin)); + assert!(B20Variant::is_b20_address(security)); + assert_eq!(B20Variant::from_address(stablecoin), Some(B20Variant::Stablecoin)); + assert_eq!(B20Variant::from_address(security), Some(B20Variant::Security)); + } + + #[test] + fn test_create_token_deploys_ef_stub() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0xAA); + let (expected_addr, _) = B20Variant::B20.compute_address(caller, salt); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token = factory.create_b20(caller, b20_call(salt)).unwrap(); + + assert_eq!(token, expected_addr); + assert!(ctx.has_bytecode(expected_addr).unwrap()); + }); + } + + #[test] + fn test_create_token_stores_metadata_and_uses_variant_decimals() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0xBB); + let call = + create_call(IB20Factory::B20Variant::DEFAULT, token_params("My Token", "MYT"), salt); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(caller, call).unwrap(); + let token = B20TokenStorage::from_address(token_addr, ctx); + + assert_eq!(token.b20.name.read().unwrap(), "My Token"); + assert_eq!(token.b20.symbol.read().unwrap(), "MYT"); + assert_eq!(token.decimals().unwrap(), 18); + assert_eq!(token.supply_cap().unwrap(), B20FactoryStorage::DEFAULT_SUPPLY_CAP); + assert_eq!(B20Variant::decimals_of(token_addr), Some(18)); + }); + } + + #[test] + fn test_create_token_init_calls_can_mint_supply() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0xCC); + let recipient = Address::repeat_byte(0xCD); + let supply = U256::from(5_000u64); + let mut call = create_call( + IB20Factory::B20Variant::DEFAULT, + token_params("Supply Token", "SUP"), + salt, + ); + call.initCalls.push(IB20::mintCall { to: recipient, amount: supply }.abi_encode().into()); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(caller, call).unwrap(); + let token = B20TokenStorage::from_address(token_addr, ctx); + + assert_eq!(token.b20.total_supply.read().unwrap(), supply); + assert_eq!(token.balance_of(recipient).unwrap(), supply); + }); + } + + #[test] + fn test_create_token_init_calls_use_factory_caller_and_restore_creator() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let creator = Address::repeat_byte(0x55); + let spender = Address::repeat_byte(0x77); + let salt = B256::repeat_byte(0xCE); + let allowance = U256::from(123u64); + let mut call = create_call( + IB20Factory::B20Variant::DEFAULT, + token_params("Caller Token", "CALL"), + salt, + ); + call.initCalls.push(IB20::approveCall { spender, amount: allowance }.abi_encode().into()); + storage.set_caller(creator); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(creator, call).unwrap(); + let token = B20TokenStorage::from_address(token_addr, ctx); + + assert_eq!(ctx.caller(), creator); + assert_eq!(token.allowance(B20FactoryStorage::ADDRESS, spender).unwrap(), allowance); + assert_eq!(token.allowance(creator, spender).unwrap(), U256::ZERO); + }); + } + + #[test] + fn test_create_token_reverts_if_salt_reused() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0xEE); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + factory.create_b20(caller, b20_call(salt)).unwrap(); + let result = factory.create_b20(caller, b20_call(salt)); + assert!(result.is_err()); + }); + } + + #[test] + fn test_create_token_reverts_for_invalid_version_and_variant() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + + let mut bad_params = token_params("Bad Version", "BAD"); + bad_params.version = B20FactoryStorage::CREATE_TOKEN_VERSION + 1; + let bad_version = + create_call(IB20Factory::B20Variant::DEFAULT, bad_params, B256::repeat_byte(0x01)); + assert!(factory.create_b20(caller, bad_version).is_err()); + + let bad_variant = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::__Invalid, + salt: B256::repeat_byte(0x02), + params: token_params("Bad Variant", "BAD").abi_encode().into(), + initCalls: Vec::new(), + }; + assert!(factory.create_b20(caller, bad_variant).is_err()); + }); + } + + #[test] + fn test_create_token_reverts_for_invalid_params_encoding() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::DEFAULT, + salt: B256::repeat_byte(0x04), + params: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + let output = dispatch_factory_revert(ctx, call); + assert!(output.starts_with(&IB20Factory::createB20Call::SELECTOR)); + }); + } + + #[test] + fn test_create_token_allows_empty_default_name_and_symbol() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0x05); + let call = create_call(IB20Factory::B20Variant::DEFAULT, token_params("", ""), salt); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(caller, call).unwrap(); + let token = B20TokenStorage::from_address(token_addr, ctx); + + assert_eq!(token.b20.name.read().unwrap(), ""); + assert_eq!(token.b20.symbol.read().unwrap(), ""); + }); + } + + #[test] + fn test_create_token_reverts_for_missing_stablecoin_currency() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let params = IB20Factory::B20StablecoinCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "Stablecoin Token".to_string(), + symbol: "USD".to_string(), + initialAdmin: Address::repeat_byte(0xAB), + currency: String::new(), + }; + let call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::STABLECOIN, + salt: B256::repeat_byte(0x06), + params: params.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_revert(ctx, call), + IB20Factory::InvalidCurrency { code: String::new() }.abi_encode(), + ); + }); + } + + #[test] + fn test_create_token_checks_stablecoin_version_before_currency() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let params = IB20Factory::B20StablecoinCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION + 1, + name: "Stablecoin Token".to_string(), + symbol: "USD".to_string(), + initialAdmin: Address::repeat_byte(0xAB), + currency: String::new(), + }; + let call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::STABLECOIN, + salt: B256::repeat_byte(0x07), + params: params.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_revert(ctx, call), + IB20Factory::UnsupportedVersion { + version: B20FactoryStorage::CREATE_TOKEN_VERSION + 1, + variant: IB20Factory::B20Variant::STABLECOIN, + } + .abi_encode(), + ); + }); + } + + #[test] + fn test_create_token_supports_stablecoin() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + + let stablecoin_params = IB20Factory::B20StablecoinCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "Stablecoin Token".to_string(), + symbol: "USD".to_string(), + initialAdmin: Address::repeat_byte(0xAB), + currency: "USD".to_string(), + }; + let stablecoin_call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::STABLECOIN, + salt: B256::repeat_byte(0x08), + params: stablecoin_params.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + let stablecoin_addr = IB20Factory::createB20Call::abi_decode_returns( + dispatch_factory_success(ctx, stablecoin_call).as_ref(), + ) + .unwrap(); + let stablecoin = B20StablecoinStorage::from_address(stablecoin_addr, ctx); + assert_eq!(stablecoin.stablecoin.currency.read().unwrap(), "USD"); + assert_eq!(stablecoin.b20.name.read().unwrap(), "Stablecoin Token"); + assert_eq!(B20Variant::from_address(stablecoin_addr), Some(B20Variant::Stablecoin)); + assert_eq!(B20Variant::decimals_of(stablecoin_addr), Some(6)); + }); + } + + #[test] + fn test_create_security_token_stores_isin_and_ratio() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0x09); + let (expected_addr, _) = B20Variant::Security.compute_address(caller, salt); + + let security_params = IB20Factory::B20SecurityCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "Security Token".to_string(), + symbol: "SEC".to_string(), + initialAdmin: Address::repeat_byte(0xAB), + isin: "US0000000000".to_string(), + minimumRedeemable: U256::ONE, + }; + let security_call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::SECURITY, + salt, + params: security_params.abi_encode().into(), + initCalls: Vec::new(), + }; + + storage.set_caller(caller); + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_success(ctx, security_call), + IB20Factory::createB20Call::abi_encode_returns(&expected_addr), + ); + assert!(ctx.has_bytecode(expected_addr).unwrap()); + + let sec_storage = B20SecurityStorage::from_address(expected_addr, ctx); + assert_eq!(sec_storage.b20.name.read().unwrap(), "Security Token"); + assert_eq!(sec_storage.b20.symbol.read().unwrap(), "SEC"); + assert_eq!(sec_storage.decimals().unwrap(), 6); + assert_eq!(sec_storage.security.shares_to_tokens_ratio.read().unwrap(), U256::ZERO); + assert_eq!(sec_storage.redeem.minimum_redeemable.read().unwrap(), U256::ONE); + // ISIN is stored in the identifiers mapping under the raw "ISIN" key. + assert_eq!( + sec_storage.security.identifiers.at(&String::from("ISIN")).read().unwrap(), + "US0000000000" + ); + }); + } + + #[test] + fn test_post_create_calls_execute_against_token() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0xDD); + let mut call = b20_call(salt); + call.initCalls + .push(IB20::updateNameCall { newName: "Configured".to_string() }.abi_encode().into()); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(caller, call).unwrap(); + let token = B20TokenStorage::from_address(token_addr, ctx); + + assert_eq!(token.b20.name.read().unwrap(), "Configured"); + }); + } + + #[test] + fn test_is_b20_and_variant_prefix_before_and_after_create() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0x11); + let (addr, _) = B20Variant::B20.compute_address(caller, salt); + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + assert!(factory.is_b20(addr).unwrap()); + + let token = factory.create_b20(caller, b20_call(salt)).unwrap(); + assert!(factory.is_b20(token).unwrap()); + assert_eq!(B20Variant::from_address(token), Some(B20Variant::B20)); + }); + } + + #[test] + fn test_is_b20_accepts_future_structural_prefixes() { + let mut storage = HashMapStorageProvider::new(1); + let caller = Address::repeat_byte(0x55); + let salt = B256::repeat_byte(0x13); + let (future_variant, _) = B20Variant::compute_address_for_discriminant(caller, 0xff, salt); + + StorageCtx::enter(&mut storage, |ctx| { + let factory = B20FactoryStorage::new(ctx); + assert!(factory.is_b20(future_variant).unwrap()); + assert_eq!(B20Variant::from_address(future_variant), None); + }); + } + + #[test] + fn test_is_b20_false_for_non_prefix_address() { + let mut storage = HashMapStorageProvider::new(1); + let random_addr = Address::repeat_byte(0x42); + + StorageCtx::enter(&mut storage, |ctx| { + let factory = B20FactoryStorage::new(ctx); + assert!(!factory.is_b20(random_addr).unwrap()); + }); + } + + #[test] + fn test_transfer_and_mint_lifecycle() { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let params = token_params("Lifecycle", "LIFE"); + let token_addr = factory + .create_b20( + Address::repeat_byte(0xCA), + create_call(IB20Factory::B20Variant::DEFAULT, params, B256::repeat_byte(0x12)), + ) + .unwrap(); + + let alice = Address::repeat_byte(0xCD); + let bob = Address::repeat_byte(0xBB); + let mut token = token_at(token_addr, ctx); + + token.mint(alice, alice, U256::from(1_000u64), true).unwrap(); + token.transfer(alice, bob, U256::from(300u64), false).unwrap(); + token.mint(alice, alice, U256::from(200u64), true).unwrap(); + + assert_eq!(token.accounting().balance_of(alice).unwrap(), U256::from(900u64)); + assert_eq!(token.accounting().balance_of(bob).unwrap(), U256::from(300u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(1_200u64)); + }); + } + + #[test] + fn test_token_identity_uses_dynamic_address() { + let mut storage = HashMapStorageProvider::new(1); + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let first = factory + .create_b20(Address::repeat_byte(0xCA), b20_call(B256::repeat_byte(0x07))) + .unwrap(); + let second = factory + .create_b20(Address::repeat_byte(0xCA), b20_call(B256::repeat_byte(0x08))) + .unwrap(); + + assert_ne!(first, second); + + let first_token = token_at(first, ctx); + let second_token = token_at(second, ctx); + + assert_eq!(first_token.token_address(), first); + assert_eq!(second_token.token_address(), second); + + let (_, _, _, _, first_domain_address, _, _) = + first_token.eip712_domain(ctx.chain_id()).unwrap(); + let (_, _, _, _, second_domain_address, _, _) = + second_token.eip712_domain(ctx.chain_id()).unwrap(); + + assert_eq!(first_domain_address, first); + assert_eq!(second_domain_address, second); + assert_ne!( + first_token.domain_separator(ctx.chain_id()).unwrap(), + second_token.domain_separator(ctx.chain_id()).unwrap() + ); + }); + } + + #[test] + fn test_factory_dispatch_create_token_predicts_and_initializes_token() { + let creator = Address::repeat_byte(0xCA); + let salt = B256::repeat_byte(0x31); + let (expected_token, _) = B20Variant::B20.compute_address(creator, salt); + let mut call = create_call( + IB20Factory::B20Variant::DEFAULT, + token_params("Dispatch Token", "DSP"), + salt, + ); + call.initCalls.push( + IB20::mintCall { to: Address::repeat_byte(0xCD), amount: U256::from(1_000u64) } + .abi_encode() + .into(), + ); + call.initCalls.push( + IB20::updateContractURICall { newURI: "ipfs://dispatch".to_string() } + .abi_encode() + .into(), + ); + + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + storage.set_caller(creator); + + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_success( + ctx, + IB20Factory::getB20AddressCall { + variant: IB20Factory::B20Variant::DEFAULT, + sender: creator, + salt, + }, + ), + IB20Factory::getB20AddressCall::abi_encode_returns(&expected_token), + ); + assert_output( + dispatch_factory_revert( + ctx, + IB20Factory::getB20AddressCall { + variant: IB20Factory::B20Variant::__Invalid, + sender: creator, + salt, + }, + ), + IB20Factory::InvalidVariant {}.abi_encode(), + ); + + assert_output( + dispatch_factory_success(ctx, call), + IB20Factory::createB20Call::abi_encode_returns(&expected_token), + ); + assert!(ctx.has_bytecode(expected_token).unwrap()); + + assert_output( + dispatch_factory_success(ctx, IB20Factory::isB20Call { token: expected_token }), + IB20Factory::isB20Call::abi_encode_returns(&true), + ); + + assert_output( + dispatch_b20_success(ctx, expected_token, IB20::nameCall {}), + "Dispatch Token".to_string().abi_encode(), + ); + assert_output( + dispatch_b20_success(ctx, expected_token, IB20::symbolCall {}), + "DSP".to_string().abi_encode(), + ); + assert_output( + dispatch_b20_success(ctx, expected_token, IB20::decimalsCall {}), + IB20::decimalsCall::abi_encode_returns(&18u8), + ); + assert_output( + dispatch_b20_success(ctx, expected_token, IB20::totalSupplyCall {}), + U256::from(1_000u64).abi_encode(), + ); + assert_output( + dispatch_b20_success( + ctx, + expected_token, + IB20::balanceOfCall { account: Address::repeat_byte(0xCD) }, + ), + U256::from(1_000u64).abi_encode(), + ); + assert_output( + dispatch_b20_success(ctx, expected_token, IB20::contractURICall {}), + "ipfs://dispatch".to_string().abi_encode(), + ); + }); + } + + #[test] + fn test_uninitialized_prefix_token_reverts() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + StorageCtx::enter(&mut storage, |ctx| { + let caller = Address::repeat_byte(0xCA); + let (token_addr, tail) = + B20Variant::B20.compute_address(caller, B256::repeat_byte(0x09)); + assert_eq!(token_addr.as_slice()[11..], tail); + assert!(!ctx.has_bytecode(token_addr).unwrap()); + + let mut token = token_at(token_addr, ctx); + let result = token.dispatch(ctx, &IB20::nameCall {}.abi_encode()).unwrap(); + + assert!(result.is_revert()); + assert!(result.bytes.is_empty()); + }); + } + + #[test] + fn test_b20_dispatch_transfer_approve_transfer_from() { + let creator = Address::repeat_byte(0xCA); + let alice = Address::repeat_byte(0xCD); + let bob = Address::repeat_byte(0xBB); + let spender = Address::repeat_byte(0xEE); + let charlie = Address::repeat_byte(0xCC); + let salt = B256::repeat_byte(0x32); + let (token_addr, _) = B20Variant::B20.compute_address(creator, salt); + let mut call = create_call( + IB20Factory::B20Variant::DEFAULT, + token_params("Dispatch Token", "DSP"), + salt, + ); + call.initCalls + .push(IB20::mintCall { to: alice, amount: U256::from(1_000u64) }.abi_encode().into()); + + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + storage.set_caller(creator); + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_success(ctx, call), + IB20Factory::createB20Call::abi_encode_returns(&token_addr), + ); + }); + + storage.set_caller(alice); + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_b20_success( + ctx, + token_addr, + IB20::transferCall { to: bob, amount: U256::from(300u64) }, + ), + true.abi_encode(), + ); + assert_output( + dispatch_b20_success( + ctx, + token_addr, + IB20::approveCall { spender, amount: U256::from(250u64) }, + ), + true.abi_encode(), + ); + }); + + storage.set_caller(spender); + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_b20_success( + ctx, + token_addr, + IB20::transferFromCall { from: alice, to: charlie, amount: U256::from(200u64) }, + ), + true.abi_encode(), + ); + assert_output( + dispatch_b20_success(ctx, token_addr, IB20::balanceOfCall { account: alice }), + U256::from(500u64).abi_encode(), + ); + assert_output( + dispatch_b20_success(ctx, token_addr, IB20::balanceOfCall { account: bob }), + U256::from(300u64).abi_encode(), + ); + assert_output( + dispatch_b20_success(ctx, token_addr, IB20::balanceOfCall { account: charlie }), + U256::from(200u64).abi_encode(), + ); + assert_output( + dispatch_b20_success( + ctx, + token_addr, + IB20::allowanceCall { owner: alice, spender }, + ), + U256::from(50u64).abi_encode(), + ); + }); + } + + #[test] + fn test_create_security_token_grants_default_admin_role() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + let caller = Address::repeat_byte(0x55); + let initial_admin = Address::repeat_byte(0xAB); + + let params = IB20Factory::B20SecurityCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "Security Token".to_string(), + symbol: "SEC".to_string(), + initialAdmin: initial_admin, + isin: "US0000000001".to_string(), + minimumRedeemable: U256::ZERO, + }; + let call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::SECURITY, + salt: B256::repeat_byte(0x50), + params: params.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(caller, call).unwrap(); + + let token = B20SecurityToken::with_storage_and_policy( + B20SecurityStorage::from_address(token_addr, ctx), + PolicyHandle::new(ctx), + ); + assert!(token.has_role(B20TokenRole::DefaultAdmin.id(), initial_admin).unwrap()); + assert!(!token.has_role(B20TokenRole::DefaultAdmin.id(), Address::ZERO).unwrap()); + }); + + // Zero initialAdmin grants no role. + let params_no_admin = IB20Factory::B20SecurityCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "No Admin".to_string(), + symbol: "NA".to_string(), + initialAdmin: Address::ZERO, + isin: "US0000000002".to_string(), + minimumRedeemable: U256::ZERO, + }; + let call_no_admin = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::SECURITY, + salt: B256::repeat_byte(0x51), + params: params_no_admin.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + let mut factory = B20FactoryStorage::new(ctx); + let token_addr = factory.create_b20(caller, call_no_admin).unwrap(); + + let token = B20SecurityToken::with_storage_and_policy( + B20SecurityStorage::from_address(token_addr, ctx), + PolicyHandle::new(ctx), + ); + assert!(!token.has_role(B20TokenRole::DefaultAdmin.id(), initial_admin).unwrap()); + assert!(!token.has_role(B20TokenRole::DefaultAdmin.id(), Address::ZERO).unwrap()); + }); + } + + #[test] + fn test_create_security_token_reverts_for_empty_isin() { + let mut storage = HashMapStorageProvider::new(1); + activate_precompiles(&mut storage); + + let params = IB20Factory::B20SecurityCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: "Security Token".to_string(), + symbol: "SEC".to_string(), + initialAdmin: Address::repeat_byte(0xAB), + isin: String::new(), + minimumRedeemable: U256::ZERO, + }; + let call = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::SECURITY, + salt: B256::repeat_byte(0x52), + params: params.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_revert(ctx, call), + IB20Factory::MissingRequiredField {}.abi_encode(), + ); + }); + + // Bad version with empty ISIN reverts with UnsupportedVersion, not MissingRequiredField. + let params_bad_version = IB20Factory::B20SecurityCreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION + 1, + name: "Security Token".to_string(), + symbol: "SEC".to_string(), + initialAdmin: Address::repeat_byte(0xAB), + isin: String::new(), + minimumRedeemable: U256::ZERO, + }; + let call_bad_version = IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::SECURITY, + salt: B256::repeat_byte(0x53), + params: params_bad_version.abi_encode().into(), + initCalls: Vec::new(), + }; + + StorageCtx::enter(&mut storage, |ctx| { + assert_output( + dispatch_factory_revert(ctx, call_bad_version), + IB20Factory::UnsupportedVersion { + version: B20FactoryStorage::CREATE_TOKEN_VERSION + 1, + variant: IB20Factory::B20Variant::SECURITY, + } + .abi_encode(), + ); + }); + } +} diff --git a/crates/common/precompiles/src/b20_factory/variant.rs b/crates/common/precompiles/src/b20_factory/variant.rs new file mode 100644 index 0000000000..ae35a47978 --- /dev/null +++ b/crates/common/precompiles/src/b20_factory/variant.rs @@ -0,0 +1,157 @@ +//! B-20 token variant address derivation. + +use alloy_primitives::{Address, B256, keccak256}; +use alloy_sol_types::SolValue; + +use crate::IB20Factory; + +/// B-20 token variant encoded in token address byte `[10]`. +/// +/// Discriminant values match the `B20Variant` ABI enum ordinals directly +/// (DEFAULT=0, STABLECOIN=1, SECURITY=2), so `uint8(variant)` in Solidity +/// equals the byte written at address position `[10]` with no offset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum B20Variant { + /// Default B-20 token. + B20 = 0, + /// Stablecoin B-20 token. + Stablecoin = 1, + /// Security B-20 token. + Security = 2, +} + +impl B20Variant { + /// First byte of every B-20 address. + pub const PREFIX_BYTE: u8 = 0xb2; + + /// Variant discriminant for default B-20 tokens. + pub const B20_DISCRIMINANT: u8 = Self::B20 as u8; + + /// Variant discriminant for stablecoin B-20 tokens. + pub const STABLECOIN_DISCRIMINANT: u8 = Self::Stablecoin as u8; + + /// Variant discriminant for security B-20 tokens. + pub const SECURITY_DISCRIMINANT: u8 = Self::Security as u8; + + /// Returns the supported token variant for `variant`, if any. + pub const fn from_discriminant(variant: u8) -> Option { + match variant { + Self::B20_DISCRIMINANT => Some(Self::B20), + Self::STABLECOIN_DISCRIMINANT => Some(Self::Stablecoin), + Self::SECURITY_DISCRIMINANT => Some(Self::Security), + _ => None, + } + } + + /// Returns the supported token variant for an ABI enum value, or `None` for unknown variants. + pub const fn from_abi(variant: IB20Factory::B20Variant) -> Option { + match variant { + IB20Factory::B20Variant::DEFAULT => Some(Self::B20), + IB20Factory::B20Variant::STABLECOIN => Some(Self::Stablecoin), + IB20Factory::B20Variant::SECURITY => Some(Self::Security), + IB20Factory::B20Variant::__Invalid => None, + } + } + + /// Returns whether `variant` is supported by this factory. + pub const fn is_supported_discriminant(variant: u8) -> bool { + Self::from_discriminant(variant).is_some() + } + + /// Returns the token variant encoded in `address`, if it has a supported B-20 prefix. + pub fn from_address(address: Address) -> Option { + let bytes = address.as_slice(); + if bytes[0] != Self::PREFIX_BYTE || bytes[1..10] != [0u8; 9] { + return None; + } + + Self::from_discriminant(bytes[10]) + } + + /// Returns whether `address` has the structural B-20 token prefix. + /// + /// This intentionally does not validate the encoded variant discriminant. + pub fn has_b20_prefix(address: Address) -> bool { + let bytes = address.as_slice(); + bytes[0] == Self::PREFIX_BYTE && bytes[1..10] == [0u8; 9] + } + + /// Returns this variant's ABI discriminant. + pub const fn discriminant(self) -> u8 { + self as u8 + } + + /// Returns this variant as the generated ABI enum. + pub const fn abi(self) -> IB20Factory::B20Variant { + match self { + Self::B20 => IB20Factory::B20Variant::DEFAULT, + Self::Stablecoin => IB20Factory::B20Variant::STABLECOIN, + Self::Security => IB20Factory::B20Variant::SECURITY, + } + } + + /// Returns this variant's fixed decimal precision. + pub const fn decimals(self) -> u8 { + match self { + Self::B20 => 18, + Self::Stablecoin | Self::Security => 6, + } + } + + /// Builds this variant's B-20 address prefix. + pub const fn address_prefix(self) -> [u8; 11] { + [Self::PREFIX_BYTE, 0, 0, 0, 0, 0, 0, 0, 0, 0, self.discriminant()] + } + + /// Computes this variant's deterministic token address for `creator` and `salt`. + /// + /// Returns the address and the 9-byte hash tail embedded in the address. + pub fn compute_address(self, creator: Address, salt: B256) -> (Address, [u8; 9]) { + let hash = keccak256((creator, salt).abi_encode()); + + let mut tail = [0u8; 9]; + tail.copy_from_slice(&hash[..9]); + + let mut addr_bytes = [0u8; 20]; + addr_bytes[..11].copy_from_slice(&self.address_prefix()); + addr_bytes[11..].copy_from_slice(&tail); + + (Address::from(addr_bytes), tail) + } + + /// Computes a deterministic B-20 token address for an ABI discriminant. + pub fn compute_address_for_discriminant( + creator: Address, + variant: u8, + salt: B256, + ) -> (Address, [u8; 9]) { + let hash = keccak256((creator, salt).abi_encode()); + + let mut tail = [0u8; 9]; + tail.copy_from_slice(&hash[..9]); + + let mut addr_bytes = [0u8; 20]; + addr_bytes[0] = Self::PREFIX_BYTE; + addr_bytes[10] = variant; + addr_bytes[11..].copy_from_slice(&tail); + + (Address::from(addr_bytes), tail) + } + + /// Returns `true` when `address` has a supported B-20 token variant prefix. + pub fn is_b20_address(address: Address) -> bool { + Self::from_address(address).is_some() + } + + /// Returns the variant discriminant encoded in `address`, if supported. + pub fn variant_of(address: Address) -> Option { + Self::from_address(address)?; + Some(address.as_slice()[10]) + } + + /// Returns the fixed decimals for the variant encoded in `address`. + pub fn decimals_of(address: Address) -> Option { + Some(Self::from_address(address)?.decimals()) + } +} diff --git a/crates/common/precompiles/src/b20_security/abi.rs b/crates/common/precompiles/src/b20_security/abi.rs new file mode 100644 index 0000000000..6ed694fdcd --- /dev/null +++ b/crates/common/precompiles/src/b20_security/abi.rs @@ -0,0 +1,200 @@ +//! ABI definitions for the security B-20 variant. +//! +//! [`IB20Security`] defines only the security-specific surface. +//! All inherited selectors come from [`crate::IB20`] defined in `b20/abi.rs`. + +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq, Eq)] + interface IB20Security { + // ── Errors ─────────────────────────────────────────────────────────── + + /// `id` has previously been consumed by `announce`. Each id may be used at most once. + error AnnouncementIdAlreadyUsed(string id); + + /// `updateSecurityIdentifier` was called with an empty `identifierType`. + error InvalidIdentifierType(); + + /// A batched function was called with parallel arrays of differing lengths. + error LengthMismatch(uint256 leftLen, uint256 rightLen); + + /// A batched function was called with empty arrays. + error EmptyBatch(); + + /// `redeem`/`redeemWithMemo` was called with a share count below the floor, or zero. + error BelowMinimumRedeemable(uint256 shares, uint256 minimum); + + /// An `internalCalls` entry tried to invoke `announce` itself. + error AnnouncementInProgress(); + + /// An `internalCalls` entry was shorter than four bytes. + error InternalCallMalformed(bytes call); + + /// An `internalCalls` entry reverted during its inner dispatch. + error InternalCallFailed(bytes call); + + // ── Events ─────────────────────────────────────────────────────────── + + /// Emitted by `redeem`/`redeemWithMemo`. Includes the active share ratio at redemption time. + event Redeemed(address indexed from, uint256 amt, uint256 sharesToTokensRatio); + + /// Emitted by `updateMinimumRedeemable`. + event MinimumRedeemableUpdated(address indexed caller, uint256 newMinimumRedeemable); + + /// Emitted by `updateShareRatio`. + event ShareRatioUpdated(uint256 sharesToTokensRatio); + + /// Emitted by `updateSecurityIdentifier`. Empty `value` indicates removal. + event SecurityIdentifierUpdated(string identifierType, string value); + + /// Emitted at the start of `announce`. Indexers join with `EndAnnouncement` via `id`. + event Announcement(address indexed caller, string id, string description, string uri); + + /// Emitted at the end of `announce` after all `internalCalls` have executed. + event EndAnnouncement(string id); + + // ── Role / precision identifiers ───────────────────────────────────── + + /// `keccak256("SECURITY_OPERATOR_ROLE")` — required for `announce`, `updateShareRatio`, `updateSecurityIdentifier`. + function SECURITY_OPERATOR_ROLE() external view returns (bytes32); + + /// `keccak256("BURN_FROM_ROLE")` — required for `batchBurn`. + function BURN_FROM_ROLE() external view returns (bytes32); + + /// Fixed-point precision for `sharesToTokensRatio`: `1e18` (one WAD). + function WAD_PRECISION() external view returns (uint256); + + /// `keccak256("REDEEM_SENDER_POLICY")` — consulted on `redeem`/`redeemWithMemo`. + function REDEEM_SENDER_POLICY() external view returns (bytes32); + + // ── Announcements ──────────────────────────────────────────────────── + + /// Posts a holder-impacting announcement and atomically executes `internalCalls`. + function announce( + bytes[] calldata internalCalls, + string calldata id, + string calldata description, + string calldata uri + ) external; + + /// Returns true if `id` has been consumed by `announce`. + function isAnnouncementIdUsed(string calldata id) external view returns (bool); + + // ── Share ratio ─────────────────────────────────────────────────────── + + /// The current share-to-tokens ratio, scaled to `WAD_PRECISION`. + function sharesToTokensRatio() external view returns (uint256); + + /// Converts `balance` tokens to shares: `balance * sharesToTokensRatio / WAD_PRECISION`. + function toShares(uint256 balance) external view returns (uint256); + + /// Convenience: `toShares(balanceOf(account))`. + function sharesOf(address account) external view returns (uint256); + + /// Sets a new share ratio. Holder balances are not rewritten; share count derives at read time. + function updateShareRatio(uint256 newSharesToTokensRatio) external; + + // ── Batched issuance and clawback ──────────────────────────────────── + + /// Mints `amounts[i]` to `recipients[i]`. Requires `MINT_ROLE`. All-or-nothing. + function batchMint(address[] calldata recipients, uint256[] calldata amounts) external; + + /// Burns `amounts[i]` from `accounts[i]`. Requires `BURN_FROM_ROLE`. All-or-nothing. + function batchBurn(address[] calldata accounts, uint256[] calldata amounts) external; + + // ── Redemption ──────────────────────────────────────────────────────── + + /// Burns `amount` from caller with a share-based minimum floor check. + function redeem(uint256 amount) external; + + /// Same as `redeem`, followed by a `Memo` event. + function redeemWithMemo(uint256 amount, bytes32 memo) external; + + /// Sets the minimum-redeemable threshold in shares. Requires `DEFAULT_ADMIN_ROLE`. + function updateMinimumRedeemable(uint256 newMinimumRedeemable) external; + + /// Returns the minimum-redeemable threshold in shares. + function minimumRedeemable() external view returns (uint256); + + // ── Security identifiers ───────────────────────────────────────────── + + /// Returns the value of the named identifier (e.g. ISIN, CUSIP). Empty string if not set. + function securityIdentifier(string calldata identifierType) external view returns (string); + + /// Sets, updates, or removes a security identifier. Empty `value` removes the entry. + function updateSecurityIdentifier( + string calldata identifierType, + string calldata value + ) external; + } +} + +impl IB20Security::IB20SecurityCalls { + /// Returns the stable label for this decoded security B-20 call. + pub const fn as_label(&self) -> &'static str { + match self { + Self::SECURITY_OPERATOR_ROLE(_) => "precompile-b20-security-SECURITY_OPERATOR_ROLE", + Self::BURN_FROM_ROLE(_) => "precompile-b20-security-BURN_FROM_ROLE", + Self::WAD_PRECISION(_) => "precompile-b20-security-WAD_PRECISION", + Self::REDEEM_SENDER_POLICY(_) => "precompile-b20-security-REDEEM_SENDER_POLICY", + Self::announce(_) => "precompile-b20-security-announce", + Self::isAnnouncementIdUsed(_) => "precompile-b20-security-isAnnouncementIdUsed", + Self::sharesToTokensRatio(_) => "precompile-b20-security-sharesToTokensRatio", + Self::toShares(_) => "precompile-b20-security-toShares", + Self::sharesOf(_) => "precompile-b20-security-sharesOf", + Self::updateShareRatio(_) => "precompile-b20-security-updateShareRatio", + Self::batchMint(_) => "precompile-b20-security-batchMint", + Self::batchBurn(_) => "precompile-b20-security-batchBurn", + Self::redeem(_) => "precompile-b20-security-redeem", + Self::redeemWithMemo(_) => "precompile-b20-security-redeemWithMemo", + Self::updateMinimumRedeemable(_) => "precompile-b20-security-updateMinimumRedeemable", + Self::minimumRedeemable(_) => "precompile-b20-security-minimumRedeemable", + Self::securityIdentifier(_) => "precompile-b20-security-securityIdentifier", + Self::updateSecurityIdentifier(_) => "precompile-b20-security-updateSecurityIdentifier", + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{U256, b256, keccak256}; + use alloy_sol_types::{SolCall, SolEvent}; + + use crate::IB20Security; + + #[test] + fn redeem_sender_policy_selector_matches_solidity_interface() { + assert_eq!(IB20Security::REDEEM_SENDER_POLICYCall::SELECTOR, [0x1c, 0x6f, 0x9d, 0x42]); + } + + #[test] + fn minimum_redeemable_updated_topic_matches_solidity_interface() { + assert_eq!( + IB20Security::MinimumRedeemableUpdated::SIGNATURE_HASH, + b256!("7fdd6ea6dad98bfcd2c5ec538e748a5e8ecc40d0fc824f55dfc7397fe78a183b") + ); + assert_eq!( + IB20Security::MinimumRedeemableUpdated::SIGNATURE_HASH, + keccak256("MinimumRedeemableUpdated(address,uint256)") + ); + } + + #[test] + fn security_call_labels_are_stable() { + assert_eq!( + IB20Security::IB20SecurityCalls::minimumRedeemable( + IB20Security::minimumRedeemableCall {}, + ) + .as_label(), + "precompile-b20-security-minimumRedeemable" + ); + assert_eq!( + IB20Security::IB20SecurityCalls::redeem(IB20Security::redeemCall { + amount: U256::ZERO, + }) + .as_label(), + "precompile-b20-security-redeem" + ); + } +} diff --git a/crates/common/precompiles/src/b20_security/accounting.rs b/crates/common/precompiles/src/b20_security/accounting.rs new file mode 100644 index 0000000000..80037482a6 --- /dev/null +++ b/crates/common/precompiles/src/b20_security/accounting.rs @@ -0,0 +1,35 @@ +//! `SecurityAccounting` — storage port extension for security tokens. + +use alloc::string::String; + +use alloy_primitives::U256; +use base_precompile_storage::Result; + +use crate::TokenAccounting; + +/// Extends [`TokenAccounting`] with security-token-specific storage slots. +/// +/// Security identifiers (ISIN, CUSIP, etc.) and redeem parameters are only +/// exposed through the security-token surface, not the base B-20 surface. +pub trait SecurityAccounting: TokenAccounting { + /// Returns the current share-to-tokens ratio scaled to WAD (1e18). + fn shares_to_tokens_ratio(&self) -> Result; + /// Writes a new share-to-tokens ratio. + fn set_shares_to_tokens_ratio(&mut self, ratio: U256) -> Result<()>; + + /// Returns the security identifier value for `identifier_type`, or an empty string if unset. + fn security_identifier(&self, identifier_type: &str) -> Result; + /// Writes (or removes when `value` is empty) the security identifier for `identifier_type`. + fn set_security_identifier_value(&mut self, identifier_type: &str, value: String) + -> Result<()>; + + /// Returns the minimum amount that may be redeemed in a single call. + fn minimum_redeemable(&self) -> Result; + /// Overwrites the minimum redeemable amount. + fn set_minimum_redeemable(&mut self, minimum: U256) -> Result<()>; + + /// Returns `true` if `id` has been consumed by `announce`. + fn is_announcement_id_used(&self, id: &str) -> Result; + /// Marks `id` as consumed. Called exactly once per announcement id. + fn mark_announcement_id_used(&mut self, id: &str) -> Result<()>; +} diff --git a/crates/common/precompiles/src/b20_security/dispatch.rs b/crates/common/precompiles/src/b20_security/dispatch.rs new file mode 100644 index 0000000000..e7fb73e072 --- /dev/null +++ b/crates/common/precompiles/src/b20_security/dispatch.rs @@ -0,0 +1,1407 @@ +//! ABI dispatch for the security B-20 variant. +//! +//! Security-specific selectors are tried first via `IB20Security::IB20SecurityCalls`. +//! This catches overridden selectors (`redeem`, `redeemWithMemo`) before the +//! inherited `IB20` fallthrough, ensuring security semantics always apply. +//! The `IB20` match block still includes those arms (Rust requires exhaustiveness) +//! and routes them to the same security implementation as a safety net. + +use alloc::{string::String, vec::Vec}; + +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_sol_types::{SolCall, SolEvent, SolInterface, SolValue}; +use base_precompile_storage::{BasePrecompileError, IntoPrecompileResult, StorageCtx}; +use revm::precompile::PrecompileResult; + +use crate::{ + ActivationFeature, ActivationRegistryStorage, B20Guards, B20PolicyType, B20SecurityToken, + B20TokenRole, Burnable, Configurable, + IB20::{self, IB20Calls as C}, + IB20Security::{self, IB20SecurityCalls as SC}, + Mintable, NoopPrecompileCallObserver, Pausable, PermitArgs, Permittable, Policy, + PrecompileCallObserver, RoleManaged, SecurityAccounting, Token, Transferable, + macros::{decode_precompile_call, deduct_calldata_cost}, +}; + +/// WAD precision for share ratio arithmetic: 1e18. +const WAD: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); + +impl B20SecurityToken { + /// Ensures `policy_scope` names either an inherited B-20 policy slot or the + /// security redeem slot. + fn is_supported_policy_scope(policy_scope: B256) -> bool { + policy_scope == Self::REDEEM_SENDER_POLICY || B20PolicyType::from_id(policy_scope).is_some() + } + + fn ensure_supported_policy_type(policy_scope: B256) -> base_precompile_storage::Result<()> { + if Self::is_supported_policy_scope(policy_scope) { + Ok(()) + } else { + Err(BasePrecompileError::revert(IB20::UnsupportedPolicyType { + policyScope: policy_scope, + })) + } + } + + fn ensure_security_operator( + &self, + caller: Address, + privileged: bool, + ) -> base_precompile_storage::Result<()> { + if privileged { Ok(()) } else { self.ensure_role(caller, Self::SECURITY_OPERATOR_ROLE) } + } + + fn ensure_default_admin( + &self, + caller: Address, + privileged: bool, + ) -> base_precompile_storage::Result<()> { + if privileged { Ok(()) } else { self.ensure_role(caller, Self::default_admin_role()) } + } + + fn ensure_burn_from_role(&self, caller: Address) -> base_precompile_storage::Result<()> { + self.ensure_role(caller, Self::BURN_FROM_ROLE) + } + + /// Returns the configured policy ID for `policy_scope`. + fn policy_id_checked(&self, policy_scope: B256) -> base_precompile_storage::Result { + Self::ensure_supported_policy_type(policy_scope)?; + self.accounting().policy_id(policy_scope) + } + + /// Updates the configured policy ID for `policy_scope`. + fn update_policy( + &mut self, + caller: Address, + policy_scope: B256, + new_policy_id: u64, + privileged: bool, + ) -> base_precompile_storage::Result<()> { + Self::ensure_supported_policy_type(policy_scope)?; + if !privileged { + self.ensure_role(caller, Self::default_admin_role())?; + } + let old_policy_id = self.accounting().policy_id(policy_scope)?; + if !self.policy().policy_exists(new_policy_id)? { + return Err(BasePrecompileError::revert(IB20::PolicyNotFound { + policyId: new_policy_id, + })); + } + self.accounting_mut().set_policy_id(policy_scope, new_policy_id)?; + self.accounting_mut().emit_event( + IB20::PolicyUpdated { + policyScope: policy_scope, + oldPolicyId: old_policy_id, + newPolicyId: new_policy_id, + } + .encode_log_data(), + ) + } +} + +impl B20SecurityToken { + /// ABI-dispatches `calldata` to the appropriate `IB20Security` handler. + pub fn dispatch(&mut self, ctx: StorageCtx<'_>, calldata: &[u8]) -> PrecompileResult { + self.dispatch_with_observer(ctx, calldata, NoopPrecompileCallObserver) + } + + /// ABI-dispatches `calldata` and observes the decoded security B-20 operation. + pub fn dispatch_with_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + observer: O, + ) -> PrecompileResult + where + O: PrecompileCallObserver, + { + deduct_calldata_cost!(ctx, calldata); + + match self.accounting().is_initialized() { + Ok(true) => {} + Ok(false) => { + return BasePrecompileError::Revert(Bytes::new()) + .into_precompile_result(ctx.gas_used(), ctx.state_gas_used()); + } + Err(e) => return e.into_precompile_result(ctx.gas_used(), ctx.state_gas_used()), + } + self.inner_with_observer(ctx, calldata, observer).into_precompile_result( + ctx.gas_used(), + ctx.state_gas_used(), + |b| b, + ) + } + + /// Decodes calldata and executes the matching `IB20Security` or inherited `IB20` operation. + pub fn inner( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + ) -> base_precompile_storage::Result { + self.inner_with_observer(ctx, calldata, NoopPrecompileCallObserver) + } + + /// Decodes calldata, observes the decoded operation, and executes the matching handler. + pub fn inner_with_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + observer: O, + ) -> base_precompile_storage::Result + where + O: PrecompileCallObserver, + { + self.inner_with_privilege_and_observer(ctx, calldata, false, observer) + } + + /// Decodes calldata and executes it with optional factory-init privilege. + pub fn inner_with_privilege( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + privileged: bool, + ) -> base_precompile_storage::Result { + self.inner_with_privilege_and_observer( + ctx, + calldata, + privileged, + NoopPrecompileCallObserver, + ) + } + + /// Decodes calldata, observes the decoded operation, and executes it with optional + /// factory-init privilege. + pub fn inner_with_privilege_and_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + privileged: bool, + observer: O, + ) -> base_precompile_storage::Result + where + O: PrecompileCallObserver, + { + ActivationRegistryStorage::new(ctx) + .ensure_activated(ActivationFeature::B20Security.id())?; + + // Security-specific and overridden selectors are caught here first. + if let Ok(call) = IB20Security::IB20SecurityCalls::abi_decode(calldata) { + let label = call.as_label(); + return observer.observe(label, || self.handle_security_call(ctx, call, privileged)); + } + + // Fall through to inherited IB20 selectors. + let call = decode_precompile_call!(calldata, IB20::IB20Calls); + let label = call.as_label(); + + observer.observe(label, || self.handle_b20_call(ctx, call, privileged)) + } + + fn handle_b20_call( + &mut self, + ctx: StorageCtx<'_>, + call: C, + privileged: bool, + ) -> base_precompile_storage::Result { + let encoded: Bytes = match call { + // --- Pure reads --- + C::name(_) => self.accounting().name()?.abi_encode().into(), + C::symbol(_) => self.accounting().symbol()?.abi_encode().into(), + C::decimals(_) => U256::from(self.accounting().decimals()?).abi_encode().into(), + C::totalSupply(_) => self.accounting().total_supply()?.abi_encode().into(), + C::balanceOf(c) => self.accounting().balance_of(c.account)?.abi_encode().into(), + C::allowance(c) => self.accounting().allowance(c.owner, c.spender)?.abi_encode().into(), + C::supplyCap(_) => self.accounting().supply_cap()?.abi_encode().into(), + C::nonces(c) => self.accounting().nonce(c.owner)?.abi_encode().into(), + C::contractURI(_) => self.accounting().contract_uri()?.abi_encode().into(), + + // --- Role identifiers --- + C::DEFAULT_ADMIN_ROLE(_) => Self::default_admin_role().abi_encode().into(), + C::MINT_ROLE(_) => B20TokenRole::Mint.id().abi_encode().into(), + C::BURN_ROLE(_) => B20TokenRole::Burn.id().abi_encode().into(), + C::BURN_BLOCKED_ROLE(_) => B20TokenRole::BurnBlocked.id().abi_encode().into(), + C::PAUSE_ROLE(_) => B20TokenRole::Pause.id().abi_encode().into(), + C::UNPAUSE_ROLE(_) => B20TokenRole::Unpause.id().abi_encode().into(), + C::METADATA_ROLE(_) => B20TokenRole::Metadata.id().abi_encode().into(), + + // --- Policy type identifiers --- + C::TRANSFER_SENDER_POLICY(_) => B20PolicyType::TransferSender.id().abi_encode().into(), + C::TRANSFER_RECEIVER_POLICY(_) => { + B20PolicyType::TransferReceiver.id().abi_encode().into() + } + C::TRANSFER_EXECUTOR_POLICY(_) => { + B20PolicyType::TransferExecutor.id().abi_encode().into() + } + C::MINT_RECEIVER_POLICY(_) => B20PolicyType::MintReceiver.id().abi_encode().into(), + + // --- Role reads --- + C::hasRole(c) => self.accounting().has_role(c.role, c.account)?.abi_encode().into(), + C::getRoleAdmin(c) => self.accounting().role_admin(c.role)?.abi_encode().into(), + + // --- Pause reads --- + C::pausedFeatures(_) => self.paused_features()?.abi_encode().into(), + C::isPaused(c) => self.is_paused(c.feature)?.abi_encode().into(), + + // --- Policy reads --- + C::policyId(c) => self.policy_id_checked(c.policyScope)?.abi_encode().into(), + + // --- Domain reads --- + C::DOMAIN_SEPARATOR(_) => self.domain_separator(ctx.chain_id())?.abi_encode().into(), + C::eip712Domain(_) => { + let (fields, name, version, chain_id, verifying_contract, salt, extensions) = + self.eip712_domain(ctx.chain_id())?; + IB20::eip712DomainCall::abi_encode_returns(&IB20::eip712DomainReturn { + fields, + name, + version, + chainId: chain_id, + verifyingContract: verifying_contract, + salt, + extensions, + }) + .into() + } + + // --- ERC-20 mutating --- + C::transfer(c) => { + let caller = ctx.caller(); + self.transfer(caller, c.to, c.amount, privileged)?; + true.abi_encode().into() + } + C::transferFrom(c) => { + let caller = ctx.caller(); + self.transfer_from(caller, c.from, c.to, c.amount, privileged)?; + true.abi_encode().into() + } + C::approve(c) => { + let caller = ctx.caller(); + self.approve(caller, c.spender, c.amount)?; + true.abi_encode().into() + } + C::transferWithMemo(c) => { + let caller = ctx.caller(); + self.transfer_with_memo(caller, c.to, c.amount, c.memo, privileged)?; + true.abi_encode().into() + } + C::transferFromWithMemo(c) => { + let caller = ctx.caller(); + self.transfer_from_with_memo(caller, c.from, c.to, c.amount, c.memo, privileged)?; + true.abi_encode().into() + } + + // --- Mint --- + C::mint(c) => { + let caller = ctx.caller(); + self.mint(caller, c.to, c.amount, privileged)?; + Bytes::new() + } + C::mintWithMemo(c) => { + let caller = ctx.caller(); + self.mint_with_memo(caller, c.to, c.amount, c.memo, privileged)?; + Bytes::new() + } + + // --- Burn --- + // Self-burn operations are never factory-privileged: during init the caller is the + // factory, not a token holder. + C::burn(c) => { + let caller = ctx.caller(); + self.burn(caller, caller, c.amount, false)?; + Bytes::new() + } + C::burnWithMemo(c) => { + let caller = ctx.caller(); + self.burn_with_memo(caller, caller, c.amount, c.memo, false)?; + Bytes::new() + } + C::burnBlocked(c) => { + let caller = ctx.caller(); + self.burn_blocked(caller, c.from, c.amount, privileged)?; + Bytes::new() + } + + // --- Pause --- + C::pause(c) => { + let caller = ctx.caller(); + self.pause(caller, c.features, privileged)?; + Bytes::new() + } + C::unpause(c) => { + let caller = ctx.caller(); + self.unpause(caller, c.features, privileged)?; + Bytes::new() + } + + // --- Admin --- + C::updateSupplyCap(c) => { + let caller = ctx.caller(); + Configurable::update_supply_cap(self, caller, c.newSupplyCap, privileged)?; + Bytes::new() + } + C::updateName(c) => { + let caller = ctx.caller(); + Configurable::update_name(self, caller, c.newName, privileged)?; + Bytes::new() + } + C::updateSymbol(c) => { + let caller = ctx.caller(); + Configurable::update_symbol(self, caller, c.newSymbol, privileged)?; + Bytes::new() + } + C::updateContractURI(c) => { + let caller = ctx.caller(); + Configurable::update_contract_uri(self, caller, c.newURI, privileged)?; + Bytes::new() + } + + // --- Role mutations --- + C::grantRole(c) => { + let caller = ctx.caller(); + self.grant_role(caller, c.role, c.account, privileged)?; + Bytes::new() + } + C::revokeRole(c) => { + let caller = ctx.caller(); + self.revoke_role(caller, c.role, c.account, privileged)?; + Bytes::new() + } + // Renounce operations are never factory-privileged: they are only meaningful for the + // role holder making the call after token creation. + C::renounceRole(c) => { + let caller = ctx.caller(); + self.renounce_role(caller, c.role, c.callerConfirmation)?; + Bytes::new() + } + C::renounceLastAdmin(_) => { + let caller = ctx.caller(); + self.renounce_last_admin(caller)?; + Bytes::new() + } + C::setRoleAdmin(c) => { + let caller = ctx.caller(); + self.set_role_admin(caller, c.role, c.newAdminRole, privileged)?; + Bytes::new() + } + + // --- Policy mutations --- + C::updatePolicy(c) => { + let caller = ctx.caller(); + self.update_policy(caller, c.policyScope, c.newPolicyId, privileged)?; + Bytes::new() + } + + // --- Permit --- + C::permit(c) => { + self.permit( + ctx.chain_id(), + ctx.timestamp(), + PermitArgs { + owner: c.owner, + spender: c.spender, + value: c.value, + deadline: c.deadline, + v: c.v, + r: c.r, + s: c.s, + }, + )?; + Bytes::new() + } + }; + Ok(encoded) + } + + fn handle_security_call( + &mut self, + ctx: StorageCtx<'_>, + call: SC, + privileged: bool, + ) -> base_precompile_storage::Result { + let encoded: Bytes = match call { + // --- Role / precision constants --- + SC::SECURITY_OPERATOR_ROLE(_) => Self::SECURITY_OPERATOR_ROLE.abi_encode().into(), + SC::BURN_FROM_ROLE(_) => Self::BURN_FROM_ROLE.abi_encode().into(), + SC::WAD_PRECISION(_) => WAD.abi_encode().into(), + SC::REDEEM_SENDER_POLICY(_) => Self::REDEEM_SENDER_POLICY.abi_encode().into(), + + // --- Share ratio reads --- + SC::sharesToTokensRatio(_) => { + self.accounting().shares_to_tokens_ratio()?.abi_encode().into() + } + SC::toShares(c) => self.to_shares(c.balance)?.abi_encode().into(), + SC::sharesOf(c) => { + let balance = self.accounting().balance_of(c.account)?; + self.to_shares(balance)?.abi_encode().into() + } + + // --- Announcement reads --- + SC::isAnnouncementIdUsed(c) => { + self.accounting().is_announcement_id_used(c.id.as_str())?.abi_encode().into() + } + + // --- Security identifier reads --- + SC::securityIdentifier(c) => self + .accounting() + .security_identifier(c.identifierType.as_str())? + .abi_encode() + .into(), + + // --- Share ratio mutations --- + SC::updateShareRatio(c) => { + let caller = ctx.caller(); + self.ensure_security_operator(caller, privileged)?; + self.accounting_mut().set_shares_to_tokens_ratio(c.newSharesToTokensRatio)?; + self.accounting_mut().emit_event( + IB20Security::ShareRatioUpdated { + sharesToTokensRatio: c.newSharesToTokensRatio, + } + .encode_log_data(), + )?; + Bytes::new() + } + + // --- Announcement --- + SC::announce(c) => { + self.announce(ctx, c.internalCalls, c.id, c.description, c.uri, privileged)?; + Bytes::new() + } + + // --- Batched mint / burn --- + SC::batchMint(c) => { + self.batch_mint(ctx, c.recipients, c.amounts, privileged)?; + Bytes::new() + } + SC::batchBurn(c) => { + self.batch_burn(ctx, c.accounts, c.amounts)?; + Bytes::new() + } + + // --- Security redeem (overrides IB20 redeem semantics) --- + SC::redeem(c) => { + let caller = ctx.caller(); + self.security_redeem(caller, c.amount)?; + Bytes::new() + } + SC::redeemWithMemo(c) => { + let caller = ctx.caller(); + self.security_redeem_with_memo(caller, c.amount, c.memo)?; + Bytes::new() + } + + // --- Minimum redeemable (security version, in shares) --- + SC::minimumRedeemable(_) => self.accounting().minimum_redeemable()?.abi_encode().into(), + SC::updateMinimumRedeemable(c) => { + let caller = ctx.caller(); + self.ensure_default_admin(caller, privileged)?; + self.accounting_mut().set_minimum_redeemable(c.newMinimumRedeemable)?; + self.accounting_mut().emit_event( + IB20Security::MinimumRedeemableUpdated { + caller, + newMinimumRedeemable: c.newMinimumRedeemable, + } + .encode_log_data(), + )?; + Bytes::new() + } + + // --- Security identifier mutations --- + SC::updateSecurityIdentifier(c) => { + let caller = ctx.caller(); + self.ensure_security_operator(caller, privileged)?; + if c.identifierType.is_empty() { + return Err(BasePrecompileError::revert( + IB20Security::InvalidIdentifierType {}, + )); + } + self.accounting_mut() + .set_security_identifier_value(c.identifierType.as_str(), c.value.clone())?; + self.accounting_mut().emit_event( + IB20Security::SecurityIdentifierUpdated { + identifierType: c.identifierType, + value: c.value, + } + .encode_log_data(), + )?; + Bytes::new() + } + }; + Ok(encoded) + } + + /// Converts a token balance to shares: `balance * sharesToTokensRatio / WAD`. + fn to_shares(&self, balance: U256) -> base_precompile_storage::Result { + let ratio = self.accounting().shares_to_tokens_ratio()?; + Ok(balance.saturating_mul(ratio) / WAD) + } + + /// Performs a security-specific redeem: share-based floor check, burn, security `Redeemed` event. + fn security_redeem( + &mut self, + caller: Address, + amount: U256, + ) -> base_precompile_storage::Result<()> { + let ratio = self.security_redeem_burn(caller, amount)?; + self.emit_redeemed(caller, amount, ratio) + } + + /// [`Self::security_redeem`] with a memo emitted between `Transfer` and `Redeemed`. + fn security_redeem_with_memo( + &mut self, + caller: Address, + amount: U256, + memo: B256, + ) -> base_precompile_storage::Result<()> { + let ratio = self.security_redeem_burn(caller, amount)?; + self.accounting_mut().emit_event(IB20::Memo { caller, memo }.encode_log_data())?; + self.emit_redeemed(caller, amount, ratio) + } + + /// Performs the shared security redeem burn and returns the ratio used for the floor check. + fn security_redeem_burn( + &mut self, + caller: Address, + amount: U256, + ) -> base_precompile_storage::Result { + B20Guards::ensure_not_paused::(self, IB20::PausableFeature::REDEEM)?; + B20Guards::ensure_policy::(self, Self::REDEEM_SENDER_POLICY, caller)?; + if amount.is_zero() { + return Err(BasePrecompileError::revert(IB20::InvalidAmount {})); + } + let ratio = self.accounting().shares_to_tokens_ratio()?; + let shares = amount.saturating_mul(ratio) / WAD; + let minimum = self.accounting().minimum_redeemable()?; + if shares == U256::ZERO || shares < minimum { + return Err(BasePrecompileError::revert(IB20Security::BelowMinimumRedeemable { + shares, + minimum, + })); + } + let balance = self.accounting().balance_of(caller)?; + if balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: caller, + balance, + needed: amount, + })); + } + self.accounting_mut().set_balance(caller, balance - amount)?; + let supply = self.accounting().total_supply()?; + self.accounting_mut().set_total_supply(supply.saturating_sub(amount))?; + self.accounting_mut().emit_event( + IB20::Transfer { from: caller, to: Address::ZERO, amount }.encode_log_data(), + )?; + Ok(ratio) + } + + fn emit_redeemed( + &mut self, + caller: Address, + amount: U256, + ratio: U256, + ) -> base_precompile_storage::Result<()> { + self.accounting_mut().emit_event( + IB20Security::Redeemed { from: caller, amt: amount, sharesToTokensRatio: ratio } + .encode_log_data(), + ) + } + + /// Mints tokens to multiple recipients. All-or-nothing. + fn batch_mint( + &mut self, + ctx: StorageCtx<'_>, + recipients: Vec
, + amounts: Vec, + privileged: bool, + ) -> base_precompile_storage::Result<()> { + if recipients.is_empty() { + return Err(BasePrecompileError::revert(IB20Security::EmptyBatch {})); + } + if recipients.len() != amounts.len() { + return Err(BasePrecompileError::revert(IB20Security::LengthMismatch { + leftLen: U256::from(recipients.len()), + rightLen: U256::from(amounts.len()), + })); + } + let caller = ctx.caller(); + for (recipient, amount) in recipients.into_iter().zip(amounts) { + self.mint(caller, recipient, amount, privileged)?; + } + Ok(()) + } + + /// Burns tokens from multiple accounts unconditionally. All-or-nothing. + /// + /// Unlike `burnBlocked`, this path has no policy precondition. The + /// `BURN_FROM_ROLE` authorization and burn pause check are the only gates. + fn batch_burn( + &mut self, + ctx: StorageCtx<'_>, + accounts: Vec
, + amounts: Vec, + ) -> base_precompile_storage::Result<()> { + let caller = ctx.caller(); + self.ensure_burn_from_role(caller)?; + if accounts.len() != amounts.len() { + return Err(BasePrecompileError::revert(IB20Security::LengthMismatch { + leftLen: U256::from(accounts.len()), + rightLen: U256::from(amounts.len()), + })); + } + if accounts.is_empty() { + return Err(BasePrecompileError::revert(IB20Security::EmptyBatch {})); + } + B20Guards::ensure_not_paused::(self, IB20::PausableFeature::BURN)?; + for (account, amount) in accounts.into_iter().zip(amounts) { + if amount.is_zero() { + return Err(BasePrecompileError::revert(IB20::InvalidAmount {})); + } + let balance = self.accounting().balance_of(account)?; + if balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: account, + balance, + needed: amount, + })); + } + self.accounting_mut().set_balance(account, balance - amount)?; + let supply = self.accounting().total_supply()?; + self.accounting_mut().set_total_supply(supply.saturating_sub(amount))?; + self.accounting_mut().emit_event( + IB20::Transfer { from: account, to: Address::ZERO, amount }.encode_log_data(), + )?; + } + Ok(()) + } + + /// Posts an announcement and atomically executes `internal_calls` via self-dispatch. + /// + /// The `in_announcement` flag and selector check prevent recursive invocation. + fn announce( + &mut self, + ctx: StorageCtx<'_>, + internal_calls: Vec, + id: String, + description: String, + uri: String, + privileged: bool, + ) -> base_precompile_storage::Result<()> { + let caller = ctx.caller(); + self.ensure_security_operator(caller, privileged)?; + if self.is_announcement_active() { + return Err(BasePrecompileError::revert(IB20Security::AnnouncementInProgress {})); + } + + if self.accounting().is_announcement_id_used(id.as_str())? { + return Err(BasePrecompileError::revert(IB20Security::AnnouncementIdAlreadyUsed { + id, + })); + } + self.accounting_mut().mark_announcement_id_used(id.as_str())?; + + self.accounting_mut().emit_event( + IB20Security::Announcement { caller, id: id.clone(), description, uri } + .encode_log_data(), + )?; + + self.begin_announcement(); + + for call in &internal_calls { + let call_bytes: &[u8] = call.as_ref(); + if call_bytes.len() < 4 { + return Err(BasePrecompileError::revert(IB20Security::InternalCallMalformed { + call: call.clone(), + })); + } + if call_bytes[..4] == IB20Security::announceCall::SELECTOR { + return Err(BasePrecompileError::revert(IB20Security::AnnouncementInProgress {})); + } + self.inner_with_privilege(ctx, call_bytes, privileged).map_err(|_| { + BasePrecompileError::revert(IB20Security::InternalCallFailed { call: call.clone() }) + })?; + } + + self.accounting_mut().emit_event(IB20Security::EndAnnouncement { id }.encode_log_data()) + } +} + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use alloy_primitives::{Address, B256, Bytes, U256}; + use alloy_sol_types::{SolCall, SolEvent}; + use base_precompile_storage::{ + BasePrecompileError, HashMapStorageProvider, Result, StorageCtx, setup_storage, + }; + + use crate::{ + ActivationFeature, ActivationRegistryStorage, B20PausableFeature, B20SecurityStorage, + B20SecurityToken, B20TokenRole, IB20, IB20Security, InMemoryPolicy, + InMemoryTokenAccounting, PolicyHandle, PolicyRegistryStorage, SecurityAccounting, Token, + TokenAccounting, + }; + + type TestSecurityToken = B20SecurityToken; + + const BURN_FROM_ROLE: B256 = TestSecurityToken::BURN_FROM_ROLE; + const REDEEM_SENDER_POLICY: B256 = TestSecurityToken::REDEEM_SENDER_POLICY; + + const ALICE: Address = Address::repeat_byte(0xaa); + const BOB: Address = Address::repeat_byte(0xbb); + const TOKEN: Address = Address::repeat_byte(0x01); + const ACTIVATION_ADMIN: Address = Address::repeat_byte(0xcb); + const WAD: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); + + fn make_token() -> TestSecurityToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN); + accounting.shares_to_tokens_ratio = WAD; // 1:1 ratio + // Explicitly open redemption so non-policy tests are not blocked by the ALWAYS_BLOCK default. + accounting.policy_ids.insert(REDEEM_SENDER_POLICY, PolicyRegistryStorage::ALWAYS_ALLOW_ID); + TestSecurityToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + fn activate_b20_security(storage: &mut HashMapStorageProvider) { + storage.set_caller(ACTIVATION_ADMIN); + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx) + .activate(ActivationFeature::B20Security.id(), Some(ACTIVATION_ADMIN)) + }) + .unwrap(); + } + + fn storage_with_caller(caller: Address) -> HashMapStorageProvider { + let mut storage = HashMapStorageProvider::new(1); + activate_b20_security(&mut storage); + storage.set_caller(caller); + storage + } + + fn call_security( + token: &mut TestSecurityToken, + caller: Address, + calldata: Vec, + ) -> Result { + let mut storage = storage_with_caller(caller); + StorageCtx::enter(&mut storage, |ctx| token.inner(ctx, calldata.as_ref())) + } + + fn batch_mint_calldata(recipients: Vec
, amounts: Vec) -> Vec { + IB20Security::batchMintCall { recipients, amounts }.abi_encode() + } + + fn batch_burn_calldata(accounts: Vec
, amounts: Vec) -> Vec { + IB20Security::batchBurnCall { accounts, amounts }.abi_encode() + } + + #[test] + fn to_shares_one_to_one_ratio() { + let token = make_token(); + assert_eq!(token.to_shares(U256::from(100u64)).unwrap(), U256::from(100u64)); + } + + #[test] + fn to_shares_two_to_one_ratio() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN); + accounting.shares_to_tokens_ratio = WAD * U256::from(2u64); + let token = TestSecurityToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + assert_eq!(token.to_shares(U256::from(50u64)).unwrap(), U256::from(100u64)); + } + + #[test] + fn batch_mint_increases_balances() { + let mut token = make_token(); + token.accounting_mut().roles.insert((B20TokenRole::Mint.id(), ALICE), true); + + call_security( + &mut token, + ALICE, + batch_mint_calldata( + alloc::vec![ALICE, BOB], + alloc::vec![U256::from(100u64), U256::from(200u64)], + ), + ) + .unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(100u64)); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(200u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(300u64)); + assert_eq!( + token.accounting().events, + alloc::vec![ + IB20::Transfer { from: Address::ZERO, to: ALICE, amount: U256::from(100u64) } + .encode_log_data(), + IB20::Transfer { from: Address::ZERO, to: BOB, amount: U256::from(200u64) } + .encode_log_data() + ] + ); + } + + #[test] + fn batch_mint_requires_mint_role() { + let mut token = make_token(); + + let err = call_security( + &mut token, + ALICE, + batch_mint_calldata(alloc::vec![BOB], alloc::vec![U256::from(100u64)]), + ) + .unwrap_err(); + + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::Mint.id(), + }) + ); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ZERO); + assert_eq!(token.accounting().total_supply().unwrap(), U256::ZERO); + } + + #[test] + fn batch_burn_decrements_balances() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().balances.insert(ALICE, U256::from(500u64)); + token.accounting_mut().total_supply = U256::from(500u64); + + call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![ALICE], alloc::vec![U256::from(200u64)]), + ) + .unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(300u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(300u64)); + assert_eq!( + token.accounting().events, + alloc::vec![ + IB20::Transfer { from: ALICE, to: Address::ZERO, amount: U256::from(200u64) } + .encode_log_data() + ] + ); + } + + #[test] + fn batch_burn_rejects_insufficient_balance() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().balances.insert(ALICE, U256::from(10u64)); + + assert_eq!( + call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![ALICE], alloc::vec![U256::from(100u64)]), + ) + .unwrap_err(), + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: U256::from(10u64), + needed: U256::from(100u64), + }) + ); + } + + #[test] + fn batch_burn_requires_burn_from_role() { + let mut token = make_token(); + token.accounting_mut().balances.insert(BOB, U256::from(50u64)); + token.accounting_mut().total_supply = U256::from(50u64); + + let err = call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![BOB], alloc::vec![U256::from(10u64)]), + ) + .unwrap_err(); + + assert_eq!( + err, + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: BURN_FROM_ROLE, + }) + ); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(50u64)); + } + + #[test] + fn batch_burn_with_role_decrements_balances() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().balances.insert(BOB, U256::from(50u64)); + token.accounting_mut().total_supply = U256::from(50u64); + + call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![BOB], alloc::vec![U256::from(10u64)]), + ) + .unwrap(); + + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(40u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(40u64)); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn batch_burn_respects_burn_pause() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().paused = B20PausableFeature::mask(IB20::PausableFeature::BURN); + token.accounting_mut().balances.insert(BOB, U256::from(50u64)); + token.accounting_mut().total_supply = U256::from(50u64); + + let err = call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![BOB], alloc::vec![U256::from(10u64)]), + ) + .unwrap_err(); + + assert_eq!( + err, + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::BURN, + }) + ); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(50u64)); + } + + #[test] + fn security_redeem_burns_and_emits_security_event() { + let mut token = make_token(); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + token.accounting_mut().minimum_redeemable = U256::from(1u64); + + token.security_redeem(ALICE, U256::from(50u64)).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(50u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(50u64)); + assert_eq!(token.accounting().events.len(), 2); // Transfer + Redeemed + } + + #[test] + fn security_redeem_rejects_below_minimum_shares() { + let mut token = make_token(); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + token.accounting_mut().minimum_redeemable = U256::from(10u64); + + // 5 tokens * 1e18 ratio / 1e18 = 5 shares < 10 minimum + assert!(token.security_redeem(ALICE, U256::from(5u64)).is_err()); + } + + #[test] + fn security_redeem_rejects_zero_shares() { + let mut token = make_token(); + token.accounting_mut().shares_to_tokens_ratio = U256::ONE; + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + + // 1 token-wei * 1 / WAD rounds down to 0 shares, which is always rejected. + assert!(token.security_redeem(ALICE, U256::ONE).is_err()); + } + + #[test] + fn security_redeem_rejects_when_redeem_feature_paused() { + let mut token = make_token(); + token.accounting_mut().paused = B20PausableFeature::mask(IB20::PausableFeature::REDEEM); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + + assert_eq!( + token.security_redeem(ALICE, U256::from(1u64)).unwrap_err(), + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::REDEEM, + }) + ); + } + + #[test] + fn security_redeem_rejects_when_sender_policy_denies() { + let policy_id = 7; + let mut accounting = InMemoryTokenAccounting::new(TOKEN); + accounting.shares_to_tokens_ratio = WAD; + accounting.balances.insert(ALICE, U256::from(100u64)); + accounting.total_supply = U256::from(100u64); + accounting.policy_ids.insert(REDEEM_SENDER_POLICY, policy_id); + let mut policy = InMemoryPolicy::new(); + policy.create_existing_policy(policy_id); + let mut token = TestSecurityToken::with_storage_and_policy(accounting, policy); + + assert_eq!( + token.security_redeem(ALICE, U256::from(1u64)).unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: REDEEM_SENDER_POLICY, + policyId: policy_id, + }) + ); + } + + #[test] + fn announce_marks_id_used() { + let mut token = make_token(); + let id = "2026-Q1-split"; + + assert!(!token.accounting().is_announcement_id_used(id).unwrap()); + token.accounting_mut().mark_announcement_id_used(id).unwrap(); + assert!(token.accounting().is_announcement_id_used(id).unwrap()); + } + + #[test] + fn security_identifier_roundtrip() { + let mut token = make_token(); + + assert_eq!(token.accounting().security_identifier("ISIN").unwrap(), ""); + token + .accounting_mut() + .set_security_identifier_value("ISIN", "US0000000000".to_string()) + .unwrap(); + assert_eq!( + token.accounting().security_identifier("ISIN").unwrap(), + "US0000000000".to_string() + ); + } + + // --- batchMint: EmptyBatch / LengthMismatch --- + + #[test] + fn batch_mint_rejects_empty() { + let mut token = make_token(); + token.accounting_mut().roles.insert((B20TokenRole::Mint.id(), ALICE), true); + + assert_eq!( + call_security(&mut token, ALICE, batch_mint_calldata(alloc::vec![], alloc::vec![])) + .unwrap_err(), + BasePrecompileError::revert(IB20Security::EmptyBatch {}) + ); + } + + #[test] + fn batch_mint_rejects_length_mismatch() { + let mut token = make_token(); + token.accounting_mut().roles.insert((B20TokenRole::Mint.id(), ALICE), true); + + assert_eq!( + call_security( + &mut token, + ALICE, + batch_mint_calldata(alloc::vec![ALICE], alloc::vec![U256::ONE, U256::ONE]), + ) + .unwrap_err(), + BasePrecompileError::revert(IB20Security::LengthMismatch { + leftLen: U256::ONE, + rightLen: U256::from(2u64), + }) + ); + } + + // --- batchBurn: EmptyBatch / LengthMismatch / multi-account Transfer events --- + + #[test] + fn batch_burn_rejects_empty() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + + let err = + call_security(&mut token, ALICE, batch_burn_calldata(alloc::vec![], alloc::vec![])) + .unwrap_err(); + + assert_eq!(err, BasePrecompileError::revert(IB20Security::EmptyBatch {})); + } + + #[test] + fn batch_burn_rejects_length_mismatch() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + + let err = call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![ALICE], alloc::vec![U256::ONE, U256::ONE]), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Security::LengthMismatch { + leftLen: U256::ONE, + rightLen: U256::from(2u64), + }) + ); + + let err = call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![], alloc::vec![U256::ONE]), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Security::LengthMismatch { + leftLen: U256::ZERO, + rightLen: U256::ONE, + }) + ); + } + + #[test] + fn batch_burn_validates_batch_shape_before_pause() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().paused = B20PausableFeature::mask(IB20::PausableFeature::BURN); + + let err = call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![ALICE], alloc::vec![U256::ONE, U256::ONE]), + ) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IB20Security::LengthMismatch { + leftLen: U256::ONE, + rightLen: U256::from(2u64), + }) + ); + + let err = + call_security(&mut token, ALICE, batch_burn_calldata(alloc::vec![], alloc::vec![])) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::revert(IB20Security::EmptyBatch {})); + } + + #[test] + fn batch_burn_rejects_zero_amount() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + + assert_eq!( + call_security( + &mut token, + ALICE, + batch_burn_calldata(alloc::vec![ALICE], alloc::vec![U256::ZERO]), + ) + .unwrap_err(), + BasePrecompileError::revert(IB20::InvalidAmount {}) + ); + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(100u64)); + assert_eq!(token.accounting().events.len(), 0); + } + + #[test] + fn batch_burn_multiple_accounts_emits_one_transfer_each() { + let mut token = make_token(); + token.accounting_mut().roles.insert((BURN_FROM_ROLE, ALICE), true); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().balances.insert(BOB, U256::from(200u64)); + token.accounting_mut().total_supply = U256::from(300u64); + + call_security( + &mut token, + ALICE, + batch_burn_calldata( + alloc::vec![ALICE, BOB], + alloc::vec![U256::from(100u64), U256::from(200u64)], + ), + ) + .unwrap(); + + // IB20Security: "Emits Transfer(accounts[i], address(0), amounts[i]) per element" + assert_eq!( + token.accounting().events, + alloc::vec![ + IB20::Transfer { from: ALICE, to: Address::ZERO, amount: U256::from(100u64) } + .encode_log_data(), + IB20::Transfer { from: BOB, to: Address::ZERO, amount: U256::from(200u64) } + .encode_log_data() + ] + ); + assert_eq!(token.accounting().total_supply().unwrap(), U256::ZERO); + } + + // --- redeem: InsufficientBalance / boundary / ratio math / event pair --- + + #[test] + fn security_redeem_rejects_insufficient_balance() { + let mut token = make_token(); + token.accounting_mut().balances.insert(ALICE, U256::from(10u64)); + token.accounting_mut().total_supply = U256::from(10u64); + token.accounting_mut().minimum_redeemable = U256::from(1u64); + // amount=100 > balance=10 → InsufficientBalance after the share-floor check passes + assert!(token.security_redeem(ALICE, U256::from(100u64)).is_err()); + // no state mutation on failure + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(10u64)); + } + + #[test] + fn security_redeem_at_exact_minimum_succeeds() { + let mut token = make_token(); // 1:1 ratio + token.accounting_mut().balances.insert(ALICE, U256::from(50u64)); + token.accounting_mut().total_supply = U256::from(50u64); + // 5 tokens * WAD / WAD = 5 shares == minimum → boundary must be accepted + token.accounting_mut().minimum_redeemable = U256::from(5u64); + token.security_redeem(ALICE, U256::from(5u64)).unwrap(); + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(45u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(45u64)); + } + + #[test] + fn security_redeem_with_non_unit_ratio_applies_correct_share_math() { + let mut token = make_token(); + // 2:1 ratio: 1 token = 2 shares + token.accounting_mut().shares_to_tokens_ratio = WAD * U256::from(2u64); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + // minimum = 10 shares → need at least 5 tokens + token.accounting_mut().minimum_redeemable = U256::from(10u64); + // 4 tokens → 8 shares < 10 → BelowMinimumRedeemable + assert!(token.security_redeem(ALICE, U256::from(4u64)).is_err()); + // 5 tokens → 10 shares == minimum → accepted + token.security_redeem(ALICE, U256::from(5u64)).unwrap(); + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(95u64)); + } + + #[test] + fn security_redeem_emits_transfer_then_redeemed() { + let mut token = make_token(); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + token.accounting_mut().minimum_redeemable = U256::from(1u64); + token.security_redeem(ALICE, U256::from(10u64)).unwrap(); + // "Emits Transfer(caller, address(0), amount) followed by Redeemed(caller, amount, ratio)" + assert_eq!(token.accounting().events.len(), 2); + } + + #[test] + fn security_redeem_with_memo_emits_memo_before_redeemed() { + let mut token = make_token(); + let amount = U256::from(10u64); + let memo = B256::repeat_byte(0x42); + token.accounting_mut().balances.insert(ALICE, U256::from(100u64)); + token.accounting_mut().total_supply = U256::from(100u64); + token.accounting_mut().minimum_redeemable = U256::from(1u64); + + token.security_redeem_with_memo(ALICE, amount, memo).unwrap(); + + assert_eq!( + token.accounting().events[0], + IB20::Transfer { from: ALICE, to: Address::ZERO, amount }.encode_log_data() + ); + assert_eq!( + token.accounting().events[1], + IB20::Memo { caller: ALICE, memo }.encode_log_data() + ); + assert_eq!( + token.accounting().events[2], + IB20Security::Redeemed { from: ALICE, amt: amount, sharesToTokensRatio: WAD } + .encode_log_data() + ); + } + + // --- toShares: zero balance / sub-WAD truncation / sharesOf delegation --- + + #[test] + fn to_shares_zero_balance_yields_zero() { + let token = make_token(); + assert_eq!(token.to_shares(U256::ZERO).unwrap(), U256::ZERO); + } + + #[test] + fn to_shares_sub_wad_ratio_truncates_to_zero() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN); + // 0.5 WAD: 1 token → 0.5 shares → truncates to 0 via integer division + accounting.shares_to_tokens_ratio = WAD / U256::from(2u64); + let token = TestSecurityToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + assert_eq!(token.to_shares(U256::from(1u64)).unwrap(), U256::ZERO); + } + + #[test] + fn shares_of_derives_from_balance() { + let mut token = make_token(); // 1:1 ratio + token.accounting_mut().balances.insert(ALICE, U256::from(75u64)); + // sharesOf(account) = toShares(balanceOf(account)) + let balance = token.accounting().balance_of(ALICE).unwrap(); + assert_eq!(token.to_shares(balance).unwrap(), U256::from(75u64)); + } + + #[test] + fn storage_backed_redeem_uses_wad_when_share_ratio_slot_is_unset() { + let (mut storage, _) = setup_storage(); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20SecurityToken::with_storage_and_policy( + B20SecurityStorage::from_address(TOKEN, ctx), + PolicyHandle::new(ctx), + ); + token.accounting_mut().set_balance(ALICE, U256::from(100u64)).unwrap(); + token.accounting_mut().set_total_supply(U256::from(100u64)).unwrap(); + token.accounting_mut().set_minimum_redeemable(U256::from(10u64)).unwrap(); + + assert_eq!(token.accounting().shares_to_tokens_ratio().unwrap(), WAD); + token.security_redeem(ALICE, U256::from(10u64)).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(90u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(90u64)); + }); + } + + // --- updateShareRatio: persistence --- + + #[test] + fn shares_to_tokens_ratio_update_persists() { + let mut token = make_token(); + let new_ratio = WAD * U256::from(3u64); + token.accounting_mut().set_shares_to_tokens_ratio(new_ratio).unwrap(); + assert_eq!(token.accounting().shares_to_tokens_ratio().unwrap(), new_ratio); + } + + // --- securityIdentifier / updateSecurityIdentifier --- + + #[test] + fn security_identifier_missing_key_returns_empty() { + let token = make_token(); + // "Returns the empty string if not set" + assert_eq!(token.accounting().security_identifier("CUSIP").unwrap(), ""); + } + + #[test] + fn security_identifier_empty_value_clears_entry() { + let mut token = make_token(); + token + .accounting_mut() + .set_security_identifier_value("FIGI", "BBG000B9XRY4".to_string()) + .unwrap(); + assert_eq!(token.accounting().security_identifier("FIGI").unwrap(), "BBG000B9XRY4"); + // "passing an empty value removes the entry" + token.accounting_mut().set_security_identifier_value("FIGI", String::new()).unwrap(); + assert_eq!(token.accounting().security_identifier("FIGI").unwrap(), ""); + } + + // --- minimumRedeemable / updateMinimumRedeemable --- + + #[test] + fn minimum_redeemable_persists() { + let mut token = make_token(); + let floor = U256::from(42u64); + token.accounting_mut().set_minimum_redeemable(floor).unwrap(); + assert_eq!(token.accounting().minimum_redeemable().unwrap(), floor); + } + + // --- isAnnouncementIdUsed: fresh state --- + + #[test] + fn announcement_id_not_used_initially() { + let token = make_token(); + let id = "2026-Q1-split"; + // "Returns true if id has previously been consumed by announce" → false for new id + assert!(!token.accounting().is_announcement_id_used(id).unwrap()); + } +} diff --git a/crates/common/precompiles/src/b20_security/mod.rs b/crates/common/precompiles/src/b20_security/mod.rs new file mode 100644 index 0000000000..8e6749636d --- /dev/null +++ b/crates/common/precompiles/src/b20_security/mod.rs @@ -0,0 +1,20 @@ +//! `B20SecurityToken` native precompile — security variant of the B-20 token. + +mod abi; +pub use abi::IB20Security; + +mod accounting; +pub use accounting::SecurityAccounting; + +mod dispatch; + +mod precompile; +pub use precompile::B20SecurityPrecompile; + +mod storage; +pub use storage::{ + B20RedeemStorage, B20SecurityExtensionStorage, B20SecurityInit, B20SecurityStorage, +}; + +mod token; +pub use token::B20SecurityToken; diff --git a/crates/common/precompiles/src/b20_security/precompile.rs b/crates/common/precompiles/src/b20_security/precompile.rs new file mode 100644 index 0000000000..2ecec6dff4 --- /dev/null +++ b/crates/common/precompiles/src/b20_security/precompile.rs @@ -0,0 +1,40 @@ +//! Precompile entry point for the security B-20 variant. + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::Address; + +use crate::{ + B20SecurityStorage, B20SecurityToken, NoopPrecompileCallObserver, PolicyHandle, + PrecompileCallObserver, macros::base_precompile, +}; + +/// Entry point for the security B-20 token precompile. +/// +/// Wraps [`B20SecurityToken`] dispatch behind a [`DynPrecompile`] for +/// registration in a [`PrecompilesMap`]. +#[derive(Debug)] +pub struct B20SecurityPrecompile; + +impl B20SecurityPrecompile { + /// Returns a [`DynPrecompile`] that dispatches to [`B20SecurityToken`] logic at + /// `token_address`. + pub fn create_precompile(token_address: Address) -> DynPrecompile { + Self::create_precompile_with_observer(token_address, NoopPrecompileCallObserver) + } + + /// Returns a [`DynPrecompile`] that observes and dispatches to [`B20SecurityToken`] logic at + /// `token_address`. + pub fn create_precompile_with_observer(token_address: Address, observer: O) -> DynPrecompile + where + O: PrecompileCallObserver, + { + base_precompile!(alloc::format!("B20SecurityToken@{token_address}"), |ctx, calldata| { + let observer = observer.clone(); + B20SecurityToken::with_storage_and_policy( + B20SecurityStorage::from_address(token_address, ctx), + PolicyHandle::new(ctx), + ) + .dispatch_with_observer(ctx, &calldata, observer) + }) + } +} diff --git a/crates/common/precompiles/src/b20_security/storage.rs b/crates/common/precompiles/src/b20_security/storage.rs new file mode 100644 index 0000000000..196bf12488 --- /dev/null +++ b/crates/common/precompiles/src/b20_security/storage.rs @@ -0,0 +1,570 @@ +//! EVM storage adapter for the security B-20 variant. + +use alloc::string::String; + +use alloy_primitives::{Address, B256, LogData, U256, b256}; +use base_precompile_macros::{Storable, contract}; +use base_precompile_storage::{ + BasePrecompileError, ContractStorage, Handler, Mapping, Result, StorageCtx, +}; + +use crate::{ + B20CoreStorage, B20PolicyType, B20TokenRole, B20Variant, IB20, PolicyRegistryStorage, + SecurityAccounting, TokenAccounting, +}; + +/// WAD precision for share ratio arithmetic: 1e18. +const WAD: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); + +/// Security-specific B-20 storage rooted at the `base.b20.security` ERC-7201 namespace. +#[derive(Debug, Clone, Storable)] +#[namespace("base.b20.security")] +pub struct B20SecurityExtensionStorage { + /// Share-to-token conversion ratio scaled to WAD. + pub shares_to_tokens_ratio: U256, // offset 0 + /// Announcement IDs that have already been consumed. + pub used_announcement_ids: Mapping, // offset 1 + /// Security identifier values by identifier type. + pub identifiers: Mapping, // offset 2 +} + +/// Redemption-specific B-20 storage rooted at the `base.b20.redeem` ERC-7201 namespace. +#[derive(Debug, Clone, Storable)] +#[namespace("base.b20.redeem")] +pub struct B20RedeemStorage { + /// Minimum share amount required for a redeem operation. + pub minimum_redeemable: U256, // offset 0 + /// Packed redeem-side policy IDs. + pub redeem_policy_ids: U256, // offset 1 +} + +/// EVM-backed storage for a security B-20 token. +#[contract] +pub struct B20SecurityStorage { + pub b20: B20CoreStorage, + pub security: B20SecurityExtensionStorage, + pub redeem: B20RedeemStorage, +} + +/// Creation-time parameters for a security B-20 token. +/// +/// Passed to [`B20SecurityStorage::initialize`] to write all fields atomically. +#[derive(Debug)] +pub struct B20SecurityInit { + /// ERC-20 token name. + pub name: String, + /// ERC-20 token symbol. + pub symbol: String, + /// Maximum total supply. + pub supply_cap: U256, + /// Share-to-token conversion ratio at WAD precision. + pub shares_to_tokens_ratio: U256, + /// ISIN identifier stored under the `"ISIN"` key. + pub isin: String, + /// Minimum redeemable amount; `0` allows any non-zero redemption. + pub minimum_redeemable: U256, +} + +impl<'a> B20SecurityStorage<'a> { + /// Policy scope identifier for the sender of a redeem operation: + /// `keccak256("REDEEM_SENDER_POLICY")`. + pub const REDEEM_SENDER_POLICY: B256 = + b256!("0ff53b08b65363a609bb561211128f4044adc0e351f0b92b6aa23f8d85462f59"); + + /// Creates a `B20SecurityStorage` instance targeting `addr`. + pub fn from_address(addr: Address, storage: StorageCtx<'a>) -> Self { + Self::__new(addr, storage) + } + + /// Writes all creation-time fields atomically. + /// + /// `isin` may be empty; when non-empty it is stored under the `"ISIN"` key + /// in the security identifiers mapping. + /// + /// `REDEEM_SENDER_POLICY` is initialised to `ALWAYS_BLOCK_ID` so redemption + /// is closed by default; issuers must explicitly open it after creation. + pub fn initialize(&mut self, init: B20SecurityInit) -> Result<()> { + self.b20.name.write(init.name)?; + self.b20.symbol.write(init.symbol)?; + self.b20.supply_cap.write(init.supply_cap)?; + self.security.shares_to_tokens_ratio.write(init.shares_to_tokens_ratio)?; + self.redeem.minimum_redeemable.write(init.minimum_redeemable)?; + if !init.isin.is_empty() { + self.security.identifiers.at_mut(&String::from("ISIN")).write(init.isin)?; + } + self.write_redeem_policy_ids_default()?; + Ok(()) + } +} + +impl TokenAccounting for B20SecurityStorage<'_> { + fn token_address(&self) -> Address { + ContractStorage::address(self) + } + + fn is_initialized(&self) -> Result { + ContractStorage::is_initialized(self) + } + + fn balance_of(&self, account: Address) -> Result { + self.b20.balances.at(&account).read() + } + + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()> { + self.b20.balances.at_mut(&account).write(balance) + } + + fn allowance(&self, owner: Address, spender: Address) -> Result { + self.b20.allowances.at(&owner).at(&spender).read() + } + + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + self.b20.allowances.at_mut(&owner).at_mut(&spender).write(amount) + } + + fn total_supply(&self) -> Result { + self.b20.total_supply.read() + } + + fn set_total_supply(&mut self, supply: U256) -> Result<()> { + self.b20.total_supply.write(supply) + } + + fn supply_cap(&self) -> Result { + self.b20.supply_cap.read() + } + + fn set_supply_cap(&mut self, cap: U256) -> Result<()> { + self.b20.supply_cap.write(cap) + } + + fn name(&self) -> Result { + self.b20.name.read() + } + + fn set_name(&mut self, name: String) -> Result<()> { + self.b20.name.write(name) + } + + fn symbol(&self) -> Result { + self.b20.symbol.read() + } + + fn set_symbol(&mut self, symbol: String) -> Result<()> { + self.b20.symbol.write(symbol) + } + + fn decimals(&self) -> Result { + Ok(B20Variant::from_address(ContractStorage::address(self)).map_or(0, B20Variant::decimals)) + } + + fn paused(&self) -> Result { + self.b20.paused.read() + } + + fn set_paused(&mut self, vectors: U256) -> Result<()> { + self.b20.paused.write(vectors) + } + + fn nonce(&self, owner: Address) -> Result { + self.b20.nonces.at(&owner).read() + } + + fn increment_nonce(&mut self, owner: Address) -> Result<()> { + let current = self.b20.nonces.at(&owner).read()?; + let next = + current.checked_add(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + self.b20.nonces.at_mut(&owner).write(next) + } + + fn contract_uri(&self) -> Result { + self.b20.contract_uri.read() + } + + fn set_contract_uri(&mut self, uri: String) -> Result<()> { + self.b20.contract_uri.write(uri) + } + + fn has_role(&self, role: B256, account: Address) -> Result { + self.b20.roles.at(&role).at(&account).read() + } + + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()> { + self.b20.roles.at_mut(&role).at_mut(&account).write(enabled) + } + + fn role_member_count(&self, role: B256) -> Result { + if role == B20TokenRole::DefaultAdmin.id() { + self.b20.admin_count.read() + } else { + Ok(U256::ZERO) + } + } + + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()> { + if role == B20TokenRole::DefaultAdmin.id() { + self.b20.admin_count.write(count) + } else { + Ok(()) + } + } + + fn role_admin(&self, role: B256) -> Result { + let admin_role = self.b20.role_admins.at(&role).read()?; + if admin_role.is_zero() && role != B20TokenRole::DefaultAdmin.id() { + Ok(B20TokenRole::DefaultAdmin.id()) + } else { + Ok(admin_role) + } + } + + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()> { + self.b20.role_admins.at_mut(&role).write(admin_role) + } + + fn policy_id(&self, policy_scope: B256) -> Result { + if policy_scope == Self::REDEEM_SENDER_POLICY { + return Ok(Self::read_policy_lane( + self.redeem.redeem_policy_ids.read()?, + Self::REDEEM_SENDER_POLICY_LANE, + )); + } + let policy_type = Self::require_b20_policy_type(policy_scope)?; + match policy_type { + B20PolicyType::TransferSender => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_SENDER_POLICY_LANE, + )), + B20PolicyType::TransferReceiver => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_RECEIVER_POLICY_LANE, + )), + B20PolicyType::TransferExecutor => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_EXECUTOR_POLICY_LANE, + )), + B20PolicyType::MintReceiver => Ok(Self::read_policy_lane( + self.b20.mint_policy_ids.read()?, + Self::MINT_RECEIVER_POLICY_LANE, + )), + } + } + + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()> { + if policy_scope == Self::REDEEM_SENDER_POLICY { + let packed = Self::write_policy_lane( + self.redeem.redeem_policy_ids.read()?, + Self::REDEEM_SENDER_POLICY_LANE, + policy_id, + ); + return self.redeem.redeem_policy_ids.write(packed); + } + let policy_type = Self::require_b20_policy_type(policy_scope)?; + match policy_type { + B20PolicyType::TransferSender => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_SENDER_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::TransferReceiver => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_RECEIVER_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::TransferExecutor => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_EXECUTOR_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::MintReceiver => { + let packed = Self::write_policy_lane( + self.b20.mint_policy_ids.read()?, + Self::MINT_RECEIVER_POLICY_LANE, + policy_id, + ); + self.b20.mint_policy_ids.write(packed) + } + } + } + + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.emit_event(log) + } +} + +impl B20SecurityStorage<'_> { + const TRANSFER_SENDER_POLICY_LANE: usize = 0; + const TRANSFER_RECEIVER_POLICY_LANE: usize = 1; + const TRANSFER_EXECUTOR_POLICY_LANE: usize = 2; + const MINT_RECEIVER_POLICY_LANE: usize = 0; + const REDEEM_SENDER_POLICY_LANE: usize = 0; + const POLICY_LANE_BITS: usize = 64; + + /// Writes the initial packed `redeem_policy_ids` word with `REDEEM_SENDER_POLICY` + /// set to `ALWAYS_BLOCK_ID`. Called once from [`initialize`]. + fn write_redeem_policy_ids_default(&mut self) -> Result<()> { + let packed = Self::write_policy_lane( + U256::ZERO, + Self::REDEEM_SENDER_POLICY_LANE, + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + ); + self.redeem.redeem_policy_ids.write(packed) + } + + fn require_b20_policy_type(policy_scope: B256) -> Result { + B20PolicyType::from_id(policy_scope).ok_or_else(|| { + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: policy_scope }) + }) + } + + fn read_policy_lane(packed: U256, lane: usize) -> u64 { + ((packed >> (lane * Self::POLICY_LANE_BITS)) & U256::from(u64::MAX)).to::() + } + + fn write_policy_lane(packed: U256, lane: usize, policy_id: u64) -> U256 { + let shift = lane * Self::POLICY_LANE_BITS; + let mask = U256::from(u64::MAX) << shift; + (packed & !mask) | (U256::from(policy_id) << shift) + } +} + +impl SecurityAccounting for B20SecurityStorage<'_> { + fn shares_to_tokens_ratio(&self) -> Result { + let ratio = self.security.shares_to_tokens_ratio.read()?; + Ok(if ratio.is_zero() { WAD } else { ratio }) + } + + fn set_shares_to_tokens_ratio(&mut self, ratio: U256) -> Result<()> { + self.security.shares_to_tokens_ratio.write(ratio) + } + + fn security_identifier(&self, identifier_type: &str) -> Result { + self.security.identifiers.at(&String::from(identifier_type)).read() + } + + fn set_security_identifier_value( + &mut self, + identifier_type: &str, + value: String, + ) -> Result<()> { + let key = String::from(identifier_type); + if value.is_empty() { + self.security.identifiers.at_mut(&key).delete() + } else { + self.security.identifiers.at_mut(&key).write(value) + } + } + + fn minimum_redeemable(&self) -> Result { + self.redeem.minimum_redeemable.read() + } + + fn set_minimum_redeemable(&mut self, minimum: U256) -> Result<()> { + self.redeem.minimum_redeemable.write(minimum) + } + + fn is_announcement_id_used(&self, id: &str) -> Result { + self.security.used_announcement_ids.at(&String::from(id)).read() + } + + fn mark_announcement_id_used(&mut self, id: &str) -> Result<()> { + self.security.used_announcement_ids.at_mut(&String::from(id)).write(true) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256, address, uint}; + use base_precompile_storage::{Handler, StorableType, StorageCtx, StorageKey, setup_storage}; + + use crate::{ + B20CoreStorage, B20RedeemStorage, B20SecurityExtensionStorage, B20SecurityInit, + B20SecurityStorage, PolicyRegistryStorage, SecurityAccounting, TokenAccounting, + b20_security::storage::{ + __packing_b20_redeem_storage, __packing_b20_security_extension_storage, WAD, slots, + }, + }; + + const TOKEN: Address = address!("000000000000000000000000000000000000b021"); + const B20_ROOT: U256 = + uint!(0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434000_U256); + const SECURITY_ROOT: U256 = + uint!(0x4a21e1b7f963e21baf0daffe6bab858a1e5fecef1144f3aca3c0c4534c7ac600_U256); + const REDEEM_ROOT: U256 = + uint!(0xc95c24ab0255f9fb9fcdcd524f71c4fe0437265856b7e5e6d0801df0e6cf5100_U256); + + #[test] + fn security_namespaces_match_base_std_roots() { + assert_eq!(::STORAGE_NAMESPACE_ROOT, B20_ROOT); + assert_eq!( + ::STORAGE_NAMESPACE_ID, + "base.b20.security" + ); + assert_eq!( + ::STORAGE_NAMESPACE_ROOT, + SECURITY_ROOT + ); + assert_eq!(::STORAGE_NAMESPACE_ID, "base.b20.redeem"); + assert_eq!(::STORAGE_NAMESPACE_ROOT, REDEEM_ROOT); + + assert_eq!(slots::B20, B20_ROOT); + assert_eq!(slots::SECURITY, SECURITY_ROOT); + assert_eq!(slots::REDEEM, REDEEM_ROOT); + } + + #[test] + fn security_extension_offsets_match_mock_storage() { + assert_eq!( + __packing_b20_security_extension_storage::SHARES_TO_TOKENS_RATIO_LOC.offset_slots, + 0 + ); + assert_eq!( + __packing_b20_security_extension_storage::USED_ANNOUNCEMENT_IDS_LOC.offset_slots, + 1 + ); + assert_eq!(__packing_b20_security_extension_storage::IDENTIFIERS_LOC.offset_slots, 2); + assert_eq!(__packing_b20_redeem_storage::MINIMUM_REDEEMABLE_LOC.offset_slots, 0); + assert_eq!(__packing_b20_redeem_storage::REDEEM_POLICY_IDS_LOC.offset_slots, 1); + } + + #[test] + fn shares_to_tokens_ratio_defaults_unset_slot_to_wad() { + let (mut storage, _) = setup_storage(); + + StorageCtx::enter(&mut storage, |ctx| { + let token = B20SecurityStorage::from_address(TOKEN, ctx); + let ratio_slot = SECURITY_ROOT + + U256::from( + __packing_b20_security_extension_storage::SHARES_TO_TOKENS_RATIO_LOC + .offset_slots, + ); + + assert_eq!(ctx.sload(TOKEN, ratio_slot).unwrap(), U256::ZERO); + assert_eq!(token.shares_to_tokens_ratio().unwrap(), WAD); + }); + } + + #[test] + fn shares_to_tokens_ratio_preserves_configured_value() { + let (mut storage, _) = setup_storage(); + let configured_ratio = WAD * U256::from(3u64); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20SecurityStorage::from_address(TOKEN, ctx); + token.set_shares_to_tokens_ratio(configured_ratio).unwrap(); + + let ratio_slot = SECURITY_ROOT + + U256::from( + __packing_b20_security_extension_storage::SHARES_TO_TOKENS_RATIO_LOC + .offset_slots, + ); + + assert_eq!(ctx.sload(TOKEN, ratio_slot).unwrap(), configured_ratio); + assert_eq!(token.shares_to_tokens_ratio().unwrap(), configured_ratio); + }); + } + + #[test] + fn security_string_mapping_slots_use_solidity_string_key_derivation() { + let (mut storage, _) = setup_storage(); + let announcement_id = String::from("2026-Q1-split"); + let identifier_type = String::from("ISIN"); + let identifier_value = String::from("US0000000000"); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20SecurityStorage::from_address(TOKEN, ctx); + token.security.used_announcement_ids.at_mut(&announcement_id).write(true).unwrap(); + token + .security + .identifiers + .at_mut(&identifier_type) + .write(identifier_value.clone()) + .unwrap(); + token.redeem.minimum_redeemable.write(U256::from(10u64)).unwrap(); + + let announcement_slot = SECURITY_ROOT + + U256::from( + __packing_b20_security_extension_storage::USED_ANNOUNCEMENT_IDS_LOC + .offset_slots, + ); + let identifiers_slot = SECURITY_ROOT + + U256::from( + __packing_b20_security_extension_storage::IDENTIFIERS_LOC.offset_slots, + ); + let minimum_slot = REDEEM_ROOT + + U256::from(__packing_b20_redeem_storage::MINIMUM_REDEEMABLE_LOC.offset_slots); + + assert_eq!( + ctx.sload(TOKEN, announcement_id.mapping_slot(announcement_slot)).unwrap(), + U256::ONE + ); + assert_eq!( + ctx.sload(TOKEN, identifier_type.mapping_slot(identifiers_slot)).unwrap(), + short_string_word(&identifier_value) + ); + assert_eq!(ctx.sload(TOKEN, minimum_slot).unwrap(), U256::from(10u64)); + }); + } + + #[test] + fn redeem_sender_policy_uses_redeem_storage_lane() { + let (mut storage, _) = setup_storage(); + let policy_id = 42u64; + + StorageCtx::enter(&mut storage, |ctx| { + { + let mut token = B20SecurityStorage::from_address(TOKEN, ctx); + token.set_policy_id(B20SecurityStorage::REDEEM_SENDER_POLICY, policy_id).unwrap(); + assert_eq!( + token.policy_id(B20SecurityStorage::REDEEM_SENDER_POLICY).unwrap(), + policy_id + ); + } + + let redeem_policy_slot = REDEEM_ROOT + + U256::from(__packing_b20_redeem_storage::REDEEM_POLICY_IDS_LOC.offset_slots); + assert_eq!(ctx.sload(TOKEN, redeem_policy_slot).unwrap(), U256::from(policy_id)); + }); + } + + #[test] + fn initialize_sets_redeem_sender_policy_to_always_block() { + let (mut storage, _) = setup_storage(); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20SecurityStorage::from_address(TOKEN, ctx); + token + .initialize(B20SecurityInit { + name: String::from("Test"), + symbol: String::from("TST"), + supply_cap: U256::from(1_000_000u64), + shares_to_tokens_ratio: WAD, + isin: String::new(), + minimum_redeemable: U256::ZERO, + }) + .unwrap(); + + assert_eq!( + token.policy_id(B20SecurityStorage::REDEEM_SENDER_POLICY).unwrap(), + PolicyRegistryStorage::ALWAYS_BLOCK_ID, + "REDEEM_SENDER_POLICY must default to ALWAYS_BLOCK_ID at creation" + ); + }); + } + + fn short_string_word(value: &str) -> U256 { + let mut word = [0u8; 32]; + word[..value.len()].copy_from_slice(value.as_bytes()); + word[31] = (value.len() * 2) as u8; + U256::from_be_bytes(word) + } +} diff --git a/crates/common/precompiles/src/b20_security/token.rs b/crates/common/precompiles/src/b20_security/token.rs new file mode 100644 index 0000000000..e115a60508 --- /dev/null +++ b/crates/common/precompiles/src/b20_security/token.rs @@ -0,0 +1,98 @@ +//! `B20SecurityToken` struct — the security B-20 token type. + +use alloy_primitives::{Address, B256, b256}; + +use crate::{ + B20SecurityStorage, Burnable, Configurable, Mintable, Pausable, Permittable, Policy, + RoleManaged, SecurityAccounting, Token, Transferable, +}; + +/// EVM precompile for the security B-20 variant. +/// +/// Mirrors the structure of [`crate::B20Token`] but requires `S: SecurityAccounting` +/// so the dispatch layer can read and write security-specific storage (share ratio, +/// security identifiers, announcement IDs). The `in_announcement` flag guards against +/// recursive `announce` calls within a single precompile invocation. +#[derive(Debug, Clone)] +pub struct B20SecurityToken { + accounting: S, + policy: P, + in_announcement: bool, +} + +impl B20SecurityToken { + /// Role identifier for security operators: `keccak256("SECURITY_OPERATOR_ROLE")`. + pub const SECURITY_OPERATOR_ROLE: B256 = + b256!("e63901dfe7775ace99fa3654743976eb0ab2009f5d19c4fc1ecd40aed27d59af"); + + /// Role identifier for delegated burns: `keccak256("BURN_FROM_ROLE")`. + pub const BURN_FROM_ROLE: B256 = + b256!("25400dba76bf0d00acf274c2b61ff56aa4ed19826e21e0186e3fecd6a6671875"); + + /// Policy scope identifier for redeem senders: `keccak256("REDEEM_SENDER_POLICY")`. + pub const REDEEM_SENDER_POLICY: B256 = B20SecurityStorage::REDEEM_SENDER_POLICY; + + /// Creates a `B20SecurityToken` backed by the provided storage and policy adapters. + pub const fn with_storage_and_policy(accounting: S, policy: P) -> Self { + Self { accounting, policy, in_announcement: false } + } + + /// Returns whether this token is currently executing an announcement. + pub const fn is_announcement_active(&self) -> bool { + self.in_announcement + } + + /// Marks this token as executing an announcement. + pub const fn begin_announcement(&mut self) { + self.in_announcement = true; + } +} + +impl Token for B20SecurityToken { + type Accounting = S; + type Policy = P; + + fn accounting(&self) -> &S { + &self.accounting + } + + fn accounting_mut(&mut self) -> &mut S { + &mut self.accounting + } + + fn policy(&self) -> &P { + &self.policy + } + + fn policy_mut(&mut self) -> &mut P { + &mut self.policy + } + + fn token_address(&self) -> Address { + self.accounting.token_address() + } +} + +impl Transferable for B20SecurityToken {} +impl Mintable for B20SecurityToken {} +impl Burnable for B20SecurityToken {} +impl Pausable for B20SecurityToken {} +impl Configurable for B20SecurityToken {} +impl Permittable for B20SecurityToken {} +impl RoleManaged for B20SecurityToken {} + +#[cfg(test)] +mod tests { + use alloy_primitives::keccak256; + + use crate::{B20SecurityToken, InMemoryPolicy, InMemoryTokenAccounting}; + + type TestSecurityToken = B20SecurityToken; + + #[test] + fn role_and_policy_ids_match_solidity_hashes() { + assert_eq!(TestSecurityToken::SECURITY_OPERATOR_ROLE, keccak256("SECURITY_OPERATOR_ROLE")); + assert_eq!(TestSecurityToken::BURN_FROM_ROLE, keccak256("BURN_FROM_ROLE")); + assert_eq!(TestSecurityToken::REDEEM_SENDER_POLICY, keccak256("REDEEM_SENDER_POLICY")); + } +} diff --git a/crates/common/precompiles/src/b20_stablecoin/abi.rs b/crates/common/precompiles/src/b20_stablecoin/abi.rs new file mode 100644 index 0000000000..c76ced6f8a --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/abi.rs @@ -0,0 +1,36 @@ +//! ABI definitions for the stablecoin B-20 variant. +//! +//! [`IB20Stablecoin`] defines only the stablecoin-specific extension. +//! All inherited selectors come from [`crate::IB20`] defined in `b20/abi.rs`. + +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq, Eq)] + interface IB20Stablecoin { + function currency() external view returns (string); + } +} + +impl IB20Stablecoin::IB20StablecoinCalls { + /// Returns the stable label for this decoded stablecoin B-20 call. + pub const fn as_label(&self) -> &'static str { + match self { + Self::currency(_) => "precompile-b20-stablecoin-currency", + } + } +} + +#[cfg(test)] +mod tests { + use crate::IB20Stablecoin; + + #[test] + fn stablecoin_call_labels_are_stable() { + assert_eq!( + IB20Stablecoin::IB20StablecoinCalls::currency(IB20Stablecoin::currencyCall {}) + .as_label(), + "precompile-b20-stablecoin-currency" + ); + } +} diff --git a/crates/common/precompiles/src/b20_stablecoin/accounting.rs b/crates/common/precompiles/src/b20_stablecoin/accounting.rs new file mode 100644 index 0000000000..362937de4c --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/accounting.rs @@ -0,0 +1,19 @@ +//! `StablecoinAccounting` — storage port extension for stablecoin tokens. + +use alloc::string::String; + +use base_precompile_storage::Result; + +use crate::TokenAccounting; + +/// Extends [`TokenAccounting`] with the stablecoin-specific `currency` slot. +/// +/// Only [`super::B20StablecoinToken`] requires this bound; default and security +/// tokens use the base [`TokenAccounting`] port exclusively. +pub trait StablecoinAccounting: TokenAccounting { + /// Returns the stablecoin currency identifier. + fn currency(&self) -> Result; + + /// Writes the currency identifier. Called once by the factory at creation. + fn set_currency(&mut self, currency: String) -> Result<()>; +} diff --git a/crates/common/precompiles/src/b20_stablecoin/dispatch.rs b/crates/common/precompiles/src/b20_stablecoin/dispatch.rs new file mode 100644 index 0000000000..d0d4764e83 --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/dispatch.rs @@ -0,0 +1,324 @@ +//! ABI dispatch for the stablecoin B-20 variant. +//! +//! Dispatches the full `IB20` selector set using B-20 stablecoin activation. +//! All logic mirrors `B20Token::inner_with_privilege` exactly; the only +//! distinction is the activation guard and the `StablecoinAccounting` bound +//! that provides `currency()` from the stablecoin extension namespace. + +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::{SolCall, SolInterface, SolValue}; +use base_precompile_storage::{BasePrecompileError, IntoPrecompileResult, StorageCtx}; +use revm::precompile::PrecompileResult; + +use crate::{ + ActivationFeature, ActivationRegistryStorage, B20StablecoinToken, B20TokenRole, Burnable, + Configurable, + IB20::{self, IB20Calls as C}, + IB20Stablecoin::{self, IB20StablecoinCalls as SC}, + Mintable, NoopPrecompileCallObserver, Pausable, PermitArgs, Permittable, Policy, + PrecompileCallObserver, RoleManaged, StablecoinAccounting, Token, Transferable, + macros::{decode_precompile_call, deduct_calldata_cost}, +}; + +impl B20StablecoinToken { + /// ABI-dispatches `calldata` to the appropriate `IB20` handler. + pub fn dispatch(&mut self, ctx: StorageCtx<'_>, calldata: &[u8]) -> PrecompileResult { + self.dispatch_with_observer(ctx, calldata, NoopPrecompileCallObserver) + } + + /// ABI-dispatches `calldata` and observes the decoded stablecoin B-20 operation. + pub fn dispatch_with_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + observer: O, + ) -> PrecompileResult + where + O: PrecompileCallObserver, + { + deduct_calldata_cost!(ctx, calldata); + // Ensure the token has been deployed (has bytecode at its address). + match self.accounting().is_initialized() { + Ok(true) => {} + Ok(false) => { + return BasePrecompileError::Revert(Bytes::new()) + .into_precompile_result(ctx.gas_used(), ctx.state_gas_used()); + } + Err(e) => return e.into_precompile_result(ctx.gas_used(), ctx.state_gas_used()), + } + self.inner_with_observer(ctx, calldata, observer).into_precompile_result( + ctx.gas_used(), + ctx.state_gas_used(), + |b| b, + ) + } + + /// Decodes calldata and executes the matching `IB20` operation. + pub fn inner( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + ) -> base_precompile_storage::Result { + self.inner_with_observer(ctx, calldata, NoopPrecompileCallObserver) + } + + /// Decodes calldata, observes the decoded operation, and executes the matching handler. + pub fn inner_with_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + observer: O, + ) -> base_precompile_storage::Result + where + O: PrecompileCallObserver, + { + self.inner_with_privilege_and_observer(ctx, calldata, false, observer) + } + + /// Decodes calldata and executes it with optional factory-init privilege. + pub fn inner_with_privilege( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + privileged: bool, + ) -> base_precompile_storage::Result { + self.inner_with_privilege_and_observer( + ctx, + calldata, + privileged, + NoopPrecompileCallObserver, + ) + } + + /// Decodes calldata, observes the decoded operation, and executes it with optional + /// factory-init privilege. + pub fn inner_with_privilege_and_observer( + &mut self, + ctx: StorageCtx<'_>, + calldata: &[u8], + privileged: bool, + observer: O, + ) -> base_precompile_storage::Result + where + O: PrecompileCallObserver, + { + ActivationRegistryStorage::new(ctx) + .ensure_activated(ActivationFeature::B20Stablecoin.id())?; + + if let Ok(call) = IB20Stablecoin::IB20StablecoinCalls::abi_decode(calldata) { + let label = call.as_label(); + return observer.observe(label, || self.handle_stablecoin_call(call)); + } + + let call = decode_precompile_call!(calldata, IB20::IB20Calls); + let label = call.as_label(); + + observer.observe(label, || { + let encoded: Bytes = match call { + // --- Pure reads: direct to accounting --- + C::name(_) => self.accounting().name()?.abi_encode().into(), + C::symbol(_) => self.accounting().symbol()?.abi_encode().into(), + C::decimals(_) => U256::from(self.accounting().decimals()?).abi_encode().into(), + C::totalSupply(_) => self.accounting().total_supply()?.abi_encode().into(), + C::balanceOf(c) => self.accounting().balance_of(c.account)?.abi_encode().into(), + C::allowance(c) => { + self.accounting().allowance(c.owner, c.spender)?.abi_encode().into() + } + C::supplyCap(_) => self.accounting().supply_cap()?.abi_encode().into(), + C::nonces(c) => self.accounting().nonce(c.owner)?.abi_encode().into(), + C::contractURI(_) => self.accounting().contract_uri()?.abi_encode().into(), + C::DEFAULT_ADMIN_ROLE(_) => B20TokenRole::DefaultAdmin.id().abi_encode().into(), + C::MINT_ROLE(_) => B20TokenRole::Mint.id().abi_encode().into(), + C::BURN_ROLE(_) => B20TokenRole::Burn.id().abi_encode().into(), + C::BURN_BLOCKED_ROLE(_) => B20TokenRole::BurnBlocked.id().abi_encode().into(), + C::PAUSE_ROLE(_) => B20TokenRole::Pause.id().abi_encode().into(), + C::UNPAUSE_ROLE(_) => B20TokenRole::Unpause.id().abi_encode().into(), + C::METADATA_ROLE(_) => B20TokenRole::Metadata.id().abi_encode().into(), + C::TRANSFER_SENDER_POLICY(_) => Self::transfer_sender_policy().abi_encode().into(), + C::TRANSFER_RECEIVER_POLICY(_) => { + Self::transfer_receiver_policy().abi_encode().into() + } + C::TRANSFER_EXECUTOR_POLICY(_) => { + Self::transfer_executor_policy().abi_encode().into() + } + C::MINT_RECEIVER_POLICY(_) => Self::mint_receiver_policy().abi_encode().into(), + C::hasRole(c) => self.has_role(c.role, c.account)?.abi_encode().into(), + C::getRoleAdmin(c) => self.role_admin(c.role)?.abi_encode().into(), + C::pausedFeatures(_) => self.paused_features()?.abi_encode().into(), + C::policyId(c) => self.policy_id(c.policyScope)?.abi_encode().into(), + + // --- Domain reads (light logic) --- + C::isPaused(c) => self.is_paused(c.feature)?.abi_encode().into(), + C::DOMAIN_SEPARATOR(_) => { + self.domain_separator(ctx.chain_id())?.abi_encode().into() + } + C::eip712Domain(_) => { + let (fields, name, version, chain_id, verifying_contract, salt, extensions) = + self.eip712_domain(ctx.chain_id())?; + IB20::eip712DomainCall::abi_encode_returns(&IB20::eip712DomainReturn { + fields, + name, + version, + chainId: chain_id, + verifyingContract: verifying_contract, + salt, + extensions, + }) + .into() + } + + // --- ERC-20 mutating --- + C::transfer(c) => { + let caller = ctx.caller(); + self.transfer(caller, c.to, c.amount, privileged)?; + true.abi_encode().into() + } + C::transferFrom(c) => { + let caller = ctx.caller(); + self.transfer_from(caller, c.from, c.to, c.amount, privileged)?; + true.abi_encode().into() + } + C::approve(c) => { + let caller = ctx.caller(); + self.approve(caller, c.spender, c.amount)?; + true.abi_encode().into() + } + C::transferWithMemo(c) => { + let caller = ctx.caller(); + self.transfer_with_memo(caller, c.to, c.amount, c.memo, privileged)?; + true.abi_encode().into() + } + C::transferFromWithMemo(c) => { + let caller = ctx.caller(); + self.transfer_from_with_memo( + caller, c.from, c.to, c.amount, c.memo, privileged, + )?; + true.abi_encode().into() + } + + // --- Mint --- + C::mint(c) => { + let caller = ctx.caller(); + self.mint(caller, c.to, c.amount, privileged)?; + Bytes::new() + } + C::mintWithMemo(c) => { + let caller = ctx.caller(); + self.mint_with_memo(caller, c.to, c.amount, c.memo, privileged)?; + Bytes::new() + } + + // --- Burn --- + C::burn(c) => { + let caller = ctx.caller(); + // Self-burn operations are never factory-privileged: during init the caller is + // the factory, not a token holder. + self.burn(caller, caller, c.amount, false)?; + Bytes::new() + } + C::burnWithMemo(c) => { + let caller = ctx.caller(); + self.burn_with_memo(caller, caller, c.amount, c.memo, false)?; + Bytes::new() + } + C::burnBlocked(c) => { + let caller = ctx.caller(); + self.burn_blocked(caller, c.from, c.amount, privileged)?; + Bytes::new() + } + + // --- Pause --- + C::pause(c) => { + let caller = ctx.caller(); + self.pause(caller, c.features, privileged)?; + Bytes::new() + } + C::unpause(c) => { + let caller = ctx.caller(); + self.unpause(caller, c.features, privileged)?; + Bytes::new() + } + + // --- Admin --- + C::updateSupplyCap(c) => { + let caller = ctx.caller(); + Configurable::update_supply_cap(self, caller, c.newSupplyCap, privileged)?; + Bytes::new() + } + C::updateName(c) => { + let caller = ctx.caller(); + Configurable::update_name(self, caller, c.newName, privileged)?; + Bytes::new() + } + C::updateSymbol(c) => { + let caller = ctx.caller(); + Configurable::update_symbol(self, caller, c.newSymbol, privileged)?; + Bytes::new() + } + C::updateContractURI(c) => { + let caller = ctx.caller(); + Configurable::update_contract_uri(self, caller, c.newURI, privileged)?; + Bytes::new() + } + C::grantRole(c) => { + let caller = ctx.caller(); + self.grant_role(caller, c.role, c.account, privileged)?; + Bytes::new() + } + C::revokeRole(c) => { + let caller = ctx.caller(); + self.revoke_role(caller, c.role, c.account, privileged)?; + Bytes::new() + } + // Renounce operations are never factory-privileged: they are only meaningful for + // the role holder making the call after token creation. + C::renounceRole(c) => { + let caller = ctx.caller(); + self.renounce_role(caller, c.role, c.callerConfirmation)?; + Bytes::new() + } + C::renounceLastAdmin(_) => { + let caller = ctx.caller(); + self.renounce_last_admin(caller)?; + Bytes::new() + } + C::setRoleAdmin(c) => { + let caller = ctx.caller(); + self.set_role_admin(caller, c.role, c.newAdminRole, privileged)?; + Bytes::new() + } + C::updatePolicy(c) => { + let caller = ctx.caller(); + self.update_policy(caller, c.policyScope, c.newPolicyId, privileged)?; + Bytes::new() + } + + // --- Permit --- + C::permit(c) => { + self.permit( + ctx.chain_id(), + ctx.timestamp(), + PermitArgs { + owner: c.owner, + spender: c.spender, + value: c.value, + deadline: c.deadline, + v: c.v, + r: c.r, + s: c.s, + }, + )?; + Bytes::new() + } + }; + Ok(encoded) + }) + } + + fn handle_stablecoin_call(&self, call: SC) -> base_precompile_storage::Result { + let encoded: Bytes = match call { + SC::currency(_) => self.accounting().currency()?.abi_encode().into(), + }; + Ok(encoded) + } +} diff --git a/crates/common/precompiles/src/b20_stablecoin/mod.rs b/crates/common/precompiles/src/b20_stablecoin/mod.rs new file mode 100644 index 0000000000..c6f1934d79 --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/mod.rs @@ -0,0 +1,18 @@ +//! `B20StablecoinToken` native precompile — stablecoin variant of the B-20 token. + +mod abi; +pub use abi::IB20Stablecoin; + +mod accounting; +pub use accounting::StablecoinAccounting; + +mod dispatch; + +mod precompile; +pub use precompile::B20StablecoinPrecompile; + +mod storage; +pub use storage::{B20StablecoinExtensionStorage, B20StablecoinInit, B20StablecoinStorage}; + +mod token; +pub use token::B20StablecoinToken; diff --git a/crates/common/precompiles/src/b20_stablecoin/precompile.rs b/crates/common/precompiles/src/b20_stablecoin/precompile.rs new file mode 100644 index 0000000000..158261fe03 --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/precompile.rs @@ -0,0 +1,39 @@ +//! Precompile entry point for the stablecoin B-20 variant. + +use alloy_evm::precompiles::DynPrecompile; +use alloy_primitives::Address; + +use crate::{ + B20StablecoinStorage, B20StablecoinToken, NoopPrecompileCallObserver, PolicyHandle, + PrecompileCallObserver, macros::base_precompile, +}; + +/// Entry point for the stablecoin B-20 token precompile. +/// +/// Wraps [`B20StablecoinToken`] dispatch behind a [`DynPrecompile`]. +#[derive(Debug)] +pub struct B20StablecoinPrecompile; + +impl B20StablecoinPrecompile { + /// Returns a [`DynPrecompile`] that dispatches to [`B20StablecoinToken`] logic at + /// `token_address`. + pub fn create_precompile(token_address: Address) -> DynPrecompile { + Self::create_precompile_with_observer(token_address, NoopPrecompileCallObserver) + } + + /// Returns a [`DynPrecompile`] that observes and dispatches to [`B20StablecoinToken`] logic at + /// `token_address`. + pub fn create_precompile_with_observer(token_address: Address, observer: O) -> DynPrecompile + where + O: PrecompileCallObserver, + { + base_precompile!(alloc::format!("B20StablecoinToken@{token_address}"), |ctx, calldata| { + let observer = observer.clone(); + B20StablecoinToken::with_storage_and_policy( + B20StablecoinStorage::from_address(token_address, ctx), + PolicyHandle::new(ctx), + ) + .dispatch_with_observer(ctx, &calldata, observer) + }) + } +} diff --git a/crates/common/precompiles/src/b20_stablecoin/storage.rs b/crates/common/precompiles/src/b20_stablecoin/storage.rs new file mode 100644 index 0000000000..959c2f0b52 --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/storage.rs @@ -0,0 +1,346 @@ +//! EVM storage adapter for the stablecoin B-20 variant. + +use alloc::string::String; + +use alloy_primitives::{Address, B256, LogData, U256}; +use base_precompile_macros::{Storable, contract}; +use base_precompile_storage::{BasePrecompileError, ContractStorage, Handler, Result, StorageCtx}; + +use crate::{ + B20CoreStorage, B20PolicyType, B20TokenRole, B20Variant, IB20, IB20Factory, + StablecoinAccounting, TokenAccounting, +}; + +/// Stablecoin-specific B-20 storage rooted at the `base.b20.stablecoin` ERC-7201 namespace. +#[derive(Debug, Clone, Storable)] +#[namespace("base.b20.stablecoin")] +pub struct B20StablecoinExtensionStorage { + /// Stablecoin currency identifier. + pub currency: String, // offset 0 +} + +/// EVM-backed storage for a stablecoin B-20 token. +#[contract] +pub struct B20StablecoinStorage { + pub b20: B20CoreStorage, + pub stablecoin: B20StablecoinExtensionStorage, +} + +/// Creation-time parameters for a stablecoin B-20 token. +/// +/// Passed to [`B20StablecoinStorage::initialize`] to write all fields atomically. +#[derive(Debug)] +pub struct B20StablecoinInit { + /// ERC-20 token name. + pub name: String, + /// ERC-20 token symbol. + pub symbol: String, + /// Maximum total supply. + pub supply_cap: U256, + /// ISO 4217 fiat currency code (e.g. `"USD"`). + pub currency: String, +} + +impl<'a> B20StablecoinStorage<'a> { + /// Creates a `B20StablecoinStorage` instance targeting `addr`. + pub fn from_address(addr: Address, storage: StorageCtx<'a>) -> Self { + Self::__new(addr, storage) + } + + /// Writes all creation-time fields atomically. + /// + /// Validates that `currency` contains only `A-Z` characters before writing + /// anything; reverts `ITokenFactory::InvalidCurrency` otherwise. + pub fn initialize(&mut self, init: B20StablecoinInit) -> Result<()> { + if init.currency.is_empty() || !init.currency.bytes().all(|b| b.is_ascii_uppercase()) { + return Err(BasePrecompileError::revert(IB20Factory::InvalidCurrency { + code: init.currency, + })); + } + self.b20.name.write(init.name)?; + self.b20.symbol.write(init.symbol)?; + self.b20.supply_cap.write(init.supply_cap)?; + self.stablecoin.currency.write(init.currency) + } +} + +impl TokenAccounting for B20StablecoinStorage<'_> { + fn token_address(&self) -> Address { + ContractStorage::address(self) + } + + fn is_initialized(&self) -> Result { + ContractStorage::is_initialized(self) + } + + fn balance_of(&self, account: Address) -> Result { + self.b20.balances.at(&account).read() + } + + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()> { + self.b20.balances.at_mut(&account).write(balance) + } + + fn allowance(&self, owner: Address, spender: Address) -> Result { + self.b20.allowances.at(&owner).at(&spender).read() + } + + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + self.b20.allowances.at_mut(&owner).at_mut(&spender).write(amount) + } + + fn total_supply(&self) -> Result { + self.b20.total_supply.read() + } + + fn set_total_supply(&mut self, supply: U256) -> Result<()> { + self.b20.total_supply.write(supply) + } + + fn supply_cap(&self) -> Result { + self.b20.supply_cap.read() + } + + fn set_supply_cap(&mut self, cap: U256) -> Result<()> { + self.b20.supply_cap.write(cap) + } + + fn name(&self) -> Result { + self.b20.name.read() + } + + fn set_name(&mut self, name: String) -> Result<()> { + self.b20.name.write(name) + } + + fn symbol(&self) -> Result { + self.b20.symbol.read() + } + + fn set_symbol(&mut self, symbol: String) -> Result<()> { + self.b20.symbol.write(symbol) + } + + fn decimals(&self) -> Result { + Ok(B20Variant::from_address(ContractStorage::address(self)).map_or(0, B20Variant::decimals)) + } + + fn paused(&self) -> Result { + self.b20.paused.read() + } + + fn set_paused(&mut self, vectors: U256) -> Result<()> { + self.b20.paused.write(vectors) + } + + fn nonce(&self, owner: Address) -> Result { + self.b20.nonces.at(&owner).read() + } + + fn increment_nonce(&mut self, owner: Address) -> Result<()> { + let current = self.b20.nonces.at(&owner).read()?; + let next = + current.checked_add(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + self.b20.nonces.at_mut(&owner).write(next) + } + + fn contract_uri(&self) -> Result { + self.b20.contract_uri.read() + } + + fn set_contract_uri(&mut self, uri: String) -> Result<()> { + self.b20.contract_uri.write(uri) + } + + fn has_role(&self, role: B256, account: Address) -> Result { + self.b20.roles.at(&role).at(&account).read() + } + + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()> { + self.b20.roles.at_mut(&role).at_mut(&account).write(enabled) + } + + fn role_member_count(&self, role: B256) -> Result { + if role == B20TokenRole::DefaultAdmin.id() { + self.b20.admin_count.read() + } else { + Ok(U256::ZERO) + } + } + + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()> { + if role == B20TokenRole::DefaultAdmin.id() { + self.b20.admin_count.write(count) + } else { + Ok(()) + } + } + + fn role_admin(&self, role: B256) -> Result { + let admin_role = self.b20.role_admins.at(&role).read()?; + if admin_role.is_zero() && role != B20TokenRole::DefaultAdmin.id() { + Ok(B20TokenRole::DefaultAdmin.id()) + } else { + Ok(admin_role) + } + } + + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()> { + self.b20.role_admins.at_mut(&role).write(admin_role) + } + + fn policy_id(&self, policy_scope: B256) -> Result { + let policy_type = Self::require_policy_type(policy_scope)?; + match policy_type { + B20PolicyType::TransferSender => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_SENDER_POLICY_LANE, + )), + B20PolicyType::TransferReceiver => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_RECEIVER_POLICY_LANE, + )), + B20PolicyType::TransferExecutor => Ok(Self::read_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_EXECUTOR_POLICY_LANE, + )), + B20PolicyType::MintReceiver => Ok(Self::read_policy_lane( + self.b20.mint_policy_ids.read()?, + Self::MINT_RECEIVER_POLICY_LANE, + )), + } + } + + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()> { + let policy_type = Self::require_policy_type(policy_scope)?; + match policy_type { + B20PolicyType::TransferSender => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_SENDER_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::TransferReceiver => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_RECEIVER_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::TransferExecutor => { + let packed = Self::write_policy_lane( + self.b20.transfer_policy_ids.read()?, + Self::TRANSFER_EXECUTOR_POLICY_LANE, + policy_id, + ); + self.b20.transfer_policy_ids.write(packed) + } + B20PolicyType::MintReceiver => { + let packed = Self::write_policy_lane( + self.b20.mint_policy_ids.read()?, + Self::MINT_RECEIVER_POLICY_LANE, + policy_id, + ); + self.b20.mint_policy_ids.write(packed) + } + } + } + + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.emit_event(log) + } +} + +impl B20StablecoinStorage<'_> { + const TRANSFER_SENDER_POLICY_LANE: usize = 0; + const TRANSFER_RECEIVER_POLICY_LANE: usize = 1; + const TRANSFER_EXECUTOR_POLICY_LANE: usize = 2; + const MINT_RECEIVER_POLICY_LANE: usize = 0; + const POLICY_LANE_BITS: usize = 64; + + fn require_policy_type(policy_scope: B256) -> Result { + B20PolicyType::from_id(policy_scope).ok_or_else(|| { + BasePrecompileError::revert(IB20::UnsupportedPolicyType { policyScope: policy_scope }) + }) + } + + fn read_policy_lane(packed: U256, lane: usize) -> u64 { + ((packed >> (lane * Self::POLICY_LANE_BITS)) & U256::from(u64::MAX)).to::() + } + + fn write_policy_lane(packed: U256, lane: usize, policy_id: u64) -> U256 { + let shift = lane * Self::POLICY_LANE_BITS; + let mask = U256::from(u64::MAX) << shift; + (packed & !mask) | (U256::from(policy_id) << shift) + } +} + +impl StablecoinAccounting for B20StablecoinStorage<'_> { + fn currency(&self) -> Result { + self.stablecoin.currency.read() + } + + fn set_currency(&mut self, currency: String) -> Result<()> { + self.stablecoin.currency.write(currency) + } +} + +#[cfg(test)] +mod tests { + use alloc::string::String; + + use alloy_primitives::{Address, U256, address, uint}; + use base_precompile_storage::{Handler, StorableType, StorageCtx, setup_storage}; + + use crate::{ + B20CoreStorage, B20StablecoinExtensionStorage, B20StablecoinStorage, + b20_stablecoin::storage::{__packing_b20_stablecoin_extension_storage, slots}, + }; + + const TOKEN: Address = address!("000000000000000000000000000000000000b022"); + const B20_ROOT: U256 = + uint!(0xc78b71fee795ddd74aff64ea9b2474194c938c3196430e10bb5f01ed48434000_U256); + const STABLECOIN_ROOT: U256 = + uint!(0x35827975a06ca0e9367ea3129b19441d45d0ca58e30b7693f09e73d0943d6200_U256); + + #[test] + fn stablecoin_namespaces_match_base_std_roots() { + assert_eq!(::STORAGE_NAMESPACE_ROOT, B20_ROOT); + assert_eq!( + ::STORAGE_NAMESPACE_ID, + "base.b20.stablecoin" + ); + assert_eq!( + ::STORAGE_NAMESPACE_ROOT, + STABLECOIN_ROOT + ); + + assert_eq!(slots::B20, B20_ROOT); + assert_eq!(slots::STABLECOIN, STABLECOIN_ROOT); + assert_eq!(__packing_b20_stablecoin_extension_storage::CURRENCY_LOC.offset_slots, 0); + } + + #[test] + fn stablecoin_currency_is_rooted_at_extension_namespace() { + let (mut storage, _) = setup_storage(); + + StorageCtx::enter(&mut storage, |ctx| { + let mut token = B20StablecoinStorage::from_address(TOKEN, ctx); + token.b20.name.write(String::from("Stablecoin")).unwrap(); + token.stablecoin.currency.write(String::from("USD")).unwrap(); + + assert_eq!(ctx.sload(TOKEN, B20_ROOT).unwrap(), short_string_word("Stablecoin")); + assert_eq!(ctx.sload(TOKEN, STABLECOIN_ROOT).unwrap(), short_string_word("USD")); + }); + } + + fn short_string_word(value: &str) -> U256 { + let mut word = [0u8; 32]; + word[..value.len()].copy_from_slice(value.as_bytes()); + word[31] = (value.len() * 2) as u8; + U256::from_be_bytes(word) + } +} diff --git a/crates/common/precompiles/src/b20_stablecoin/token.rs b/crates/common/precompiles/src/b20_stablecoin/token.rs new file mode 100644 index 0000000000..5df9152868 --- /dev/null +++ b/crates/common/precompiles/src/b20_stablecoin/token.rs @@ -0,0 +1,128 @@ +//! `B20StablecoinToken` struct — the stablecoin B-20 token type. + +use alloy_primitives::{Address, B256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{ + B20Guards, B20PolicyType, B20TokenRole, Burnable, Configurable, IB20, Mintable, Pausable, + Permittable, Policy, RoleManaged, StablecoinAccounting, Token, Transferable, +}; + +/// EVM precompile for the stablecoin B-20 variant. +/// +/// Mirrors the structure of [`crate::B20Token`] but requires `S: StablecoinAccounting` +/// so the dispatch layer can read `currency()` from stablecoin storage. All inherited +/// `IB20` capability traits are wired in identically. +#[derive(Debug, Clone)] +pub struct B20StablecoinToken { + accounting: S, + policy: P, +} + +impl B20StablecoinToken { + /// Creates a `B20StablecoinToken` backed by the provided storage and policy adapters. + pub const fn with_storage_and_policy(accounting: S, policy: P) -> Self { + Self { accounting, policy } + } +} + +impl Token for B20StablecoinToken { + type Accounting = S; + type Policy = P; + + fn accounting(&self) -> &S { + &self.accounting + } + + fn accounting_mut(&mut self) -> &mut S { + &mut self.accounting + } + + fn policy(&self) -> &P { + &self.policy + } + + fn policy_mut(&mut self) -> &mut P { + &mut self.policy + } + + fn token_address(&self) -> Address { + self.accounting.token_address() + } +} + +impl Transferable for B20StablecoinToken {} +impl Mintable for B20StablecoinToken {} +impl Burnable for B20StablecoinToken {} +impl Pausable for B20StablecoinToken {} +impl Configurable for B20StablecoinToken {} +impl Permittable for B20StablecoinToken {} +impl RoleManaged for B20StablecoinToken {} + +impl B20StablecoinToken { + /// Policy slot checked against transfer senders. + pub const fn transfer_sender_policy() -> B256 { + B20PolicyType::TransferSender.id() + } + + /// Policy slot checked against transfer receivers. + pub const fn transfer_receiver_policy() -> B256 { + B20PolicyType::TransferReceiver.id() + } + + /// Policy slot checked against delegated transfer executors. + pub const fn transfer_executor_policy() -> B256 { + B20PolicyType::TransferExecutor.id() + } + + /// Policy slot checked against mint receivers. + pub const fn mint_receiver_policy() -> B256 { + B20PolicyType::MintReceiver.id() + } + + /// Returns the configured policy ID for `policy_scope`. + pub fn policy_id(&self, policy_scope: B256) -> Result { + Self::ensure_supported_policy_type(policy_scope)?; + self.accounting.policy_id(policy_scope) + } + + /// Updates the configured policy ID for `policy_scope`. + pub fn update_policy( + &mut self, + caller: Address, + policy_scope: B256, + new_policy_id: u64, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role(self, caller, B20TokenRole::DefaultAdmin)?; + } + let old_policy_id = self.policy_id(policy_scope)?; + if !self.policy.policy_exists(new_policy_id)? { + return Err(BasePrecompileError::revert(IB20::PolicyNotFound { + policyId: new_policy_id, + })); + } + self.accounting_mut().set_policy_id(policy_scope, new_policy_id)?; + self.accounting_mut().emit_event( + IB20::PolicyUpdated { + policyScope: policy_scope, + oldPolicyId: old_policy_id, + newPolicyId: new_policy_id, + } + .encode_log_data(), + ) + } + + /// Ensures `policy_scope` names a B-20 policy slot. + pub fn ensure_supported_policy_type(policy_scope: B256) -> Result<()> { + if B20PolicyType::from_id(policy_scope).is_some() { + Ok(()) + } else { + Err(BasePrecompileError::revert(IB20::UnsupportedPolicyType { + policyScope: policy_scope, + })) + } + } +} diff --git a/crates/common/precompiles/src/bls12_381.rs b/crates/common/precompiles/src/bls12_381.rs index 965ee4a588..f9435a8ea5 100644 --- a/crates/common/precompiles/src/bls12_381.rs +++ b/crates/common/precompiles/src/bls12_381.rs @@ -1,152 +1,146 @@ +use alloc::string::ToString; + use revm::precompile::{ - self as precompile, Precompile, PrecompileError, PrecompileId, + self as precompile, Precompile, PrecompileError, PrecompileId, PrecompileResult, bls12_381_const::{G1_MSM_ADDRESS, G2_MSM_ADDRESS, PAIRING_ADDRESS}, + call_eth_precompile, }; /// Max input size for the BLS12-381 G1 MSM precompile after the Isthmus hardfork. -pub(crate) const ISTHMUS_G1_MSM_MAX_INPUT_SIZE: usize = 513760; +pub const ISTHMUS_G1_MSM_MAX_INPUT_SIZE: usize = 513760; /// Max input size for the BLS12-381 G1 MSM precompile after the Jovian hardfork. -pub(crate) const JOVIAN_G1_MSM_MAX_INPUT_SIZE: usize = 288_960; +pub const JOVIAN_G1_MSM_MAX_INPUT_SIZE: usize = 288_960; /// Max input size for the BLS12-381 G2 MSM precompile after the Isthmus hardfork. -pub(crate) const ISTHMUS_G2_MSM_MAX_INPUT_SIZE: usize = 488448; +pub const ISTHMUS_G2_MSM_MAX_INPUT_SIZE: usize = 488448; /// Max input size for the BLS12-381 G2 MSM precompile after the Jovian hardfork. -pub(crate) const JOVIAN_G2_MSM_MAX_INPUT_SIZE: usize = 278_784; +pub const JOVIAN_G2_MSM_MAX_INPUT_SIZE: usize = 278_784; /// Max input size for the BLS12-381 pairing precompile after the Isthmus hardfork. -pub(crate) const ISTHMUS_PAIRING_MAX_INPUT_SIZE: usize = 235008; +pub const ISTHMUS_PAIRING_MAX_INPUT_SIZE: usize = 235008; /// Max input size for the BLS12-381 pairing precompile after the Jovian hardfork. -pub(crate) const JOVIAN_PAIRING_MAX_INPUT_SIZE: usize = 156_672; +pub const JOVIAN_PAIRING_MAX_INPUT_SIZE: usize = 156_672; /// BLS12-381 G1 MSM precompile with Isthmus input limits. -pub(crate) const ISTHMUS_G1_MSM: Precompile = Precompile::new( - PrecompileId::Bls12G1Msm, - G1_MSM_ADDRESS, - |input, gas_limit| { - if input.len() > ISTHMUS_G1_MSM_MAX_INPUT_SIZE { - return Err(PrecompileError::Other( - "G1MSM input length too long for Base input size limitation after the Isthmus Hardfork" - .into(), - )); - } - precompile::bls12_381::g1_msm::g1_msm(input, gas_limit) - }, -); +pub const ISTHMUS_G1_MSM: Precompile = + Precompile::new(PrecompileId::Bls12G1Msm, G1_MSM_ADDRESS, run_isthmus_g1_msm); + +/// Run the BLS12-381 G1 MSM precompile with Isthmus input limits. +pub fn run_isthmus_g1_msm(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > ISTHMUS_G1_MSM_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal( + "G1MSM input length too long for Base input size limitation after the Isthmus Hardfork" + .to_string(), + )); + } + Ok(call_eth_precompile(precompile::bls12_381::g1_msm::g1_msm, input, gas_limit, reservoir)) +} + /// BLS12-381 G2 MSM precompile with Isthmus input limits. -pub(crate) const ISTHMUS_G2_MSM: Precompile = - Precompile::new(PrecompileId::Bls12G2Msm, G2_MSM_ADDRESS, |input, gas_limit| { - if input.len() > ISTHMUS_G2_MSM_MAX_INPUT_SIZE { - return Err(PrecompileError::Other( - "G2MSM input length too long for Base input size limitation".into(), - )); - } - precompile::bls12_381::g2_msm::g2_msm(input, gas_limit) - }); +pub const ISTHMUS_G2_MSM: Precompile = + Precompile::new(PrecompileId::Bls12G2Msm, G2_MSM_ADDRESS, run_isthmus_g2_msm); + +/// Run the BLS12-381 G2 MSM precompile with Isthmus input limits. +pub fn run_isthmus_g2_msm(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > ISTHMUS_G2_MSM_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal( + "G2MSM input length too long for Base input size limitation".to_string(), + )); + } + Ok(call_eth_precompile(precompile::bls12_381::g2_msm::g2_msm, input, gas_limit, reservoir)) +} + /// BLS12-381 pairing precompile with Isthmus input limits. -pub(crate) const ISTHMUS_PAIRING: Precompile = - Precompile::new(PrecompileId::Bls12Pairing, PAIRING_ADDRESS, |input, gas_limit| { - if input.len() > ISTHMUS_PAIRING_MAX_INPUT_SIZE { - return Err(PrecompileError::Other( - "Pairing input length too long for Base input size limitation".into(), - )); - } - precompile::bls12_381::pairing::pairing(input, gas_limit) - }); +pub const ISTHMUS_PAIRING: Precompile = + Precompile::new(PrecompileId::Bls12Pairing, PAIRING_ADDRESS, run_isthmus_pairing); + +/// Run the BLS12-381 pairing precompile with Isthmus input limits. +pub fn run_isthmus_pairing(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > ISTHMUS_PAIRING_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal( + "Pairing input length too long for Base input size limitation".to_string(), + )); + } + Ok(call_eth_precompile(precompile::bls12_381::pairing::pairing, input, gas_limit, reservoir)) +} /// BLS12-381 G1 MSM precompile with Jovian input limits. -pub(crate) const JOVIAN_G1_MSM: Precompile = Precompile::new( - PrecompileId::Bls12G1Msm, - G1_MSM_ADDRESS, - |input, gas_limit| { - if input.len() > JOVIAN_G1_MSM_MAX_INPUT_SIZE { - return Err(PrecompileError::Other( - "G1MSM input length too long for Base input size limitation after the Jovian Hardfork" - .into(), - )); - } - precompile::bls12_381::g1_msm::g1_msm(input, gas_limit) - }, -); -/// BLS12-381 G2 MSM precompile with Jovian input limits. -pub(crate) const JOVIAN_G2_MSM: Precompile = Precompile::new( - PrecompileId::Bls12G2Msm, - G2_MSM_ADDRESS, - |input, gas_limit| { - if input.len() > JOVIAN_G2_MSM_MAX_INPUT_SIZE { - return Err(PrecompileError::Other( - "G2MSM input length too long for Base input size limitation after the Jovian Hardfork" - .into(), - )); - } - precompile::bls12_381::g2_msm::g2_msm(input, gas_limit) - }, -); -/// BLS12-381 pairing precompile with Jovian input limits. -pub(crate) const JOVIAN_PAIRING: Precompile = Precompile::new( - PrecompileId::Bls12Pairing, - PAIRING_ADDRESS, - |input, gas_limit| { - if input.len() > JOVIAN_PAIRING_MAX_INPUT_SIZE { - return Err(PrecompileError::Other( - "Pairing input length too long for Base input size limitation after the Jovian Hardfork" - .into(), - )); - } - precompile::bls12_381::pairing::pairing(input, gas_limit) - }, -); +pub const JOVIAN_G1_MSM: Precompile = + Precompile::new(PrecompileId::Bls12G1Msm, G1_MSM_ADDRESS, run_jovian_g1_msm); -#[cfg(test)] -mod tests { - use revm::{precompile::PrecompileError, primitives::Bytes}; +/// Run the BLS12-381 G1 MSM precompile with Jovian input limits. +pub fn run_jovian_g1_msm(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > JOVIAN_G1_MSM_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal( + "G1MSM input length too long for Base input size limitation after the Jovian Hardfork" + .to_string(), + )); + } + Ok(call_eth_precompile(precompile::bls12_381::g1_msm::g1_msm, input, gas_limit, reservoir)) +} - use super::*; +/// BLS12-381 G2 MSM precompile with Jovian input limits. +pub const JOVIAN_G2_MSM: Precompile = + Precompile::new(PrecompileId::Bls12G2Msm, G2_MSM_ADDRESS, run_jovian_g2_msm); - #[test] - fn test_g1_msm_isthmus_max_size() { - let input = Bytes::from(vec![0u8; ISTHMUS_G1_MSM_MAX_INPUT_SIZE + 1]); - assert!( - matches!(ISTHMUS_G1_MSM.execute(&input, 260_000), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); +/// Run the BLS12-381 G2 MSM precompile with Jovian input limits. +pub fn run_jovian_g2_msm(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > JOVIAN_G2_MSM_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal( + "G2MSM input length too long for Base input size limitation after the Jovian Hardfork" + .to_string(), + )); } + Ok(call_eth_precompile(precompile::bls12_381::g2_msm::g2_msm, input, gas_limit, reservoir)) +} - #[test] - fn test_g1_msm_jovian_max_size() { - let input = Bytes::from(vec![0u8; JOVIAN_G1_MSM_MAX_INPUT_SIZE + 1]); - assert!( - matches!(JOVIAN_G1_MSM.execute(&input, u64::MAX), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); - } +/// BLS12-381 pairing precompile with Jovian input limits. +pub const JOVIAN_PAIRING: Precompile = + Precompile::new(PrecompileId::Bls12Pairing, PAIRING_ADDRESS, run_jovian_pairing); - #[test] - fn test_g2_msm_isthmus_max_size() { - let input = Bytes::from(vec![0u8; ISTHMUS_G2_MSM_MAX_INPUT_SIZE + 1]); - assert!( - matches!(ISTHMUS_G2_MSM.execute(&input, 260_000), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); +/// Run the BLS12-381 pairing precompile with Jovian input limits. +pub fn run_jovian_pairing(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > JOVIAN_PAIRING_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal( + "Pairing input length too long for Base input size limitation after the Jovian Hardfork" + .to_string(), + )); } + Ok(call_eth_precompile(precompile::bls12_381::pairing::pairing, input, gas_limit, reservoir)) +} - #[test] - fn test_g2_msm_jovian_max_size() { - let input = Bytes::from(vec![0u8; JOVIAN_G2_MSM_MAX_INPUT_SIZE + 1]); - assert!( - matches!(JOVIAN_G2_MSM.execute(&input, u64::MAX), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); - } +#[cfg(test)] +mod tests { + use revm::{ + precompile::{Precompile, PrecompileError}, + primitives::Bytes, + }; + use rstest::rstest; - #[test] - fn test_pairing_isthmus_max_size() { - let input = Bytes::from(vec![0u8; ISTHMUS_PAIRING_MAX_INPUT_SIZE + 1]); - assert!( - matches!(ISTHMUS_PAIRING.execute(&input, 260_000), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); - } + use crate::{ + ISTHMUS_G1_MSM, ISTHMUS_G1_MSM_MAX_INPUT_SIZE, ISTHMUS_G2_MSM, + ISTHMUS_G2_MSM_MAX_INPUT_SIZE, ISTHMUS_PAIRING, ISTHMUS_PAIRING_MAX_INPUT_SIZE, + JOVIAN_G1_MSM, JOVIAN_G1_MSM_MAX_INPUT_SIZE, JOVIAN_G2_MSM, JOVIAN_G2_MSM_MAX_INPUT_SIZE, + JOVIAN_PAIRING, JOVIAN_PAIRING_MAX_INPUT_SIZE, + }; - #[test] - fn test_pairing_jovian_max_size() { - let input = Bytes::from(vec![0u8; JOVIAN_PAIRING_MAX_INPUT_SIZE + 1]); + #[rstest] + #[case::g1_msm_isthmus(ISTHMUS_G1_MSM, ISTHMUS_G1_MSM_MAX_INPUT_SIZE, 260_000)] + #[case::g1_msm_jovian(JOVIAN_G1_MSM, JOVIAN_G1_MSM_MAX_INPUT_SIZE, u64::MAX)] + #[case::g2_msm_isthmus(ISTHMUS_G2_MSM, ISTHMUS_G2_MSM_MAX_INPUT_SIZE, 260_000)] + #[case::g2_msm_jovian(JOVIAN_G2_MSM, JOVIAN_G2_MSM_MAX_INPUT_SIZE, u64::MAX)] + #[case::pairing_isthmus(ISTHMUS_PAIRING, ISTHMUS_PAIRING_MAX_INPUT_SIZE, 260_000)] + #[case::pairing_jovian(JOVIAN_PAIRING, JOVIAN_PAIRING_MAX_INPUT_SIZE, u64::MAX)] + fn test_max_size_rejects_oversized_input( + #[case] precompile: Precompile, + #[case] max_input_size: usize, + #[case] gas_limit: u64, + ) { + let input = Bytes::from(vec![0u8; max_input_size + 1]); + let result = precompile.execute(&input, gas_limit, 0); assert!( - matches!(JOVIAN_PAIRING.execute(&input, u64::MAX), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) + matches!(&result, Err(PrecompileError::Fatal(msg)) if msg.contains("input length too long")), + "expected Fatal error for oversized input, got {result:?}" ); } } diff --git a/crates/common/precompiles/src/bn254_pair.rs b/crates/common/precompiles/src/bn254_pair.rs index 1d6b74c0ca..a760a91c71 100644 --- a/crates/common/precompiles/src/bn254_pair.rs +++ b/crates/common/precompiles/src/bn254_pair.rs @@ -1,36 +1,60 @@ -use revm::precompile::{Precompile, PrecompileError, PrecompileId, bn254}; +use alloc::string::ToString; + +use revm::precompile::{ + Precompile, PrecompileError, PrecompileId, PrecompileResult, bn254, call_eth_precompile, +}; /// Max input size for the bn254 pair precompile after the Granite hardfork. -pub(crate) const GRANITE_MAX_INPUT_SIZE: usize = 112687; +pub const GRANITE_MAX_INPUT_SIZE: usize = 112687; /// Bn254 pair precompile with Granite input limits. -pub(crate) const GRANITE: Precompile = - Precompile::new(PrecompileId::Bn254Pairing, bn254::pair::ADDRESS, |input, gas_limit| { - if input.len() > GRANITE_MAX_INPUT_SIZE { - return Err(PrecompileError::Bn254PairLength); - } - bn254::run_pair( - input, - bn254::pair::ISTANBUL_PAIR_PER_POINT, - bn254::pair::ISTANBUL_PAIR_BASE, - gas_limit, - ) - }); +pub const GRANITE: Precompile = + Precompile::new(PrecompileId::Bn254Pairing, bn254::pair::ADDRESS, run_pair_granite); + +/// Run the bn254 pair precompile with Granite input limit. +pub fn run_pair_granite(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > GRANITE_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal("Bn254PairLength".to_string())); + } + Ok(call_eth_precompile( + |i, g| { + bn254::run_pair( + i, + bn254::pair::ISTANBUL_PAIR_PER_POINT, + bn254::pair::ISTANBUL_PAIR_BASE, + g, + ) + }, + input, + gas_limit, + reservoir, + )) +} /// Max input size for the bn254 pair precompile after the Jovian hardfork. -pub(crate) const JOVIAN_MAX_INPUT_SIZE: usize = 81_984; +pub const JOVIAN_MAX_INPUT_SIZE: usize = 81_984; /// Bn254 pair precompile with Jovian input limits. -pub(crate) const JOVIAN: Precompile = - Precompile::new(PrecompileId::Bn254Pairing, bn254::pair::ADDRESS, |input, gas_limit| { - if input.len() > JOVIAN_MAX_INPUT_SIZE { - return Err(PrecompileError::Bn254PairLength); - } - bn254::run_pair( - input, - bn254::pair::ISTANBUL_PAIR_PER_POINT, - bn254::pair::ISTANBUL_PAIR_BASE, - gas_limit, - ) - }); +pub const JOVIAN: Precompile = + Precompile::new(PrecompileId::Bn254Pairing, bn254::pair::ADDRESS, run_pair_jovian); + +/// Run the bn254 pair precompile with Jovian input limit. +pub fn run_pair_jovian(input: &[u8], gas_limit: u64, reservoir: u64) -> PrecompileResult { + if input.len() > JOVIAN_MAX_INPUT_SIZE { + return Err(PrecompileError::Fatal("Bn254PairLength".to_string())); + } + Ok(call_eth_precompile( + |i, g| { + bn254::run_pair( + i, + bn254::pair::ISTANBUL_PAIR_PER_POINT, + bn254::pair::ISTANBUL_PAIR_BASE, + g, + ) + }, + input, + gas_limit, + reservoir, + )) +} #[cfg(test)] mod tests { @@ -39,7 +63,7 @@ mod tests { primitives::hex, }; - use super::*; + use crate::{JOVIAN_MAX_INPUT_SIZE, run_pair_granite, run_pair_jovian}; #[test] fn test_bn254_pair_granite() { @@ -62,7 +86,7 @@ mod tests { let expected = hex::decode("0000000000000000000000000000000000000000000000000000000000000001") .unwrap(); - let outcome = GRANITE.execute(&input, 260_000).unwrap(); + let outcome = run_pair_granite(&input, 260_000, 0).unwrap(); assert_eq!(outcome.bytes, expected); // Invalid input length @@ -74,20 +98,20 @@ mod tests { ", ) .unwrap(); - assert!(matches!( - GRANITE.execute(&bad_input, 260_000), - Err(PrecompileError::Bn254PairLength) - )); + assert!( + matches!(run_pair_granite(&bad_input, 260_000, 0), Ok(o) if o.halt_reason().is_some()) + ); - // Valid input length shorter than 112687 + // Valid input length shorter than 112687 - halts are wrapped in Ok let at_gas_limit = vec![1u8; 586 * bn254::PAIR_ELEMENT_LEN]; - assert!(matches!(GRANITE.execute(&at_gas_limit, 260_000), Err(PrecompileError::OutOfGas))); + let result = run_pair_granite(&at_gas_limit, 260_000, 0); + assert!(result.is_ok(), "halts are wrapped in Ok by call_eth_precompile"); // Input length longer than 112687 let over_limit = vec![1u8; 587 * bn254::PAIR_ELEMENT_LEN]; assert!(matches!( - GRANITE.execute(&over_limit, 260_000), - Err(PrecompileError::Bn254PairLength) + run_pair_granite(&over_limit, 260_000, 0), + Err(PrecompileError::Fatal(_)) )); } @@ -99,13 +123,13 @@ mod tests { const EXPECTED_OUTPUT: [u8; 32] = hex!("0000000000000000000000000000000000000000000000000000000000000001"); - let res = JOVIAN.execute(TEST_INPUT.as_ref(), u64::MAX); + let res = run_pair_jovian(TEST_INPUT.as_ref(), u64::MAX, 0); assert!(matches!(res, Ok(outcome) if **outcome.bytes == EXPECTED_OUTPUT)); } #[test] fn test_bn254_pair_jovian_bad_input_len() { let input = [0u8; JOVIAN_MAX_INPUT_SIZE + 1]; - assert!(matches!(JOVIAN.execute(&input, u64::MAX), Err(PrecompileError::Bn254PairLength))); + assert!(matches!(run_pair_jovian(&input, u64::MAX, 0), Err(PrecompileError::Fatal(_)))); } } diff --git a/crates/common/precompiles/src/common/mod.rs b/crates/common/precompiles/src/common/mod.rs new file mode 100644 index 0000000000..b7e95046c4 --- /dev/null +++ b/crates/common/precompiles/src/common/mod.rs @@ -0,0 +1,21 @@ +//! Shared business logic for all Base-native token variants. + +mod ops; +pub use ops::{ + B20Guards, B20TokenRole, Burnable, Configurable, Eip712Domain, Mintable, Pausable, PermitArgs, + Permittable, RoleManaged, Transferable, +}; + +mod policy; +pub use policy::{Policy, PolicyRegistry}; + +#[cfg(any(test, feature = "test-utils"))] +pub(super) mod test_utils; +#[cfg(any(test, feature = "test-utils"))] +pub use test_utils::{InMemoryPolicy, InMemoryTokenAccounting, TestStablecoinToken, TestToken}; + +mod token; +pub use token::Token; + +mod token_accounting; +pub use token_accounting::TokenAccounting; diff --git a/crates/common/precompiles/src/common/ops/burnable.rs b/crates/common/precompiles/src/common/ops/burnable.rs new file mode 100644 index 0000000000..d8fc087717 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/burnable.rs @@ -0,0 +1,211 @@ +use alloy_primitives::{Address, B256, U256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, B20TokenRole, IB20, Token, TokenAccounting}; + +/// Token burn operations. +/// +/// All methods have default implementations that go through [`Token::accounting`]. +/// Implement this trait with an empty body to opt in. +pub trait Burnable: Token { + /// Destroys `amount` tokens from `from`. Emits `Transfer(from, 0x0, amount)`. + fn burn( + &mut self, + caller: Address, + from: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Burn)?; + } + B20Guards::ensure_not_paused::(self, IB20::PausableFeature::BURN)?; + let balance = self.accounting().balance_of(from)?; + if balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: from, + balance, + needed: amount, + })); + } + self.accounting_mut().set_balance(from, balance - amount)?; + let supply = self.accounting().total_supply()?; + let new_supply = + supply.checked_sub(amount).ok_or_else(BasePrecompileError::under_overflow)?; + self.accounting_mut().set_total_supply(new_supply)?; + self.accounting_mut() + .emit_event(IB20::Transfer { from, to: Address::ZERO, amount }.encode_log_data()) + } + + /// [`Self::burn`] followed by a `Memo` event. + fn burn_with_memo( + &mut self, + caller: Address, + from: Address, + amount: U256, + memo: B256, + privileged: bool, + ) -> Result<()> { + self.burn(caller, from, amount, privileged)?; + self.accounting_mut().emit_event(IB20::Memo { caller, memo }.encode_log_data()) + } + + /// Destroys `amount` from a policy-blocked account. Emits `Transfer` and `BurnedBlocked`. + fn burn_blocked( + &mut self, + caller: Address, + from: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::BurnBlocked)?; + } + B20Guards::ensure_blocked::(self, from)?; + // Intentional asymmetry: BURN_BLOCKED_ROLE replaces BURN_ROLE, but emergency burn pauses + // still halt every burn path, including burnBlocked. + self.burn(caller, from, amount, true)?; + self.accounting_mut() + .emit_event(IB20::BurnedBlocked { caller, from, amount }.encode_log_data()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256}; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20PausableFeature, B20PolicyType, B20TokenRole, Burnable, IB20, InMemoryPolicy, + InMemoryTokenAccounting, PolicyRegistryStorage, TestToken, Token, TokenAccounting, + }; + + const CALLER: Address = Address::repeat_byte(0xcc); + const ALICE: Address = Address::repeat_byte(0xaa); + const TOKEN_ADDR: Address = Address::repeat_byte(1); + + fn token_with_balance(balance: U256) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, balance); + accounting.total_supply = balance; + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + fn token_with_role(role: B20TokenRole, account: Address, balance: U256) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, balance); + accounting.total_supply = balance; + accounting.roles.insert((role.id(), account), true); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn burn_decreases_balance_and_supply() { + let mut token = token_with_balance(U256::from(100u64)); + + token.burn(CALLER, ALICE, U256::from(40u64), true).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(60u64)); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn burn_insufficient_balance_reverts() { + let mut token = token_with_balance(U256::from(10u64)); + + assert_eq!( + token.burn(CALLER, ALICE, U256::from(11u64), true).unwrap_err(), + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: U256::from(10u64), + needed: U256::from(11u64), + }) + ); + } + + #[test] + fn non_privileged_burn_without_role_reverts() { + let mut token = token_with_balance(U256::from(10u64)); + + assert_eq!( + token.burn(CALLER, ALICE, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Burn.id(), + }) + ); + } + + #[test] + fn non_privileged_burn_with_role_succeeds() { + let mut token = token_with_role(B20TokenRole::Burn, CALLER, U256::from(10u64)); + + token.burn(CALLER, ALICE, U256::from(4u64), false).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(6u64)); + } + + #[test] + fn burn_reverts_when_burn_feature_paused() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.total_supply = U256::from(10u64); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::BURN); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.burn(CALLER, ALICE, U256::ONE, true).unwrap_err(), + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::BURN, + }) + ); + } + + #[test] + fn burn_blocked_reverts_when_account_is_not_blocked() { + let mut token = token_with_balance(U256::from(10u64)); + + assert_eq!( + token.burn_blocked(CALLER, ALICE, U256::ONE, true).unwrap_err(), + BasePrecompileError::revert(IB20::AccountNotBlocked { account: ALICE }) + ); + } + + #[test] + fn burn_blocked_burns_blocked_account_and_emits_events() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(100u64)); + accounting.total_supply = U256::from(100u64); + accounting + .policy_ids + .insert(B20PolicyType::TransferSender.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + token.burn_blocked(CALLER, ALICE, U256::from(25u64), true).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(75u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(75u64)); + assert_eq!(token.accounting().events.len(), 2); + } + + #[test] + fn non_privileged_burn_blocked_without_role_reverts() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.total_supply = U256::from(10u64); + accounting + .policy_ids + .insert(B20PolicyType::TransferSender.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.burn_blocked(CALLER, ALICE, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::BurnBlocked.id(), + }) + ); + } +} diff --git a/crates/common/precompiles/src/common/ops/configurable.rs b/crates/common/precompiles/src/common/ops/configurable.rs new file mode 100644 index 0000000000..69a3b6a6c9 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/configurable.rs @@ -0,0 +1,228 @@ +use alloc::string::String; + +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, B20TokenRole, IB20, Token, TokenAccounting}; + +/// Mutable configuration operations: supply cap, metadata, and contract URI updates. +/// +/// All methods have default implementations that go through [`Token::accounting`]. +/// Implement with an empty body to opt in. +pub trait Configurable: Token { + /// Updates the supply cap. Requires `DEFAULT_ADMIN_ROLE`. Emits `SupplyCapUpdated`. + fn update_supply_cap( + &mut self, + caller: Address, + new_cap: U256, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::DefaultAdmin)?; + } + let supply = self.accounting().total_supply()?; + if new_cap < supply { + return Err(BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: supply, + proposedCap: new_cap, + })); + } + let old = self.accounting().supply_cap()?; + self.accounting_mut().set_supply_cap(new_cap)?; + self.accounting_mut().emit_event( + IB20::SupplyCapUpdated { updater: caller, oldSupplyCap: old, newSupplyCap: new_cap } + .encode_log_data(), + ) + } + + /// Updates the token name. Emits `NameUpdated` and `EIP712DomainChanged` (ERC-5267). + fn update_name(&mut self, caller: Address, name: String, privileged: bool) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Metadata)?; + } + self.accounting_mut().set_name(name.clone())?; + self.accounting_mut() + .emit_event(IB20::NameUpdated { updater: caller, newName: name }.encode_log_data())?; + self.accounting_mut().emit_event(IB20::EIP712DomainChanged {}.encode_log_data()) + } + + /// Updates the token symbol. Emits `SymbolUpdated`. + fn update_symbol(&mut self, caller: Address, symbol: String, privileged: bool) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Metadata)?; + } + self.accounting_mut().set_symbol(symbol.clone())?; + self.accounting_mut().emit_event( + IB20::SymbolUpdated { updater: caller, newSymbol: symbol }.encode_log_data(), + ) + } + + /// Updates the contract URI. Emits `ContractURIUpdated`. + fn update_contract_uri( + &mut self, + caller: Address, + uri: String, + privileged: bool, + ) -> Result<()> { + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Metadata)?; + } + self.accounting_mut().set_contract_uri(uri)?; + self.accounting_mut().emit_event(IB20::ContractURIUpdated {}.encode_log_data()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256}; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20TokenRole, Configurable, IB20, InMemoryPolicy, InMemoryTokenAccounting, TestToken, + Token, TokenAccounting, + }; + + const CALLER: Address = Address::repeat_byte(0xaa); + const TOKEN_ADDR: Address = Address::repeat_byte(1); + + fn make_token() -> TestToken { + TestToken::with_storage_and_policy( + InMemoryTokenAccounting::new(TOKEN_ADDR), + InMemoryPolicy::new(), + ) + } + + fn token_with_default_admin(account: Address) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.roles.insert((B20TokenRole::DefaultAdmin.id(), account), true); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn update_supply_cap_updates_cap_and_emits_event() { + let mut token = make_token(); + + token.update_supply_cap(CALLER, U256::from(500u64), true).unwrap(); + + assert_eq!(token.accounting().supply_cap().unwrap(), U256::from(500u64)); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn update_supply_cap_below_current_supply_reverts() { + let mut token = make_token(); + token.accounting_mut().total_supply = U256::from(100u64); + + assert_eq!( + token.update_supply_cap(CALLER, U256::from(99u64), true).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidSupplyCap { + currentSupply: U256::from(100u64), + proposedCap: U256::from(99u64), + }) + ); + } + + #[test] + fn update_name_round_trips_and_emits_event() { + let mut token = make_token(); + + token.update_name(CALLER, "MyToken".into(), true).unwrap(); + + assert_eq!(token.accounting().name().unwrap(), "MyToken"); + assert_eq!(token.accounting().events.len(), 2); + } + + #[test] + fn update_symbol_round_trips_and_emits_event() { + let mut token = make_token(); + + token.update_symbol(CALLER, "MTK".into(), true).unwrap(); + + assert_eq!(token.accounting().symbol().unwrap(), "MTK"); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn update_contract_uri_round_trips_and_emits_event() { + let mut token = make_token(); + + token.update_contract_uri(CALLER, "ipfs://abc".into(), true).unwrap(); + + assert_eq!(token.accounting().contract_uri().unwrap(), "ipfs://abc"); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn non_privileged_config_update_without_admin_role_reverts() { + let mut token = make_token(); + + assert_eq!( + token.update_name(CALLER, "MyToken".into(), false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Metadata.id(), + }) + ); + } + + #[test] + fn non_privileged_config_update_with_admin_role_succeeds() { + let mut token = token_with_default_admin(CALLER); + token.accounting_mut().roles.insert((B20TokenRole::Metadata.id(), CALLER), true); + + token.update_supply_cap(CALLER, U256::from(500u64), false).unwrap(); + token.update_name(CALLER, "MyToken".into(), false).unwrap(); + token.update_symbol(CALLER, "MTK".into(), false).unwrap(); + token.update_contract_uri(CALLER, "ipfs://abc".into(), false).unwrap(); + + assert_eq!(token.accounting().supply_cap().unwrap(), U256::from(500u64)); + assert_eq!(token.accounting().name().unwrap(), "MyToken"); + assert_eq!(token.accounting().symbol().unwrap(), "MTK"); + assert_eq!(token.accounting().contract_uri().unwrap(), "ipfs://abc"); + assert_eq!(token.accounting().events.len(), 5); + } + + fn token_with_metadata_role(account: Address) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.roles.insert((B20TokenRole::Metadata.id(), account), true); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn update_contract_uri_without_metadata_role_reverts() { + let mut token = make_token(); + + assert_eq!( + token.update_contract_uri(CALLER, "ipfs://abc".into(), false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Metadata.id(), + }) + ); + } + + #[test] + fn update_contract_uri_with_only_default_admin_reverts() { + // DEFAULT_ADMIN_ROLE alone is not sufficient; METADATA_ROLE is required. + let mut token = token_with_default_admin(CALLER); + + assert_eq!( + token.update_contract_uri(CALLER, "ipfs://abc".into(), false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Metadata.id(), + }) + ); + } + + #[test] + fn update_contract_uri_with_metadata_role_succeeds() { + let mut token = token_with_metadata_role(CALLER); + + token.update_contract_uri(CALLER, "ipfs://xyz".into(), false).unwrap(); + + assert_eq!(token.accounting().contract_uri().unwrap(), "ipfs://xyz"); + assert_eq!(token.accounting().events.len(), 1); + } +} diff --git a/crates/common/precompiles/src/common/ops/guards.rs b/crates/common/precompiles/src/common/ops/guards.rs new file mode 100644 index 0000000000..ac494efaa5 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/guards.rs @@ -0,0 +1,165 @@ +//! Shared authorization and policy guards for B-20 token operations. + +use alloy_primitives::{Address, B256, U256}; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{ + B20PausableFeature, B20PolicyType, B20TokenRole, IB20, Policy, Token, TokenAccounting, +}; + +/// Authorization and policy guard helpers for B-20 operations. +#[derive(Debug, Clone, Copy)] +pub struct B20Guards; + +impl B20Guards { + /// Ensures `caller` has the B-20 role. + pub fn ensure_token_role( + token: &T, + caller: Address, + role: B20TokenRole, + ) -> Result<()> { + Self::ensure_role(token, caller, role.id()) + } + + /// Ensures `caller` has `role`. + pub fn ensure_role(token: &T, caller: Address, role: B256) -> Result<()> { + if token.accounting().has_role(role, caller)? { + Ok(()) + } else { + Err(BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: caller, + neededRole: role, + })) + } + } + + /// Ensures `feature` is not paused. + pub fn ensure_not_paused( + token: &T, + feature: IB20::PausableFeature, + ) -> Result<()> { + if (token.accounting().paused()? & B20PausableFeature::mask(feature)) == U256::ZERO { + Ok(()) + } else { + Err(BasePrecompileError::revert(IB20::ContractPaused { feature })) + } + } + + /// Ensures `account` is allowed by `policy_type`. + pub fn ensure_policy_type( + token: &T, + policy_type: B20PolicyType, + account: Address, + ) -> Result<()> { + Self::ensure_policy(token, policy_type.id(), account) + } + + /// Ensures `account` is allowed by the raw `policy_scope`. + /// + /// All policy IDs, including built-ins, are delegated to the configured policy registry. + pub fn ensure_policy( + token: &T, + policy_scope: B256, + account: Address, + ) -> Result<()> { + let policy_id = token.accounting().policy_id(policy_scope)?; + if token.policy().is_authorized(policy_id, account)? { + Ok(()) + } else { + Err(BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: policy_scope, + policyId: policy_id, + })) + } + } + + /// Ensures `account` is blocked by the current transfer-sender policy. + /// + /// Accounts are blocked when the configured registry policy does not authorize them. + pub fn ensure_blocked(token: &T, account: Address) -> Result<()> { + let policy_scope = B20PolicyType::TransferSender.id(); + let policy_id = token.accounting().policy_id(policy_scope)?; + if token.policy().is_authorized(policy_id, account)? { + Err(BasePrecompileError::revert(IB20::AccountNotBlocked { account })) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::Address; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20Guards, B20PolicyType, IB20, InMemoryPolicy, InMemoryTokenAccounting, + PolicyRegistryStorage, TestToken, + }; + + const EXTERNAL_POLICY_ID: u64 = 7; + + fn token_with_transfer_sender_policy(account: Address) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(Address::repeat_byte(0x20)); + accounting.policy_ids.insert(B20PolicyType::TransferSender.id(), EXTERNAL_POLICY_ID); + + let mut policy = InMemoryPolicy::new(); + policy.allow(EXTERNAL_POLICY_ID, account); + + TestToken::with_storage_and_policy(accounting, policy) + } + + #[test] + fn test_ensure_policy_delegates_external_policy_ids_to_registry() { + let allowed = Address::repeat_byte(0xaa); + let denied = Address::repeat_byte(0xbb); + let token = token_with_transfer_sender_policy(allowed); + + B20Guards::ensure_policy_type(&token, B20PolicyType::TransferSender, allowed).unwrap(); + + assert_eq!( + B20Guards::ensure_policy_type(&token, B20PolicyType::TransferSender, denied) + .unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferSender.id(), + policyId: EXTERNAL_POLICY_ID, + }) + ); + } + + #[test] + fn test_ensure_blocked_uses_external_policy_authorization() { + let allowed = Address::repeat_byte(0xaa); + let denied = Address::repeat_byte(0xbb); + let token = token_with_transfer_sender_policy(allowed); + + assert_eq!( + B20Guards::ensure_blocked(&token, allowed).unwrap_err(), + BasePrecompileError::revert(IB20::AccountNotBlocked { account: allowed }) + ); + B20Guards::ensure_blocked(&token, denied).unwrap(); + } + + #[test] + fn test_ensure_blocked_preserves_global_block_semantics() { + let account = Address::repeat_byte(0xaa); + let mut accounting = InMemoryTokenAccounting::new(Address::repeat_byte(0x20)); + accounting + .policy_ids + .insert(B20PolicyType::TransferSender.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + B20Guards::ensure_blocked(&token, account).unwrap(); + + let mut accounting = InMemoryTokenAccounting::new(Address::repeat_byte(0x20)); + accounting + .policy_ids + .insert(B20PolicyType::TransferSender.id(), PolicyRegistryStorage::ALWAYS_ALLOW_ID); + let token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + B20Guards::ensure_blocked(&token, account).unwrap_err(), + BasePrecompileError::revert(IB20::AccountNotBlocked { account }) + ); + } +} diff --git a/crates/common/precompiles/src/common/ops/mintable.rs b/crates/common/precompiles/src/common/ops/mintable.rs new file mode 100644 index 0000000000..0045b8a504 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/mintable.rs @@ -0,0 +1,191 @@ +use alloy_primitives::{Address, B256, U256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, B20PolicyType, B20TokenRole, IB20, Token, TokenAccounting}; + +/// Token minting operations. +/// +/// All methods have default implementations that go through [`Token::accounting`]. +/// Implement this trait with an empty body to opt in. +pub trait Mintable: Token { + /// Creates `amount` tokens at `to`. Enforces supply cap. Emits `Transfer(0x0, to, amount)`. + fn mint(&mut self, caller: Address, to: Address, amount: U256, privileged: bool) -> Result<()> { + if to == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Mint)?; + } + B20Guards::ensure_policy_type::(self, B20PolicyType::MintReceiver, to)?; + B20Guards::ensure_not_paused::(self, IB20::PausableFeature::MINT)?; + let supply = self.accounting().total_supply()?; + let cap = self.accounting().supply_cap()?; + let new_supply = + supply.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + if new_supply > cap { + return Err(BasePrecompileError::revert(IB20::SupplyCapExceeded { + cap, + attempted: new_supply, + })); + } + self.accounting_mut().set_total_supply(new_supply)?; + let to_balance = self.accounting().balance_of(to)?; + let new_balance = + to_balance.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + self.accounting_mut().set_balance(to, new_balance)?; + self.accounting_mut() + .emit_event(IB20::Transfer { from: Address::ZERO, to, amount }.encode_log_data()) + } + + /// [`Self::mint`] followed by a `Memo` event. + fn mint_with_memo( + &mut self, + caller: Address, + to: Address, + amount: U256, + memo: B256, + privileged: bool, + ) -> Result<()> { + self.mint(caller, to, amount, privileged)?; + self.accounting_mut().emit_event(IB20::Memo { caller, memo }.encode_log_data()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256}; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20PausableFeature, B20PolicyType, B20TokenRole, IB20, InMemoryPolicy, + InMemoryTokenAccounting, Mintable, PolicyRegistryStorage, TestToken, Token, + TokenAccounting, + }; + + const CALLER: Address = Address::repeat_byte(0xcc); + const ALICE: Address = Address::repeat_byte(0xaa); + const TOKEN_ADDR: Address = Address::repeat_byte(1); + + fn make_token() -> TestToken { + TestToken::with_storage_and_policy( + InMemoryTokenAccounting::new(TOKEN_ADDR), + InMemoryPolicy::new(), + ) + } + + fn token_with_role(role: B20TokenRole, account: Address) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.roles.insert((role.id(), account), true); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn mint_increases_balance_and_total_supply() { + let mut token = make_token(); + + token.mint(CALLER, ALICE, U256::from(100u64), true).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(100u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(100u64)); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn mint_to_zero_address_reverts() { + let mut token = make_token(); + + assert_eq!( + token.mint(CALLER, Address::ZERO, U256::ONE, true).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO }) + ); + } + + #[test] + fn mint_allows_supply_cap_boundary() { + let mut token = make_token(); + token.accounting_mut().supply_cap = U256::from(100u64); + + token.mint(CALLER, ALICE, U256::from(100u64), true).unwrap(); + + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(100u64)); + } + + #[test] + fn mint_reverts_when_supply_cap_exceeded() { + let mut token = make_token(); + token.accounting_mut().supply_cap = U256::from(50u64); + + assert_eq!( + token.mint(CALLER, ALICE, U256::from(51u64), true).unwrap_err(), + BasePrecompileError::revert(IB20::SupplyCapExceeded { + cap: U256::from(50u64), + attempted: U256::from(51u64), + }) + ); + } + + #[test] + fn mint_accumulates_across_calls() { + let mut token = make_token(); + + token.mint(CALLER, ALICE, U256::from(40u64), true).unwrap(); + token.mint(CALLER, ALICE, U256::from(60u64), true).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(100u64)); + assert_eq!(token.accounting().total_supply().unwrap(), U256::from(100u64)); + } + + #[test] + fn non_privileged_mint_without_role_reverts() { + let mut token = make_token(); + + assert_eq!( + token.mint(CALLER, ALICE, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Mint.id(), + }) + ); + } + + #[test] + fn non_privileged_mint_with_role_succeeds() { + let mut token = token_with_role(B20TokenRole::Mint, CALLER); + + token.mint(CALLER, ALICE, U256::from(10u64), false).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(10u64)); + } + + #[test] + fn mint_reverts_when_mint_feature_paused() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::MINT); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.mint(CALLER, ALICE, U256::ONE, true).unwrap_err(), + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::MINT, + }) + ); + } + + #[test] + fn mint_reverts_when_receiver_policy_denies() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting + .policy_ids + .insert(B20PolicyType::MintReceiver.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.mint(CALLER, ALICE, U256::ONE, true).unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::MintReceiver.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } +} diff --git a/crates/common/precompiles/src/common/ops/mod.rs b/crates/common/precompiles/src/common/ops/mod.rs new file mode 100644 index 0000000000..397fd7070b --- /dev/null +++ b/crates/common/precompiles/src/common/ops/mod.rs @@ -0,0 +1,32 @@ +//! Capability extension traits for B-20 token variants. +//! +//! Each trait provides a composable set of token operations with default implementations +//! built entirely on top of [`TokenAccounting`]. A token variant opts in to a +//! capability by implementing the corresponding trait — no body required when the default +//! impl is sufficient. +//! +//! [`TokenAccounting`]: crate::TokenAccounting + +mod burnable; +pub use burnable::Burnable; + +mod configurable; +pub use configurable::Configurable; + +mod guards; +pub use guards::B20Guards; + +mod mintable; +pub use mintable::Mintable; + +mod pausable; +pub use pausable::Pausable; + +mod permittable; +pub use permittable::{Eip712Domain, PermitArgs, Permittable}; + +mod roles; +pub use roles::{B20TokenRole, RoleManaged}; + +mod transferable; +pub use transferable::Transferable; diff --git a/crates/common/precompiles/src/common/ops/pausable.rs b/crates/common/precompiles/src/common/ops/pausable.rs new file mode 100644 index 0000000000..4ca22f88b4 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/pausable.rs @@ -0,0 +1,228 @@ +use alloc::vec::Vec; + +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, B20PausableFeature, B20TokenRole, IB20, Token, TokenAccounting}; + +/// Pause and unpause operations. +/// +/// All methods have default implementations that go through [`Token::accounting`]. +/// Implement this trait with an empty body to opt in. +pub trait Pausable: Token { + /// Returns whether the given pause `feature` is currently set. + fn is_paused(&self, feature: IB20::PausableFeature) -> Result { + Ok((self.accounting().paused()? & B20PausableFeature::mask(feature)) != U256::ZERO) + } + + /// Returns all currently paused features. + fn paused_features(&self) -> Result> { + let paused = self.accounting().paused()?; + let mut features = Vec::new(); + for feature in [ + IB20::PausableFeature::TRANSFER, + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::REDEEM, + ] { + if (paused & B20PausableFeature::mask(feature)) != U256::ZERO { + features.push(feature); + } + } + Ok(features) + } + + /// ORs `features` into the current paused bitmask. + fn pause( + &mut self, + caller: Address, + features: Vec, + privileged: bool, + ) -> Result<()> { + if features.is_empty() { + return Err(BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Pause)?; + } + let current = self.accounting().paused()?; + let mut next = current; + for feature in &features { + next |= B20PausableFeature::mask(*feature); + } + self.accounting_mut().set_paused(next)?; + self.accounting_mut() + .emit_event(IB20::Paused { updater: caller, features }.encode_log_data()) + } + + /// Clears `features` from the current paused bitmask. + fn unpause( + &mut self, + caller: Address, + features: Vec, + privileged: bool, + ) -> Result<()> { + if features.is_empty() { + return Err(BasePrecompileError::revert(IB20::EmptyFeatureSet {})); + } + if !privileged { + B20Guards::ensure_token_role::(self, caller, B20TokenRole::Unpause)?; + } + let mut next = self.accounting().paused()?; + for feature in &features { + next &= !B20PausableFeature::mask(*feature); + } + self.accounting_mut().set_paused(next)?; + self.accounting_mut() + .emit_event(IB20::Unpaused { updater: caller, features }.encode_log_data()) + } +} + +#[cfg(test)] +mod tests { + use alloc::vec; + + use alloy_primitives::Address; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20PausableFeature, B20TokenRole, IB20, InMemoryPolicy, InMemoryTokenAccounting, Pausable, + TestToken, Token, + }; + + const CALLER: Address = Address::repeat_byte(0xaa); + const TOKEN_ADDR: Address = Address::repeat_byte(1); + + fn make_token() -> TestToken { + TestToken::with_storage_and_policy( + InMemoryTokenAccounting::new(TOKEN_ADDR), + InMemoryPolicy::new(), + ) + } + + fn token_with_role(role: B20TokenRole, account: Address) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.roles.insert((role.id(), account), true); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn pause_sets_feature_and_emits_event() { + let mut token = make_token(); + + token.pause(CALLER, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + + assert!(token.is_paused(IB20::PausableFeature::TRANSFER).unwrap()); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn pause_ors_multiple_features_into_existing_bitmask() { + let mut token = make_token(); + + token.pause(CALLER, vec![IB20::PausableFeature::TRANSFER], true).unwrap(); + token + .pause(CALLER, vec![IB20::PausableFeature::MINT, IB20::PausableFeature::BURN], true) + .unwrap(); + + assert!(token.is_paused(IB20::PausableFeature::TRANSFER).unwrap()); + assert!(token.is_paused(IB20::PausableFeature::MINT).unwrap()); + assert!(token.is_paused(IB20::PausableFeature::BURN).unwrap()); + } + + #[test] + fn unpause_clears_selected_feature_and_leaves_others_paused() { + let mut token = make_token(); + + token + .pause(CALLER, vec![IB20::PausableFeature::TRANSFER, IB20::PausableFeature::MINT], true) + .unwrap(); + token.unpause(CALLER, vec![IB20::PausableFeature::MINT], true).unwrap(); + + assert!(token.is_paused(IB20::PausableFeature::TRANSFER).unwrap()); + assert!(!token.is_paused(IB20::PausableFeature::MINT).unwrap()); + } + + #[test] + fn paused_features_returns_active_features_in_abi_order() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::TRANSFER) + | B20PausableFeature::mask(IB20::PausableFeature::BURN); + let token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.paused_features().unwrap(), + vec![IB20::PausableFeature::TRANSFER, IB20::PausableFeature::BURN] + ); + } + + #[test] + fn pause_empty_feature_set_reverts() { + let mut token = make_token(); + + assert_eq!( + token.pause(CALLER, vec![], true).unwrap_err(), + BasePrecompileError::revert(IB20::EmptyFeatureSet {}) + ); + } + + #[test] + fn unpause_empty_feature_set_reverts() { + let mut token = make_token(); + + assert_eq!( + token.unpause(CALLER, vec![], true).unwrap_err(), + BasePrecompileError::revert(IB20::EmptyFeatureSet {}) + ); + } + + #[test] + fn non_privileged_pause_without_role_reverts() { + let mut token = make_token(); + + assert_eq!( + token.pause(CALLER, vec![IB20::PausableFeature::TRANSFER], false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Pause.id(), + }) + ); + } + + #[test] + fn non_privileged_pause_with_role_succeeds() { + let mut token = token_with_role(B20TokenRole::Pause, CALLER); + + token.pause(CALLER, vec![IB20::PausableFeature::TRANSFER], false).unwrap(); + + assert!(token.is_paused(IB20::PausableFeature::TRANSFER).unwrap()); + } + + #[test] + fn non_privileged_unpause_without_role_reverts() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::TRANSFER); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.unpause(CALLER, vec![IB20::PausableFeature::TRANSFER], false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: CALLER, + neededRole: B20TokenRole::Unpause.id(), + }) + ); + } + + #[test] + fn non_privileged_unpause_with_role_succeeds() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::TRANSFER); + accounting.roles.insert((B20TokenRole::Unpause.id(), CALLER), true); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + token.unpause(CALLER, vec![IB20::PausableFeature::TRANSFER], false).unwrap(); + + assert!(!token.is_paused(IB20::PausableFeature::TRANSFER).unwrap()); + } +} diff --git a/crates/common/precompiles/src/common/ops/permittable.rs b/crates/common/precompiles/src/common/ops/permittable.rs new file mode 100644 index 0000000000..331d5d44c3 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/permittable.rs @@ -0,0 +1,420 @@ +use alloc::{string::String, vec, vec::Vec}; + +use alloy_primitives::{Address, B256, FixedBytes, U256, keccak256}; +use alloy_sol_types::SolValue; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{IB20, TokenAccounting, Transferable}; + +/// ERC-5267 `eip712Domain()` return tuple: (fields, name, version, chainId, verifyingContract, salt, extensions). +pub type Eip712Domain = (FixedBytes<1>, String, String, U256, Address, B256, Vec); + +/// Arguments for [`Permittable::permit`], grouping the EIP-2612 ABI fields. +#[derive(Clone, Debug)] +pub struct PermitArgs { + /// Token owner whose allowance is being set. + pub owner: Address, + /// Account being granted the allowance. + pub spender: Address, + /// Allowance amount. + pub value: U256, + /// Unix timestamp after which the signature is no longer valid. + pub deadline: U256, + /// Signature recovery id: 27 or 28. + pub v: u8, + /// Signature `r` component. + pub r: B256, + /// Signature `s` component. + pub s: B256, +} + +impl PermitArgs { + /// `keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")` + pub const TYPEHASH: B256 = + alloy_primitives::b256!("6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9"); + + /// EIP-191 prefix for structured data, followed by the EIP-712 version byte. + pub const EIP712_SIGNING_PREFIX: [u8; 2] = [0x19, 0x01]; + + /// Legacy `v` value for even-Y ECDSA recovery (`ecrecover`). + pub const RECOVERY_ID_EVEN_Y: u8 = 27; + /// Legacy `v` value for odd-Y ECDSA recovery (`ecrecover`). + pub const RECOVERY_ID_ODD_Y: u8 = 28; + + /// Hashes the EIP-2612 `Permit` struct for `nonce`. + pub fn struct_hash(&self, nonce: U256) -> B256 { + keccak256( + (Self::TYPEHASH, self.owner, self.spender, self.value, nonce, self.deadline) + .abi_encode(), + ) + } + + /// Builds the EIP-712 signing digest: `keccak256("\x19\x01" ‖ domainSeparator ‖ structHash)`. + pub fn signing_hash(&self, domain_separator: B256, nonce: U256) -> B256 { + let struct_hash = self.struct_hash(nonce); + let mut buf = [0u8; 66]; + buf[..2].copy_from_slice(&Self::EIP712_SIGNING_PREFIX); + buf[2..34].copy_from_slice(domain_separator.as_slice()); + buf[34..66].copy_from_slice(struct_hash.as_slice()); + keccak256(buf) + } + + /// Maps Ethereum `v` (27/28) to secp256k1 recovery parity, then recovers the signer. + pub fn recover_signer(&self, signing_hash: B256) -> Result
{ + let odd_y_parity = match self.v { + Self::RECOVERY_ID_EVEN_Y => false, + Self::RECOVERY_ID_ODD_Y => true, + _ => { + return Err(BasePrecompileError::revert(IB20::InvalidSigner { + signer: Address::ZERO, + owner: self.owner, + })); + } + }; + + let sig = + alloy_primitives::Signature::from_scalars_and_parity(self.r, self.s, odd_y_parity); + sig.recover_address_from_prehash(&signing_hash).map_err(|_| { + BasePrecompileError::revert(IB20::InvalidSigner { + signer: Address::ZERO, + owner: self.owner, + }) + }) + } +} + +// keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") +const DOMAIN_TYPEHASH: B256 = + alloy_primitives::b256!("8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f"); + +/// EIP-712 domain version string pinned to `"1"`. +/// +/// # Breaking change note +/// +/// Adding `name` and `version` to the domain separator is an intentional, acknowledged breaking +/// change. Any permit signatures produced against the old domain (which only encoded `chainId` and +/// `verifyingContract`) will be invalid after this change. New tokens start with the canonical +/// four-field domain; existing token holders must re-sign outstanding permits. +const VERSION: &[u8] = b"1"; + +/// EIP-2612 permit and EIP-712 domain operations. +/// +/// Requires [`Transferable`] since `permit` internally calls [`Transferable::approve`]. +/// `token_address()` is inherited via `Permittable: Transferable: Token`. +pub trait Permittable: Transferable { + /// Computes the EIP-712 domain separator for this token. + /// + /// Domain: `(name, version, chainId, verifyingContract)` — the canonical EIP-712 shape. + /// `version` is pinned to `"1"`; `name` is read live from token storage so that + /// a successful `updateName` invalidates outstanding permit signatures. + fn domain_separator(&self, chain_id: u64) -> Result { + let name = self.accounting().name()?; + let name_hash = keccak256(name.as_bytes()); + let version_hash = keccak256(VERSION); + let encoded = + (DOMAIN_TYPEHASH, name_hash, version_hash, U256::from(chain_id), self.token_address()) + .abi_encode(); + Ok(keccak256(&encoded)) + } + + /// Returns the ERC-5267 `eip712Domain()` tuple for this token. + fn eip712_domain(&self, chain_id: u64) -> Result { + let name = self.accounting().name()?; + Ok(( + FixedBytes::<1>::from([0x0f]), // bits 0+1+2+3: name + version + chainId + verifyingContract + name, + String::from("1"), + U256::from(chain_id), + self.token_address(), + B256::ZERO, + vec![], + )) + } + + /// EIP-2612 permit. EOA signatures only (no ERC-1271). + fn permit(&mut self, chain_id: u64, now: U256, args: PermitArgs) -> Result<()> { + if now > args.deadline { + return Err(BasePrecompileError::revert(IB20::ExpiredSignature { + deadline: args.deadline, + })); + } + + let domain_sep = self.domain_separator(chain_id)?; + let nonce = self.accounting().nonce(args.owner)?; + let signing_hash = args.signing_hash(domain_sep, nonce); + let recovered = args.recover_signer(signing_hash)?; + + if recovered != args.owner { + return Err(BasePrecompileError::revert(IB20::InvalidSigner { + signer: recovered, + owner: args.owner, + })); + } + + self.accounting_mut().increment_nonce(args.owner)?; + self.approve(args.owner, args.spender, args.value) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, U256, keccak256}; + use alloy_sol_types::SolValue; + use base_precompile_storage::BasePrecompileError; + use k256::ecdsa::SigningKey; + + use crate::{ + IB20, InMemoryPolicy, InMemoryTokenAccounting, PermitArgs, Permittable, TestToken, Token, + TokenAccounting, + common::ops::permittable::{DOMAIN_TYPEHASH, VERSION}, + }; + + const CHAIN_ID: u64 = 1; + const SPENDER: Address = Address::repeat_byte(0xbb); + const TOKEN_ADDR: Address = Address::repeat_byte(1); + const TOKEN_NAME: &str = "TestToken"; + + // Anvil/Hardhat account 0 — well-known test key, never use in production. + const PRIVATE_KEY: [u8; 32] = + alloy_primitives::hex!("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + fn make_token() -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.name = TOKEN_NAME.to_string(); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + fn owner_address() -> Address { + let key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); + let point = key.verifying_key().to_encoded_point(false); + let hash = keccak256(&point.as_bytes()[1..]); + Address::from_slice(&hash[12..]) + } + + fn sample_permit_args(owner: Address) -> PermitArgs { + PermitArgs { + owner, + spender: SPENDER, + value: U256::from(500u64), + deadline: U256::MAX, + v: PermitArgs::RECOVERY_ID_EVEN_Y, + r: B256::ZERO, + s: B256::ZERO, + } + } + + fn domain_separator_for_token(token: &TestToken, chain_id: u64) -> B256 { + token.domain_separator(chain_id).unwrap() + } + + fn signed_permit_args( + token: &TestToken, + owner: Address, + spender: Address, + value: U256, + deadline: U256, + ) -> PermitArgs { + let domain_sep = domain_separator_for_token(token, CHAIN_ID); + let nonce = token.accounting().nonce(owner).unwrap(); + let mut args = + PermitArgs { owner, spender, value, deadline, v: 0, r: B256::ZERO, s: B256::ZERO }; + let signing_hash = args.signing_hash(domain_sep, nonce); + + let signing_key = SigningKey::from_slice(&PRIVATE_KEY).unwrap(); + let (sig, recid) = signing_key.sign_prehash_recoverable(signing_hash.as_slice()).unwrap(); + let sig_bytes = sig.to_bytes(); + args.r = B256::from_slice(&sig_bytes[..32]); + args.s = B256::from_slice(&sig_bytes[32..]); + args.v = if recid.is_y_odd() { + PermitArgs::RECOVERY_ID_ODD_Y + } else { + PermitArgs::RECOVERY_ID_EVEN_Y + }; + args + } + + // ---- PermitArgs ---- + + #[test] + fn permit_args_struct_hash_matches_abi_encode() { + let owner = owner_address(); + let args = sample_permit_args(owner); + let nonce = U256::from(3u64); + let expected = keccak256( + (PermitArgs::TYPEHASH, owner, SPENDER, args.value, nonce, args.deadline).abi_encode(), + ); + + assert_eq!(args.struct_hash(nonce), expected); + } + + #[test] + fn permit_args_signing_hash_matches_eip712_digest() { + let owner = owner_address(); + let args = sample_permit_args(owner); + let nonce = U256::ZERO; + let name_hash = keccak256(TOKEN_NAME.as_bytes()); + let version_hash = keccak256(VERSION); + let domain_sep = keccak256( + (DOMAIN_TYPEHASH, name_hash, version_hash, U256::from(CHAIN_ID), TOKEN_ADDR) + .abi_encode(), + ); + let struct_hash = args.struct_hash(nonce); + let mut expected_preimage = [0u8; 66]; + expected_preimage[..2].copy_from_slice(&[0x19, 0x01]); + expected_preimage[2..34].copy_from_slice(domain_sep.as_slice()); + expected_preimage[34..66].copy_from_slice(struct_hash.as_slice()); + + assert_eq!(args.signing_hash(domain_sep, nonce), keccak256(expected_preimage)); + } + + #[test] + fn permit_args_signing_hash_differs_by_nonce() { + let owner = owner_address(); + let args = sample_permit_args(owner); + let domain_sep = B256::repeat_byte(0x42); + + assert_ne!( + args.signing_hash(domain_sep, U256::ZERO), + args.signing_hash(domain_sep, U256::ONE) + ); + } + + #[test] + fn permit_args_recover_signer_returns_owner() { + let token = make_token(); + let owner = owner_address(); + let args = signed_permit_args(&token, owner, SPENDER, U256::from(100u64), U256::MAX); + let domain_sep = domain_separator_for_token(&token, CHAIN_ID); + let signing_hash = args.signing_hash(domain_sep, U256::ZERO); + + assert_eq!(args.recover_signer(signing_hash).unwrap(), owner); + } + + #[test] + fn permit_args_recover_signer_rejects_invalid_v() { + let owner = owner_address(); + let mut args = sample_permit_args(owner); + args.v = 26; + + assert_eq!( + args.recover_signer(B256::ZERO).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidSigner { signer: Address::ZERO, owner }) + ); + } + + #[test] + fn permit_args_recover_signer_rejects_invalid_signature() { + let owner = owner_address(); + let args = sample_permit_args(owner); + + assert!(args.recover_signer(B256::repeat_byte(0x11)).is_err()); + } + + // ---- Permittable ---- + + #[test] + fn domain_typehash_matches_eip712_domain_type() { + let domain_type = + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"; + assert_eq!(DOMAIN_TYPEHASH, keccak256(domain_type)); + } + + #[test] + fn permit_typehash_matches_permit_type_string() { + let permit_type = + b"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"; + assert_eq!(PermitArgs::TYPEHASH, keccak256(permit_type)); + } + + #[test] + fn domain_separator_is_deterministic() { + let token = make_token(); + let sep1 = token.domain_separator(CHAIN_ID).unwrap(); + let sep2 = token.domain_separator(CHAIN_ID).unwrap(); + assert_eq!(sep1, sep2); + } + + #[test] + fn domain_separator_differs_by_chain_id() { + let token = make_token(); + assert_ne!(token.domain_separator(1).unwrap(), token.domain_separator(2).unwrap()); + } + + #[test] + fn eip712_domain_returns_correct_fields() { + let token = make_token(); + let (fields, name, version, chain_id, verifying, _salt, extensions) = + token.eip712_domain(CHAIN_ID).unwrap(); + + assert_eq!(fields.as_slice(), &[0x0f]); + assert_eq!(name, TOKEN_NAME); + assert_eq!(version, "1"); + assert_eq!(chain_id, U256::from(CHAIN_ID)); + assert_eq!(verifying, TOKEN_ADDR); + assert!(extensions.is_empty()); + } + + #[test] + fn domain_separator_differs_by_name() { + let token = make_token(); + let sep_a = token.domain_separator(CHAIN_ID).unwrap(); + let mut token2 = make_token(); + token2.accounting_mut().set_name("OtherToken".to_string()).unwrap(); + let sep_b = token2.domain_separator(CHAIN_ID).unwrap(); + assert_ne!(sep_a, sep_b); + } + + #[test] + fn permit_expired_deadline_reverts() { + let mut token = make_token(); + let owner = owner_address(); + let deadline = U256::from(999u64); + let now = U256::from(1000u64); + let args = signed_permit_args(&token, owner, SPENDER, U256::from(100u64), deadline); + + assert!(token.permit(CHAIN_ID, now, args).is_err()); + } + + #[test] + fn permit_sets_allowance_and_increments_nonce() { + let mut token = make_token(); + let owner = owner_address(); + let value = U256::from(500u64); + let deadline = U256::MAX; + let now = U256::ZERO; + let args = signed_permit_args(&token, owner, SPENDER, value, deadline); + + token.permit(CHAIN_ID, now, args).unwrap(); + + assert_eq!(token.accounting().allowance(owner, SPENDER).unwrap(), value); + assert_eq!(token.accounting().nonce(owner).unwrap(), U256::from(1u64)); + } + + #[test] + fn permit_wrong_signer_reverts() { + let mut token = make_token(); + let owner = owner_address(); + let wrong_owner = Address::repeat_byte(0xde); + let value = U256::from(100u64); + let deadline = U256::MAX; + let now = U256::ZERO; + let mut args = signed_permit_args(&token, owner, SPENDER, value, deadline); + args.owner = wrong_owner; + + assert!(token.permit(CHAIN_ID, now, args).is_err()); + } + + #[test] + fn permit_nonce_prevents_replay() { + let mut token = make_token(); + let owner = owner_address(); + let value = U256::from(100u64); + let deadline = U256::MAX; + let now = U256::ZERO; + let args = signed_permit_args(&token, owner, SPENDER, value, deadline); + token.permit(CHAIN_ID, now, args.clone()).unwrap(); + + // Replay the same (v, r, s) — nonce has advanced so the recovered address won't match. + assert!(token.permit(CHAIN_ID, now, args).is_err()); + } +} diff --git a/crates/common/precompiles/src/common/ops/roles.rs b/crates/common/precompiles/src/common/ops/roles.rs new file mode 100644 index 0000000000..0e157361f4 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/roles.rs @@ -0,0 +1,429 @@ +//! Role-management operations for B-20 tokens. + +use alloy_primitives::{Address, B256, U256, b256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, IB20, Token, TokenAccounting}; + +const MINT_ROLE: B256 = b256!("154c00819833dac601ee5ddded6fda79d9d8b506b911b3dbd54cdb95fe6c3686"); +const BURN_ROLE: B256 = b256!("e97b137254058bd94f28d2f3eb79e2d34074ffb488d042e3bc958e0a57d2fa22"); +const BURN_BLOCKED_ROLE: B256 = + b256!("7408fdc0d31c7bcb349eab611f5d1168acd4303574993f8cdc98b1cd18c41cae"); +const PAUSE_ROLE: B256 = b256!("139c2898040ef16910dc9f44dc697df79363da767d8bc92f2e310312b816e46d"); +const UNPAUSE_ROLE: B256 = + b256!("265b220c5a8891efdd9e1b1b7fa72f257bd5169f8d87e319cf3dad6ff52b94ae"); +const METADATA_ROLE: B256 = + b256!("6bd6b5318a46e5fff572d5e4258a20774aab40cc35ac7680654b9081fcc82f80"); + +/// Built-in B-20 roles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum B20TokenRole { + /// The default top-level admin role. + DefaultAdmin, + /// Role required for `mint` and `mintWithMemo`. + Mint, + /// Role required for `burn` and `burnWithMemo`. + Burn, + /// Role required for `burnBlocked`; permits burning from blocked accounts without `BURN_ROLE`. + BurnBlocked, + /// Role required for `pause`. + Pause, + /// Role required for `unpause`. + Unpause, + /// Role required for `updateName` and `updateSymbol`. + Metadata, +} + +impl B20TokenRole { + /// Returns the `AccessControl` role identifier. + pub const fn id(self) -> B256 { + match self { + Self::DefaultAdmin => B256::ZERO, + Self::Mint => MINT_ROLE, + Self::Burn => BURN_ROLE, + Self::BurnBlocked => BURN_BLOCKED_ROLE, + Self::Pause => PAUSE_ROLE, + Self::Unpause => UNPAUSE_ROLE, + Self::Metadata => METADATA_ROLE, + } + } +} + +/// Role-management operations. +/// +/// All methods have default implementations that go through [`Token::accounting`]. +/// Implement with an empty body to opt in. +pub trait RoleManaged: Token { + /// The default top-level admin role. + fn default_admin_role() -> B256 { + B20TokenRole::DefaultAdmin.id() + } + + /// Role required for `mint` and `mintWithMemo`. + fn mint_role() -> B256 { + B20TokenRole::Mint.id() + } + + /// Role required for `burn` and `burnWithMemo`. + fn burn_role() -> B256 { + B20TokenRole::Burn.id() + } + + /// Role required for `burnBlocked`; permits burning from blocked accounts without `BURN_ROLE`. + fn burn_blocked_role() -> B256 { + B20TokenRole::BurnBlocked.id() + } + + /// Role required for `pause`. + fn pause_role() -> B256 { + B20TokenRole::Pause.id() + } + + /// Role required for `unpause`. + fn unpause_role() -> B256 { + B20TokenRole::Unpause.id() + } + + /// Role required for `updateName` and `updateSymbol`. + fn metadata_role() -> B256 { + B20TokenRole::Metadata.id() + } + + /// Returns the admin role for `role`. + fn role_admin(&self, role: B256) -> Result { + self.accounting().role_admin(role) + } + + /// Returns whether `account` has `role`. + fn has_role(&self, role: B256, account: Address) -> Result { + self.accounting().has_role(role, account) + } + + /// Grants `role` to `account` without checking caller authorization. + fn grant_role_unchecked( + &mut self, + role: B256, + account: Address, + sender: Address, + ) -> Result<()> { + if self.accounting().has_role(role, account)? { + return Ok(()); + } + self.accounting_mut().set_role(role, account, true)?; + if role == Self::default_admin_role() { + let current = self.accounting().role_member_count(role)?; + let next = + current.checked_add(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + self.accounting_mut().set_role_member_count(role, next)?; + } + self.accounting_mut() + .emit_event(IB20::RoleGranted { role, account, sender }.encode_log_data()) + } + + /// Revokes `role` from `account` without checking caller authorization. + fn revoke_role_unchecked( + &mut self, + role: B256, + account: Address, + sender: Address, + ) -> Result<()> { + if !self.accounting().has_role(role, account)? { + return Ok(()); + } + self.accounting_mut().set_role(role, account, false)?; + if role == Self::default_admin_role() { + let current = self.accounting().role_member_count(role)?; + let next = + current.checked_sub(U256::ONE).ok_or_else(BasePrecompileError::under_overflow)?; + self.accounting_mut().set_role_member_count(role, next)?; + } + self.accounting_mut() + .emit_event(IB20::RoleRevoked { role, account, sender }.encode_log_data()) + } + + /// Ensures `caller` has `role`. + fn ensure_role(&self, caller: Address, role: B256) -> Result<()> { + B20Guards::ensure_role(self, caller, role) + } + + /// Ensures role-admin mutations are still reachable. + fn ensure_role_admin_mutations_available(&self, caller: Address) -> Result<()> { + let admin_role = Self::default_admin_role(); + if self.accounting().role_member_count(admin_role)? == U256::ZERO { + return Err(BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: caller, + neededRole: admin_role, + })); + } + Ok(()) + } + + /// Grants `role` to `account`, optionally bypassing authorization during factory init. + fn grant_role( + &mut self, + caller: Address, + role: B256, + account: Address, + privileged: bool, + ) -> Result<()> { + if !privileged { + self.ensure_role_admin_mutations_available(caller)?; + self.ensure_role(caller, self.role_admin(role)?)?; + } + self.grant_role_unchecked(role, account, caller) + } + + /// Revokes `role` from `account`, optionally bypassing authorization during factory init. + fn revoke_role( + &mut self, + caller: Address, + role: B256, + account: Address, + privileged: bool, + ) -> Result<()> { + if !privileged { + self.ensure_role_admin_mutations_available(caller)?; + self.ensure_role(caller, self.role_admin(role)?)?; + } + self.revoke_role_unchecked(role, account, caller) + } + + /// Renounces `role` for `caller`. + /// + /// Matches `AccessControl` no-op semantics for accounts that do not hold `role`: the call + /// succeeds and emits no `RoleRevoked` event. The only stricter path is the final + /// `DEFAULT_ADMIN_ROLE` holder, which must use [`Self::renounce_last_admin`]. + fn renounce_role(&mut self, caller: Address, role: B256, confirmation: Address) -> Result<()> { + if confirmation != caller { + return Err(BasePrecompileError::revert(IB20::AccessControlBadConfirmation {})); + } + if role == Self::default_admin_role() + && self.accounting().has_role(role, caller)? + && self.accounting().role_member_count(role)? == U256::ONE + { + return Err(BasePrecompileError::revert(IB20::LastAdminCannotRenounce {})); + } + self.revoke_role_unchecked(role, caller, caller) + } + + /// Permanently removes the final default admin. + fn renounce_last_admin(&mut self, caller: Address) -> Result<()> { + let admin_role = Self::default_admin_role(); + self.ensure_role(caller, admin_role)?; + if self.accounting().role_member_count(admin_role)? != U256::ONE { + return Err(BasePrecompileError::revert(IB20::NotSoleAdmin {})); + } + self.revoke_role_unchecked(admin_role, caller, caller)?; + self.accounting_mut() + .emit_event(IB20::LastAdminRenounced { previousAdmin: caller }.encode_log_data()) + } + + /// Sets the admin role for `role`. + /// + /// This intentionally follows `AccessControl` semantics, including for + /// `DEFAULT_ADMIN_ROLE`. Setting its admin to a role with no members can make ordinary admin + /// recovery impossible; [`Self::renounce_last_admin`] remains the explicit terminal path for + /// burning the final admin. + fn set_role_admin( + &mut self, + caller: Address, + role: B256, + new_admin_role: B256, + privileged: bool, + ) -> Result<()> { + let previous_admin_role = self.role_admin(role)?; + if !privileged { + self.ensure_role_admin_mutations_available(caller)?; + self.ensure_role(caller, previous_admin_role)?; + } + self.accounting_mut().set_role_admin(role, new_admin_role)?; + self.accounting_mut().emit_event( + IB20::RoleAdminChanged { + role, + previousAdminRole: previous_admin_role, + newAdminRole: new_admin_role, + } + .encode_log_data(), + ) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, U256}; + use alloy_sol_types::SolEvent; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20TokenRole, IB20, InMemoryPolicy, InMemoryTokenAccounting, RoleManaged, TestToken, Token, + TokenAccounting, + }; + + const ADMIN: Address = Address::repeat_byte(0xaa); + const ALICE: Address = Address::repeat_byte(0xbb); + const BOB: Address = Address::repeat_byte(0xcc); + const TOKEN_ADDR: Address = Address::repeat_byte(0x11); + const CUSTOM_ROLE: B256 = B256::repeat_byte(0x42); + + fn make_token() -> TestToken { + TestToken::with_storage_and_policy( + InMemoryTokenAccounting::new(TOKEN_ADDR), + InMemoryPolicy::new(), + ) + } + + fn token_with_default_admin() -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.roles.insert((B20TokenRole::DefaultAdmin.id(), ADMIN), true); + accounting.role_member_counts.insert(B20TokenRole::DefaultAdmin.id(), U256::ONE); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn grant_role_authorizes_against_role_admin_and_emits_event() { + let mut token = token_with_default_admin(); + + token.grant_role(ADMIN, B20TokenRole::Mint.id(), ALICE, false).unwrap(); + + assert!(token.has_role(B20TokenRole::Mint.id(), ALICE).unwrap()); + assert_eq!( + token.accounting().events[0], + IB20::RoleGranted { role: B20TokenRole::Mint.id(), account: ALICE, sender: ADMIN } + .encode_log_data() + ); + } + + #[test] + fn grant_role_without_admin_reverts() { + let mut token = make_token(); + + assert_eq!( + token.grant_role(ADMIN, B20TokenRole::Mint.id(), ALICE, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ADMIN, + neededRole: B256::ZERO, + }) + ); + } + + #[test] + fn renounce_role_rejects_final_default_admin() { + let mut token = token_with_default_admin(); + + assert_eq!( + token.renounce_role(ADMIN, B20TokenRole::DefaultAdmin.id(), ADMIN).unwrap_err(), + BasePrecompileError::revert(IB20::LastAdminCannotRenounce {}) + ); + } + + #[test] + fn renounce_last_admin_revokes_and_emits_terminal_event() { + let mut token = token_with_default_admin(); + + token.renounce_last_admin(ADMIN).unwrap(); + + assert!(!token.has_role(B20TokenRole::DefaultAdmin.id(), ADMIN).unwrap()); + assert_eq!( + token.accounting().role_member_count(B20TokenRole::DefaultAdmin.id()).unwrap(), + U256::ZERO + ); + assert_eq!( + token.accounting().events.last().unwrap(), + &IB20::LastAdminRenounced { previousAdmin: ADMIN }.encode_log_data() + ); + } + + #[test] + fn renounce_last_admin_prevents_custom_admin_resurrection() { + let mut token = token_with_default_admin(); + + token.set_role_admin(ADMIN, B20TokenRole::DefaultAdmin.id(), CUSTOM_ROLE, false).unwrap(); + token.grant_role(ADMIN, CUSTOM_ROLE, ALICE, false).unwrap(); + token.renounce_last_admin(ADMIN).unwrap(); + + assert_eq!( + token.grant_role(ALICE, B20TokenRole::DefaultAdmin.id(), ALICE, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); + assert!(!token.has_role(B20TokenRole::DefaultAdmin.id(), ALICE).unwrap()); + } + + #[test] + fn renounce_last_admin_disables_role_admin_mutations() { + let mut token = token_with_default_admin(); + + token.set_role_admin(ADMIN, B20TokenRole::Mint.id(), CUSTOM_ROLE, false).unwrap(); + token.grant_role(ADMIN, CUSTOM_ROLE, ALICE, false).unwrap(); + token.renounce_last_admin(ADMIN).unwrap(); + + assert_eq!( + token.grant_role(ALICE, B20TokenRole::Mint.id(), ALICE, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); + assert!(!token.has_role(B20TokenRole::Mint.id(), ALICE).unwrap()); + } + + #[test] + fn renounce_last_admin_disables_custom_admin_revoke() { + let mut token = token_with_default_admin(); + + token.set_role_admin(ADMIN, B20TokenRole::Mint.id(), CUSTOM_ROLE, false).unwrap(); + token.grant_role(ADMIN, CUSTOM_ROLE, ALICE, false).unwrap(); + token.grant_role(ALICE, B20TokenRole::Mint.id(), BOB, false).unwrap(); + token.renounce_last_admin(ADMIN).unwrap(); + + assert_eq!( + token.revoke_role(ALICE, B20TokenRole::Mint.id(), BOB, false).unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); + assert!(token.has_role(B20TokenRole::Mint.id(), BOB).unwrap()); + } + + #[test] + fn renounce_last_admin_disables_custom_admin_reassignment() { + let mut token = token_with_default_admin(); + + token.set_role_admin(ADMIN, B20TokenRole::Mint.id(), CUSTOM_ROLE, false).unwrap(); + token.grant_role(ADMIN, CUSTOM_ROLE, ALICE, false).unwrap(); + token.renounce_last_admin(ADMIN).unwrap(); + + assert_eq!( + token + .set_role_admin(ALICE, B20TokenRole::Mint.id(), B20TokenRole::Burn.id(), false) + .unwrap_err(), + BasePrecompileError::revert(IB20::AccessControlUnauthorizedAccount { + account: ALICE, + neededRole: B20TokenRole::DefaultAdmin.id(), + }) + ); + assert_eq!(token.role_admin(B20TokenRole::Mint.id()).unwrap(), CUSTOM_ROLE); + } + + #[test] + fn set_role_admin_emits_previous_and_new_admin_roles() { + let mut token = token_with_default_admin(); + + token.set_role_admin(ADMIN, CUSTOM_ROLE, B20TokenRole::Mint.id(), false).unwrap(); + + assert_eq!(token.role_admin(CUSTOM_ROLE).unwrap(), B20TokenRole::Mint.id()); + assert_eq!( + token.accounting().events[0], + IB20::RoleAdminChanged { + role: CUSTOM_ROLE, + previousAdminRole: B256::ZERO, + newAdminRole: B20TokenRole::Mint.id(), + } + .encode_log_data() + ); + } +} diff --git a/crates/common/precompiles/src/common/ops/transferable.rs b/crates/common/precompiles/src/common/ops/transferable.rs new file mode 100644 index 0000000000..311fa7e8e5 --- /dev/null +++ b/crates/common/precompiles/src/common/ops/transferable.rs @@ -0,0 +1,581 @@ +use alloy_primitives::{Address, B256, U256}; +use alloy_sol_types::SolEvent; +use base_precompile_storage::{BasePrecompileError, Result}; + +use crate::{B20Guards, B20PolicyType, IB20, Token, TokenAccounting}; + +/// ERC-20 transfer, approval, and memo-decorated transfer operations. +/// +/// All methods have default implementations that go through [`Token::accounting`]. +/// Implement this trait with an empty body to opt in. +pub trait Transferable: Token { + /// Moves `amount` tokens from `from` to `to`. Emits `Transfer`. + /// + /// When `privileged` is true (factory bootstrap window) the pause and + /// policy checks are skipped; balance invariants are always enforced. + fn transfer( + &mut self, + from: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + if from == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSender { sender: from })); + } + if to == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidReceiver { receiver: to })); + } + if !privileged { + B20Guards::ensure_not_paused::(self, IB20::PausableFeature::TRANSFER)?; + B20Guards::ensure_policy_type::(self, B20PolicyType::TransferSender, from)?; + B20Guards::ensure_policy_type::(self, B20PolicyType::TransferReceiver, to)?; + } + let from_balance = self.accounting().balance_of(from)?; + if from_balance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientBalance { + sender: from, + balance: from_balance, + needed: amount, + })); + } + let new_from_balance = + from_balance.checked_sub(amount).ok_or_else(BasePrecompileError::under_overflow)?; + self.accounting_mut().set_balance(from, new_from_balance)?; + let to_balance = self.accounting().balance_of(to)?; + let new_to_balance = + to_balance.checked_add(amount).ok_or_else(BasePrecompileError::under_overflow)?; + self.accounting_mut().set_balance(to, new_to_balance)?; + self.accounting_mut().emit_event(IB20::Transfer { from, to, amount }.encode_log_data()) + } + + /// Moves `amount` tokens from `from` to `to` using `spender`'s allowance. + /// Emits `Transfer`. Skips allowance decrement when allowance is `U256::MAX`. + /// + /// When `privileged` is true the executor policy check is skipped; the + /// inner `transfer` call also receives `privileged`. + fn transfer_from( + &mut self, + spender: Address, + from: Address, + to: Address, + amount: U256, + privileged: bool, + ) -> Result<()> { + if from == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSender { sender: from })); + } + if !privileged && spender != from { + B20Guards::ensure_policy_type::(self, B20PolicyType::TransferExecutor, spender)?; + } + let allowance = self.accounting().allowance(from, spender)?; + if allowance == U256::MAX { + return self.transfer(from, to, amount, privileged); + } + if allowance < amount { + return Err(BasePrecompileError::revert(IB20::InsufficientAllowance { + spender, + allowance, + needed: amount, + })); + } + self.transfer(from, to, amount, privileged)?; + self.accounting_mut().set_allowance(from, spender, allowance - amount) + } + + /// Sets `spender`'s allowance from `owner` to `amount`. Emits `Approval`. + fn approve(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + if owner == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidApprover { approver: owner })); + } + if spender == Address::ZERO { + return Err(BasePrecompileError::revert(IB20::InvalidSpender { spender })); + } + self.accounting_mut().set_allowance(owner, spender, amount)?; + self.accounting_mut() + .emit_event(IB20::Approval { owner, spender, amount }.encode_log_data()) + } + + /// [`Self::transfer`] followed by a `Memo` event. + fn transfer_with_memo( + &mut self, + from: Address, + to: Address, + amount: U256, + memo: B256, + privileged: bool, + ) -> Result<()> { + self.transfer(from, to, amount, privileged)?; + self.accounting_mut().emit_event(IB20::Memo { caller: from, memo }.encode_log_data()) + } + + /// [`Self::transfer_from`] followed by a `Memo` event. + fn transfer_from_with_memo( + &mut self, + spender: Address, + from: Address, + to: Address, + amount: U256, + memo: B256, + privileged: bool, + ) -> Result<()> { + self.transfer_from(spender, from, to, amount, privileged)?; + self.accounting_mut().emit_event(IB20::Memo { caller: spender, memo }.encode_log_data()) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, U256}; + use alloy_sol_types::SolEvent; + use base_precompile_storage::BasePrecompileError; + + use crate::{ + B20PausableFeature, B20PolicyType, IB20, InMemoryPolicy, InMemoryTokenAccounting, + PolicyRegistryStorage, TestToken, Token, TokenAccounting, Transferable, + }; + + const ALICE: Address = Address::repeat_byte(0xaa); + const BOB: Address = Address::repeat_byte(0xbb); + const SPENDER: Address = Address::repeat_byte(0xcc); + const TOKEN_ADDR: Address = Address::repeat_byte(1); + + fn make_token() -> TestToken { + TestToken::with_storage_and_policy( + InMemoryTokenAccounting::new(TOKEN_ADDR), + InMemoryPolicy::new(), + ) + } + + fn token_with_balance(balance: U256) -> TestToken { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, balance); + TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()) + } + + #[test] + fn transfer_moves_balances_and_emits_event() { + let mut token = token_with_balance(U256::from(100u64)); + + token.transfer(ALICE, BOB, U256::from(40u64), false).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(60u64)); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(40u64)); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn transfer_to_self_preserves_balance_and_emits_event() { + let mut token = token_with_balance(U256::from(100u64)); + + token.transfer(ALICE, ALICE, U256::from(30u64), false).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(100u64)); + assert_eq!( + token.accounting().events[0], + IB20::Transfer { from: ALICE, to: ALICE, amount: U256::from(30u64) }.encode_log_data() + ); + } + + /// Regression: self-transfers must not mint tokens. + /// + /// A naive two-write transfer computes `new_from = balance - amount` and + /// `new_to = balance + amount` from the same pre-debit read, then writes both + /// to the same slot when `from == to`. The second write wins at `balance + amount`, + /// inflating supply by `amount` on every self-transfer. + #[test] + fn transfer_to_self_repeated_calls_do_not_inflate_balance() { + let initial = U256::from(100u64); + let amount = U256::from(50u64); + let mut token = token_with_balance(initial); + + for _ in 0..5 { + token.transfer(ALICE, ALICE, amount, false).unwrap(); + } + + assert_eq!( + token.accounting().balance_of(ALICE).unwrap(), + initial, + "each self-transfer must leave balance unchanged; a buggy dual absolute write would mint 50 tokens per call" + ); + assert_eq!(token.accounting().events.len(), 5); + } + + #[test] + fn transfer_from_zero_sender_reverts() { + let mut token = make_token(); + + assert_eq!( + token.transfer(Address::ZERO, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidSender { sender: Address::ZERO }) + ); + } + + #[test] + fn transfer_to_zero_receiver_reverts() { + let mut token = token_with_balance(U256::from(100u64)); + + assert_eq!( + token.transfer(ALICE, Address::ZERO, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidReceiver { receiver: Address::ZERO }) + ); + } + + #[test] + fn transfer_insufficient_balance_reverts() { + let mut token = token_with_balance(U256::from(5u64)); + + assert_eq!( + token.transfer(ALICE, BOB, U256::from(10u64), false).unwrap_err(), + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: U256::from(5u64), + needed: U256::from(10u64), + }) + ); + } + + #[test] + fn approve_sets_allowance_and_emits_event() { + let mut token = make_token(); + + token.approve(ALICE, SPENDER, U256::from(50u64)).unwrap(); + + assert_eq!(token.accounting().allowance(ALICE, SPENDER).unwrap(), U256::from(50u64)); + assert_eq!(token.accounting().events.len(), 1); + } + + #[test] + fn approve_from_zero_owner_reverts() { + let mut token = make_token(); + + assert_eq!( + token.approve(Address::ZERO, SPENDER, U256::ONE).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidApprover { approver: Address::ZERO }) + ); + } + + #[test] + fn approve_to_zero_spender_reverts() { + let mut token = make_token(); + + assert_eq!( + token.approve(ALICE, Address::ZERO, U256::ONE).unwrap_err(), + BasePrecompileError::revert(IB20::InvalidSpender { spender: Address::ZERO }) + ); + } + + #[test] + fn transfer_from_with_finite_allowance_decrements_allowance() { + let mut token = token_with_balance(U256::from(100u64)); + token.accounting_mut().allowances.insert((ALICE, SPENDER), U256::from(30u64)); + + token.transfer_from(SPENDER, ALICE, BOB, U256::from(20u64), false).unwrap(); + + assert_eq!(token.accounting().allowance(ALICE, SPENDER).unwrap(), U256::from(10u64)); + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(80u64)); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(20u64)); + } + + #[test] + fn transfer_from_with_max_allowance_preserves_allowance() { + let mut token = token_with_balance(U256::from(100u64)); + token.accounting_mut().allowances.insert((ALICE, SPENDER), U256::MAX); + + token.transfer_from(SPENDER, ALICE, BOB, U256::from(20u64), false).unwrap(); + + assert_eq!(token.accounting().allowance(ALICE, SPENDER).unwrap(), U256::MAX); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(20u64)); + } + + #[test] + fn transfer_from_with_insufficient_allowance_reverts() { + let mut token = token_with_balance(U256::from(100u64)); + token.accounting_mut().allowances.insert((ALICE, SPENDER), U256::from(5u64)); + + assert_eq!( + token.transfer_from(SPENDER, ALICE, BOB, U256::from(10u64), false).unwrap_err(), + BasePrecompileError::revert(IB20::InsufficientAllowance { + spender: SPENDER, + allowance: U256::from(5u64), + needed: U256::from(10u64), + }) + ); + } + + #[test] + fn transfer_with_memo_emits_transfer_and_memo() { + let mut token = token_with_balance(U256::from(100u64)); + + token + .transfer_with_memo(ALICE, BOB, U256::from(10u64), B256::repeat_byte(0x42), false) + .unwrap(); + + assert_eq!(token.accounting().events.len(), 2); + } + + #[test] + fn transfer_reverts_when_transfer_feature_paused() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::TRANSFER); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.transfer(ALICE, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::ContractPaused { + feature: IB20::PausableFeature::TRANSFER, + }) + ); + } + + #[test] + fn transfer_reverts_when_sender_policy_denies() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting + .policy_ids + .insert(B20PolicyType::TransferSender.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.transfer(ALICE, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferSender.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn transfer_reverts_when_receiver_policy_denies() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting + .policy_ids + .insert(B20PolicyType::TransferReceiver.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.transfer(ALICE, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferReceiver.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn transfer_from_reverts_when_executor_policy_denies() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.allowances.insert((ALICE, SPENDER), U256::from(10u64)); + accounting + .policy_ids + .insert(B20PolicyType::TransferExecutor.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.transfer_from(SPENDER, ALICE, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferExecutor.id(), + policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID, + }) + ); + } + + #[test] + fn transfer_privileged_skips_pause_check() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.paused = B20PausableFeature::mask(IB20::PausableFeature::TRANSFER); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + token.transfer(ALICE, BOB, U256::ONE, true).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::from(9u64)); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ONE); + } + + #[test] + fn transfer_privileged_skips_sender_policy() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting + .policy_ids + .insert(B20PolicyType::TransferSender.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + token.transfer(ALICE, BOB, U256::ONE, true).unwrap(); + + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ONE); + } + + #[test] + fn transfer_privileged_skips_receiver_policy() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting + .policy_ids + .insert(B20PolicyType::TransferReceiver.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + token.transfer(ALICE, BOB, U256::ONE, true).unwrap(); + + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ONE); + } + + #[test] + fn transfer_from_privileged_skips_executor_policy() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.allowances.insert((ALICE, SPENDER), U256::from(10u64)); + accounting + .policy_ids + .insert(B20PolicyType::TransferExecutor.id(), PolicyRegistryStorage::ALWAYS_BLOCK_ID); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + token.transfer_from(SPENDER, ALICE, BOB, U256::ONE, true).unwrap(); + + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ONE); + } + + // ---- Balance checks ---- + + #[test] + fn transfer_exact_balance_succeeds_and_drains_sender() { + let mut token = token_with_balance(U256::from(50u64)); + + token.transfer(ALICE, BOB, U256::from(50u64), false).unwrap(); + + assert_eq!(token.accounting().balance_of(ALICE).unwrap(), U256::ZERO); + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::from(50u64)); + } + + #[test] + fn transfer_from_reverts_when_sender_has_insufficient_balance() { + let mut token = make_token(); // ALICE has zero balance + token.accounting_mut().allowances.insert((ALICE, SPENDER), U256::MAX); + + assert_eq!( + token.transfer_from(SPENDER, ALICE, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::InsufficientBalance { + sender: ALICE, + balance: U256::ZERO, + needed: U256::ONE, + }) + ); + } + + // ---- Overflow ---- + + #[test] + fn transfer_reverts_on_receiver_balance_overflow() { + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::ONE); + accounting.balances.insert(BOB, U256::MAX); + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert!(token.transfer(ALICE, BOB, U256::ONE, true).is_err()); + } + + // ---- Policy guards (external policy registry path) ---- + + #[test] + fn transfer_allowed_by_external_sender_policy_succeeds() { + const POLICY_ID: u64 = 7; + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.policy_ids.insert(B20PolicyType::TransferSender.id(), POLICY_ID); + let mut policy = InMemoryPolicy::new(); + policy.allow(POLICY_ID, ALICE); + let mut token = TestToken::with_storage_and_policy(accounting, policy); + + token.transfer(ALICE, BOB, U256::ONE, false).unwrap(); + + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ONE); + } + + #[test] + fn transfer_reverts_when_denied_by_external_sender_policy() { + const POLICY_ID: u64 = 7; + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.policy_ids.insert(B20PolicyType::TransferSender.id(), POLICY_ID); + // ALICE is not in the allow-list so the external policy denies her. + let mut token = TestToken::with_storage_and_policy(accounting, InMemoryPolicy::new()); + + assert_eq!( + token.transfer(ALICE, BOB, U256::ONE, false).unwrap_err(), + BasePrecompileError::revert(IB20::PolicyForbids { + policyScope: B20PolicyType::TransferSender.id(), + policyId: POLICY_ID, + }) + ); + } + + #[test] + fn transfer_allowed_by_external_receiver_policy_succeeds() { + const POLICY_ID: u64 = 8; + let mut accounting = InMemoryTokenAccounting::new(TOKEN_ADDR); + accounting.balances.insert(ALICE, U256::from(10u64)); + accounting.policy_ids.insert(B20PolicyType::TransferReceiver.id(), POLICY_ID); + let mut policy = InMemoryPolicy::new(); + policy.allow(POLICY_ID, BOB); + let mut token = TestToken::with_storage_and_policy(accounting, policy); + + token.transfer(ALICE, BOB, U256::ONE, false).unwrap(); + + assert_eq!(token.accounting().balance_of(BOB).unwrap(), U256::ONE); + } + + // ---- Event content ---- + + #[test] + fn transfer_emits_transfer_event_with_correct_fields() { + let mut token = token_with_balance(U256::from(100u64)); + + token.transfer(ALICE, BOB, U256::from(40u64), false).unwrap(); + + assert_eq!(token.accounting().events.len(), 1); + let decoded = IB20::Transfer::decode_log_data(&token.accounting().events[0]).unwrap(); + assert_eq!(decoded.from, ALICE); + assert_eq!(decoded.to, BOB); + assert_eq!(decoded.amount, U256::from(40u64)); + } + + #[test] + fn transfer_from_emits_transfer_event_with_correct_fields() { + let mut token = token_with_balance(U256::from(100u64)); + token.accounting_mut().allowances.insert((ALICE, SPENDER), U256::MAX); + + token.transfer_from(SPENDER, ALICE, BOB, U256::from(30u64), false).unwrap(); + + assert_eq!(token.accounting().events.len(), 1); + let decoded = IB20::Transfer::decode_log_data(&token.accounting().events[0]).unwrap(); + assert_eq!(decoded.from, ALICE); + assert_eq!(decoded.to, BOB); + assert_eq!(decoded.amount, U256::from(30u64)); + } + + #[test] + fn transfer_from_with_memo_emits_transfer_then_memo() { + let mut token = token_with_balance(U256::from(100u64)); + token.accounting_mut().allowances.insert((ALICE, SPENDER), U256::MAX); + + token + .transfer_from_with_memo( + SPENDER, + ALICE, + BOB, + U256::from(10u64), + B256::repeat_byte(0x42), + false, + ) + .unwrap(); + + assert_eq!(token.accounting().events.len(), 2); + // First event must be the Transfer. + IB20::Transfer::decode_log_data(&token.accounting().events[0]).unwrap(); + } +} diff --git a/crates/common/precompiles/src/common/policy.rs b/crates/common/precompiles/src/common/policy.rs new file mode 100644 index 0000000000..844e8ad6bb --- /dev/null +++ b/crates/common/precompiles/src/common/policy.rs @@ -0,0 +1,64 @@ +//! Policy traits — the outward-facing interfaces tokens and callers use for the policy registry. + +use alloy_primitives::Address; +use base_precompile_storage::Result; + +use crate::IPolicyRegistry::PolicyType; + +/// Minimal read-only policy interface consulted by B-20 tokens on every transfer, mint, and redeem. +/// +/// # `is_authorized` vs `policy_exists` +/// +/// These two methods can diverge for never-created BLOCKLIST IDs: `policy_exists` returns `false` +/// (the slot was never written) while `is_authorized` returns `true` (empty blocklist allows +/// everyone). Do not gate `is_authorized` calls on a prior `policy_exists` check — call +/// `is_authorized` directly; it handles all cases correctly on its own. +pub trait Policy { + /// Returns `true` if `account` is authorized under the given `policy_id`. + fn is_authorized(&self, policy_id: u64, account: Address) -> Result; + + /// Returns `true` if `policy_id` is a built-in or previously created policy. + fn policy_exists(&self, policy_id: u64) -> Result; +} + +/// Full policy registry interface including administrative mutations. +/// +/// Extends [`Policy`] so any `PolicyRegistry` implementor also satisfies the minimal token bound. +pub trait PolicyRegistry: Policy { + /// Creates a new ALLOWLIST or BLOCKLIST policy, returning its encoded ID. + fn create_policy(&mut self, admin: Address, policy_type: PolicyType) -> Result; + /// Creates a new policy and seeds it with an initial member list. + fn create_policy_with_accounts( + &mut self, + admin: Address, + policy_type: PolicyType, + accounts: alloc::vec::Vec
, + ) -> Result; + /// Stages a pending admin transfer for `policy_id`. + /// Pass `Address::ZERO` to clear a previously staged transfer without nominating a replacement. + fn stage_update_admin(&mut self, policy_id: u64, new_admin: Address) -> Result<()>; + /// Completes a pending admin transfer; caller must be the staged pending admin. + fn finalize_update_admin(&mut self, policy_id: u64) -> Result<()>; + /// Permanently relinquishes admin of `policy_id`. + fn renounce_admin(&mut self, policy_id: u64) -> Result<()>; + /// Adds or removes accounts from an ALLOWLIST policy's member set. + fn update_allowlist( + &mut self, + policy_id: u64, + allowed: bool, + accounts: alloc::vec::Vec
, + ) -> Result<()>; + /// Adds or removes accounts from a BLOCKLIST policy's member set. + fn update_blocklist( + &mut self, + policy_id: u64, + blocked: bool, + accounts: alloc::vec::Vec
, + ) -> Result<()>; + /// Returns the current admin of `policy_id`, or `address(0)` if the policy does not exist + /// or the policy ID is malformed. Never reverts. + fn get_policy_admin(&self, policy_id: u64) -> Result
; + /// Returns the staged pending admin for `policy_id`, or `address(0)` if none, the policy + /// does not exist, or the policy ID is malformed. Never reverts. + fn pending_policy_admin(&self, policy_id: u64) -> Result
; +} diff --git a/crates/common/precompiles/src/common/test_utils.rs b/crates/common/precompiles/src/common/test_utils.rs new file mode 100644 index 0000000000..cf8bff8337 --- /dev/null +++ b/crates/common/precompiles/src/common/test_utils.rs @@ -0,0 +1,439 @@ +//! In-memory fakes of [`TokenAccounting`] and [`Policy`] for unit tests. +//! +//! Use these for capability/ops logic tests (Transferable, Mintable, …). +//! For factory, dispatch, and storage-layout tests keep the EVM harness. + +use std::collections::{HashMap, HashSet}; + +use alloy_primitives::{Address, B256, LogData, U256}; +use base_precompile_storage::Result; + +use crate::{ + B20SecurityStorage, IPolicyRegistry, PolicyRegistry, PolicyRegistryStorage, + b20::B20Token, + b20_security::SecurityAccounting, + b20_stablecoin::{B20StablecoinToken, StablecoinAccounting}, + common::{Policy, TokenAccounting}, +}; + +/// Convenience alias: [`B20Token`] wired with both in-memory fakes. +/// +/// Use this in unit tests instead of spelling out the full generic each time. +pub type TestToken = B20Token; + +/// Convenience alias: [`B20StablecoinToken`] wired with both in-memory fakes. +/// +/// Use this in unit tests instead of spelling out the full generic each time. +pub type TestStablecoinToken = B20StablecoinToken; + +/// HashMap-backed [`TokenAccounting`] for unit tests. +/// +/// Collect emitted events via the public `events` field after calling token ops. +#[derive(Debug)] +pub struct InMemoryTokenAccounting { + address: Address, + /// Whether `is_initialized` returns `true`. + pub initialized: bool, + /// Per-account token balances. + pub balances: HashMap, + /// Approved spending allowances keyed by `(owner, spender)`. + pub allowances: HashMap<(Address, Address), U256>, + /// Current total token supply. + pub total_supply: U256, + /// Defaults to `U256::MAX` so mint tests don't need to set a cap explicitly. + pub supply_cap: U256, + /// Token name. + pub name: String, + /// Token symbol. + pub symbol: String, + /// Number of decimal places. + pub decimals: u8, + /// Stablecoin currency identifier. + pub currency: String, + /// Security ISIN identifier (legacy field; prefer `security_identifiers` map for security tokens). + pub security_isin: String, + /// Bitmask of active pause vectors. + pub paused: U256, + /// Per-account EIP-2612 nonces. + pub nonces: HashMap, + /// Minimum amount required for a redeem operation. + pub minimum_redeemable: U256, + /// URI pointing to the contract-level metadata. + pub contract_uri: String, + /// Role membership keyed by `(role, account)`. + pub roles: HashMap<(B256, Address), bool>, + /// Number of accounts assigned to each role. + pub role_member_counts: HashMap, + /// Admin role for each role. + pub role_admins: HashMap, + /// Policy IDs keyed by policy type. + pub policy_ids: HashMap, + /// Share-to-tokens ratio scaled to WAD (1e18). Security tokens only. + pub shares_to_tokens_ratio: U256, + /// Security identifier values keyed by raw `identifier_type`. Security tokens only. + pub security_identifiers: HashMap, + /// Consumed announcement ids keyed by raw announcement id. Security tokens only. + pub announcement_ids_used: HashSet, + /// Events collected by `emit_event`; does not produce real EVM logs. + pub events: Vec, +} + +impl InMemoryTokenAccounting { + /// Creates an initialized accounting instance at `address` with sensible defaults. + pub fn new(address: Address) -> Self { + Self { + address, + initialized: true, + balances: HashMap::new(), + allowances: HashMap::new(), + total_supply: U256::ZERO, + supply_cap: U256::MAX, + name: String::new(), + symbol: String::new(), + decimals: 18, + currency: String::new(), + security_isin: String::new(), + paused: U256::ZERO, + nonces: HashMap::new(), + minimum_redeemable: U256::ZERO, + contract_uri: String::new(), + roles: HashMap::new(), + role_member_counts: HashMap::new(), + role_admins: HashMap::new(), + policy_ids: HashMap::new(), + shares_to_tokens_ratio: U256::ZERO, + security_identifiers: HashMap::new(), + announcement_ids_used: HashSet::new(), + events: Vec::new(), + } + } +} + +impl TokenAccounting for InMemoryTokenAccounting { + fn token_address(&self) -> Address { + self.address + } + + fn is_initialized(&self) -> Result { + Ok(self.initialized) + } + + fn balance_of(&self, account: Address) -> Result { + Ok(*self.balances.get(&account).unwrap_or(&U256::ZERO)) + } + + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()> { + self.balances.insert(account, balance); + Ok(()) + } + + fn allowance(&self, owner: Address, spender: Address) -> Result { + Ok(*self.allowances.get(&(owner, spender)).unwrap_or(&U256::ZERO)) + } + + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> { + self.allowances.insert((owner, spender), amount); + Ok(()) + } + + fn total_supply(&self) -> Result { + Ok(self.total_supply) + } + + fn set_total_supply(&mut self, supply: U256) -> Result<()> { + self.total_supply = supply; + Ok(()) + } + + fn supply_cap(&self) -> Result { + Ok(self.supply_cap) + } + + fn set_supply_cap(&mut self, cap: U256) -> Result<()> { + self.supply_cap = cap; + Ok(()) + } + + fn name(&self) -> Result { + Ok(self.name.clone()) + } + + fn set_name(&mut self, name: String) -> Result<()> { + self.name = name; + Ok(()) + } + + fn symbol(&self) -> Result { + Ok(self.symbol.clone()) + } + + fn set_symbol(&mut self, symbol: String) -> Result<()> { + self.symbol = symbol; + Ok(()) + } + + fn decimals(&self) -> Result { + Ok(self.decimals) + } + + fn paused(&self) -> Result { + Ok(self.paused) + } + + fn set_paused(&mut self, vectors: U256) -> Result<()> { + self.paused = vectors; + Ok(()) + } + + fn nonce(&self, owner: Address) -> Result { + Ok(*self.nonces.get(&owner).unwrap_or(&U256::ZERO)) + } + + fn increment_nonce(&mut self, owner: Address) -> Result<()> { + let n = self.nonces.entry(owner).or_default(); + *n += U256::from(1u64); + Ok(()) + } + + fn contract_uri(&self) -> Result { + Ok(self.contract_uri.clone()) + } + + fn set_contract_uri(&mut self, uri: String) -> Result<()> { + self.contract_uri = uri; + Ok(()) + } + + fn has_role(&self, role: B256, account: Address) -> Result { + Ok(*self.roles.get(&(role, account)).unwrap_or(&false)) + } + + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()> { + self.roles.insert((role, account), enabled); + Ok(()) + } + + fn role_member_count(&self, role: B256) -> Result { + Ok(*self.role_member_counts.get(&role).unwrap_or(&U256::ZERO)) + } + + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()> { + self.role_member_counts.insert(role, count); + Ok(()) + } + + fn role_admin(&self, role: B256) -> Result { + Ok(*self.role_admins.get(&role).unwrap_or(&B256::ZERO)) + } + + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()> { + self.role_admins.insert(role, admin_role); + Ok(()) + } + + fn policy_id(&self, policy_scope: B256) -> Result { + let default = if policy_scope == B20SecurityStorage::REDEEM_SENDER_POLICY { + PolicyRegistryStorage::ALWAYS_BLOCK_ID + } else { + PolicyRegistryStorage::ALWAYS_ALLOW_ID + }; + Ok(*self.policy_ids.get(&policy_scope).unwrap_or(&default)) + } + + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()> { + self.policy_ids.insert(policy_scope, policy_id); + Ok(()) + } + + fn emit_event(&mut self, log: LogData) -> Result<()> { + self.events.push(log); + Ok(()) + } +} + +impl StablecoinAccounting for InMemoryTokenAccounting { + fn currency(&self) -> Result { + Ok(self.currency.clone()) + } + + fn set_currency(&mut self, currency: String) -> Result<()> { + self.currency = currency; + Ok(()) + } +} + +/// Lookup-table-backed [`Policy`] for unit tests. +/// +/// Call [`InMemoryPolicy::allow`] to grant authorization before exercising token ops. +/// Missing entries default to `false`. +#[derive(Debug)] +pub struct InMemoryPolicy { + /// Authorization grants keyed by `(policy_id, account)`. + pub authorizations: HashMap<(u64, Address), bool>, + /// Policy IDs that should be treated as existing. + pub policies: HashSet, + /// Next custom policy counter for tests that exercise registry creation. + pub next_policy_counter: u64, +} + +impl Default for InMemoryPolicy { + fn default() -> Self { + Self { authorizations: HashMap::new(), policies: HashSet::new(), next_policy_counter: 2 } + } +} + +impl InMemoryPolicy { + /// Creates an empty policy with no authorizations. + pub fn new() -> Self { + Self::default() + } + + /// Marks `account` as authorized under `policy_id`. + pub fn allow(&mut self, policy_id: u64, account: Address) { + self.policies.insert(policy_id); + self.authorizations.insert((policy_id, account), true); + } + + /// Marks `policy_id` as an existing policy without granting any account. + pub fn create_existing_policy(&mut self, policy_id: u64) { + self.policies.insert(policy_id); + } +} + +impl Policy for InMemoryPolicy { + fn is_authorized(&self, policy_id: u64, account: Address) -> Result { + match policy_id { + PolicyRegistryStorage::ALWAYS_ALLOW_ID => Ok(true), + PolicyRegistryStorage::ALWAYS_BLOCK_ID => Ok(false), + _ => Ok(*self.authorizations.get(&(policy_id, account)).unwrap_or(&false)), + } + } + + fn policy_exists(&self, policy_id: u64) -> Result { + Ok(policy_id == PolicyRegistryStorage::ALWAYS_ALLOW_ID + || policy_id == PolicyRegistryStorage::ALWAYS_BLOCK_ID + || self.policies.contains(&policy_id)) + } +} + +impl PolicyRegistry for InMemoryPolicy { + fn create_policy( + &mut self, + _admin: Address, + policy_type: IPolicyRegistry::PolicyType, + ) -> Result { + let policy_id = (policy_type as u64) << 56 | self.next_policy_counter; + self.next_policy_counter += 1; + self.policies.insert(policy_id); + Ok(policy_id) + } + + fn create_policy_with_accounts( + &mut self, + admin: Address, + policy_type: IPolicyRegistry::PolicyType, + accounts: Vec
, + ) -> Result { + let policy_id = self.create_policy(admin, policy_type)?; + for account in accounts { + self.allow(policy_id, account); + } + Ok(policy_id) + } + + fn stage_update_admin(&mut self, _policy_id: u64, _new_admin: Address) -> Result<()> { + Ok(()) + } + + fn finalize_update_admin(&mut self, _policy_id: u64) -> Result<()> { + Ok(()) + } + + fn renounce_admin(&mut self, _policy_id: u64) -> Result<()> { + Ok(()) + } + + fn update_allowlist( + &mut self, + policy_id: u64, + allowed: bool, + accounts: Vec
, + ) -> Result<()> { + self.policies.insert(policy_id); + for account in accounts { + self.authorizations.insert((policy_id, account), allowed); + } + Ok(()) + } + + fn update_blocklist( + &mut self, + policy_id: u64, + blocked: bool, + accounts: Vec
, + ) -> Result<()> { + self.policies.insert(policy_id); + for account in accounts { + self.authorizations.insert((policy_id, account), !blocked); + } + Ok(()) + } + + fn get_policy_admin(&self, _policy_id: u64) -> Result
{ + Ok(Address::ZERO) + } + + fn pending_policy_admin(&self, _policy_id: u64) -> Result
{ + Ok(Address::ZERO) + } +} + +impl SecurityAccounting for InMemoryTokenAccounting { + fn shares_to_tokens_ratio(&self) -> Result { + const WAD: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); + Ok(if self.shares_to_tokens_ratio.is_zero() { WAD } else { self.shares_to_tokens_ratio }) + } + + fn set_shares_to_tokens_ratio(&mut self, ratio: U256) -> Result<()> { + self.shares_to_tokens_ratio = ratio; + Ok(()) + } + + fn security_identifier(&self, identifier_type: &str) -> Result { + if let Some(val) = self.security_identifiers.get(identifier_type) { + return Ok(val.clone()); + } + if identifier_type == "ISIN" { Ok(self.security_isin.clone()) } else { Ok(String::new()) } + } + + fn set_security_identifier_value( + &mut self, + identifier_type: &str, + value: String, + ) -> Result<()> { + if value.is_empty() { + self.security_identifiers.remove(identifier_type); + } else { + self.security_identifiers.insert(identifier_type.to_owned(), value); + } + Ok(()) + } + + fn minimum_redeemable(&self) -> Result { + Ok(self.minimum_redeemable) + } + + fn set_minimum_redeemable(&mut self, minimum: U256) -> Result<()> { + self.minimum_redeemable = minimum; + Ok(()) + } + + fn is_announcement_id_used(&self, id: &str) -> Result { + Ok(self.announcement_ids_used.contains(id)) + } + + fn mark_announcement_id_used(&mut self, id: &str) -> Result<()> { + self.announcement_ids_used.insert(id.to_owned()); + Ok(()) + } +} diff --git a/crates/common/precompiles/src/common/token.rs b/crates/common/precompiles/src/common/token.rs new file mode 100644 index 0000000000..5440c5803a --- /dev/null +++ b/crates/common/precompiles/src/common/token.rs @@ -0,0 +1,37 @@ +use alloy_primitives::Address; + +use crate::{Policy, TokenAccounting}; + +/// Token identity layer, bridging the storage port to capability traits. +/// +/// `Token` provides three things: +/// - Accessors to the underlying storage ([`Self::accounting`] / +/// [`Self::accounting_mut`]) that all capability trait default impls use to +/// read and write state without the 22-method delegation block. +/// - Accessors to the global policy registry ([`Self::policy`] / +/// [`Self::policy_mut`]) for policy decisions shared across all tokens. +/// - [`Self::token_address`], the on-chain address of this token. +/// +/// All capability traits extend `Token`. Implement it on a token struct by +/// wiring the `accounting` and `policy` fields and delegating address identity to the backing storage. +/// +/// The associated types `Accounting` and `Policy` are resolved at compile +/// time, so all storage and policy calls in the capability traits are +/// monomorphized — no vtable overhead on the hot path. +pub trait Token { + /// The concrete storage adapter backing this token. + type Accounting: TokenAccounting; + /// The global policy registry precompile backing this token. + type Policy: Policy; + + /// Returns a shared reference to this token's storage adapter. + fn accounting(&self) -> &Self::Accounting; + /// Returns an exclusive reference to this token's storage adapter. + fn accounting_mut(&mut self) -> &mut Self::Accounting; + /// Returns a shared reference to the global policy registry. + fn policy(&self) -> &Self::Policy; + /// Returns an exclusive reference to the global policy registry. + fn policy_mut(&mut self) -> &mut Self::Policy; + /// Returns the on-chain address of this token contract. + fn token_address(&self) -> Address; +} diff --git a/crates/common/precompiles/src/common/token_accounting.rs b/crates/common/precompiles/src/common/token_accounting.rs new file mode 100644 index 0000000000..b135c42246 --- /dev/null +++ b/crates/common/precompiles/src/common/token_accounting.rs @@ -0,0 +1,104 @@ +//! `TokenAccounting` — the driven port all token storage adapters implement. +use alloc::string::String; + +use alloy_primitives::{Address, B256, LogData, U256}; +use base_precompile_storage::Result; + +/// Outbound port: all data reads and writes the core business logic requires. +/// +/// Each token variant's `#[contract]` storage struct implements this trait. +/// Capability trait default implementations only depend on this interface, never on EVM storage +/// directly. +pub trait TokenAccounting { + /// Returns the on-chain address backing this token's storage. + fn token_address(&self) -> Address; + + /// Returns whether marker bytecode is deployed at this token's address. + fn is_initialized(&self) -> Result; + + // --- Balances --- + + /// Returns the token balance of `account`. + fn balance_of(&self, account: Address) -> Result; + /// Overwrites the token balance of `account`. + fn set_balance(&mut self, account: Address, balance: U256) -> Result<()>; + + // --- Allowances --- + + /// Returns the allowance granted by `owner` to `spender`. + fn allowance(&self, owner: Address, spender: Address) -> Result; + /// Overwrites the allowance granted by `owner` to `spender`. + fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()>; + + // --- Supply --- + + /// Returns the total token supply currently in circulation. + fn total_supply(&self) -> Result; + /// Overwrites the total supply. + fn set_total_supply(&mut self, supply: U256) -> Result<()>; + /// Returns the maximum total supply enforced on mint. + fn supply_cap(&self) -> Result; + /// Overwrites the supply cap. + fn set_supply_cap(&mut self, cap: U256) -> Result<()>; + + // --- Metadata --- + + /// Returns the token name. + fn name(&self) -> Result; + /// Overwrites the token name. + fn set_name(&mut self, name: String) -> Result<()>; + /// Returns the token symbol. + fn symbol(&self) -> Result; + /// Overwrites the token symbol. + fn set_symbol(&mut self, symbol: String) -> Result<()>; + /// Returns the number of decimal places. + fn decimals(&self) -> Result; + + // --- Pause --- + + /// Returns the current paused-vector bitmask. + fn paused(&self) -> Result; + /// Overwrites the paused-vector bitmask. + fn set_paused(&mut self, vectors: U256) -> Result<()>; + + // --- Permit nonces --- + + /// Returns the current EIP-2612 permit nonce for `owner`. + fn nonce(&self, owner: Address) -> Result; + /// Increments the EIP-2612 permit nonce for `owner` by one. + fn increment_nonce(&mut self, owner: Address) -> Result<()>; + + // --- Contract URI --- + + /// Returns the off-chain metadata URI for this token (ERC-7572). + fn contract_uri(&self) -> Result; + /// Overwrites the contract URI. + fn set_contract_uri(&mut self, uri: String) -> Result<()>; + + // --- Roles --- + + /// Returns whether `account` has `role`. + fn has_role(&self, role: B256, account: Address) -> Result; + /// Sets whether `account` has `role`. + fn set_role(&mut self, role: B256, account: Address, enabled: bool) -> Result<()>; + /// Returns the number of accounts holding `role`. + fn role_member_count(&self, role: B256) -> Result; + /// Overwrites the number of accounts holding `role`. + fn set_role_member_count(&mut self, role: B256, count: U256) -> Result<()>; + /// Returns the admin role for `role`. + fn role_admin(&self, role: B256) -> Result; + /// Overwrites the admin role for `role`. + fn set_role_admin(&mut self, role: B256, admin_role: B256) -> Result<()>; + + // --- Policies --- + + /// Returns the policy ID assigned to `policy_scope`. + fn policy_id(&self, policy_scope: B256) -> Result; + /// Overwrites the policy ID assigned to `policy_scope`. + fn set_policy_id(&mut self, policy_scope: B256, policy_id: u64) -> Result<()>; + + // --- Event emission --- + + /// Publishes a pre-encoded EVM event log from this token's address. + fn emit_event(&mut self, log: LogData) -> Result<()>; +} diff --git a/crates/common/precompiles/src/lib.rs b/crates/common/precompiles/src/lib.rs index 28e92cead5..828e259870 100644 --- a/crates/common/precompiles/src/lib.rs +++ b/crates/common/precompiles/src/lib.rs @@ -5,12 +5,72 @@ extern crate alloc; +mod macros; + mod provider; pub use provider::BasePrecompiles; +mod lookup; +pub use lookup::{BerylLookup, BerylLookupWithObserver}; + mod spec; pub use spec::BasePrecompileSpec; +mod activation; +pub use activation::{ + ActivationFeature, ActivationRegistry, ActivationRegistryStorage, IActivationRegistry, +}; + mod bn254_pair; +pub use bn254_pair::{ + GRANITE, GRANITE_MAX_INPUT_SIZE, JOVIAN, JOVIAN_MAX_INPUT_SIZE, run_pair_granite, + run_pair_jovian, +}; mod bls12_381; +pub use bls12_381::{ + ISTHMUS_G1_MSM, ISTHMUS_G1_MSM_MAX_INPUT_SIZE, ISTHMUS_G2_MSM, ISTHMUS_G2_MSM_MAX_INPUT_SIZE, + ISTHMUS_PAIRING, ISTHMUS_PAIRING_MAX_INPUT_SIZE, JOVIAN_G1_MSM, JOVIAN_G1_MSM_MAX_INPUT_SIZE, + JOVIAN_G2_MSM, JOVIAN_G2_MSM_MAX_INPUT_SIZE, JOVIAN_PAIRING, JOVIAN_PAIRING_MAX_INPUT_SIZE, + run_isthmus_g1_msm, run_isthmus_g2_msm, run_isthmus_pairing, run_jovian_g1_msm, + run_jovian_g2_msm, run_jovian_pairing, +}; + +mod common; +pub use common::{ + B20Guards, B20TokenRole, Burnable, Configurable, Eip712Domain, Mintable, Pausable, PermitArgs, + Permittable, Policy, PolicyRegistry, RoleManaged, Token, TokenAccounting, Transferable, +}; +#[cfg(any(test, feature = "test-utils"))] +pub use common::{InMemoryPolicy, InMemoryTokenAccounting, TestStablecoinToken, TestToken}; + +mod observer; +pub use observer::{EndGuard, NoopPrecompileCallObserver, PrecompileCallObserver}; + +mod b20; +pub use b20::{ + B20CoreStorage, B20PausableFeature, B20PolicyType, B20Token, B20TokenInit, B20TokenPrecompile, + B20TokenStorage, IB20, +}; + +mod b20_security; +pub use b20_security::{ + B20RedeemStorage, B20SecurityExtensionStorage, B20SecurityInit, B20SecurityPrecompile, + B20SecurityStorage, B20SecurityToken, IB20Security, SecurityAccounting, +}; + +mod b20_stablecoin; +pub use b20_stablecoin::{ + B20StablecoinExtensionStorage, B20StablecoinInit, B20StablecoinPrecompile, + B20StablecoinStorage, B20StablecoinToken, IB20Stablecoin, StablecoinAccounting, +}; + +mod b20_factory; +pub use b20_factory::{ + B20Factory, B20FactoryStorage, B20Variant, CommonParams, IB20Factory, TokenCreateParams, +}; + +mod policy; +pub use policy::{ + IPolicyRegistry, PackedPolicy, PolicyHandle, PolicyRegistryPrecompile, PolicyRegistryStorage, +}; diff --git a/crates/common/precompiles/src/lookup.rs b/crates/common/precompiles/src/lookup.rs new file mode 100644 index 0000000000..523f4f2966 --- /dev/null +++ b/crates/common/precompiles/src/lookup.rs @@ -0,0 +1,73 @@ +//! Dynamic lookup for Beryl-native precompiles. + +use alloy_evm::precompiles::{DynPrecompile, PrecompileLookup, PrecompilesMap}; +use alloy_primitives::Address; + +use crate::{ + B20SecurityPrecompile, B20StablecoinPrecompile, B20TokenPrecompile, B20Variant, + NoopPrecompileCallObserver, PrecompileCallObserver, +}; + +/// Dynamic precompile lookup installed for Beryl and later forks. +#[derive(Debug, Default, Clone, Copy)] +pub struct BerylLookup; + +impl BerylLookup { + /// Installs the Beryl dynamic precompile lookup into `precompiles`. + pub fn install(precompiles: &mut PrecompilesMap) { + Self::install_with_observer(precompiles, NoopPrecompileCallObserver); + } + + /// Installs the Beryl dynamic precompile lookup with an observer into `precompiles`. + pub fn install_with_observer(precompiles: &mut PrecompilesMap, observer: O) + where + O: PrecompileCallObserver, + { + precompiles.set_precompile_lookup(BerylLookupWithObserver::new(observer)); + } + + /// Returns the B-20 variant precompile for `address`, if it encodes one. + pub fn lookup(address: &Address) -> Option { + Self::lookup_with_observer(address, NoopPrecompileCallObserver) + } + + /// Returns an observed B-20 variant precompile for `address`, if it encodes one. + pub fn lookup_with_observer(address: &Address, observer: O) -> Option + where + O: PrecompileCallObserver, + { + match B20Variant::from_address(*address)? { + B20Variant::B20 => { + Some(B20TokenPrecompile::create_precompile_with_observer(*address, observer)) + } + B20Variant::Stablecoin => { + Some(B20StablecoinPrecompile::create_precompile_with_observer(*address, observer)) + } + B20Variant::Security => { + Some(B20SecurityPrecompile::create_precompile_with_observer(*address, observer)) + } + } + } +} + +/// Dynamic Beryl precompile lookup with an observer. +#[derive(Debug, Clone)] +pub struct BerylLookupWithObserver { + observer: O, +} + +impl BerylLookupWithObserver { + /// Creates a Beryl dynamic precompile lookup with `observer`. + pub const fn new(observer: O) -> Self { + Self { observer } + } +} + +impl PrecompileLookup for BerylLookupWithObserver +where + O: PrecompileCallObserver, +{ + fn lookup(&self, address: &Address) -> Option { + BerylLookup::lookup_with_observer(address, self.observer.clone()) + } +} diff --git a/crates/common/precompiles/src/macros.rs b/crates/common/precompiles/src/macros.rs new file mode 100644 index 0000000000..6035e9a54f --- /dev/null +++ b/crates/common/precompiles/src/macros.rs @@ -0,0 +1,123 @@ +//! Runtime helpers for wrapping native precompile dispatch. + +/// Wraps a stateful native precompile body in the Base storage-provider setup. +macro_rules! base_precompile { + ($id:expr, |$ctx:ident, $calldata:ident| $impl:expr $(,)?) => {{ + ::alloy_evm::precompiles::DynPrecompile::new_stateful( + ::revm::precompile::PrecompileId::Custom($id.into()), + move |input| { + if !input.is_direct_call() { + return ::base_precompile_storage::BasePrecompileError::revert( + ::base_precompile_storage::DelegateCallNotAllowed {}, + ) + .into_precompile_result(0, 0); + } + + let $calldata: ::alloy_primitives::Bytes = input.data.to_vec().into(); + let mut provider = ::base_precompile_storage::EvmPrecompileStorageProvider::new( + input, + ::revm::context_interface::cfg::GasParams::default(), + ); + + ::base_precompile_storage::StorageCtx::enter(&mut provider, |$ctx| $impl) + }, + ) + }}; + ($id:expr, |$input:ident, $ctx:ident, $calldata:ident| $impl:expr $(,)?) => {{ + ::alloy_evm::precompiles::DynPrecompile::new_stateful( + ::revm::precompile::PrecompileId::Custom($id.into()), + move |$input| { + if !$input.is_direct_call() { + return ::base_precompile_storage::BasePrecompileError::revert( + ::base_precompile_storage::DelegateCallNotAllowed {}, + ) + .into_precompile_result(0, 0); + } + + let $calldata: ::alloy_primitives::Bytes = $input.data.to_vec().into(); + let mut provider = + ::base_precompile_storage::EvmPrecompileStorageProvider::new($input); + + ::base_precompile_storage::StorageCtx::enter(&mut provider, |$ctx| $impl) + }, + ) + }}; +} + +pub(crate) use base_precompile; + +/// Deducts the per-word calldata gas charged by Base native precompile dispatch. +macro_rules! deduct_calldata_cost { + ($ctx:expr, $calldata:expr $(,)?) => {{ + const G_SHA3WORD: u64 = 6; + + let calldata_len = $calldata.len(); + let calldata_cost = calldata_len.div_ceil(32).saturating_mul(G_SHA3WORD as usize) as u64; + if let Err(e) = $ctx.deduct_gas(calldata_cost) { + return e.into_precompile_result($ctx.gas_used(), $ctx.state_gas_used()); + } + }}; +} + +pub(crate) use deduct_calldata_cost; + +/// Decodes calldata into the requested ABI interface call or returns an unknown selector error. +macro_rules! decode_precompile_call { + ($calldata:expr, $call_ty:ty $(,)?) => {{ + let calldata = $calldata; + let selector = match calldata.get(..4) { + Some(bytes) => { + let mut selector = [0u8; 4]; + selector.copy_from_slice(bytes); + selector + } + None => { + return Err( + ::base_precompile_storage::BasePrecompileError::UnknownFunctionSelector( + [0u8; 4], + ), + ); + } + }; + + <$call_ty as ::alloy_sol_types::SolInterface>::abi_decode(calldata).map_err(|_| { + ::base_precompile_storage::BasePrecompileError::UnknownFunctionSelector(selector) + })? + }}; +} + +pub(crate) use decode_precompile_call; + +#[cfg(test)] +mod tests { + use alloy_sol_types::SolCall; + use base_precompile_storage::{BasePrecompileError, Result}; + + use crate::IPolicyRegistry; + + fn decode_policy_call(calldata: &[u8]) -> Result { + Ok(decode_precompile_call!(calldata, IPolicyRegistry::IPolicyRegistryCalls,)) + } + + #[test] + fn decode_precompile_call_rejects_short_calldata() { + let err = decode_policy_call(&[1, 2, 3]).unwrap_err(); + + assert_eq!(err, BasePrecompileError::UnknownFunctionSelector([0u8; 4])); + } + + #[test] + fn decode_precompile_call_preserves_unknown_selector() { + let err = decode_policy_call(&[1, 2, 3, 4]).unwrap_err(); + + assert_eq!(err, BasePrecompileError::UnknownFunctionSelector([1, 2, 3, 4])); + } + + #[test] + fn decode_precompile_call_decodes_known_call() { + let calldata = IPolicyRegistry::policyExistsCall { policyId: 0 }.abi_encode(); + let call = decode_policy_call(&calldata).unwrap(); + + assert!(matches!(call, IPolicyRegistry::IPolicyRegistryCalls::policyExists(_))); + } +} diff --git a/crates/common/precompiles/src/observer.rs b/crates/common/precompiles/src/observer.rs new file mode 100644 index 0000000000..b1b6fcf430 --- /dev/null +++ b/crates/common/precompiles/src/observer.rs @@ -0,0 +1,105 @@ +//! Native precompile observation hooks. + +/// Observer that does not record native precompile calls. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopPrecompileCallObserver; + +/// Observer for native precompile call execution. +pub trait PrecompileCallObserver: Clone + Send + Sync + 'static { + /// Called before executing a labeled precompile operation. + fn start(&self, _label: &'static str) {} + + /// Called after executing a labeled precompile operation. + fn end(&self, _label: &'static str) {} + + /// Executes `f` between the observer's start and end hooks. + fn observe(&self, label: &'static str, f: impl FnOnce() -> R) -> R + where + Self: Sized, + { + self.start(label); + let _guard = EndGuard { observer: self, label }; + f() + } +} + +impl PrecompileCallObserver for NoopPrecompileCallObserver {} + +/// Guard that calls [`PrecompileCallObserver::end`] when observed work finishes. +#[derive(Debug)] +pub struct EndGuard<'a, O> +where + O: PrecompileCallObserver, +{ + observer: &'a O, + label: &'static str, +} + +impl Drop for EndGuard<'_, O> +where + O: PrecompileCallObserver, +{ + fn drop(&mut self) { + self.observer.end(self.label); + } +} + +#[cfg(test)] +mod tests { + use std::{ + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Arc, Mutex}, + }; + + use crate::PrecompileCallObserver; + + #[derive(Debug, Clone)] + struct RecordingObserver { + events: Arc>>, + } + + impl RecordingObserver { + fn new() -> Self { + Self { events: Arc::new(Mutex::new(Vec::new())) } + } + + fn events(&self) -> Vec<(&'static str, &'static str)> { + self.events.lock().unwrap().clone() + } + } + + impl PrecompileCallObserver for RecordingObserver { + fn start(&self, label: &'static str) { + self.events.lock().unwrap().push(("start", label)); + } + + fn end(&self, label: &'static str) { + self.events.lock().unwrap().push(("end", label)); + } + } + + #[test] + fn observe_brackets_result() { + let observer = RecordingObserver::new(); + let result = observer.observe("precompile-b20-transfer", || 42); + + assert_eq!(result, 42); + assert_eq!( + observer.events(), + [("start", "precompile-b20-transfer"), ("end", "precompile-b20-transfer"),] + ); + } + + #[test] + fn observe_ends_when_observed_work_panics() { + let observer = RecordingObserver::new(); + let result = catch_unwind(AssertUnwindSafe(|| { + observer.observe("precompile-b20-transfer", || panic!("observed panic")); + })); + assert!(result.is_err()); + assert_eq!( + observer.events(), + [("start", "precompile-b20-transfer"), ("end", "precompile-b20-transfer"),] + ); + } +} diff --git a/crates/common/precompiles/src/policy/abi.rs b/crates/common/precompiles/src/policy/abi.rs new file mode 100644 index 0000000000..4eeb5119d2 --- /dev/null +++ b/crates/common/precompiles/src/policy/abi.rs @@ -0,0 +1,70 @@ +use alloy_sol_types::sol; + +sol! { + #[derive(Debug, PartialEq, Eq)] + interface IPolicyRegistry { + enum PolicyType { + /// Rejects only accounts explicitly added to the blocklist. + /// An empty blocklist authorizes everyone. + BLOCKLIST, + /// Authorizes only accounts explicitly added to the allowlist. + /// An empty allowlist rejects everyone. + ALLOWLIST + } + + error Unauthorized(); + error PolicyNotFound(); + error IncompatiblePolicyType(); + error ZeroAddress(); + error BatchSizeTooLarge(uint256 maxBatchSize); + error NoPendingAdmin(); + + event PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType); + event PolicyAdminStaged(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin); + event PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin); + event AllowlistUpdated(uint64 indexed policyId, address indexed updater, bool allowed, address[] accounts); + event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); + + function createPolicy(address admin, PolicyType policyType) external returns (uint64); + function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) external returns (uint64); + /// Pass address(0) as newAdmin to clear a previously staged transfer without nominating a replacement. + function stageUpdateAdmin(uint64 policyId, address newAdmin) external; + function finalizeUpdateAdmin(uint64 policyId) external; + function renounceAdmin(uint64 policyId) external; + function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; + function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; + function isAuthorized(uint64 policyId, address account) external view returns (bool); + function policyExists(uint64 policyId) external view returns (bool); + function policyAdmin(uint64 policyId) external view returns (address); + function pendingPolicyAdmin(uint64 policyId) external view returns (address); + } +} + +impl IPolicyRegistry::PolicyType { + /// Returns the raw `u8` discriminant for this policy type. + pub const fn as_discriminant(self) -> u8 { + self as u8 + } + + /// Returns whether this value is one of the supported policy types. + pub const fn is_valid(self) -> bool { + matches!(self, Self::BLOCKLIST | Self::ALLOWLIST) + } +} + +#[cfg(test)] +mod tests { + use alloy_sol_types::SolEnum; + + use super::IPolicyRegistry; + + #[test] + fn all_policy_type_variants_are_valid() { + for discriminant in 0..IPolicyRegistry::PolicyType::COUNT { + let policy_type = IPolicyRegistry::PolicyType::try_from(discriminant as u8) + .expect("generated PolicyType discriminant should decode"); + + assert!(policy_type.is_valid()); + } + } +} diff --git a/crates/common/precompiles/src/policy/dispatch.rs b/crates/common/precompiles/src/policy/dispatch.rs new file mode 100644 index 0000000000..7007ba1ef4 --- /dev/null +++ b/crates/common/precompiles/src/policy/dispatch.rs @@ -0,0 +1,381 @@ +use alloy_primitives::Bytes; +use alloy_sol_types::SolCall; +use base_precompile_storage::{IntoPrecompileResult, StorageCtx}; +use revm::precompile::PrecompileResult; + +use crate::{ + ActivationFeature, ActivationRegistryStorage, + IPolicyRegistry::{self, IPolicyRegistryCalls as C}, + PolicyRegistryStorage, + macros::{decode_precompile_call, deduct_calldata_cost}, +}; + +impl PolicyRegistryStorage<'_> { + /// ABI-dispatches policy registry calldata. + pub fn dispatch(&mut self, ctx: StorageCtx<'_>, calldata: &[u8]) -> PrecompileResult { + deduct_calldata_cost!(ctx, calldata); + ActivationRegistryStorage::new(ctx) + .ensure_activated(ActivationFeature::PolicyRegistry.id()) + .and_then(|()| self.inner(calldata)) + .into_precompile_result(ctx.gas_used(), ctx.state_gas_used(), |b| b) + } + + fn inner(&mut self, calldata: &[u8]) -> base_precompile_storage::Result { + match decode_precompile_call!(calldata, IPolicyRegistry::IPolicyRegistryCalls) { + C::createPolicy(call) => { + let id = self.create_policy(call.admin, call.policyType)?; + Ok(IPolicyRegistry::createPolicyCall::abi_encode_returns(&id).into()) + } + C::createPolicyWithAccounts(call) => { + let id = + self.create_policy_with_accounts(call.admin, call.policyType, call.accounts)?; + Ok(IPolicyRegistry::createPolicyWithAccountsCall::abi_encode_returns(&id).into()) + } + C::stageUpdateAdmin(call) => { + self.stage_update_admin(call.policyId, call.newAdmin)?; + Ok(Bytes::new()) + } + C::finalizeUpdateAdmin(call) => { + self.finalize_update_admin(call.policyId)?; + Ok(Bytes::new()) + } + C::renounceAdmin(call) => { + self.renounce_admin(call.policyId)?; + Ok(Bytes::new()) + } + C::updateAllowlist(call) => { + self.update_allowlist(call.policyId, call.allowed, call.accounts)?; + Ok(Bytes::new()) + } + C::updateBlocklist(call) => { + self.update_blocklist(call.policyId, call.blocked, call.accounts)?; + Ok(Bytes::new()) + } + C::isAuthorized(call) => { + let authorized = self.is_authorized(call.policyId, call.account)?; + Ok(IPolicyRegistry::isAuthorizedCall::abi_encode_returns(&authorized).into()) + } + C::policyExists(call) => { + let exists = self.policy_exists(call.policyId)?; + Ok(IPolicyRegistry::policyExistsCall::abi_encode_returns(&exists).into()) + } + C::policyAdmin(call) => { + let admin = self.get_policy_admin(call.policyId)?; + Ok(IPolicyRegistry::policyAdminCall::abi_encode_returns(&admin).into()) + } + C::pendingPolicyAdmin(call) => { + let pending = self.pending_policy_admin(call.policyId)?; + Ok(IPolicyRegistry::pendingPolicyAdminCall::abi_encode_returns(&pending).into()) + } + } + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, Bytes, U256, address}; + use alloy_sol_types::{Panic, PanicKind, SolCall, SolError, SolValue}; + use base_precompile_storage::{HashMapStorageProvider, StorageCtx}; + + use crate::{ + ActivationFeature, ActivationRegistryStorage, IPolicyRegistry, PolicyRegistryStorage, + }; + + const ACTIVATION_ADMIN: Address = address!("0xcb00000000000000000000000000000000000000"); + const ADMIN: Address = address!("0x1000000000000000000000000000000000000001"); + const ALICE: Address = address!("0xA000000000000000000000000000000000000001"); + + fn activate_policy_registry(storage: &mut HashMapStorageProvider) { + storage.set_caller(ACTIVATION_ADMIN); + StorageCtx::enter(storage, |ctx| { + ActivationRegistryStorage::new(ctx) + .activate(ActivationFeature::PolicyRegistry.id(), Some(ACTIVATION_ADMIN)) + .unwrap() + }); + } + + /// Activates the policy registry and writes the built-in policies to storage. + /// + /// Call this instead of `activate_policy_registry` when the test needs to query + /// built-in policy IDs (`ALWAYS_ALLOW_ID`, `ALWAYS_BLOCK_ID`) directly. + fn activate_and_init(storage: &mut HashMapStorageProvider) { + activate_policy_registry(storage); + StorageCtx::enter(storage, |ctx| PolicyRegistryStorage::new(ctx).write_builtins()).unwrap(); + } + + #[test] + fn dispatch_reverts_when_policy_registry_is_inactive() { + let mut storage = HashMapStorageProvider::new(1); + let calldata = IPolicyRegistry::policyExistsCall { policyId: 0 }.abi_encode(); + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .expect("dispatch should not fatally error"); + + assert!(output.is_revert()); + } + + #[test] + fn dispatch_succeeds_when_policy_registry_is_active() { + let mut storage = HashMapStorageProvider::new(1); + activate_and_init(&mut storage); + let calldata = + IPolicyRegistry::policyExistsCall { policyId: PolicyRegistryStorage::ALWAYS_ALLOW_ID } + .abi_encode(); + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .expect("dispatch should not fatally error"); + + assert!(!output.is_revert()); + assert!(IPolicyRegistry::policyExistsCall::abi_decode_returns(&output.bytes).unwrap()); + } + + #[test] + fn dispatch_create_policy_returns_policy_id() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + storage.set_caller(ADMIN); + let calldata = IPolicyRegistry::createPolicyCall { + admin: ADMIN, + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + } + .abi_encode(); + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .expect("dispatch should not fatally error"); + + assert!(!output.is_revert()); + let id = IPolicyRegistry::createPolicyCall::abi_decode_returns(&output.bytes).unwrap(); + assert_eq!((id >> 56) as u8, IPolicyRegistry::PolicyType::ALLOWLIST as u8); + } + + #[test] + fn dispatch_create_policy_rejects_invalid_policy_type_calldata() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + storage.set_caller(ADMIN); + let mut calldata = Vec::from(IPolicyRegistry::createPolicyCall::SELECTOR); + calldata.extend_from_slice(&ADMIN.abi_encode()); + calldata.extend_from_slice(&[0u8; 31]); + calldata.push(0xff); + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .expect("dispatch should not fatally error"); + + let expected: Bytes = + Panic { code: U256::from(PanicKind::EnumConversionError as u32) }.abi_encode().into(); + assert!(output.is_revert()); + assert_eq!(output.bytes, expected); + + let valid_calldata = IPolicyRegistry::createPolicyCall { + admin: ADMIN, + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + } + .abi_encode(); + let valid_output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &valid_calldata) + }) + .expect("dispatch should not fatally error"); + + assert!(!valid_output.is_revert()); + let id = + IPolicyRegistry::createPolicyCall::abi_decode_returns(&valid_output.bytes).unwrap(); + assert_eq!(id, 0x0100000000000002); + } + + #[test] + fn dispatch_is_authorized_always_allow_returns_true() { + let mut storage = HashMapStorageProvider::new(1); + activate_and_init(&mut storage); + let calldata = IPolicyRegistry::isAuthorizedCall { + policyId: PolicyRegistryStorage::ALWAYS_ALLOW_ID, + account: ALICE, + } + .abi_encode(); + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .expect("dispatch should not fatally error"); + + assert!(!output.is_revert()); + assert!(IPolicyRegistry::isAuthorizedCall::abi_decode_returns(&output.bytes).unwrap()); + } + + #[test] + fn dispatch_unknown_selector_reverts() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + let calldata = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x00, 0x00, 0x00]; + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .expect("dispatch should not fatally error"); + + assert!(output.is_revert()); + } + + fn create_allowlist_policy(storage: &mut HashMapStorageProvider) -> u64 { + storage.set_caller(ADMIN); + let calldata = IPolicyRegistry::createPolicyCall { + admin: ADMIN, + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + } + .abi_encode(); + let output = StorageCtx::enter(storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .unwrap(); + assert!(!output.is_revert(), "create_allowlist_policy setup unexpectedly reverted"); + IPolicyRegistry::createPolicyCall::abi_decode_returns(&output.bytes).unwrap() + } + + #[test] + fn dispatch_create_policy_with_accounts() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + storage.set_caller(ADMIN); + let calldata = IPolicyRegistry::createPolicyWithAccountsCall { + admin: ADMIN, + policyType: IPolicyRegistry::PolicyType::ALLOWLIST, + accounts: alloc::vec![ALICE], + } + .abi_encode(); + + let output = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .unwrap(); + + assert!(!output.is_revert()); + let id = IPolicyRegistry::createPolicyWithAccountsCall::abi_decode_returns(&output.bytes) + .unwrap(); + assert_eq!((id >> 56) as u8, IPolicyRegistry::PolicyType::ALLOWLIST as u8); + } + + #[test] + fn dispatch_stage_and_finalize_update_admin() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + let id = create_allowlist_policy(&mut storage); + let new_admin = address!("0x3000000000000000000000000000000000000003"); + + // stage + storage.set_caller(ADMIN); + let stage_calldata = + IPolicyRegistry::stageUpdateAdminCall { policyId: id, newAdmin: new_admin } + .abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &stage_calldata) + }) + .unwrap(); + assert!(!out.is_revert()); + + // finalize + storage.set_caller(new_admin); + let finalize_calldata = + IPolicyRegistry::finalizeUpdateAdminCall { policyId: id }.abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &finalize_calldata) + }) + .unwrap(); + assert!(!out.is_revert()); + + // confirm admin changed + let admin_calldata = IPolicyRegistry::policyAdminCall { policyId: id }.abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &admin_calldata) + }) + .unwrap(); + let admin = IPolicyRegistry::policyAdminCall::abi_decode_returns(&out.bytes).unwrap(); + assert_eq!(admin, new_admin); + } + + #[test] + fn dispatch_renounce_admin() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + let id = create_allowlist_policy(&mut storage); + + storage.set_caller(ADMIN); + let calldata = IPolicyRegistry::renounceAdminCall { policyId: id }.abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .unwrap(); + assert!(!out.is_revert()); + } + + #[test] + fn dispatch_update_allowlist_and_blocklist() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + let id = create_allowlist_policy(&mut storage); + + storage.set_caller(ADMIN); + let calldata = IPolicyRegistry::updateAllowlistCall { + policyId: id, + allowed: true, + accounts: alloc::vec![ALICE], + } + .abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .unwrap(); + assert!(!out.is_revert()); + + // updateBlocklist on a blocklist policy + storage.set_caller(ADMIN); + let blocklist_calldata = IPolicyRegistry::createPolicyCall { + admin: ADMIN, + policyType: IPolicyRegistry::PolicyType::BLOCKLIST, + } + .abi_encode(); + let blocklist_out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &blocklist_calldata) + }) + .unwrap(); + assert!(!blocklist_out.is_revert(), "blocklist policy creation unexpectedly reverted"); + let bid = + IPolicyRegistry::createPolicyCall::abi_decode_returns(&blocklist_out.bytes).unwrap(); + + storage.set_caller(ADMIN); + let update_blocklist = IPolicyRegistry::updateBlocklistCall { + policyId: bid, + blocked: true, + accounts: alloc::vec![ALICE], + } + .abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &update_blocklist) + }) + .unwrap(); + assert!(!out.is_revert()); + } + + #[test] + fn dispatch_pending_policy_admin() { + let mut storage = HashMapStorageProvider::new(1); + activate_policy_registry(&mut storage); + let id = create_allowlist_policy(&mut storage); + + let calldata = IPolicyRegistry::pendingPolicyAdminCall { policyId: id }.abi_encode(); + let out = StorageCtx::enter(&mut storage, |ctx| { + PolicyRegistryStorage::new(ctx).dispatch(ctx, &calldata) + }) + .unwrap(); + assert!(!out.is_revert()); + let pending = + IPolicyRegistry::pendingPolicyAdminCall::abi_decode_returns(&out.bytes).unwrap(); + assert_eq!(pending, Address::ZERO); + } +} diff --git a/crates/common/precompiles/src/policy/handle.rs b/crates/common/precompiles/src/policy/handle.rs new file mode 100644 index 0000000000..2530e96334 --- /dev/null +++ b/crates/common/precompiles/src/policy/handle.rs @@ -0,0 +1,173 @@ +//! Business logic for the `PolicyRegistry` precompile. +//! +//! [`PolicyHandle`] is the concrete type the token holds. It wraps [`PolicyRegistryStorage`] +//! and implements [`Policy`] (for authorization checks) and [`PolicyRegistry`] (for admin ops). + +use alloc::vec::Vec; +use core::fmt; + +use alloy_primitives::Address; +use base_precompile_storage::{Result, StorageCtx}; + +use crate::{IPolicyRegistry::PolicyType, Policy, PolicyRegistry, PolicyRegistryStorage}; + +/// Wraps [`PolicyRegistryStorage`] and implements [`Policy`] and [`PolicyRegistry`], +/// separating authorization decisions from raw storage reads. +pub struct PolicyHandle<'a> { + inner: PolicyRegistryStorage<'a>, +} + +impl<'a> PolicyHandle<'a> { + /// Creates a `PolicyHandle` backed by the registry storage at its singleton address. + pub fn new(ctx: StorageCtx<'a>) -> Self { + Self { inner: PolicyRegistryStorage::new(ctx) } + } +} + +impl fmt::Debug for PolicyHandle<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PolicyHandle").finish_non_exhaustive() + } +} + +impl Policy for PolicyHandle<'_> { + fn is_authorized(&self, policy_id: u64, account: Address) -> Result { + self.inner.is_authorized(policy_id, account) + } + + fn policy_exists(&self, policy_id: u64) -> Result { + self.inner.policy_exists(policy_id) + } +} + +impl PolicyRegistry for PolicyHandle<'_> { + fn create_policy(&mut self, admin: Address, policy_type: PolicyType) -> Result { + self.inner.create_policy(admin, policy_type) + } + + fn create_policy_with_accounts( + &mut self, + admin: Address, + policy_type: PolicyType, + accounts: Vec
, + ) -> Result { + self.inner.create_policy_with_accounts(admin, policy_type, accounts) + } + + fn stage_update_admin(&mut self, policy_id: u64, new_admin: Address) -> Result<()> { + self.inner.stage_update_admin(policy_id, new_admin) + } + + fn finalize_update_admin(&mut self, policy_id: u64) -> Result<()> { + self.inner.finalize_update_admin(policy_id) + } + + fn renounce_admin(&mut self, policy_id: u64) -> Result<()> { + self.inner.renounce_admin(policy_id) + } + + fn update_allowlist( + &mut self, + policy_id: u64, + allowed: bool, + accounts: Vec
, + ) -> Result<()> { + self.inner.update_allowlist(policy_id, allowed, accounts) + } + + fn update_blocklist( + &mut self, + policy_id: u64, + blocked: bool, + accounts: Vec
, + ) -> Result<()> { + self.inner.update_blocklist(policy_id, blocked, accounts) + } + + fn get_policy_admin(&self, policy_id: u64) -> Result
{ + self.inner.get_policy_admin(policy_id) + } + + fn pending_policy_admin(&self, policy_id: u64) -> Result
{ + self.inner.pending_policy_admin(policy_id) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, address}; + use base_precompile_storage::{HashMapStorageProvider, StorageCtx}; + + use crate::{IPolicyRegistry, Policy, PolicyHandle, PolicyRegistry, PolicyRegistryStorage}; + + const ADMIN: Address = address!("0x1000000000000000000000000000000000000001"); + const ALICE: Address = address!("0xA000000000000000000000000000000000000001"); + const NEW_ADMIN: Address = address!("0x2000000000000000000000000000000000000002"); + + fn storage() -> HashMapStorageProvider { + let mut s = HashMapStorageProvider::new(1); + s.set_caller(ADMIN); + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).write_builtins()).unwrap(); + s + } + + #[test] + fn policy_trait_is_authorized_builtin_ids() { + let mut s = storage(); + StorageCtx::enter(&mut s, |ctx| { + let handle = PolicyHandle::new(ctx); + assert!(handle.is_authorized(PolicyRegistryStorage::ALWAYS_ALLOW_ID, ALICE).unwrap()); + assert!(!handle.is_authorized(PolicyRegistryStorage::ALWAYS_BLOCK_ID, ALICE).unwrap()); + }); + } + + #[test] + fn policy_registry_trait_create_and_authorize() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyHandle::new(ctx).create_policy(ADMIN, IPolicyRegistry::PolicyType::ALLOWLIST) + }) + .unwrap(); + + s.set_caller(ADMIN); + StorageCtx::enter(&mut s, |ctx| { + PolicyHandle::new(ctx).update_allowlist(id, true, alloc::vec![ALICE]) + }) + .unwrap(); + + StorageCtx::enter(&mut s, |ctx| { + let handle = PolicyHandle::new(ctx); + assert!(handle.is_authorized(id, ALICE).unwrap()); + }); + } + + #[test] + fn policy_registry_trait_policy_exists() { + let mut s = storage(); + StorageCtx::enter(&mut s, |ctx| { + let handle = PolicyHandle::new(ctx); + assert!(handle.policy_exists(PolicyRegistryStorage::ALWAYS_ALLOW_ID).unwrap()); + assert!(handle.policy_exists(PolicyRegistryStorage::ALWAYS_BLOCK_ID).unwrap()); + assert!(!handle.policy_exists(0xdeadbeef).unwrap()); + }); + } + + #[test] + fn policy_registry_trait_admin_transfer() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyHandle::new(ctx).create_policy(ADMIN, IPolicyRegistry::PolicyType::BLOCKLIST) + }) + .unwrap(); + + StorageCtx::enter(&mut s, |ctx| PolicyHandle::new(ctx).stage_update_admin(id, NEW_ADMIN)) + .unwrap(); + + s.set_caller(NEW_ADMIN); + StorageCtx::enter(&mut s, |ctx| PolicyHandle::new(ctx).finalize_update_admin(id)).unwrap(); + + StorageCtx::enter(&mut s, |ctx| { + assert_eq!(PolicyHandle::new(ctx).get_policy_admin(id).unwrap(), NEW_ADMIN); + }); + } +} diff --git a/crates/common/precompiles/src/policy/mod.rs b/crates/common/precompiles/src/policy/mod.rs new file mode 100644 index 0000000000..edcd0cd7a0 --- /dev/null +++ b/crates/common/precompiles/src/policy/mod.rs @@ -0,0 +1,15 @@ +//! `PolicyRegistry` native precompile — global singleton transfer-policy registry for B-20 tokens. + +mod abi; +pub use abi::IPolicyRegistry; + +mod dispatch; + +mod precompile; +pub use precompile::PolicyRegistryPrecompile; + +mod handle; +pub use handle::PolicyHandle; + +mod storage; +pub use storage::{PackedPolicy, PolicyRegistryStorage}; diff --git a/crates/common/precompiles/src/policy/precompile.rs b/crates/common/precompiles/src/policy/precompile.rs new file mode 100644 index 0000000000..d9f39ae338 --- /dev/null +++ b/crates/common/precompiles/src/policy/precompile.rs @@ -0,0 +1,10 @@ +//! Entry point for the `PolicyRegistry` precompile. + +use base_precompile_macros::precompile; + +use crate::PolicyRegistryStorage; + +/// EVM entry point for the `PolicyRegistry` precompile. +#[precompile(install)] +#[derive(Debug, Default, Clone, Copy)] +pub struct PolicyRegistryPrecompile; diff --git a/crates/common/precompiles/src/policy/storage.rs b/crates/common/precompiles/src/policy/storage.rs new file mode 100644 index 0000000000..1eed43b9b2 --- /dev/null +++ b/crates/common/precompiles/src/policy/storage.rs @@ -0,0 +1,1509 @@ +use alloc::vec::Vec; + +use alloy_primitives::{Address, U256, address}; +use base_precompile_macros::contract; +use base_precompile_storage::{BasePrecompileError, ContractStorage, Handler, Mapping, Result}; + +use crate::{IPolicyRegistry, IPolicyRegistry::PolicyType}; + +/// A packed policy storage word. +/// +/// Layout: `[255]` exists flag | `[254:160]` reserved (zero) | `[159:0]` admin (160 bits). +/// +/// The policy type is not stored here — it is encoded in the high byte of the policy ID +/// and derived from there. Bit 255 is always set for any written slot, making the zero word +/// a reliable "never written" sentinel even when admin is `Address::ZERO`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PackedPolicy(U256); + +impl PackedPolicy { + /// Bit 255: the highest bit of limb 3. + const EXISTS_BIT: U256 = U256::from_limbs([0, 0, 0, 1u64 << 63]); + /// Mask covering the low 160 bits where the admin address lives. + const ADMIN_MASK: U256 = U256::from_limbs([u64::MAX, u64::MAX, 0xFFFF_FFFF, 0]); + + /// Creates a packed policy word for `admin`. + pub fn new(admin: Address) -> Self { + let mut word = [0u8; 32]; + word[12..32].copy_from_slice(admin.as_slice()); + Self(U256::from_be_slice(&word) | Self::EXISTS_BIT) + } + + /// Returns this policy word with its admin replaced. + pub fn with_admin(self, new_admin: Address) -> Self { + Self::new(new_admin) + } + + /// Returns the admin address encoded in this policy word. + pub fn admin(self) -> Address { + let bytes = (self.0 & Self::ADMIN_MASK).to_be_bytes::<32>(); + Address::from_slice(&bytes[12..]) + } + + /// Returns whether this policy word has the exists bit set. + pub fn exists(self) -> bool { + !(self.0 & Self::EXISTS_BIT).is_zero() + } + + /// Returns the raw packed policy word. + pub const fn into_u256(self) -> U256 { + self.0 + } + + /// Creates a packed policy wrapper from a raw storage word. + pub const fn from_raw(v: U256) -> Self { + Self(v) + } +} + +/// Storage layout for the `PolicyRegistry` precompile. +/// +/// Slots are append-only — never reorder across hardforks. +#[contract(addr = Self::ADDRESS)] +#[namespace("base.policy_registry")] +pub struct PolicyRegistryStorage { + pub policies: Mapping, // slot 0 + pub members: Mapping>, // slot 1 + pub pending_admins: Mapping, // slot 2 + /// Global monotonic counter for the low 56 bits of all custom policy IDs. + /// Intentionally shared across ALLOWLIST and BLOCKLIST types — the type + /// discriminator is encoded in the top byte, so both types draw from the + /// same 56-bit space without collision. + pub next_counter: u64, // slot 3 +} + +impl PolicyRegistryStorage<'_> { + /// Singleton precompile address for the `PolicyRegistry`. + pub const ADDRESS: Address = address!("8453000000000000000000000000000000000002"); + + /// Built-in policy ID that always authorizes every account. + /// Encoded as BLOCKLIST (type=0) with counter=0 — an empty blocklist authorizes everyone. + /// Also the EVM zero default: zero-initialized policy ID fields map here. + pub const ALWAYS_ALLOW_ID: u64 = 0; + + /// Built-in policy ID that always rejects every account. + /// Encoded as ALLOWLIST (type=1) with counter=1 and an empty member set, + /// so no account is on the allowlist and nobody passes. + pub const ALWAYS_BLOCK_ID: u64 = (1u64 << Self::POLICY_ID_TYPE_SHIFT) | 1; + + const ALLOWLIST_TYPE: u8 = PolicyType::ALLOWLIST as u8; + const BLOCKLIST_TYPE: u8 = PolicyType::BLOCKLIST as u8; + const COUNTER_MASK: u64 = (1u64 << 56) - 1; + const POLICY_ID_TYPE_SHIFT: usize = 56; + /// Number of built-in policies; the counter is set to this value after `write_builtins`. + const BUILTIN_POLICY_COUNT: u64 = 2; + /// Maximum number of accounts per membership batch (`createPolicyWithAccounts`, + /// `updateAllowlist`, `updateBlocklist`). + const MAX_ACCOUNTS_PER_BATCH: usize = 64; + + const fn policy_id_type(policy_id: u64) -> u8 { + (policy_id >> Self::POLICY_ID_TYPE_SHIFT) as u8 + } + + fn require_custom(&self, policy_id: u64) -> Result { + let packed: PackedPolicy = PackedPolicy::from_raw(self.policies.at(&policy_id).read()?); + if !packed.exists() { + return Err(BasePrecompileError::revert(IPolicyRegistry::PolicyNotFound {})); + } + Ok(packed) + } + + fn require_account_batch_size(accounts: &[Address]) -> Result<()> { + if accounts.len() > Self::MAX_ACCOUNTS_PER_BATCH { + return Err(BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(Self::MAX_ACCOUNTS_PER_BATCH), + })); + } + Ok(()) + } + + fn next_counter(&self) -> Result { + self.next_counter.read() + } + + const fn make_id(policy_type: u8, counter: u64) -> u64 { + (policy_type as u64) << Self::POLICY_ID_TYPE_SHIFT | (counter & Self::COUNTER_MASK) + } + + /// Validates the policy exists and the caller is its current admin. + /// Returns `(packed, caller)` on success. + fn require_admin(&self, policy_id: u64) -> Result<(PackedPolicy, Address)> { + let packed = self.require_custom(policy_id)?; + let caller = self.storage.caller(); + if packed.admin() != caller { + return Err(BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + Ok((packed, caller)) + } + + /// Writes the two built-in policies into the `policies` mapping. + /// + /// Consumes counters 0 and 1, leaving the counter at 2 so custom policies + /// start there. Both built-ins have a renounced (zero) admin. Idempotent: + /// if the counter is already past 0 the builtins were already written. + /// - `ALWAYS_ALLOW_ID` (counter=0, BLOCKLIST): no members blocked — everyone is authorized. + /// - `ALWAYS_BLOCK_ID` (counter=1, ALLOWLIST): no members allowed — nobody is authorized. + pub fn write_builtins(&mut self) -> Result<()> { + if self.next_counter.read()? >= Self::BUILTIN_POLICY_COUNT { + return Ok(()); + } + // Assert that the ID constants match the enum discriminants and counter slots, + // catching any future drift from enum reordering or constant changes. + debug_assert_eq!( + Self::make_id(PolicyType::BLOCKLIST.as_discriminant(), 0), + Self::ALWAYS_ALLOW_ID + ); + debug_assert_eq!( + Self::make_id(PolicyType::ALLOWLIST.as_discriminant(), 1), + Self::ALWAYS_BLOCK_ID + ); + let builtin = PackedPolicy::new(Address::ZERO).into_u256(); + self.policies.at_mut(&Self::ALWAYS_ALLOW_ID).write(builtin)?; + self.policies.at_mut(&Self::ALWAYS_BLOCK_ID).write(builtin)?; + self.next_counter.write(Self::BUILTIN_POLICY_COUNT)?; + Ok(()) + } + + /// Creates a new ALLOWLIST or BLOCKLIST policy, returning its encoded ID. + pub fn create_policy(&mut self, admin: Address, policy_type: PolicyType) -> Result { + if !policy_type.is_valid() { + return Err(BasePrecompileError::enum_conversion_error()); + } + let policy_type_u8 = policy_type.as_discriminant(); + if admin == Address::ZERO { + return Err(BasePrecompileError::revert(IPolicyRegistry::ZeroAddress {})); + } + + // The registry account must be non-empty before the first policy storage write; otherwise + // the EVM path can prune writes made under an empty native-precompile account. + // TODO: Revisit this guard against the finalized Beryl gas model, since `is_initialized` + // charges warm/cold account-read gas before skipping repeated `set_code`. + if !self.is_initialized()? { + self.__initialize()?; + } + // Seed built-ins independently of bytecode presence: harnesses (e.g., anvil/foundry forks) + // may pre-warm precompile bytecode to satisfy Solidity's `EXTCODESIZE` check, which would + // otherwise short-circuit the lazy init above and leave `policies[ALWAYS_ALLOW_ID]` / + // `policies[ALWAYS_BLOCK_ID]` unset. `write_builtins` self-gates via `next_counter`, so + // the extra SLOAD on every subsequent `create_policy` is a no-op cost. + self.write_builtins()?; + + let counter = self.next_counter()?; + let next = counter.checked_add(1).ok_or_else(BasePrecompileError::under_overflow)?; + self.next_counter.write(next)?; + let policy_id = Self::make_id(policy_type_u8, counter); + self.policies.at_mut(&policy_id).write(PackedPolicy::new(admin).into_u256())?; + + let caller = self.storage.caller(); + self.emit_event(IPolicyRegistry::PolicyCreated { + policyId: policy_id, + creator: caller, + policyType: policy_type, + })?; + self.emit_event(IPolicyRegistry::PolicyAdminUpdated { + policyId: policy_id, + previousAdmin: Address::ZERO, + newAdmin: admin, + })?; + + Ok(policy_id) + } + + /// Creates a new policy and populates its initial member list. + pub fn create_policy_with_accounts( + &mut self, + admin: Address, + policy_type: PolicyType, + accounts: Vec
, + ) -> Result { + Self::require_account_batch_size(&accounts)?; + let policy_id = self.create_policy(admin, policy_type)?; + let caller = self.storage.caller(); + for account in &accounts { + self.members.at_mut(&policy_id).at_mut(account).write(true)?; + } + match policy_type { + PolicyType::ALLOWLIST => self.emit_event(IPolicyRegistry::AllowlistUpdated { + policyId: policy_id, + updater: caller, + allowed: true, + accounts, + })?, + PolicyType::BLOCKLIST => self.emit_event(IPolicyRegistry::BlocklistUpdated { + policyId: policy_id, + updater: caller, + blocked: true, + accounts, + })?, + _ => return Err(BasePrecompileError::enum_conversion_error()), + } + Ok(policy_id) + } + + /// Stages `new_admin` as the pending admin for `policy_id`. + /// + /// Passing `address(0)` clears a previously-staged transfer per the interface spec. + pub fn stage_update_admin(&mut self, policy_id: u64, new_admin: Address) -> Result<()> { + let (_, caller) = self.require_admin(policy_id)?; + if new_admin == Address::ZERO { + self.pending_admins.at_mut(&policy_id).delete()?; + } else { + self.pending_admins.at_mut(&policy_id).write(new_admin)?; + } + self.emit_event(IPolicyRegistry::PolicyAdminStaged { + policyId: policy_id, + currentAdmin: caller, + pendingAdmin: new_admin, + })?; + Ok(()) + } + + /// Completes a pending admin transfer; caller must be the staged pending admin. + pub fn finalize_update_admin(&mut self, policy_id: u64) -> Result<()> { + let packed = self.require_custom(policy_id)?; + let pending = self.pending_admins.at(&policy_id).read()?; + if pending == Address::ZERO { + return Err(BasePrecompileError::revert(IPolicyRegistry::NoPendingAdmin {})); + } + let caller = self.storage.caller(); + if pending != caller { + return Err(BasePrecompileError::revert(IPolicyRegistry::Unauthorized {})); + } + let previous_admin = packed.admin(); + self.policies.at_mut(&policy_id).write(packed.with_admin(caller).into_u256())?; + self.pending_admins.at_mut(&policy_id).delete()?; + self.emit_event(IPolicyRegistry::PolicyAdminUpdated { + policyId: policy_id, + previousAdmin: previous_admin, + newAdmin: caller, + })?; + Ok(()) + } + + /// Clears the admin of `policy_id`, leaving it permanently un-administered. + pub fn renounce_admin(&mut self, policy_id: u64) -> Result<()> { + let (packed, caller) = self.require_admin(policy_id)?; + self.policies.at_mut(&policy_id).write(packed.with_admin(Address::ZERO).into_u256())?; + self.pending_admins.at_mut(&policy_id).delete()?; + self.emit_event(IPolicyRegistry::PolicyAdminUpdated { + policyId: policy_id, + previousAdmin: caller, + newAdmin: Address::ZERO, + })?; + Ok(()) + } + + /// Adds or removes `accounts` from the allowlist for an ALLOWLIST policy. + pub fn update_allowlist( + &mut self, + policy_id: u64, + allowed: bool, + accounts: Vec
, + ) -> Result<()> { + let caller = self.update_membership(policy_id, Self::ALLOWLIST_TYPE, allowed, &accounts)?; + self.emit_event(IPolicyRegistry::AllowlistUpdated { + policyId: policy_id, + updater: caller, + allowed, + accounts, + }) + } + + /// Adds or removes `accounts` from the blocklist for a BLOCKLIST policy. + pub fn update_blocklist( + &mut self, + policy_id: u64, + blocked: bool, + accounts: Vec
, + ) -> Result<()> { + let caller = self.update_membership(policy_id, Self::BLOCKLIST_TYPE, blocked, &accounts)?; + self.emit_event(IPolicyRegistry::BlocklistUpdated { + policyId: policy_id, + updater: caller, + blocked, + accounts, + }) + } + + fn update_membership( + &mut self, + policy_id: u64, + expected_type: u8, + add: bool, + accounts: &[Address], + ) -> Result
{ + let (_, caller) = self.require_admin(policy_id)?; + if Self::policy_id_type(policy_id) != expected_type { + return Err(BasePrecompileError::revert(IPolicyRegistry::IncompatiblePolicyType {})); + } + Self::require_account_batch_size(accounts)?; + for account in accounts { + if add { + self.members.at_mut(&policy_id).at_mut(account).write(true)?; + } else { + self.members.at_mut(&policy_id).at_mut(account).delete()?; + } + } + Ok(caller) + } + + /// Returns `true` if `account` is authorized under `policy_id`. + /// + /// Malformed policy IDs (type byte > 1) return `Ok(false)` rather than reverting. + /// + /// If the policy slot has never been written, the function falls back to default + /// semantics for that type: an ALLOWLIST with no members authorizes nobody (`false`), + /// a BLOCKLIST with no members blocks nobody (`true`). `PolicyNotFound` is never returned. + pub fn is_authorized(&self, policy_id: u64, account: Address) -> Result { + // Malformed IDs (type byte > 1) are treated as unauthorized rather than reverting. + if Self::policy_id_type(policy_id) > PolicyType::ALLOWLIST as u8 { + return Ok(false); + } + // Fast-paths for built-in IDs: ALWAYS_ALLOW_ID = 0 is the EVM default for any + // uninitialized policy field, so this must work before write_builtins() has run. + if policy_id == Self::ALWAYS_ALLOW_ID { + return Ok(true); + } + if policy_id == Self::ALWAYS_BLOCK_ID { + return Ok(false); + } + // Read membership directly without requiring the policy slot to be written first. + // If the slot is unwritten the mapping returns false, which naturally gives: + // ALLOWLIST => false (no members => not authorized) + // BLOCKLIST => !false (no members blocked => authorized) + let member = self.members.at(&policy_id).at(&account).read()?; + match Self::policy_id_type(policy_id) { + Self::ALLOWLIST_TYPE => Ok(member), + Self::BLOCKLIST_TYPE => Ok(!member), + _ => unreachable!("type byte > 1 was rejected by the malformed-ID guard above"), + } + } + + /// Returns `true` if `policy_id` refers to an existing policy. + /// + /// Malformed policy IDs (type byte > 1) return `Ok(false)` rather than reverting. + /// Built-in IDs always return `true` via a fast-path, without reading storage. + /// This is necessary because `ALWAYS_ALLOW_ID = 0` is the EVM default for any + /// uninitialized policy field, so it must be recognized as valid before + /// `write_builtins` has run. + pub fn policy_exists(&self, policy_id: u64) -> Result { + // Malformed IDs (type byte > 1) are not well-formed, so they do not exist. + if Self::policy_id_type(policy_id) > PolicyType::ALLOWLIST as u8 { + return Ok(false); + } + if policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID { + return Ok(true); + } + let packed = PackedPolicy::from_raw(self.policies.at(&policy_id).read()?); + Ok(packed.exists()) + } + + /// Returns the current admin of `policy_id`, or `address(0)` for policies with renounced admin. + /// + /// Returns `address(0)` without reverting for malformed policy IDs (type byte > 1) and for + /// policy IDs that have never been written to storage. + pub fn get_policy_admin(&self, policy_id: u64) -> Result
{ + if Self::policy_id_type(policy_id) > PolicyType::ALLOWLIST as u8 { + return Ok(Address::ZERO); + } + let packed = PackedPolicy::from_raw(self.policies.at(&policy_id).read()?); + if !packed.exists() { + return Ok(Address::ZERO); + } + Ok(packed.admin()) + } + + /// Returns the pending admin staged for `policy_id`, or `address(0)` if none. + /// + /// Returns `address(0)` without reverting for malformed policy IDs (type byte > 1). For + /// policy IDs that exist but have no pending transfer, the storage slot returns `address(0)` + /// naturally. + pub fn pending_policy_admin(&self, policy_id: u64) -> Result
{ + if Self::policy_id_type(policy_id) > PolicyType::ALLOWLIST as u8 { + return Ok(Address::ZERO); + } + if policy_id == Self::ALWAYS_ALLOW_ID || policy_id == Self::ALWAYS_BLOCK_ID { + return Ok(Address::ZERO); + } + self.pending_admins.at(&policy_id).read() + } +} + +impl crate::Policy for PolicyRegistryStorage<'_> { + fn is_authorized(&self, policy_id: u64, account: Address) -> Result { + PolicyRegistryStorage::is_authorized(self, policy_id, account) + } + + fn policy_exists(&self, policy_id: u64) -> Result { + PolicyRegistryStorage::policy_exists(self, policy_id) + } +} + +impl crate::PolicyRegistry for PolicyRegistryStorage<'_> { + fn create_policy(&mut self, admin: Address, policy_type: PolicyType) -> Result { + PolicyRegistryStorage::create_policy(self, admin, policy_type) + } + + fn create_policy_with_accounts( + &mut self, + admin: Address, + policy_type: PolicyType, + accounts: alloc::vec::Vec
, + ) -> Result { + PolicyRegistryStorage::create_policy_with_accounts(self, admin, policy_type, accounts) + } + + fn stage_update_admin(&mut self, policy_id: u64, new_admin: Address) -> Result<()> { + PolicyRegistryStorage::stage_update_admin(self, policy_id, new_admin) + } + + fn finalize_update_admin(&mut self, policy_id: u64) -> Result<()> { + PolicyRegistryStorage::finalize_update_admin(self, policy_id) + } + + fn renounce_admin(&mut self, policy_id: u64) -> Result<()> { + PolicyRegistryStorage::renounce_admin(self, policy_id) + } + + fn update_allowlist( + &mut self, + policy_id: u64, + allowed: bool, + accounts: alloc::vec::Vec
, + ) -> Result<()> { + PolicyRegistryStorage::update_allowlist(self, policy_id, allowed, accounts) + } + + fn update_blocklist( + &mut self, + policy_id: u64, + blocked: bool, + accounts: alloc::vec::Vec
, + ) -> Result<()> { + PolicyRegistryStorage::update_blocklist(self, policy_id, blocked, accounts) + } + + fn get_policy_admin(&self, policy_id: u64) -> Result
{ + PolicyRegistryStorage::get_policy_admin(self, policy_id) + } + + fn pending_policy_admin(&self, policy_id: u64) -> Result
{ + PolicyRegistryStorage::pending_policy_admin(self, policy_id) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, U256, address, uint}; + use alloy_sol_types::SolEvent; + use base_precompile_storage::{ + BasePrecompileError, Handler, HashMapStorageProvider, StorageCtx, StorageKey, + }; + + use crate::{ + IPolicyRegistry, + IPolicyRegistry::PolicyType, + policy::storage::{PackedPolicy, PolicyRegistryStorage, slots}, + }; + + // --- PackedPolicy unit tests --- + + #[test] + fn packed_policy_new_roundtrips_admin() { + let p = PackedPolicy::new(ADMIN); + assert_eq!(p.admin(), ADMIN); + assert!(p.exists()); + } + + #[test] + fn packed_policy_zero_signals_never_created() { + let p = PackedPolicy::from_raw(U256::ZERO); + assert!(!p.exists()); + assert_eq!(p.admin(), Address::ZERO, "zero word admin must be Address::ZERO"); + } + + #[test] + fn packed_policy_zero_admin_is_non_zero() { + // Exists flag at bit 255 keeps the word non-zero even with zero admin. + let p = PackedPolicy::new(Address::ZERO); + assert!(p.exists()); + assert_eq!(p.admin(), Address::ZERO); + } + + #[test] + fn packed_policy_into_u256_from_raw_roundtrip() { + let p = PackedPolicy::new(ADMIN); + let p2 = PackedPolicy::from_raw(p.into_u256()); + assert_eq!(p, p2); + assert_eq!(p2.admin(), ADMIN); + } + + #[test] + fn packed_policy_different_admins_produce_different_words() { + let other = address!("0x2000000000000000000000000000000000000002"); + assert_ne!(PackedPolicy::new(ADMIN), PackedPolicy::new(other)); + } + + #[test] + fn packed_policy_new_roundtrips_admin_for_various_addresses() { + // Verify that admin round-trips correctly for a range of addresses. + let addrs = [ + ADMIN, + Address::ZERO, + address!("0xffffffffffffffffffffffffffffffffffffffff"), + address!("0x2000000000000000000000000000000000000002"), + ]; + for addr in addrs { + let p = PackedPolicy::new(addr); + assert_eq!(p.admin(), addr, "admin must round-trip for address {addr}"); + assert!(p.exists(), "exists must be true for any new PackedPolicy"); + } + } + + #[test] + fn exists_bit_does_not_bleed_into_admin_bits() { + // The EXISTS_BIT is at bit 255; the admin is extracted from bits [159:0]. + // These must not overlap. + let p = PackedPolicy::new(ADMIN); + assert_eq!(p.admin(), ADMIN, "exists bit must not corrupt the admin field"); + // A raw word with only the exists bit set should have zero admin. + let exists_only = PackedPolicy::from_raw(PackedPolicy::EXISTS_BIT); + assert_eq!(exists_only.admin(), Address::ZERO, "exists-only word must have zero admin"); + assert!(exists_only.exists()); + } + + const ADMIN: Address = address!("0x1000000000000000000000000000000000000001"); + const ALICE: Address = address!("0xA000000000000000000000000000000000000001"); + const BOB: Address = address!("0xB000000000000000000000000000000000000001"); + const NEW_ADMIN: Address = address!("0x2000000000000000000000000000000000000002"); + const POLICY_REGISTRY_ROOT: U256 = + uint!(0x00503aeb06982fa1fe3151dc68f90b3946c55c449dfd447e49dcaece71ba4a00_U256); + + /// Returns a storage provider with both built-in policies pre-written. + fn storage() -> HashMapStorageProvider { + let mut s = HashMapStorageProvider::new(1); + s.set_caller(ADMIN); + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).write_builtins()).unwrap(); + s + } + + fn create_allowlist(s: &mut HashMapStorageProvider) -> u64 { + StorageCtx::enter(s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy(ADMIN, PolicyType::ALLOWLIST) + }) + .unwrap() + } + + fn create_blocklist(s: &mut HashMapStorageProvider) -> u64 { + StorageCtx::enter(s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy(ADMIN, PolicyType::BLOCKLIST) + }) + .unwrap() + } + + fn is_authorized(s: &mut HashMapStorageProvider, policy_id: u64, account: Address) -> bool { + StorageCtx::enter(s, |ctx| { + PolicyRegistryStorage::new(ctx).is_authorized(policy_id, account) + }) + .unwrap() + } + + fn many_accounts(count: usize) -> Vec
{ + (0..count).map(|i| Address::from_word(U256::from(i as u64 + 1).into())).collect() + } + + #[test] + fn policy_registry_namespace_matches_base_std_root() { + assert_eq!(slots::POLICIES, POLICY_REGISTRY_ROOT); + assert_eq!(slots::MEMBERS, POLICY_REGISTRY_ROOT + U256::from(1)); + assert_eq!(slots::PENDING_ADMINS, POLICY_REGISTRY_ROOT + U256::from(2)); + assert_eq!(slots::NEXT_COUNTER, POLICY_REGISTRY_ROOT + U256::from(3)); + } + + #[test] + fn policy_registry_writes_use_base_std_namespace_slots() { + let mut s = storage(); + let id = create_allowlist(&mut s); + + StorageCtx::enter(&mut s, |ctx| { + assert_ne!( + ctx.sload(PolicyRegistryStorage::ADDRESS, id.mapping_slot(slots::POLICIES)) + .unwrap(), + U256::ZERO + ); + assert_eq!( + ctx.sload(PolicyRegistryStorage::ADDRESS, slots::NEXT_COUNTER).unwrap(), + U256::from(3) + ); + assert_eq!( + ctx.sload(PolicyRegistryStorage::ADDRESS, id.mapping_slot(U256::ZERO)).unwrap(), + U256::ZERO + ); + }); + } + + // --- built-in IDs --- + + #[test] + fn always_allow_id_authorizes_any_account() { + let mut s = storage(); + assert!(is_authorized(&mut s, PolicyRegistryStorage::ALWAYS_ALLOW_ID, ALICE)); + assert!(is_authorized(&mut s, PolicyRegistryStorage::ALWAYS_ALLOW_ID, BOB)); + } + + #[test] + fn always_block_id_rejects_any_account() { + let mut s = storage(); + assert!(!is_authorized(&mut s, PolicyRegistryStorage::ALWAYS_BLOCK_ID, ALICE)); + assert!(!is_authorized(&mut s, PolicyRegistryStorage::ALWAYS_BLOCK_ID, BOB)); + } + + #[test] + fn unknown_blocklist_policy_id_authorizes_account() { + // 0xdeadbeef has type byte 0 (BLOCKLIST); no members blocked => authorized. + let mut s = storage(); + assert!(is_authorized(&mut s, 0xdeadbeef, ALICE)); + } + + #[test] + fn unknown_allowlist_policy_id_does_not_authorize_account() { + // A well-formed ALLOWLIST ID that was never written to storage. + // No members exist => not authorized. + let unknown_allowlist = PolicyRegistryStorage::make_id(PolicyType::ALLOWLIST as u8, 9999); + let mut s = storage(); + assert!(!is_authorized(&mut s, unknown_allowlist, ALICE)); + } + + #[test] + fn malformed_policy_id_is_authorized_returns_false() { + // Type byte > 1 => malformed; is_authorized must return false, not revert. + let malformed: u64 = (2u64 << 56) | 42; + let mut s = storage(); + let result = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).is_authorized(malformed, ALICE) + }) + .unwrap(); + assert!(!result); + } + + #[test] + fn malformed_policy_id_policy_exists_returns_false() { + // Type byte > 1 => malformed; policy_exists must return false, not revert. + let malformed: u64 = (5u64 << 56) | 100; + let mut s = storage(); + let result = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).policy_exists(malformed) + }) + .unwrap(); + assert!(!result); + } + + // --- write_builtins initialization --- + + #[test] + fn first_create_policy_initializes_builtins_and_starts_counter_at_two() { + // Start from bare storage — write_builtins has NOT been called yet. + let mut s = HashMapStorageProvider::new(1); + s.set_caller(ADMIN); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy(ADMIN, PolicyType::ALLOWLIST) + }) + .unwrap(); + // Builtins claimed counters 0 and 1; first custom policy gets 2. + assert_eq!(id & PolicyRegistryStorage::COUNTER_MASK, 2); + // Builtins are now in storage. + assert!( + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx) + .policy_exists(PolicyRegistryStorage::ALWAYS_ALLOW_ID)) + .unwrap() + ); + assert!( + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx) + .policy_exists(PolicyRegistryStorage::ALWAYS_BLOCK_ID)) + .unwrap() + ); + } + + #[test] + fn write_builtins_is_idempotent() { + let mut s = HashMapStorageProvider::new(1); + for _ in 0..3 { + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).write_builtins()) + .unwrap(); + } + // Counter must be exactly BUILTIN_POLICY_COUNT regardless of how many times called. + let counter = + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).next_counter.read()) + .unwrap(); + assert_eq!(counter, PolicyRegistryStorage::BUILTIN_POLICY_COUNT); + } + + // --- createPolicy --- + + #[test] + fn create_policy_zero_admin_reverts() { + let mut s = storage(); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy(Address::ZERO, PolicyType::ALLOWLIST) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn create_policy_ids_encode_type_in_top_byte_and_increment_counter() { + let mut s = storage(); + let id1 = create_allowlist(&mut s); + let id2 = create_blocklist(&mut s); + assert_eq!((id1 >> 56) as u8, PolicyType::ALLOWLIST as u8); + assert_eq!((id2 >> 56) as u8, PolicyType::BLOCKLIST as u8); + assert_eq!(id1 & PolicyRegistryStorage::COUNTER_MASK, 2); + assert_eq!(id2 & PolicyRegistryStorage::COUNTER_MASK, 3); + } + + #[test] + fn create_policy_emits_policy_created_and_admin_updated_events() { + let mut s = storage(); + let id = create_allowlist(&mut s); + let events = s.get_events(PolicyRegistryStorage::ADDRESS); + assert_eq!(events.len(), 2); + let created = IPolicyRegistry::PolicyCreated::decode_log_data(&events[0]).unwrap(); + assert_eq!(created.policyId, id); + assert_eq!(created.creator, ADMIN); + assert_eq!(created.policyType, PolicyType::ALLOWLIST); + } + + #[test] + fn update_allowlist_emits_allowlist_updated_event() { + let mut s = storage(); + let id = create_allowlist(&mut s); + s.set_caller(ADMIN); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap(); + let events = s.get_events(PolicyRegistryStorage::ADDRESS); + let updated = + IPolicyRegistry::AllowlistUpdated::decode_log_data(events.last().unwrap()).unwrap(); + assert_eq!(updated.policyId, id); + assert_eq!(updated.updater, ADMIN); + assert!(updated.allowed); + assert_eq!(updated.accounts, vec![ALICE]); + } + + // --- ALLOWLIST membership --- + + #[test] + fn allowlist_non_member_is_not_authorized() { + let mut s = storage(); + let id = create_allowlist(&mut s); + assert!(!is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn allowlist_add_then_remove_member() { + let mut s = storage(); + let id = create_allowlist(&mut s); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, false, vec![ALICE]) + }) + .unwrap(); + assert!(!is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn allowlist_batch_update_flips_all_accounts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE, BOB]) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, false, vec![ALICE, BOB]) + }) + .unwrap(); + assert!(!is_authorized(&mut s, id, ALICE)); + assert!(!is_authorized(&mut s, id, BOB)); + } + + #[test] + fn update_allowlist_too_many_accounts_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + let accounts = many_accounts(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH + 1); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, accounts) + }) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH), + }) + ); + } + + #[test] + fn update_allowlist_max_batch_size_succeeds() { + let mut s = storage(); + let id = create_allowlist(&mut s); + let accounts = many_accounts(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, accounts) + }) + .unwrap(); + } + + #[test] + fn allowlist_readding_existing_member_is_idempotent() { + let mut s = storage(); + let id = create_allowlist(&mut s); + for _ in 0..2 { + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap(); + } + assert!(is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn allowlist_removing_non_member_is_idempotent() { + let mut s = storage(); + let id = create_allowlist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, false, vec![ALICE]) + }) + .unwrap(); + assert!(!is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn update_allowlist_on_blocklist_policy_reverts() { + let mut s = storage(); + let id = create_blocklist(&mut s); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- BLOCKLIST membership --- + + #[test] + fn blocklist_non_member_is_authorized() { + let mut s = storage(); + let id = create_blocklist(&mut s); + assert!(is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn blocklist_block_then_unblock_member() { + let mut s = storage(); + let id = create_blocklist(&mut s); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_blocklist(id, true, vec![ALICE]) + }) + .unwrap(); + assert!(!is_authorized(&mut s, id, ALICE)); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_blocklist(id, false, vec![ALICE]) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn update_blocklist_on_allowlist_policy_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_blocklist(id, true, vec![ALICE]) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + #[test] + fn update_blocklist_too_many_accounts_reverts() { + let mut s = storage(); + let id = create_blocklist(&mut s); + let accounts = many_accounts(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH + 1); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_blocklist(id, true, accounts) + }) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH), + }) + ); + } + + // --- createPolicyWithAccounts --- + + #[test] + fn create_policy_with_accounts_seeds_members() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy_with_accounts( + ADMIN, + PolicyType::ALLOWLIST, + vec![ALICE, BOB], + ) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + assert!(is_authorized(&mut s, id, BOB)); + } + + #[test] + fn create_policy_with_accounts_empty_batch_emits_seed_event() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy_with_accounts( + ADMIN, + PolicyType::ALLOWLIST, + Vec::new(), + ) + }) + .unwrap(); + + let events = s.get_events(PolicyRegistryStorage::ADDRESS); + assert_eq!(events.len(), 3); + let updated = + IPolicyRegistry::AllowlistUpdated::decode_log_data(events.last().unwrap()).unwrap(); + assert_eq!(updated.policyId, id); + assert_eq!(updated.updater, ADMIN); + assert!(updated.allowed); + assert!(updated.accounts.is_empty()); + } + + // --- two-step admin transfer --- + + #[test] + fn admin_transfer_two_step() { + let mut s = storage(); + let id = create_allowlist(&mut s); + + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).stage_update_admin(id, NEW_ADMIN) + }) + .unwrap(); + + s.set_caller(NEW_ADMIN); + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).finalize_update_admin(id)) + .unwrap(); + + s.set_caller(NEW_ADMIN); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn finalize_update_admin_without_pending_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).finalize_update_admin(id) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- renounceAdmin --- + + #[test] + fn renounce_admin_freezes_policy() { + let mut s = storage(); + let id = create_allowlist(&mut s); + + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).renounce_admin(id)) + .unwrap(); + + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- static call guard --- + + #[test] + fn write_in_static_context_reverts() { + let mut s = storage(); + s.set_static(true); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy(ADMIN, PolicyType::ALLOWLIST) + }) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::StaticCallViolation); + } + + // --- create_policy_with_accounts edge cases --- + + #[test] + fn create_policy_with_accounts_zero_account_is_seeded() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy_with_accounts( + ADMIN, + PolicyType::ALLOWLIST, + vec![ALICE, Address::ZERO], + ) + }) + .unwrap(); + StorageCtx::enter(&mut s, |ctx| { + assert!(PolicyRegistryStorage::new(ctx).is_authorized(id, Address::ZERO).unwrap()); + }); + } + + #[test] + fn create_policy_with_accounts_too_many_accounts_reverts() { + let mut s = storage(); + let accounts = many_accounts(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH + 1); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy_with_accounts( + ADMIN, + PolicyType::ALLOWLIST, + accounts, + ) + }) + .unwrap_err(); + assert_eq!( + err, + BasePrecompileError::revert(IPolicyRegistry::BatchSizeTooLarge { + maxBatchSize: U256::from(PolicyRegistryStorage::MAX_ACCOUNTS_PER_BATCH), + }) + ); + } + + #[test] + fn create_policy_with_accounts_blocklist_seeds_blocked_members() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).create_policy_with_accounts( + ADMIN, + PolicyType::BLOCKLIST, + vec![ALICE, BOB], + ) + }) + .unwrap(); + assert!(!is_authorized(&mut s, id, ALICE)); + assert!(!is_authorized(&mut s, id, BOB)); + } + + // --- stage_update_admin authorization --- + + #[test] + fn stage_update_admin_unauthorized_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + s.set_caller(ALICE); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).stage_update_admin(id, NEW_ADMIN) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- finalize_update_admin authorization --- + + #[test] + fn finalize_update_admin_unauthorized_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).stage_update_admin(id, NEW_ADMIN) + }) + .unwrap(); + s.set_caller(ALICE); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).finalize_update_admin(id) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- renounce_admin authorization --- + + #[test] + fn renounce_admin_unauthorized_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + s.set_caller(ALICE); + let err = + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).renounce_admin(id)) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + + // --- update_allowlist static call --- + + #[test] + fn update_allowlist_static_call_reverts() { + let mut s = storage(); + let id = create_allowlist(&mut s); + s.set_static(true); + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).update_allowlist(id, true, vec![ALICE]) + }) + .unwrap_err(); + assert_eq!(err, BasePrecompileError::StaticCallViolation); + } + + // --- policy_exists for built-in IDs --- + + #[test] + fn policy_exists_builtin_ids_always_return_true() { + let mut s = HashMapStorageProvider::new(1); + assert!( + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .policy_exists(PolicyRegistryStorage::ALWAYS_ALLOW_ID) + }) + .unwrap() + ); + assert!( + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .policy_exists(PolicyRegistryStorage::ALWAYS_BLOCK_ID) + }) + .unwrap() + ); + } + + // --- get_policy_admin for built-in IDs --- + + #[test] + fn get_policy_admin_builtin_ids_return_zero_address() { + let mut s = storage(); + assert_eq!( + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .get_policy_admin(PolicyRegistryStorage::ALWAYS_ALLOW_ID) + }) + .unwrap(), + Address::ZERO + ); + assert_eq!( + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .get_policy_admin(PolicyRegistryStorage::ALWAYS_BLOCK_ID) + }) + .unwrap(), + Address::ZERO + ); + } + + // --- pending_policy_admin for built-in IDs and unknown IDs --- + + #[test] + fn pending_policy_admin_builtin_ids_return_zero_address() { + let mut s = storage(); + assert_eq!( + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .pending_policy_admin(PolicyRegistryStorage::ALWAYS_ALLOW_ID) + }) + .unwrap(), + Address::ZERO + ); + assert_eq!( + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .pending_policy_admin(PolicyRegistryStorage::ALWAYS_BLOCK_ID) + }) + .unwrap(), + Address::ZERO + ); + } + + #[test] + fn pending_policy_admin_builtin_ids_short_circuit_staged_slot() { + let mut s = storage(); + for policy_id in + [PolicyRegistryStorage::ALWAYS_ALLOW_ID, PolicyRegistryStorage::ALWAYS_BLOCK_ID] + { + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_admins.at_mut(&policy_id).write(NEW_ADMIN) + }) + .unwrap(); + + let pending = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_policy_admin(policy_id) + }) + .unwrap(); + assert_eq!( + pending, + Address::ZERO, + "built-in policy {policy_id} must ignore a staged pending slot" + ); + } + } + + #[test] + fn pending_policy_admin_counter_one_blocklist_reads_staged_slot() { + // BLOCKLIST counter=1 is not ALWAYS_BLOCK_ID, which is ALLOWLIST counter=1. + let counter_one_blocklist = PolicyRegistryStorage::make_id(PolicyType::BLOCKLIST as u8, 1); + assert_ne!(counter_one_blocklist, PolicyRegistryStorage::ALWAYS_BLOCK_ID); + + let mut s = storage(); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx) + .pending_admins + .at_mut(&counter_one_blocklist) + .write(NEW_ADMIN) + }) + .unwrap(); + + let pending = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_policy_admin(counter_one_blocklist) + }) + .unwrap(); + assert_eq!(pending, NEW_ADMIN); + } + + #[test] + fn pending_policy_admin_unknown_id_returns_zero_address() { + let mut s = storage(); + let pending = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_policy_admin(0xdeadbeef) + }) + .unwrap(); + assert_eq!(pending, Address::ZERO); + } + + // A policy ID whose type byte is 2 (> ALLOWLIST=1) is malformed. + const MALFORMED_POLICY_ID: u64 = (2u64 << 56) | 42; + + #[test] + fn get_policy_admin_malformed_policy_id_returns_zero_address() { + let mut s = storage(); + let admin = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).get_policy_admin(MALFORMED_POLICY_ID) + }) + .unwrap(); + assert_eq!(admin, Address::ZERO); + } + + #[test] + fn get_policy_admin_nonexistent_policy_returns_zero_address() { + let mut s = storage(); + // 0xdeadbeef has type byte 0, so it is well-formed but was never created. + let admin = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).get_policy_admin(0xdeadbeef) + }) + .unwrap(); + assert_eq!(admin, Address::ZERO); + } + + #[test] + fn pending_policy_admin_malformed_policy_id_returns_zero_address() { + let mut s = storage(); + let pending = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_policy_admin(MALFORMED_POLICY_ID) + }) + .unwrap(); + assert_eq!(pending, Address::ZERO); + } + + #[test] + fn pending_policy_admin_nonexistent_well_formed_policy_returns_zero_address() { + // A well-formed ID (type byte in range) that was never created: storage + // slot is unwritten, so the read returns Address::ZERO without reverting. + let mut s = storage(); + let nonexistent = PolicyRegistryStorage::make_id(0, 999); + let pending = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_policy_admin(nonexistent) + }) + .unwrap(); + assert_eq!(pending, Address::ZERO); + } + + // --- builtin policies block mutations via Unauthorized --- + + #[test] + fn builtin_policies_reject_admin_mutations() { + let mut s = storage(); + // Both built-in policies have zero admin, so any caller gets Unauthorized. + for policy_id in + [PolicyRegistryStorage::ALWAYS_ALLOW_ID, PolicyRegistryStorage::ALWAYS_BLOCK_ID] + { + let err = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).stage_update_admin(policy_id, ALICE) + }) + .unwrap_err(); + assert!(matches!(err, BasePrecompileError::Revert(_))); + } + } + + // --- PolicyRegistryTrait delegation --- + + #[test] + fn trait_create_policy_delegates() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::create_policy(&mut reg, ADMIN, PolicyType::ALLOWLIST) + }) + .unwrap(); + assert_eq!((id >> 56) as u8, PolicyType::ALLOWLIST as u8); + } + + #[test] + fn trait_create_policy_with_accounts_delegates() { + let mut s = storage(); + let id = StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::create_policy_with_accounts( + &mut reg, + ADMIN, + PolicyType::ALLOWLIST, + vec![ALICE], + ) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn trait_stage_update_admin_delegates() { + let mut s = storage(); + let id = create_allowlist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::stage_update_admin(&mut reg, id, NEW_ADMIN) + }) + .unwrap(); + let pending = StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).pending_policy_admin(id) + }) + .unwrap(); + assert_eq!(pending, NEW_ADMIN); + } + + #[test] + fn trait_finalize_update_admin_delegates() { + let mut s = storage(); + let id = create_allowlist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + PolicyRegistryStorage::new(ctx).stage_update_admin(id, NEW_ADMIN) + }) + .unwrap(); + s.set_caller(NEW_ADMIN); + StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::finalize_update_admin(&mut reg, id) + }) + .unwrap(); + let admin = + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).get_policy_admin(id)) + .unwrap(); + assert_eq!(admin, NEW_ADMIN); + } + + #[test] + fn trait_renounce_admin_delegates() { + let mut s = storage(); + let id = create_allowlist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::renounce_admin(&mut reg, id) + }) + .unwrap(); + let admin = + StorageCtx::enter(&mut s, |ctx| PolicyRegistryStorage::new(ctx).get_policy_admin(id)) + .unwrap(); + assert_eq!(admin, Address::ZERO); + } + + #[test] + fn trait_update_allowlist_delegates() { + let mut s = storage(); + let id = create_allowlist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::update_allowlist(&mut reg, id, true, vec![ALICE]) + }) + .unwrap(); + assert!(is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn trait_update_blocklist_delegates() { + let mut s = storage(); + let id = create_blocklist(&mut s); + StorageCtx::enter(&mut s, |ctx| { + let mut reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::update_blocklist(&mut reg, id, true, vec![ALICE]) + }) + .unwrap(); + assert!(!is_authorized(&mut s, id, ALICE)); + } + + #[test] + fn trait_is_authorized_delegates() { + let mut s = storage(); + let authorized = StorageCtx::enter(&mut s, |ctx| { + let reg = PolicyRegistryStorage::new(ctx); + crate::Policy::is_authorized(®, PolicyRegistryStorage::ALWAYS_ALLOW_ID, ALICE) + }) + .unwrap(); + assert!(authorized); + } + + #[test] + fn trait_policy_exists_delegates() { + let mut s = storage(); + let exists = StorageCtx::enter(&mut s, |ctx| { + let reg = PolicyRegistryStorage::new(ctx); + crate::Policy::policy_exists(®, PolicyRegistryStorage::ALWAYS_ALLOW_ID) + }) + .unwrap(); + assert!(exists); + } + + #[test] + fn trait_get_policy_admin_delegates() { + let mut s = storage(); + let admin = StorageCtx::enter(&mut s, |ctx| { + let reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::get_policy_admin(®, PolicyRegistryStorage::ALWAYS_ALLOW_ID) + }) + .unwrap(); + assert_eq!(admin, Address::ZERO); + } + + #[test] + fn trait_pending_policy_admin_delegates() { + let mut s = storage(); + let id = create_allowlist(&mut s); + let pending = StorageCtx::enter(&mut s, |ctx| { + let reg = PolicyRegistryStorage::new(ctx); + crate::PolicyRegistry::pending_policy_admin(®, id) + }) + .unwrap(); + assert_eq!(pending, Address::ZERO); + } +} diff --git a/crates/common/precompiles/src/provider.rs b/crates/common/precompiles/src/provider.rs index 7888f8d522..e92e4d54dd 100644 --- a/crates/common/precompiles/src/provider.rs +++ b/crates/common/precompiles/src/provider.rs @@ -1,5 +1,7 @@ use alloc::{boxed::Box, string::String}; +use alloy_evm::precompiles::PrecompilesMap; +use alloy_primitives::Address; use base_common_chains::BaseUpgrade; use revm::{ context::Cfg, @@ -7,10 +9,13 @@ use revm::{ handler::{EthPrecompiles, PrecompileProvider}, interpreter::{CallInputs, InterpreterResult}, precompile::{self, Precompiles, bn254, modexp, secp256r1}, - primitives::{Address, OnceLock, hardfork::SpecId}, + primitives::{OnceLock, hardfork::SpecId}, }; -use crate::{BasePrecompileSpec, bls12_381, bn254_pair}; +use crate::{ + ActivationRegistry, B20Factory, BasePrecompileSpec, BerylLookup, NoopPrecompileCallObserver, + PolicyRegistryPrecompile, PrecompileCallObserver, bls12_381, bn254_pair, +}; /// Base precompile provider. #[derive(Debug, Clone)] @@ -19,6 +24,8 @@ pub struct BasePrecompiles { inner: EthPrecompiles, /// Spec id of the precompile provider. spec: S, + /// Activation registry admin address. + activation_admin_address: Option
, } impl BasePrecompiles { @@ -38,7 +45,25 @@ impl BasePrecompiles { upgrade => panic!("unsupported Base precompile upgrade: {upgrade}"), }; - Self { inner: EthPrecompiles { precompiles, spec: SpecId::default() }, spec } + Self { + inner: EthPrecompiles { precompiles, spec: SpecId::default() }, + spec, + activation_admin_address: None, + } + } + + /// Sets the activation registry admin address. + pub const fn with_activation_admin_address( + mut self, + activation_admin_address: Option
, + ) -> Self { + self.activation_admin_address = activation_admin_address; + self + } + + /// Returns the activation registry admin address. + pub const fn activation_admin_address(&self) -> Option
{ + self.activation_admin_address } /// Converts a Base upgrade into its Ethereum precompile spec. @@ -131,6 +156,39 @@ impl BasePrecompiles { precompiles }) } + + /// Returns precompiles for the Base Beryl spec. + /// + /// Static precompiles are the same as Azul; Beryl adds dynamic precompiles at install time. + pub fn beryl() -> &'static Precompiles { + Self::azul() + } + + /// Builds a [`PrecompilesMap`] with all Base precompiles for this spec installed. + /// + /// For Beryl and later, this also installs the dynamic token and registry precompiles. + #[must_use = "install returns the PrecompilesMap containing all installed Base precompiles"] + pub fn install(self) -> PrecompilesMap { + self.install_with_observer(NoopPrecompileCallObserver) + } + + /// Builds a [`PrecompilesMap`] with all Base precompiles for this spec installed and observed. + /// + /// For Beryl and later, this also installs the dynamic token and registry precompiles. + #[must_use = "install_with_observer returns the PrecompilesMap containing all installed Base precompiles"] + pub fn install_with_observer(self, observer: O) -> PrecompilesMap + where + O: PrecompileCallObserver, + { + let mut precompiles = PrecompilesMap::from_static(self.precompiles()); + if self.spec.upgrade() >= BaseUpgrade::Beryl { + B20Factory::install(&mut precompiles); + BerylLookup::install_with_observer(&mut precompiles, observer); + PolicyRegistryPrecompile::install(&mut precompiles); + ActivationRegistry::install(&mut precompiles, self.activation_admin_address); + } + precompiles + } } impl PrecompileProvider for BasePrecompiles @@ -145,7 +203,8 @@ where if spec == self.spec { return false; } - *self = Self::new_with_spec(spec); + *self = + Self::new_with_spec(spec).with_activation_admin_address(self.activation_admin_address); true } @@ -179,13 +238,18 @@ impl Default for BasePrecompiles { mod tests { use std::vec; + use alloy_primitives::{Address, B256}; + use base_common_chains::BaseUpgrade; use revm::{ - precompile::{PrecompileError, Precompiles, bls12_381_const, bn254, modexp, secp256r1}, + precompile::{Precompiles, bls12_381_const, bn254, modexp, secp256r1}, primitives::eip7823, }; + use rstest::rstest; - use super::*; - use crate::{bls12_381, bn254_pair}; + use crate::{ + ActivationRegistryStorage, B20FactoryStorage, B20Variant, BasePrecompiles, bls12_381, + bn254_pair, + }; type TestPrecompiles = BasePrecompiles; @@ -220,7 +284,7 @@ mod tests { let precompile = precompiles.get(&address).unwrap(); let input = vec![0u8; input_len]; assert!( - precompile.execute(&input, u64::MAX).is_ok(), + precompile.execute(&input, u64::MAX, 0).is_ok(), "precompile {address} should succeed at max input size" ); } @@ -257,34 +321,25 @@ mod tests { let mut bad_input_len = bn254_pair::JOVIAN_MAX_INPUT_SIZE + 1; assert!(bad_input_len < bn254_pair::GRANITE_MAX_INPUT_SIZE); let input = vec![0u8; bad_input_len]; - assert!(matches!( - bn254_pair_precompile.execute(&input, u64::MAX), - Err(PrecompileError::Bn254PairLength) - )); + assert!(bn254_pair_precompile.execute(&input, u64::MAX, 0).is_err()); let g1_msm = precompiles.precompiles().get(&bls12_381_const::G1_MSM_ADDRESS).unwrap(); bad_input_len = bls12_381::JOVIAN_G1_MSM_MAX_INPUT_SIZE + 1; assert!(bad_input_len < bls12_381::ISTHMUS_G1_MSM_MAX_INPUT_SIZE); let input = vec![0u8; bad_input_len]; - assert!( - matches!(g1_msm.execute(&input, u64::MAX), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); + assert!(g1_msm.execute(&input, u64::MAX, 0).is_err()); let g2_msm = precompiles.precompiles().get(&bls12_381_const::G2_MSM_ADDRESS).unwrap(); bad_input_len = bls12_381::JOVIAN_G2_MSM_MAX_INPUT_SIZE + 1; assert!(bad_input_len < bls12_381::ISTHMUS_G2_MSM_MAX_INPUT_SIZE); let input = vec![0u8; bad_input_len]; - assert!( - matches!(g2_msm.execute(&input, u64::MAX), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); + assert!(g2_msm.execute(&input, u64::MAX, 0).is_err()); let pairing = precompiles.precompiles().get(&bls12_381_const::PAIRING_ADDRESS).unwrap(); bad_input_len = bls12_381::JOVIAN_PAIRING_MAX_INPUT_SIZE + 1; assert!(bad_input_len < bls12_381::ISTHMUS_PAIRING_MAX_INPUT_SIZE); let input = vec![0u8; bad_input_len]; - assert!( - matches!(pairing.execute(&input, u64::MAX), Err(PrecompileError::Other(msg)) if msg.contains("input length too long")) - ); + assert!(pairing.execute(&input, u64::MAX, 0).is_err()); } #[test] @@ -318,19 +373,21 @@ mod tests { azul_precompiles.precompiles().get(secp256r1::P256VERIFY_OSAKA.address()).unwrap(); assert!(matches!( - jovian_p256.execute(&[], 5_000), + jovian_p256.execute(&[], 5_000, 0), Ok(output) if output.gas_used == secp256r1::P256VERIFY_BASE_GAS_FEE )); - assert!(matches!(azul_p256.execute(&[], 5_000), Err(PrecompileError::OutOfGas))); + assert!( + matches!(azul_p256.execute(&[], 5_000, 0), Ok(output) if output.halt_reason().is_some()) + ); let jovian_modexp = jovian_precompiles.precompiles().get(modexp::BERLIN.address()).unwrap(); let azul_modexp = azul_precompiles.precompiles().get(modexp::OSAKA.address()).unwrap(); let oversized_input = oversized_modexp_input(); - assert!(jovian_modexp.execute(&oversized_input, u64::MAX).is_ok()); + assert!(jovian_modexp.execute(&oversized_input, u64::MAX, 0).is_ok()); assert!(matches!( - azul_modexp.execute(&oversized_input, u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) + azul_modexp.execute(&oversized_input, u64::MAX, 0), + Ok(output) if output.halt_reason().is_some() )); } @@ -392,46 +449,19 @@ mod tests { fn test_modexp_eip7823_boundary() { let input_ok = modexp_input(eip7823::INPUT_SIZE_LIMIT, 1, 1); assert!( - !matches!( - modexp::osaka_run(&input_ok, u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - ), + modexp::osaka_run(&input_ok, u64::MAX).is_ok(), "base_len=1024 should not hit size limit" ); let input_too_large = modexp_input(eip7823::INPUT_SIZE_LIMIT + 1, 1, 1); - assert!(matches!( - modexp::osaka_run(&input_too_large, u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - )); - } - - #[test] - fn test_modexp_eip7823_each_field_rejects() { - let over = eip7823::INPUT_SIZE_LIMIT + 1; - - assert!(matches!( - modexp::osaka_run(&modexp_input(over, 0, 1), u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - )); - assert!(matches!( - modexp::osaka_run(&modexp_input(0, over, 1), u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - )); - assert!(matches!( - modexp::osaka_run(&modexp_input(0, 0, over), u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - )); + assert!(modexp::osaka_run(&input_too_large, u64::MAX).is_err()); } #[test] fn test_modexp_eip7823_all_fields_at_limit() { let limit = eip7823::INPUT_SIZE_LIMIT; assert!( - !matches!( - modexp::osaka_run(&modexp_input(limit, limit, limit), u64::MAX), - Err(PrecompileError::ModexpEip7823LimitSize) - ), + modexp::osaka_run(&modexp_input(limit, limit, limit), u64::MAX).is_ok(), "all fields at limit should not trigger size error" ); } @@ -461,7 +491,7 @@ mod tests { secp256r1::p256_verify_osaka(&[], 6_900), Ok(output) if output.gas_used == 6_900 )); - assert!(matches!(secp256r1::p256_verify_osaka(&[], 6_899), Err(PrecompileError::OutOfGas))); + assert!(secp256r1::p256_verify_osaka(&[], 6_899).is_err()); } #[test] @@ -471,4 +501,39 @@ mod tests { secp256r1::P256VERIFY_BASE_GAS_FEE * 2 ); } + + #[test] + fn install_preserves_base_precompile_set() { + let precompiles = BasePrecompiles::new_with_spec(BaseUpgrade::Jovian).install(); + + assert!(precompiles.get(&bn254::pair::ADDRESS).is_some()); + assert!(precompiles.get(secp256r1::P256VERIFY.address()).is_some()); + } + + #[rstest] + #[case::azul(BaseUpgrade::Azul, false)] + #[case::beryl(BaseUpgrade::Beryl, true)] + fn install_routes_b20_precompiles_by_fork(#[case] spec: BaseUpgrade, #[case] expected: bool) { + let precompiles = BasePrecompiles::new_with_spec(spec).install(); + let (token, _) = + B20Variant::B20.compute_address(Address::repeat_byte(0x11), B256::repeat_byte(0x22)); + + assert_eq!(precompiles.get(&B20FactoryStorage::ADDRESS).is_some(), expected); + assert_eq!(precompiles.get(&token).is_some(), expected); + assert!(precompiles.get(&Address::repeat_byte(0x42)).is_none()); + } + + #[test] + fn activation_registry_is_not_installed_before_beryl() { + let precompiles = BasePrecompiles::new_with_spec(BaseUpgrade::Azul).install(); + + assert!(precompiles.get(&ActivationRegistryStorage::ADDRESS).is_none()); + } + + #[test] + fn activation_registry_is_installed_at_beryl() { + let precompiles = BasePrecompiles::new_with_spec(BaseUpgrade::Beryl).install(); + + assert!(precompiles.get(&ActivationRegistryStorage::ADDRESS).is_some()); + } } diff --git a/crates/common/rpc-types-engine/src/attributes.rs b/crates/common/rpc-types-engine/src/attributes.rs index a654bea78c..fbcb55d248 100644 --- a/crates/common/rpc-types-engine/src/attributes.rs +++ b/crates/common/rpc-types-engine/src/attributes.rs @@ -235,6 +235,7 @@ mod test { suggested_fee_recipient: address!("0x4200000000000000000000000000000000000011"), withdrawals: Some([].into()), parent_beacon_block_root: b256!("0x8fe0193b9bf83cb7e5a08538e494fecc23046aab9a497af3704f4afdae3250ff").into(), + slot_number: None, }, transactions: Some([bytes!("7ef8f8a0dc19cfa777d90980e4875d0a548a881baaa3f83f14d1bc0d3038bc329350e54194deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8a4440a5e20000f424000000000000000000000000300000000670d6d890000000000000125000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000014bf9181db6e381d4384bbf69c48b0ee0eed23c6ca26143c6d2544f9d39997a590000000000000000000000007f83d659683caf2767fd3c720981d51f5bc365bc")].into()), no_tx_pool: None, @@ -268,6 +269,7 @@ mod test { suggested_fee_recipient: address!("0x4200000000000000000000000000000000000011"), withdrawals: Some([].into()), parent_beacon_block_root: b256!("0x8fe0193b9bf83cb7e5a08538e494fecc23046aab9a497af3704f4afdae3250ff").into(), + slot_number: None, }, transactions: Some([bytes!("7ef8f8a0dc19cfa777d90980e4875d0a548a881baaa3f83f14d1bc0d3038bc329350e54194deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8a4440a5e20000f424000000000000000000000000300000000670d6d890000000000000125000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000014bf9181db6e381d4384bbf69c48b0ee0eed23c6ca26143c6d2544f9d39997a590000000000000000000000007f83d659683caf2767fd3c720981d51f5bc365bc")].into()), no_tx_pool: None, @@ -296,6 +298,7 @@ mod test { suggested_fee_recipient: Address::ZERO, withdrawals: Default::default(), parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, }, transactions: Some(vec![b"hello".to_vec().into()]), no_tx_pool: Some(true), @@ -319,6 +322,7 @@ mod test { suggested_fee_recipient: Address::ZERO, withdrawals: Default::default(), parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, }, transactions: Some(vec![b"hello".to_vec().into()]), no_tx_pool: Some(true), @@ -360,6 +364,7 @@ mod test { suggested_fee_recipient: Address::ZERO, withdrawals: Default::default(), parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, }, transactions: Some(vec![b"hello".to_vec().into()]), no_tx_pool: Some(true), @@ -383,6 +388,7 @@ mod test { suggested_fee_recipient: Address::ZERO, withdrawals: Default::default(), parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, }, transactions: Some(vec![b"hello".to_vec().into()]), no_tx_pool: Some(true), diff --git a/crates/common/rpc-types-engine/src/payload/mod.rs b/crates/common/rpc-types-engine/src/payload/mod.rs index 113e9e1c41..271024c482 100644 --- a/crates/common/rpc-types-engine/src/payload/mod.rs +++ b/crates/common/rpc-types-engine/src/payload/mod.rs @@ -521,6 +521,7 @@ impl BaseExecutionPayload { blob_gas_used: self.blob_gas_used(), difficulty: U256::ZERO, mix_hash: Some(self.prev_randao()), + slot_number: None, } } diff --git a/crates/common/rpc-types-engine/src/reth.rs b/crates/common/rpc-types-engine/src/reth.rs index 1ecd22c1dc..87492c4933 100644 --- a/crates/common/rpc-types-engine/src/reth.rs +++ b/crates/common/rpc-types-engine/src/reth.rs @@ -4,11 +4,16 @@ use alloc::vec::Vec; use alloy_eips::eip4895::Withdrawal; use alloy_primitives::{B256, Bytes}; +use alloy_rpc_types_engine::PayloadId; use reth_payload_primitives::{ExecutionPayload, PayloadAttributes}; use crate::{BasePayloadAttributes, ExecutionData}; impl PayloadAttributes for BasePayloadAttributes { + fn payload_id(&self, parent_hash: &B256) -> PayloadId { + self.payload_attributes.payload_id(parent_hash) + } + fn timestamp(&self) -> u64 { self.payload_attributes.timestamp } @@ -20,6 +25,10 @@ impl PayloadAttributes for BasePayloadAttributes { fn parent_beacon_block_root(&self) -> Option { self.payload_attributes.parent_beacon_block_root } + + fn slot_number(&self) -> Option { + self.payload_attributes.slot_number + } } impl ExecutionPayload for ExecutionData { @@ -55,6 +64,14 @@ impl ExecutionPayload for ExecutionData { self.payload.as_v1().gas_used } + fn gas_limit(&self) -> u64 { + self.payload.gas_limit() + } + + fn slot_number(&self) -> Option { + None + } + fn transaction_count(&self) -> usize { self.payload.as_v1().transactions.len() } diff --git a/crates/common/rpc-types/src/genesis.rs b/crates/common/rpc-types/src/genesis.rs index 6aa2e80e04..fab874deac 100644 --- a/crates/common/rpc-types/src/genesis.rs +++ b/crates/common/rpc-types/src/genesis.rs @@ -1,5 +1,6 @@ //! Base types for genesis data. +use alloy_primitives::Address; use alloy_serde::OtherFields; use serde::de::Error; @@ -69,6 +70,9 @@ pub struct GenesisInfo { /// Base-specific hardfork activation times. #[serde(default)] pub base: HardforkInfo, + /// Activation registry admin address. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub activation_admin_address: Option
, } impl GenesisInfo { @@ -152,6 +156,7 @@ mod tests { isthmus_time: None, jovian_time: None, base: HardforkInfo { azul: Some(14), beryl: Some(16) }, + activation_admin_address: None, } ); } @@ -217,6 +222,7 @@ mod tests { isthmus_time: None, jovian_time: None, base: HardforkInfo { azul: Some(14), beryl: Some(16) }, + activation_admin_address: None, }), base_fee_info: Some(FeeInfo { eip1559_elasticity: None, @@ -242,6 +248,7 @@ mod tests { isthmus_time: None, jovian_time: None, base: HardforkInfo { azul: Some(14), beryl: Some(16) }, + activation_admin_address: None, }), base_fee_info: Some(FeeInfo { eip1559_elasticity: None, @@ -285,6 +292,7 @@ mod tests { isthmus_time: Some(0), jovian_time: Some(0), base: HardforkInfo::default(), + activation_admin_address: None, }), base_fee_info: None, } diff --git a/crates/common/rpc-types/src/receipt.rs b/crates/common/rpc-types/src/receipt.rs index 639cd2c1a1..1d087ec3c9 100644 --- a/crates/common/rpc-types/src/receipt.rs +++ b/crates/common/rpc-types/src/receipt.rs @@ -237,6 +237,9 @@ impl From for BaseReceiptEnvelope { BaseReceipt::Eip7702(receipt) => { Self::Eip7702(convert_standard_receipt(receipt, logs_bloom)) } + BaseReceipt::Eip8130(receipt) => { + Self::Eip8130(convert_standard_receipt(receipt, logs_bloom)) + } BaseReceipt::Deposit(receipt) => { let consensus_logs = receipt.inner.logs.into_iter().map(|log| log.inner).collect(); let consensus_receipt = DepositReceiptWithBloom { diff --git a/crates/common/rpc-types/src/reth.rs b/crates/common/rpc-types/src/reth.rs index ccf18c9f38..ad2838cbc7 100644 --- a/crates/common/rpc-types/src/reth.rs +++ b/crates/common/rpc-types/src/reth.rs @@ -13,9 +13,7 @@ use alloy_primitives::{Address, Bytes}; use alloy_signer::Signature; use base_common_consensus::{BaseTransactionInfo, BaseTxEnvelope}; use base_common_evm::BaseTransaction as BaseRevm; -use reth_rpc_convert::{ - SignTxRequestError, SignableTxRequest, TryIntoSimTx, transaction::FromConsensusTx, -}; +use reth_rpc_convert::{FromConsensusTx, SignTxRequestError, SignableTxRequest, TryIntoSimTx}; use revm::context::TxEnv; use crate::{BaseTransactionRequest, Transaction}; @@ -36,13 +34,12 @@ impl FromConsensusTx for Transaction { } } -impl TryIntoTxEnv, Block> for BaseTransactionRequest { +impl TryIntoTxEnv, Spec, Block> + for BaseTransactionRequest +{ type Err = EthTxEnvError; - fn try_into_tx_env( - self, - evm_env: &EvmEnv, - ) -> Result, Self::Err> { + fn try_into_tx_env(self, evm_env: &EvmEnv) -> Result, Self::Err> { Ok(BaseRevm { base: self.as_ref().clone().try_into_tx_env(evm_env)?, enveloped_tx: Some(Bytes::new()), diff --git a/crates/common/rpc-types/src/transaction.rs b/crates/common/rpc-types/src/transaction.rs index 62a97c606a..398e6c0ef9 100644 --- a/crates/common/rpc-types/src/transaction.rs +++ b/crates/common/rpc-types/src/transaction.rs @@ -51,6 +51,7 @@ impl Transaction { inner: tx, block_hash: tx_info.inner.block_hash, block_number: tx_info.inner.block_number, + block_timestamp: tx_info.inner.block_timestamp, transaction_index: tx_info.inner.index, effective_gas_price: Some(effective_gas_price), }, @@ -247,6 +248,12 @@ mod tx_serde { skip_serializing_if = "Option::is_none", with = "alloy_serde::quantity::opt" )] + block_timestamp: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "alloy_serde::quantity::opt" + )] deposit_receipt_version: Option, #[serde(flatten)] @@ -261,6 +268,7 @@ mod tx_serde { inner, block_hash, block_number, + block_timestamp, transaction_index, effective_gas_price, }, @@ -279,6 +287,7 @@ mod tx_serde { block_hash, block_number, transaction_index, + block_timestamp, deposit_receipt_version, other: OptionalFields { from, effective_gas_price, deposit_nonce }, } @@ -294,6 +303,7 @@ mod tx_serde { block_hash, block_number, transaction_index, + block_timestamp, deposit_receipt_version, other, } = value; @@ -319,6 +329,7 @@ mod tx_serde { inner: Recovered::new_unchecked(inner, from), block_hash, block_number, + block_timestamp, transaction_index, effective_gas_price, }, @@ -331,6 +342,12 @@ mod tx_serde { #[cfg(test)] mod tests { + use alloc::vec; + + use alloy_consensus::transaction::SignerRecoverable; + use alloy_primitives::Bytes; + use base_common_consensus::{Eip8130Signed, TxEip8130}; + use super::*; #[test] @@ -352,4 +369,69 @@ mod tests { let expected = serde_json::from_str::(rpc_tx).unwrap(); similar_asserts::assert_eq!(deserialized, expected); } + + #[test] + fn can_serialize_eip8130() { + let tx_body = TxEip8130 { + chain_id: 8453, + sender: Some(Address::with_last_byte(0x11)), + nonce_key: U256::from(0u64), + nonce_sequence: 7, + expiry: 0, + max_priority_fee_per_gas: 1_000_000_000, + max_fee_per_gas: 5_000_000_000, + gas_limit: 1_000_000, + account_changes: vec![], + calls: vec![], + payer: None, + }; + let sender_auth = Bytes::from_static(&[0xAB; 32]); + let payer_auth = Bytes::new(); + let signed = Eip8130Signed::new(tx_body, sender_auth, payer_auth); + let envelope = BaseTxEnvelope::Eip8130(signed); + + let recovered = envelope.try_into_recovered().expect("recover eip-8130 explicit sender"); + let tx_info = BaseTransactionInfo { + inner: alloy_rpc_types_eth::TransactionInfo { + hash: Some(B256::repeat_byte(0x42)), + block_hash: Some(B256::repeat_byte(0x01)), + block_number: Some(100), + block_timestamp: Some(1_700_000_000), + index: Some(0), + base_fee: Some(1_000_000_000), + }, + deposit_meta: Default::default(), + }; + let rpc_tx = Transaction::from_transaction(recovered, tx_info); + + assert_eq!(rpc_tx.ty(), 0x7D); + assert_eq!(rpc_tx.deposit_nonce, None); + assert_eq!(rpc_tx.deposit_receipt_version, None); + assert_eq!(rpc_tx.inner.inner.signer(), Address::with_last_byte(0x11)); + + let serialized = serde_json::to_string(&rpc_tx).expect("serialize eip-8130 rpc tx"); + let value: serde_json::Value = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(value["type"], "0x7d", "tx type byte exposed in RPC response"); + assert_eq!(value["from"], "0x0000000000000000000000000000000000000011"); + assert_eq!(value["blockNumber"], "0x64"); + assert_eq!(value["transactionIndex"], "0x0"); + + let tx_payload = &value["tx"]; + assert!(tx_payload.is_object(), "EIP-8130 inner tx payload present"); + assert_eq!(tx_payload["chainId"], 8453); + assert_eq!(tx_payload["nonceKey"], "0x0"); + assert_eq!(tx_payload["nonceSequence"], 7); + assert_eq!(tx_payload["gasLimit"], 1_000_000); + assert!(tx_payload["accountChanges"].is_array()); + assert!(tx_payload["calls"].is_array()); + assert_eq!(tx_payload["sender"], "0x0000000000000000000000000000000000000011"); + + assert_eq!(value["senderAuth"], format!("0x{}", "ab".repeat(32))); + assert_eq!(value["payerAuth"], "0x"); + + assert!(value.get("sourceHash").is_none(), "no deposit-only fields leak"); + assert!(value.get("depositReceiptVersion").is_none()); + assert!(value.get("mint").is_none()); + } } diff --git a/crates/common/rpc-types/src/transaction/request.rs b/crates/common/rpc-types/src/transaction/request.rs index f096eba3a4..62e61e442d 100644 --- a/crates/common/rpc-types/src/transaction/request.rs +++ b/crates/common/rpc-types/src/transaction/request.rs @@ -4,12 +4,16 @@ use alloy_consensus::{ Sealed, SignableTransaction, Signed, TxEip1559, TxEip4844, TypedTransaction, }; use alloy_eips::eip7702::SignedAuthorization; +#[cfg(feature = "reth")] +use alloy_network::TransactionBuilder; use alloy_network_primitives::TransactionBuilder7702; use alloy_primitives::{Address, Bytes, ChainId, Signature, TxKind, U256}; use alloy_rpc_types_eth::{AccessList, TransactionInput, TransactionRequest}; use base_common_consensus::{BaseTxEnvelope, BaseTypedTransaction, TxDeposit}; use serde::{Deserialize, Serialize}; +use crate::Transaction; + /// Builder for [`BaseTypedTransaction`]. #[derive( Clone, @@ -195,6 +199,9 @@ impl From for BaseTransactionRequest { BaseTypedTransaction::Eip2930(tx) => Self(tx.into()), BaseTypedTransaction::Eip1559(tx) => Self(tx.into()), BaseTypedTransaction::Eip7702(tx) => Self(tx.into()), + BaseTypedTransaction::Eip8130(_) => unimplemented!( + "BaseTypedTransaction::Eip8130 cannot be projected onto BaseTransactionRequest; AA transactions have no single sender/recipient/value" + ), BaseTypedTransaction::Deposit(tx) => tx.into(), } } @@ -207,11 +214,23 @@ impl From for BaseTransactionRequest { BaseTxEnvelope::Eip2930(tx) => tx.into(), BaseTxEnvelope::Eip1559(tx) => tx.into(), BaseTxEnvelope::Eip7702(tx) => tx.into(), + BaseTxEnvelope::Eip8130(_) => unimplemented!( + "BaseTxEnvelope::Eip8130 cannot be projected onto BaseTransactionRequest; AA transactions have no single sender/recipient/value" + ), BaseTxEnvelope::Deposit(tx) => tx.into(), } } } +impl From for BaseTransactionRequest { + fn from(value: Transaction) -> Self { + let (tx, signer) = value.inner.inner.into_parts(); + let mut request: Self = tx.into(); + request.as_mut().from = Some(signer); + request + } +} + impl TransactionBuilder7702 for BaseTransactionRequest { fn authorization_list(&self) -> Option<&Vec> { self.as_ref().authorization_list() @@ -221,3 +240,102 @@ impl TransactionBuilder7702 for BaseTransactionRequest { self.as_mut().set_authorization_list(authorization_list); } } + +#[cfg(feature = "reth")] +impl TransactionBuilder for BaseTransactionRequest { + fn chain_id(&self) -> Option { + self.as_ref().chain_id() + } + + fn set_chain_id(&mut self, chain_id: ChainId) { + self.as_mut().set_chain_id(chain_id); + } + + fn nonce(&self) -> Option { + self.as_ref().nonce() + } + + fn set_nonce(&mut self, nonce: u64) { + self.as_mut().set_nonce(nonce); + } + + fn take_nonce(&mut self) -> Option { + self.as_mut().nonce.take() + } + + fn input(&self) -> Option<&Bytes> { + self.as_ref().input() + } + + fn set_input>(&mut self, input: T) { + self.as_mut().set_input(input); + } + + fn from(&self) -> Option
{ + self.as_ref().from() + } + + fn set_from(&mut self, from: Address) { + self.as_mut().set_from(from); + } + + fn kind(&self) -> Option { + self.as_ref().kind() + } + + fn clear_kind(&mut self) { + self.as_mut().clear_kind(); + } + + fn set_kind(&mut self, kind: TxKind) { + self.as_mut().set_kind(kind); + } + + fn value(&self) -> Option { + self.as_ref().value() + } + + fn set_value(&mut self, value: U256) { + self.as_mut().set_value(value); + } + + fn gas_price(&self) -> Option { + self.as_ref().gas_price() + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.as_mut().set_gas_price(gas_price); + } + + fn max_fee_per_gas(&self) -> Option { + self.as_ref().max_fee_per_gas() + } + + fn set_max_fee_per_gas(&mut self, max_fee_per_gas: u128) { + self.as_mut().set_max_fee_per_gas(max_fee_per_gas); + } + + fn max_priority_fee_per_gas(&self) -> Option { + self.as_ref().max_priority_fee_per_gas() + } + + fn set_max_priority_fee_per_gas(&mut self, max_priority_fee_per_gas: u128) { + self.as_mut().set_max_priority_fee_per_gas(max_priority_fee_per_gas); + } + + fn gas_limit(&self) -> Option { + self.as_ref().gas_limit() + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.as_mut().set_gas_limit(gas_limit); + } + + fn access_list(&self) -> Option<&AccessList> { + self.as_ref().access_list() + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.as_mut().set_access_list(access_list); + } +} diff --git a/crates/common/signer/Cargo.toml b/crates/common/signer/Cargo.toml index 6052584811..27fdbed0cd 100644 --- a/crates/common/signer/Cargo.toml +++ b/crates/common/signer/Cargo.toml @@ -30,6 +30,6 @@ thiserror = { workspace = true, features = ["std"] } jsonrpsee = { workspace = true, features = ["http-client", "macros"] } [dev-dependencies] -alloy-signer-local.workspace = true alloy-node-bindings.workspace = true +alloy-signer-local.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/consensus/cli/Cargo.toml b/crates/consensus/cli/Cargo.toml index 818d4f40bc..2c3b9b0faa 100644 --- a/crates/consensus/cli/Cargo.toml +++ b/crates/consensus/cli/Cargo.toml @@ -49,6 +49,7 @@ clap = { workspace = true, features = ["derive", "env"] } # tokio tokio.workspace = true +tokio-util.workspace = true # tracing tracing = { workspace = true, features = ["std"] } diff --git a/crates/consensus/cli/src/config.rs b/crates/consensus/cli/src/config.rs index ac56ea8eb7..a4e2fa080e 100644 --- a/crates/consensus/cli/src/config.rs +++ b/crates/consensus/cli/src/config.rs @@ -7,7 +7,6 @@ use std::{fs::File, path::PathBuf}; use alloy_chains::Chain; use alloy_genesis::ChainConfig; -use base_common_chains::Registry; use base_common_genesis::RollupConfig; use serde_json::from_reader; use tracing::debug; @@ -33,7 +32,7 @@ pub enum ConfigError { #[derive(Clone, Debug, Default, clap::Args)] pub struct L1ConfigFile { /// Path to a custom L1 chain configuration file. - /// (overrides the default configuration from the registry) + /// (overrides the default configuration from the built-in Ethereum L1 mapping) #[arg(long, visible_alias = "rollup-l1-cfg", env = "BASE_NODE_L1_CHAIN_CONFIG")] pub l1_config_file: Option, } @@ -74,11 +73,11 @@ impl L1ConfigFile { /// L2 rollup configuration file path wrapper. /// /// Wraps an optional path to a custom L2 rollup configuration file. -/// If no path is provided, the configuration is loaded from the registry. +/// If no path is provided, the configuration is loaded from the built-in Base chain config. #[derive(Clone, Debug, Default, clap::Args)] pub struct L2ConfigFile { /// Path to a custom L2 rollup configuration file. - /// (overrides the default rollup configuration from the registry) + /// (overrides the default rollup configuration from the built-in Base chain config) #[arg(long, visible_alias = "rollup-cfg", env = "BASE_NODE_ROLLUP_CONFIG")] pub l2_config_file: Option, } @@ -97,7 +96,7 @@ impl L2ConfigFile { /// Loads the L2 rollup configuration. /// /// If a file path is set, loads the configuration from the JSON file. - /// Otherwise, falls back to the superchain registry using the provided chain. + /// Otherwise, falls back to the built-in Base chain config using the provided chain. pub fn load(&self, l2_chain: &Chain) -> Result { match &self.l2_config_file { Some(path) => { @@ -106,10 +105,9 @@ impl L2ConfigFile { from_reader(file).map_err(ConfigError::Parse) } None => { - debug!("Loading l2 config from registry"); - let cfg = Registry::rollup_config_by_chain(l2_chain) - .ok_or_else(|| ConfigError::NotFound(l2_chain.id()))?; - Ok(cfg.clone()) + debug!("loading l2 config from built-in chain config"); + base_common_chains::rollup_config!(l2_chain) + .ok_or_else(|| ConfigError::NotFound(l2_chain.id())) } } } diff --git a/crates/consensus/cli/src/follow.rs b/crates/consensus/cli/src/follow.rs index 204c282109..af367a7382 100644 --- a/crates/consensus/cli/src/follow.rs +++ b/crates/consensus/cli/src/follow.rs @@ -1,14 +1,16 @@ //! Reusable consensus follow-node arguments and launch helpers. -use std::sync::Arc; +use std::{num::ParseIntError, sync::Arc, time::Duration}; use alloy_provider::{Provider, RootProvider}; -use alloy_rpc_types_engine::JwtSecret; use base_cli_utils::{LogConfig, RuntimeManager}; use base_common_genesis::RollupConfig; use base_common_network::Base; -use base_consensus_node::{DelegateL2Client, EngineConfig, FollowNode, L1Config, NodeMode}; +use base_consensus_node::{ + EngineConfig, FollowNode, FollowNodeConfig, L1Config, NodeMode, RemoteL2Client, +}; use base_consensus_providers::OnlineBeaconClient; +use base_consensus_rpc::RpcBuilder; use clap::Args; use tracing::{error, info, warn}; use url::Url; @@ -18,17 +20,6 @@ use crate::{ MetricsArgs, RpcArgs, metrics::CliMetrics, }; -/// Overrides supplied by callers that embed a follow node alongside another service. -#[derive(Clone, Debug, Default)] -pub struct ConsensusFollowNodeOverrides { - /// Override for the L2 Engine API endpoint. - pub l2_engine_rpc: Option, - /// Override for the L2 Engine API JWT secret. - pub l2_engine_jwt_secret: Option, - /// Override for the local unauthenticated L2 RPC endpoint. - pub local_l2_rpc: Option, -} - /// Standalone consensus follow-node command. #[derive(Args, Clone, Debug)] pub struct ConsensusFollowNodeCommand { @@ -58,12 +49,12 @@ impl ConsensusFollowNodeCommand { })?; let args = ConsensusFollowNodeArgs::new(chain, self.args); - let cfg = args.load_rollup_config()?; if self.metrics.enabled { + let cfg = args.load_rollup_config()?; CliMetrics::init_rollup_config(&cfg); } - RuntimeManager::new().run_until_ctrl_c(args.start_with_overrides(cfg, Default::default())) + RuntimeManager::new().run_until_ctrl_c(args.start()) } } @@ -107,18 +98,29 @@ pub struct ConsensusFollowNodeConfigArgs { pub l2_client_args: L2ClientArgs, /// Gate sync behind proofs progress via `debug_proofsSyncStatus`. - #[arg(long = "proofs", env = "BASE_NODE_PROOFS")] + #[arg(long = "proofs", default_value_t = false, env = "BASE_NODE_PROOFS")] pub proofs: bool, /// Maximum number of blocks the follow node may advance beyond the proofs /// `ExEx` head. Only effective when `--proofs` is enabled. #[arg( long = "proofs.max-blocks-ahead", - default_value_t = 512, + default_value_t = 16, env = "BASE_NODE_PROOFS_MAX_BLOCKS_AHEAD" )] pub proofs_max_blocks_ahead: u64, + /// Delay after each successful source payload insert, in milliseconds. + #[arg( + long = "follow.insert-delay-ms", + default_value = "0", + value_parser = |arg: &str| -> Result { + Ok(Duration::from_millis(arg.parse()?)) + }, + env = "BASE_NODE_FOLLOW_INSERT_DELAY_MS" + )] + pub insert_delay: Duration, + /// RPC CLI arguments. #[command(flatten)] pub rpc_flags: RpcArgs, @@ -144,39 +146,20 @@ impl ConsensusFollowNodeArgs { /// Builds a follow node with default external endpoint configuration. pub async fn build_follow_node(&self) -> eyre::Result { - self.build_follow_node_with_overrides( - self.load_rollup_config()?, - ConsensusFollowNodeOverrides::default(), - ) - .await - } - - /// Builds a follow node with caller-supplied endpoint overrides. - pub async fn build_follow_node_with_overrides( - &self, - cfg: RollupConfig, - overrides: ConsensusFollowNodeOverrides, - ) -> eyre::Result { - let local_l2_provider = self.local_l2_provider(&overrides); - self.build_follow_node_with_provider(cfg, overrides, local_l2_provider).await + let cfg = self.load_rollup_config()?; + let local_l2_provider = self.local_l2_provider(); + self.follow_node(cfg, local_l2_provider).await } - /// Builds a follow node with a caller-supplied local L2 provider. - pub async fn build_follow_node_with_provider( + /// Builds a follow node from explicit runtime dependencies. + async fn follow_node( &self, cfg: RollupConfig, - overrides: ConsensusFollowNodeOverrides, local_l2_provider: RootProvider, ) -> eyre::Result { - let l2_engine_rpc = overrides - .l2_engine_rpc - .unwrap_or_else(|| self.config.l2_client_args.l2_engine_rpc.clone()); - let jwt_secret = match overrides.l2_engine_jwt_secret { - Some(secret) => secret, - None => { - self.config.l2_client_args.resolve_jwt_secret_for_endpoint(&l2_engine_rpc).await? - } - }; + let l2_engine_rpc = self.config.l2_client_args.l2_engine_rpc.clone(); + let jwt_secret = + self.config.l2_client_args.resolve_jwt_secret_for_endpoint(&l2_engine_rpc).await?; let rollup_config = Arc::new(cfg.clone()); let engine_config = EngineConfig { @@ -186,37 +169,26 @@ impl ConsensusFollowNodeArgs { l1_url: self.config.l1_rpc_args.l1_eth_rpc.clone(), mode: NodeMode::Validator, }; - let l2_source = DelegateL2Client::new(self.config.source_l2_rpc.clone()); - let rpc_builder = self.config.rpc_flags.clone().into(); - let l1_config = self.l1_config(&cfg)?; + let engine_client = + Arc::new(engine_config.build_engine_client().await.map_err(|e| eyre::eyre!(e))?); + let l2_source = RemoteL2Client::new(self.config.source_l2_rpc.clone()); + let rpc_builder = Option::::from(self.config.rpc_flags.clone()); - Ok(FollowNode::new( + Ok(FollowNode::new(FollowNodeConfig { rollup_config, - engine_config, + engine_client, local_l2_provider, l2_source, rpc_builder, - l1_config, - ) - .with_proofs(self.config.proofs) - .with_proofs_max_blocks_ahead(self.config.proofs_max_blocks_ahead)) + proofs_enabled: self.config.proofs, + proofs_max_blocks_ahead: self.config.proofs_max_blocks_ahead, + insert_delay: self.config.insert_delay, + })) } - /// Starts a follow node with default external endpoint configuration. + /// Starts a follow node. pub async fn start(&self) -> eyre::Result<()> { - self.start_with_overrides( - self.load_rollup_config()?, - ConsensusFollowNodeOverrides::default(), - ) - .await - } - - /// Starts a follow node with caller-supplied endpoint overrides. - pub async fn start_with_overrides( - &self, - cfg: RollupConfig, - overrides: ConsensusFollowNodeOverrides, - ) -> eyre::Result<()> { + let cfg = self.load_rollup_config()?; if !self.config.proofs { warn!( target: "rollup_node", @@ -231,31 +203,22 @@ impl ConsensusFollowNodeArgs { "Starting follow node" ); - let local_l2_provider = self.local_l2_provider(&overrides); + let local_l2_provider = self.local_l2_provider(); if self.config.proofs { self.check_proofs_rpc(&local_l2_provider).await?; } - self.build_follow_node_with_provider(cfg, overrides, local_l2_provider) - .await? - .start() - .await - .map_err(|e| { - error!(target: "rollup_node", error = %e, "Failed to start follow node"); - eyre::eyre!(e) - })?; + self.follow_node(cfg, local_l2_provider).await?.start().await.map_err(|e| { + error!(target: "rollup_node", error = %e, "Failed to start follow node"); + eyre::eyre!(e) + })?; Ok(()) } - /// Builds the local L2 RPC provider from CLI arguments and overrides. - pub fn local_l2_provider( - &self, - overrides: &ConsensusFollowNodeOverrides, - ) -> RootProvider { - let local_l2_rpc = - overrides.local_l2_rpc.clone().unwrap_or_else(|| self.config.l2_rpc_url.clone()); - RootProvider::::new_http(local_l2_rpc) + /// Builds the local L2 RPC provider from CLI arguments. + pub fn local_l2_provider(&self) -> RootProvider { + RootProvider::::new_http(self.config.l2_rpc_url.clone()) } /// Checks that the local execution node exposes the proofs sync RPC. @@ -287,3 +250,48 @@ impl ConsensusFollowNodeArgs { }) } } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[derive(Parser)] + struct Command { + #[command(flatten)] + args: ConsensusFollowNodeConfigArgs, + } + + fn parse_config(args: &[&str]) -> ConsensusFollowNodeConfigArgs { + let required = [ + "test", + "--source-l2-rpc", + "http://localhost:8545", + "--l2-engine-rpc", + "http://localhost:8551", + "--l1-eth-rpc", + "http://localhost:8545", + "--l1-beacon", + "http://localhost:5052", + ]; + Command::parse_from([required.as_slice(), args].concat()).args + } + + #[test] + fn proofs_default_to_disabled() { + assert!(!parse_config(&[]).proofs); + } + + #[test] + fn proofs_accept_bare_flag() { + assert!(parse_config(&["--proofs"]).proofs); + } + + #[test] + fn rpc_disabled_stays_optional() { + let config = parse_config(&["--rpc.disabled"]); + + assert!(Option::::from(config.rpc_flags).is_none()); + } +} diff --git a/crates/consensus/cli/src/l2.rs b/crates/consensus/cli/src/l2.rs index 5b81c8fed3..bb4119cb54 100644 --- a/crates/consensus/cli/src/l2.rs +++ b/crates/consensus/cli/src/l2.rs @@ -42,6 +42,35 @@ pub struct L2ClientArgs { pub l2_trust_rpc: bool, } +/// L2 client arguments for embedded consensus nodes. +#[derive(Clone, Debug, clap::Args)] +pub struct EmbeddedL2ClientArgs { + /// JWT secret for the auth-rpc endpoint of the execution client. + /// This MUST be a valid path to a file containing the hex-encoded JWT secret. + #[arg(long, visible_alias = "l2.jwt-secret", env = "BASE_NODE_L2_ENGINE_AUTH")] + pub l2_engine_jwt_secret: Option, + /// Hex encoded JWT secret to use for the authenticated engine-API RPC server. + /// This MUST be a valid hex-encoded JWT secret of 64 digits. + #[arg(long, visible_alias = "l2.jwt-secret-encoded", env = "BASE_NODE_L2_ENGINE_AUTH_ENCODED")] + pub l2_engine_jwt_encoded: Option, + /// Timeout for http calls in milliseconds. + #[arg( + long, + visible_alias = "l2.timeout", + env = "BASE_NODE_L2_ENGINE_TIMEOUT", + default_value_t = DEFAULT_L2_ENGINE_TIMEOUT + )] + pub l2_engine_timeout: u64, + /// If false, block hash verification is performed for all retrieved blocks. + #[arg( + long, + visible_alias = "l2.trust-rpc", + env = "BASE_NODE_L2_TRUST_RPC", + default_value_t = DEFAULT_L2_TRUST_RPC + )] + pub l2_trust_rpc: bool, +} + impl Default for L2ClientArgs { fn default() -> Self { Self { @@ -54,6 +83,29 @@ impl Default for L2ClientArgs { } } +impl Default for EmbeddedL2ClientArgs { + fn default() -> Self { + Self { + l2_engine_jwt_secret: None, + l2_engine_jwt_encoded: None, + l2_engine_timeout: DEFAULT_L2_ENGINE_TIMEOUT, + l2_trust_rpc: DEFAULT_L2_TRUST_RPC, + } + } +} + +impl From for L2ClientArgs { + fn from(args: EmbeddedL2ClientArgs) -> Self { + Self { + l2_engine_jwt_secret: args.l2_engine_jwt_secret, + l2_engine_jwt_encoded: args.l2_engine_jwt_encoded, + l2_engine_timeout: args.l2_engine_timeout, + l2_trust_rpc: args.l2_trust_rpc, + ..Self::default() + } + } +} + impl L2ClientArgs { /// Returns the L2 JWT secret for the engine API. /// diff --git a/crates/consensus/cli/src/lib.rs b/crates/consensus/cli/src/lib.rs index bdadc5de8f..62fc11524c 100644 --- a/crates/consensus/cli/src/lib.rs +++ b/crates/consensus/cli/src/lib.rs @@ -22,14 +22,13 @@ pub use config::{ConfigError, L1ConfigFile, L2ConfigFile}; mod follow; pub use follow::{ ConsensusFollowNodeArgs, ConsensusFollowNodeCommand, ConsensusFollowNodeConfigArgs, - ConsensusFollowNodeOverrides, }; mod l1; pub use l1::L1ClientArgs; mod l2; -pub use l2::L2ClientArgs; +pub use l2::{EmbeddedL2ClientArgs, L2ClientArgs}; mod metrics; pub use metrics::CliMetrics; @@ -37,10 +36,11 @@ pub use metrics::CliMetrics; mod node; pub use node::{ ConsensusNodeArgs, ConsensusNodeCommand, ConsensusNodeConfigArgs, ConsensusNodeOverrides, + EmbeddedConsensusNodeConfigArgs, }; mod rpc; -pub use rpc::RpcArgs; +pub use rpc::{EmbeddedRpcArgs, RpcArgs}; mod sequencer; pub use sequencer::SequencerArgs; @@ -49,4 +49,4 @@ pub mod signer; pub use signer::{SignerArgs, SignerArgsParseError}; pub mod p2p; -pub use p2p::{P2PArgs, P2PConfigError}; +pub use p2p::{EmbeddedP2PArgs, P2PArgs, P2PConfigError}; diff --git a/crates/consensus/cli/src/node.rs b/crates/consensus/cli/src/node.rs index f070ef560a..a9cc51fd54 100644 --- a/crates/consensus/cli/src/node.rs +++ b/crates/consensus/cli/src/node.rs @@ -5,18 +5,20 @@ use std::{path::PathBuf, sync::Arc}; use alloy_primitives::Address; use alloy_rpc_types_engine::JwtSecret; use base_cli_utils::{LogConfig, RuntimeManager}; -use base_common_chains::Registry; +use base_common_chains::ChainConfig; use base_common_genesis::RollupConfig; use base_consensus_node::{EngineConfig, L1ConfigBuilder, NodeMode, RollupNode, RollupNodeBuilder}; use clap::Args; use eyre::Context; use strum::IntoEnumIterator; +use tokio_util::sync::CancellationToken; use tracing::{error, info}; use url::Url; use crate::{ - ConsensusChainArgs, L1ClientArgs, L1ConfigFile, L2ClientArgs, L2ConfigFile, LogArgs, - MetricsArgs, P2PArgs, RpcArgs, SequencerArgs, metrics::CliMetrics, + ConsensusChainArgs, EmbeddedL2ClientArgs, EmbeddedP2PArgs, EmbeddedRpcArgs, L1ClientArgs, + L1ConfigFile, L2ClientArgs, L2ConfigFile, LogArgs, MetricsArgs, P2PArgs, RpcArgs, + SequencerArgs, metrics::CliMetrics, }; /// Overrides supplied by callers that embed consensus alongside another service. @@ -141,6 +143,59 @@ pub struct ConsensusNodeConfigArgs { pub checkpoint_path: Option, } +/// Consensus node configuration arguments for embedded callers. +#[derive(Args, Clone, Debug)] +pub struct EmbeddedConsensusNodeConfigArgs { + /// L1 RPC CLI arguments. + #[clap(flatten)] + pub l1_rpc_args: L1ClientArgs, + + /// L2 engine CLI arguments. + #[clap(flatten)] + pub l2_client_args: EmbeddedL2ClientArgs, + + /// L1 configuration file. + #[clap(flatten)] + pub l1_config: L1ConfigFile, + + /// L2 configuration file. + #[clap(flatten)] + pub l2_config: L2ConfigFile, + + /// P2P CLI arguments. + #[command(flatten)] + pub p2p_flags: EmbeddedP2PArgs, + + /// RPC CLI arguments. + #[command(flatten)] + pub rpc_flags: EmbeddedRpcArgs, + + /// Path to the `SafeDB` directory. If not set, safe head tracking is disabled. + #[arg(long = "safedb.path", env = "BASE_NODE_SAFEDB_PATH")] + pub safedb_path: Option, + + /// Path to the checkpoint database. If not set, a default path under `~/.base` is used. + #[arg(long = "checkpoint.path", env = "BASE_NODE_CHECKPOINT_PATH")] + pub checkpoint_path: Option, +} + +impl From for ConsensusNodeConfigArgs { + fn from(args: EmbeddedConsensusNodeConfigArgs) -> Self { + Self { + node_mode: NodeMode::Validator, + l1_rpc_args: args.l1_rpc_args, + l2_client_args: args.l2_client_args.into(), + l1_config: args.l1_config, + l2_config: args.l2_config, + p2p_flags: args.p2p_flags.into(), + rpc_flags: args.rpc_flags.into(), + sequencer_flags: SequencerArgs::default(), + safedb_path: args.safedb_path, + checkpoint_path: args.checkpoint_path, + } + } +} + impl ConsensusNodeArgs { /// Loads the configured L2 rollup config. pub fn load_rollup_config(&self) -> eyre::Result { @@ -267,23 +322,38 @@ impl ConsensusNodeArgs { cfg: RollupConfig, overrides: ConsensusNodeOverrides, ) -> eyre::Result<()> { - self.build_rollup_node_with_overrides(cfg, overrides).await?.start().await.map_err(|e| { - error!(target: "rollup_node", error = %e, "Failed to start rollup node service"); - eyre::eyre!(e) - }) + self.start_with_overrides_and_cancellation(cfg, overrides, CancellationToken::new()).await + } + + /// Starts a rollup node with caller-supplied endpoint overrides and cancellation. + pub async fn start_with_overrides_and_cancellation( + &self, + cfg: RollupConfig, + overrides: ConsensusNodeOverrides, + cancellation: CancellationToken, + ) -> eyre::Result<()> { + self.build_rollup_node_with_overrides(cfg, overrides) + .await? + .start_with_cancellation(cancellation) + .await + .map_err(|e| { + error!(target: "rollup_node", error = %e, "Failed to start rollup node service"); + eyre::eyre!(e) + }) } /// Returns the configured genesis signer address for the selected L2 chain. pub fn genesis_signer(&self) -> eyre::Result
{ let id = self.chain.l2_chain_id; - Registry::unsafe_block_signer(id.id()) + ChainConfig::by_chain_id(id.id()) + .and_then(|cfg| cfg.unsafe_block_signer) .ok_or_else(|| eyre::eyre!("No unsafe block signer found for chain ID: {id}")) } } #[cfg(test)] mod tests { - use std::{path::PathBuf, sync::Mutex}; + use std::{path::PathBuf, process::Command}; use alloy_chains::Chain; use alloy_primitives::B256; @@ -293,13 +363,13 @@ mod tests { use super::*; use crate::SignerArgs; - static SIGNER_ENV_LOCK: Mutex<()> = Mutex::new(()); const SIGNER_ENV_KEYS: &[&str] = &[ "BASE_NODE_P2P_SEQUENCER_KEY", "BASE_NODE_P2P_SEQUENCER_KEY_PATH", "BASE_NODE_P2P_SIGNER_ENDPOINT", "BASE_NODE_P2P_SIGNER_ADDRESS", ]; + const SIGNER_ENV_CHILD_TEST: &str = "node::tests::validates_sequencer_key_from_env_child"; fn default_node_config_args() -> ConsensusNodeConfigArgs { ConsensusNodeConfigArgs { @@ -311,8 +381,8 @@ mod tests { p2p_flags: P2PArgs::default(), rpc_flags: RpcArgs::default(), sequencer_flags: SequencerArgs::default(), - checkpoint_path: None, safedb_path: None, + checkpoint_path: None, } } @@ -327,21 +397,29 @@ mod tests { ("BASE_NODE_P2P_SIGNER_ADDRESS", "0xAf6E19BE0F9cE7f8afd49a1824851023A8249e8a"), ])] fn validates_sequencer_key_from_env(#[case] env_vars: Vec<(&str, &str)>) { - let _guard = SIGNER_ENV_LOCK.lock().unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command.arg("--exact").arg(SIGNER_ENV_CHILD_TEST).arg("--ignored"); for key in SIGNER_ENV_KEYS { - // SAFETY: guarded by SIGNER_ENV_LOCK. - unsafe { std::env::remove_var(key) } + command.env_remove(key); } - for (key, value) in &env_vars { - // SAFETY: guarded by SIGNER_ENV_LOCK. - unsafe { std::env::set_var(key, value) } + for (key, value) in env_vars { + command.env(key, value); } + let output = command.output().unwrap(); + + assert!( + output.status.success(), + "child env parsing test failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + #[ignore = "spawned by validates_sequencer_key_from_env with isolated process env"] + fn validates_sequencer_key_from_env_child() { let signer = SignerArgs::parse_from(["test"]); - for key in SIGNER_ENV_KEYS { - // SAFETY: guarded by SIGNER_ENV_LOCK. - unsafe { std::env::remove_var(key) } - } let args = ConsensusNodeArgs::new( ConsensusChainArgs { l2_chain_id: Chain::from(8453_u64) }, ConsensusNodeConfigArgs { diff --git a/crates/consensus/cli/src/p2p.rs b/crates/consensus/cli/src/p2p.rs index 4a86b423f0..5949b763bc 100644 --- a/crates/consensus/cli/src/p2p.rs +++ b/crates/consensus/cli/src/p2p.rs @@ -4,6 +4,7 @@ use std::{ fs, net::{IpAddr, SocketAddr, ToSocketAddrs}, num::{NonZeroUsize, ParseIntError}, + ops::{Deref, DerefMut}, path::PathBuf, str::FromStr, }; @@ -63,7 +64,7 @@ fn parse_nonzero_usize(arg: &str) -> Result { /// P2P CLI Flags #[derive(Parser, Clone, Debug, PartialEq, Eq)] -pub struct P2PArgs { +pub struct P2PNetworkArgs { /// Disable Discv5 (node discovery). #[arg(long = "p2p.no-discovery", default_value = "false", env = "BASE_NODE_P2P_NO_DISCOVERY")] pub no_discovery: bool, @@ -191,6 +192,7 @@ pub struct P2PArgs { /// The interval in seconds to find peers using the discovery service. /// Defaults to 5 seconds. #[arg( + id = "consensus_p2p_discovery_interval", long = "p2p.discovery.interval", default_value = "5", env = "BASE_NODE_P2P_DISCOVERY_INTERVAL" @@ -215,13 +217,22 @@ pub struct P2PArgs { pub redial_period: u64, /// An optional list of bootnode ENRs or node records to start the node with. - #[arg(long = "p2p.bootnodes", value_delimiter = ',', env = "BASE_NODE_P2P_BOOTNODES")] + #[arg( + id = "consensus_p2p_bootnodes", + long = "p2p.bootnodes", + value_delimiter = ',', + env = "BASE_NODE_P2P_BOOTNODES" + )] pub bootnodes: Vec, /// Path to a file containing bootnode ENRs or node records. /// /// Entries may be separated by newlines or commas. - #[arg(long = "p2p.bootnodes-file", env = "BASE_NODE_P2P_BOOTNODES_FILE")] + #[arg( + id = "consensus_p2p_bootnodes_file", + long = "p2p.bootnodes-file", + env = "BASE_NODE_P2P_BOOTNODES_FILE" + )] pub bootnodes_file: Option, /// Optionally enable topic scoring. @@ -243,7 +254,7 @@ pub struct P2PArgs { /// An optional unsafe block signer address. /// - /// By default, this is fetched from the chain config in the superchain-registry using the + /// By default, this is fetched from the built-in chain config using the /// specified L2 chain ID. #[arg(long = "p2p.unsafe.block.signer", env = "BASE_NODE_P2P_UNSAFE_BLOCK_SIGNER")] pub unsafe_block_signer: Option, @@ -256,6 +267,22 @@ pub struct P2PArgs { /// This is useful for discovering a wider set of peers. #[arg(long = "p2p.discovery.randomize", env = "BASE_NODE_P2P_DISCOVERY_RANDOMIZE")] pub discovery_randomize: Option, +} + +impl Default for P2PNetworkArgs { + fn default() -> Self { + // Construct default values using the clap parser. + // This works since none of the cli flags are required. + Self::parse_from::<[_; 0], &str>([]) + } +} + +/// P2P CLI flags for a node that may sign unsafe block gossip. +#[derive(Parser, Clone, Debug, PartialEq, Eq)] +pub struct P2PArgs { + /// P2P network configuration. + #[command(flatten)] + pub network: P2PNetworkArgs, /// Specify optional remote signer configuration. Note that this argument is mutually exclusive /// with `p2p.sequencer.key` that specifies a local sequencer signer. @@ -271,6 +298,42 @@ impl Default for P2PArgs { } } +impl Deref for P2PArgs { + type Target = P2PNetworkArgs; + + fn deref(&self) -> &Self::Target { + &self.network + } +} + +impl DerefMut for P2PArgs { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.network + } +} + +/// P2P CLI flags for an embedded validator-only node. +#[derive(Parser, Clone, Debug, PartialEq, Eq)] +pub struct EmbeddedP2PArgs { + /// P2P network configuration. + #[command(flatten)] + pub network: P2PNetworkArgs, +} + +impl Default for EmbeddedP2PArgs { + fn default() -> Self { + // Construct default values using the clap parser. + // This works since none of the cli flags are required. + Self::parse_from::<[_; 0], &str>([]) + } +} + +impl From for P2PArgs { + fn from(args: EmbeddedP2PArgs) -> Self { + Self { network: args.network, signer: SignerArgs::default() } + } +} + /// Errors that can occur when building a P2P network configuration. #[derive(Debug, thiserror::Error)] pub enum P2PConfigError { @@ -398,7 +461,7 @@ impl P2PArgs { // If storage returns zero (e.g. L1 is still early in sync and the SystemConfig // contract hadn't been deployed at the queried block), fall through to the - // genesis/registry signer rather than using the zero address. + // genesis/built-in signer rather than using the zero address. if !signer.is_zero() { return Ok(signer); } @@ -407,7 +470,7 @@ impl P2PArgs { target: "p2p::flags", block_number = block_info.number, "L1 SystemConfig returned zero unsafe block signer (L1 may still be syncing), \ - falling back to registry/genesis signer" + falling back to built-in/genesis signer" ); } @@ -415,7 +478,7 @@ impl P2PArgs { genesis_signer.or(self.unsafe_block_signer).ok_or_else(|| { eyre::eyre!( "Unsafe block signer not provided for chain ID {}. \ - Provide --p2p.unsafe.block.signer or ensure the chain is in the superchain registry.", + Provide --p2p.unsafe.block.signer or ensure the chain is supported by the built-in chain config.", l2_chain_id ) }) @@ -515,7 +578,7 @@ impl P2PArgs { if self.disable_bootstore { None } else { - Some(self.bootstore.map_or( + Some(self.bootstore.clone().map_or( BootStoreFile::Default { chain_id: l2_chain_id }, BootStoreFile::Custom, )) diff --git a/crates/consensus/cli/src/rpc.rs b/crates/consensus/cli/src/rpc.rs index 56bc8e9d21..51b62b6d4e 100644 --- a/crates/consensus/cli/src/rpc.rs +++ b/crates/consensus/cli/src/rpc.rs @@ -53,6 +53,47 @@ pub struct RpcArgs { pub max_concurrent_requests: NonZeroUsize, } +/// RPC CLI arguments for embedded consensus nodes. +#[derive(Parser, Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedRpcArgs { + /// Whether to disable the rpc server. + #[arg(long = "rpc.disabled", default_value = "false", env = "BASE_NODE_RPC_DISABLED")] + pub rpc_disabled: bool, + /// Prevent the RPC server from attempting to restart. + #[arg(long = "rpc.no-restart", default_value = "false", env = "BASE_NODE_RPC_NO_RESTART")] + pub no_restart: bool, + /// RPC listening address. + #[arg(long = "rpc.addr", default_value = "0.0.0.0", env = "BASE_NODE_RPC_ADDR")] + pub listen_addr: IpAddr, + /// RPC listening port. + #[arg(long = "rpc.port", default_value = "9545", env = "BASE_NODE_RPC_PORT")] + pub listen_port: u16, + /// Enable the admin API. + #[arg(long = "rpc.enable-admin", env = "BASE_NODE_RPC_ENABLE_ADMIN")] + pub enable_admin: bool, + /// File path used to persist state changes made via the admin API so they persist across + /// restarts. Disabled if not set. + #[arg(long = "rpc.admin-state", env = "BASE_NODE_RPC_ADMIN_STATE")] + pub admin_persistence: Option, + /// Enables websocket rpc server to track block production + #[arg(long = "rpc.ws-enabled", default_value = "false", env = "BASE_NODE_RPC_WS_ENABLED")] + pub ws_enabled: bool, + /// Enables development RPC endpoints for engine state introspection + #[arg(long = "rpc.dev-enabled", default_value = "false", env = "BASE_NODE_RPC_DEV_ENABLED")] + pub dev_enabled: bool, + /// HTTP request timeout in seconds for the RPC server. + #[arg(long = "rpc.timeout", default_value = "60", env = "BASE_NODE_RPC_TIMEOUT")] + pub http_timeout_secs: u64, + /// Maximum number of concurrent in-flight RPC requests. + #[arg( + long = "rpc.max-concurrent", + default_value = "1024", + env = "BASE_NODE_RPC_MAX_CONCURRENT", + value_parser = clap::value_parser!(NonZeroUsize), + )] + pub max_concurrent_requests: NonZeroUsize, +} + impl Default for RpcArgs { fn default() -> Self { // Construct default values using the clap parser. @@ -61,6 +102,31 @@ impl Default for RpcArgs { } } +impl Default for EmbeddedRpcArgs { + fn default() -> Self { + // Construct default values using the clap parser. + // This works since none of the cli flags are required. + Self::parse_from::<[_; 0], &str>([]) + } +} + +impl From for RpcArgs { + fn from(args: EmbeddedRpcArgs) -> Self { + Self { + rpc_disabled: args.rpc_disabled, + no_restart: args.no_restart, + listen_addr: args.listen_addr, + listen_port: args.listen_port, + enable_admin: args.enable_admin, + admin_persistence: args.admin_persistence, + ws_enabled: args.ws_enabled, + dev_enabled: args.dev_enabled, + http_timeout_secs: args.http_timeout_secs, + max_concurrent_requests: args.max_concurrent_requests, + } + } +} + impl From for Option { fn from(args: RpcArgs) -> Self { if args.rpc_disabled { @@ -90,11 +156,11 @@ mod tests { #[rstest] #[case::disable_rpc(&["--rpc.disabled"], |args: &mut RpcArgs| { args.rpc_disabled = true; })] #[case::no_restart(&["--rpc.no-restart"], |args: &mut RpcArgs| { args.no_restart = true; })] - #[case::disable_rpc(&["--rpc.addr", "1.1.1.1"], |args: &mut RpcArgs| { args.listen_addr = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); })] - #[case::disable_rpc(&["--port", "8743"], |args: &mut RpcArgs| { args.listen_port = 8743; })] - #[case::disable_rpc_alias(&["--rpc.port", "8743"], |args: &mut RpcArgs| { args.listen_port = 8743; })] - #[case::disable_rpc(&["--rpc.enable-admin"], |args: &mut RpcArgs| { args.enable_admin = true; })] - #[case::disable_rpc(&["--rpc.admin-state", "/"], |args: &mut RpcArgs| { args.admin_persistence = Some(PathBuf::from("/")); })] + #[case::set_addr(&["--rpc.addr", "1.1.1.1"], |args: &mut RpcArgs| { args.listen_addr = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); })] + #[case::set_port(&["--port", "8743"], |args: &mut RpcArgs| { args.listen_port = 8743; })] + #[case::set_port_alias(&["--rpc.port", "8743"], |args: &mut RpcArgs| { args.listen_port = 8743; })] + #[case::enable_admin(&["--rpc.enable-admin"], |args: &mut RpcArgs| { args.enable_admin = true; })] + #[case::admin_state(&["--rpc.admin-state", "/"], |args: &mut RpcArgs| { args.admin_persistence = Some(PathBuf::from("/")); })] fn test_parse_rpc_args(#[case] args: &[&str], #[case] mutate: impl Fn(&mut RpcArgs)) { let args = [&["base-consensus"], args].concat(); let cli = RpcArgs::parse_from(args); @@ -102,4 +168,30 @@ mod tests { mutate(&mut expected); assert_eq!(cli, expected); } + + #[rstest] + #[case::disable_rpc(&["--rpc.disabled"], |args: &mut EmbeddedRpcArgs| { args.rpc_disabled = true; })] + #[case::no_restart(&["--rpc.no-restart"], |args: &mut EmbeddedRpcArgs| { args.no_restart = true; })] + #[case::set_addr(&["--rpc.addr", "1.1.1.1"], |args: &mut EmbeddedRpcArgs| { args.listen_addr = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)); })] + #[case::set_port(&["--rpc.port", "8743"], |args: &mut EmbeddedRpcArgs| { args.listen_port = 8743; })] + #[case::enable_admin(&["--rpc.enable-admin"], |args: &mut EmbeddedRpcArgs| { args.enable_admin = true; })] + #[case::admin_state(&["--rpc.admin-state", "/"], |args: &mut EmbeddedRpcArgs| { args.admin_persistence = Some(PathBuf::from("/")); })] + fn test_parse_embedded_rpc_args( + #[case] args: &[&str], + #[case] mutate: impl Fn(&mut EmbeddedRpcArgs), + ) { + let args = [&["base-consensus"], args].concat(); + let cli = EmbeddedRpcArgs::parse_from(args); + let mut expected = EmbeddedRpcArgs::default(); + mutate(&mut expected); + assert_eq!(cli, expected); + } + + #[test] + fn embedded_rpc_args_do_not_accept_bare_port() { + let err = EmbeddedRpcArgs::try_parse_from(["base-consensus", "--port", "8743"]) + .expect_err("embedded consensus RPC args should reserve bare --port for execution"); + + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } } diff --git a/crates/consensus/cli/src/sequencer.rs b/crates/consensus/cli/src/sequencer.rs index 1b485c83f6..4c645eced6 100644 --- a/crates/consensus/cli/src/sequencer.rs +++ b/crates/consensus/cli/src/sequencer.rs @@ -52,6 +52,16 @@ pub struct SequencerArgs { value_parser = |arg: &str| -> Result {Ok(Duration::from_secs(arg.parse()?))} )] pub conductor_rpc_timeout: Duration, + + /// Use the conductor's SSZ-binary commit-unsafe-payload endpoint instead of JSON-RPC. + /// Avoids JSON encode/decode (~6-11x faster on the leader RPC handler for typical + /// mainnet payloads). Requires conductor with binary endpoint support. + #[arg( + long = "conductor.binary-commit", + default_value = "false", + env = "BASE_NODE_CONDUCTOR_BINARY_COMMIT" + )] + pub conductor_binary_commit: bool, } impl Default for SequencerArgs { @@ -69,6 +79,8 @@ impl SequencerArgs { sequencer_stopped: self.stopped, sequencer_recovery_mode: self.recover, conductor_rpc_url: self.conductor_rpc.clone(), + conductor_binary_commit: self.conductor_binary_commit, + conductor_rpc_timeout: self.conductor_rpc_timeout, l1_conf_delay: self.l1_confs, } } diff --git a/crates/consensus/derive/Cargo.toml b/crates/consensus/derive/Cargo.toml index 23dc48642e..348e9dcfd0 100644 --- a/crates/consensus/derive/Cargo.toml +++ b/crates/consensus/derive/Cargo.toml @@ -45,13 +45,20 @@ metrics = { workspace = true, optional = true } [dev-dependencies] spin.workspace = true proptest.workspace = true +criterion.workspace = true serde_json.workspace = true base-common-chains.workspace = true tokio = { workspace = true, features = ["full"] } tracing = { workspace = true, features = ["std"] } tracing-subscriber = { workspace = true, features = ["fmt"] } +base-protocol = { workspace = true, features = ["test-utils"] } alloy-primitives = { workspace = true, features = ["rlp", "k256", "map", "arbitrary"] } +[[bench]] +name = "batch_queue" +harness = false +required-features = ["test-utils"] + [features] default = [] metrics = [ "base-metrics/metrics", "dep:metrics" ] diff --git a/crates/consensus/derive/benches/batch_queue.rs b/crates/consensus/derive/benches/batch_queue.rs new file mode 100644 index 0000000000..2762dcdf21 --- /dev/null +++ b/crates/consensus/derive/benches/batch_queue.rs @@ -0,0 +1,49 @@ +//! Benchmarks for [`BatchQueue`] span-batch cache handling. + +use std::{collections::VecDeque, hint::black_box, sync::Arc}; + +use base_common_genesis::RollupConfig; +use base_consensus_derive::{ + BatchQueue, + test_utils::{TestL2ChainProvider, TestNextBatchProvider}, +}; +use base_protocol::{L2BlockInfo, SingleBatch}; +use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; + +const CACHED_SPANS: usize = 4_096; + +fn batch_queue_with_cached_spans( + len: usize, +) -> BatchQueue { + let cfg = Arc::new(RollupConfig::default()); + let mock = TestNextBatchProvider::new(Vec::new()); + let fetcher = TestL2ChainProvider::default(); + let mut batch_queue = BatchQueue::new(cfg, mock, fetcher); + batch_queue.next_spans = (0..len) + .map(|i| SingleBatch { timestamp: i as u64, ..Default::default() }) + .collect::>(); + batch_queue +} + +fn drain_cached_spans(mut batch_queue: BatchQueue) { + let parent = L2BlockInfo::default(); + while !batch_queue.next_spans.is_empty() { + black_box(batch_queue.pop_next_batch(parent).expect("cached span batch")); + } +} + +fn bench_batch_queue(c: &mut Criterion) { + let mut group = c.benchmark_group("batch_queue"); + group.throughput(Throughput::Elements(CACHED_SPANS as u64)); + group.bench_function("drain_cached_span_batches", |b| { + b.iter_batched( + || batch_queue_with_cached_spans(CACHED_SPANS), + drain_cached_spans, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +criterion_group!(benches, bench_batch_queue); +criterion_main!(benches); diff --git a/crates/consensus/derive/src/attributes/stateful.rs b/crates/consensus/derive/src/attributes/stateful.rs index c7617540f8..4a2b8ce093 100644 --- a/crates/consensus/derive/src/attributes/stateful.rs +++ b/crates/consensus/derive/src/attributes/stateful.rs @@ -203,6 +203,7 @@ where suggested_fee_recipient: Predeploys::SEQUENCER_FEE_VAULT, parent_beacon_block_root: parent_beacon_root, withdrawals, + slot_number: None, }, transactions: Some(txs), no_tx_pool: Some(true), @@ -487,6 +488,7 @@ mod tests { suggested_fee_recipient: Predeploys::SEQUENCER_FEE_VAULT, parent_beacon_block_root: None, withdrawals: None, + slot_number: None, }, transactions: payload.transactions.clone(), no_tx_pool: Some(true), @@ -539,6 +541,7 @@ mod tests { suggested_fee_recipient: Predeploys::SEQUENCER_FEE_VAULT, parent_beacon_block_root: None, withdrawals: Some(Vec::default()), + slot_number: None, }, transactions: payload.transactions.clone(), no_tx_pool: Some(true), @@ -592,6 +595,7 @@ mod tests { suggested_fee_recipient: Predeploys::SEQUENCER_FEE_VAULT, parent_beacon_block_root, withdrawals: Some(vec![]), + slot_number: None, }, transactions: payload.transactions.clone(), no_tx_pool: Some(true), @@ -644,6 +648,7 @@ mod tests { suggested_fee_recipient: Predeploys::SEQUENCER_FEE_VAULT, parent_beacon_block_root: Some(B256::ZERO), withdrawals: Some(vec![]), + slot_number: None, }, transactions: payload.transactions.clone(), no_tx_pool: Some(true), diff --git a/crates/consensus/derive/src/pipeline/core.rs b/crates/consensus/derive/src/pipeline/core.rs index fd3cd3a362..67c51d144e 100644 --- a/crates/consensus/derive/src/pipeline/core.rs +++ b/crates/consensus/derive/src/pipeline/core.rs @@ -283,6 +283,7 @@ mod tests { suggested_fee_recipient: Default::default(), withdrawals: None, parent_beacon_block_root: None, + slot_number: None, }, transactions: None, no_tx_pool: None, diff --git a/crates/consensus/derive/src/sources/blobs.rs b/crates/consensus/derive/src/sources/blobs.rs index 594b896713..d740a234d3 100644 --- a/crates/consensus/derive/src/sources/blobs.rs +++ b/crates/consensus/derive/src/sources/blobs.rs @@ -236,7 +236,7 @@ pub(super) mod tests { use alloy_consensus::{Blob, Signed, TxEip4844, TxEip4844Variant}; use alloy_primitives::Signature; - use base_common_chains::Registry; + use base_common_chains::ChainConfig; use super::*; use crate::{ @@ -256,7 +256,7 @@ pub(super) mod tests { } pub(crate) fn valid_blob_txs() -> Vec { - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; let sig = Signature::test_signature(); vec![TxEnvelope::Eip4844(Signed::new_unchecked( TxEip4844Variant::TxEip4844(TxEip4844 { @@ -317,7 +317,7 @@ pub(super) mod tests { let mut source = default_test_blob_source(); let block_info = BlockInfo::default(); let batcher_address = valid_blob_batcher_address(); - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; source.batcher_address = batch_inbox_address; let txs = valid_blob_txs(); source.blob_fetcher.should_error = true; @@ -333,7 +333,7 @@ pub(super) mod tests { let mut source = default_test_blob_source(); let block_info = BlockInfo::default(); let batcher_address = valid_blob_batcher_address(); - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; source.batcher_address = batch_inbox_address; let txs = valid_blob_txs(); source.chain_provider.insert_block_with_transactions(1, block_info, txs); @@ -403,7 +403,7 @@ pub(super) mod tests { let mut source = default_test_blob_source(); let block_info = BlockInfo::default(); let batcher_address = valid_blob_batcher_address(); - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; source.batcher_address = batch_inbox_address; let txs = valid_blob_txs(); source.blob_fetcher.should_return_extra_blob = true; @@ -490,7 +490,7 @@ pub(super) mod tests { let mut source = default_test_blob_source(); let block_info = BlockInfo::default(); let batcher_address = valid_blob_batcher_address(); - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; source.batcher_address = batch_inbox_address; source.chain_provider.insert_block_with_transactions(1, block_info, valid_blob_txs()); source.blob_fetcher.should_return_not_found = true; @@ -509,7 +509,7 @@ pub(super) mod tests { let mut source = default_test_blob_source(); let block_info = BlockInfo::default(); let batcher_address = valid_blob_batcher_address(); - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; source.batcher_address = batch_inbox_address; source.chain_provider.insert_block_with_transactions(1, block_info, valid_blob_txs()); source.blob_fetcher.should_return_not_found = true; @@ -551,7 +551,7 @@ pub(super) mod tests { #[test] fn test_extract_blob_data_non_batcher_blobs_excluded() { // Case 1: source.batcher_address = Address::ZERO does not match the tx's batch inbox - // address from `Registry::rollup_config(8453)`, so the transaction is skipped and no + // address from `ChainConfig::MAINNET`, so the transaction is skipped and no // blobs are captured. let source = default_test_blob_source(); // batch_inbox_address = Address::ZERO let batcher_address = valid_blob_batcher_address(); @@ -561,7 +561,7 @@ pub(super) mod tests { // Case 2: correct batch inbox address → all 5 blobs from the batcher transaction captured. let mut source2 = default_test_blob_source(); - let batch_inbox_address = Registry::rollup_config(8453).unwrap().batch_inbox_address; + let batch_inbox_address = ChainConfig::MAINNET.batch_inbox_address; source2.batcher_address = batch_inbox_address; let batcher_address = valid_blob_batcher_address(); let (data, hashes) = source2.extract_blob_data(valid_blob_txs(), batcher_address); diff --git a/crates/consensus/derive/src/stages/attributes_queue.rs b/crates/consensus/derive/src/stages/attributes_queue.rs index 585bdb2fc5..b391744b58 100644 --- a/crates/consensus/derive/src/stages/attributes_queue.rs +++ b/crates/consensus/derive/src/stages/attributes_queue.rs @@ -233,6 +233,7 @@ mod tests { prev_randao: B256::default(), withdrawals: None, parent_beacon_block_root: None, + slot_number: None, }, no_tx_pool: Some(false), transactions: None, diff --git a/crates/consensus/derive/src/stages/batch/batch_queue.rs b/crates/consensus/derive/src/stages/batch/batch_queue.rs index 1b770b9a2e..7a410f4ab8 100644 --- a/crates/consensus/derive/src/stages/batch/batch_queue.rs +++ b/crates/consensus/derive/src/stages/batch/batch_queue.rs @@ -1,6 +1,6 @@ //! This module contains the `BatchQueue` stage implementation. -use alloc::{boxed::Box, sync::Arc, vec::Vec}; +use alloc::{boxed::Box, collections::VecDeque, sync::Arc, vec::Vec}; use core::fmt::Debug; use alloy_eips::BlockNumHash; @@ -55,7 +55,7 @@ where /// A set of cached [`SingleBatch`]es derived from [`SpanBatch`]es. /// /// [`SpanBatch`]: base_protocol::SpanBatch - pub next_spans: Vec, + pub next_spans: VecDeque, /// Used to validate the batches. pub fetcher: BF, } @@ -73,7 +73,7 @@ where origin: None, l1_blocks: Vec::new(), batches: Vec::new(), - next_spans: Vec::new(), + next_spans: VecDeque::new(), fetcher, } } @@ -85,7 +85,7 @@ where if self.next_spans.is_empty() { panic!("Invalid state: must have next spans to pop"); } - let mut next = self.next_spans.remove(0); + let mut next = self.next_spans.pop_front()?; next.parent_hash = parent.block_info.hash; Some(next) } @@ -232,8 +232,10 @@ where // that we can, so we can advance to the next epoch. info!( target: "batch_queue", - "Advancing to next epoch: {}, timestamp: {}, epoch timestamp: {}", - next_epoch.number, next_timestamp, next_epoch.timestamp + next_epoch_number = next_epoch.number, + next_timestamp, + next_epoch_timestamp = next_epoch.timestamp, + "Advancing to next epoch" ); self.l1_blocks.remove(0); Err(PipelineError::Eof.temp()) @@ -288,7 +290,9 @@ where if !self.next_spans.is_empty() { // There are cached singular batches derived from the span batch. // Check if the next cached batch matches the given parent block. - if self.next_spans[0].timestamp == parent.block_info.timestamp + self.cfg.block_time { + if self.next_spans.front().expect("checked non-empty").timestamp + == parent.block_info.timestamp + self.cfg.block_time + { return self.pop_next_batch(parent).ok_or(PipelineError::BatchQueueEmpty.crit()); } // Parent block does not match the next batch. @@ -296,8 +300,8 @@ where // Drop cached batches and find another batch. warn!( target: "batch_queue", - "Parent block does not match the next batch. Dropping {} cached batches.", - self.next_spans.len() + cached_batches = self.next_spans.len(), + "Parent block does not match next batch, dropping cached batches" ); self.next_spans.clear(); } @@ -403,7 +407,7 @@ where return Err(e); } }; - self.next_spans = batches; + self.next_spans = VecDeque::from(batches); let nb = match self .pop_next_batch(parent) .ok_or(PipelineError::BatchQueueEmpty.crit()) @@ -486,12 +490,11 @@ mod tests { use base_common_genesis::{ChainGenesis, HardForkConfig, RollupConfig, SystemConfig}; use base_protocol::{BatchReader, L1BlockInfoBedrock, L1BlockInfoTx}; use tracing::Level; - use tracing_subscriber::layer::SubscriberExt; use super::*; use crate::{ StageReset, - test_utils::{CollectingLayer, TestL2ChainProvider, TestNextBatchProvider, TraceStorage}, + test_utils::{TestL2ChainProvider, TestNextBatchProvider}, }; fn new_batch_reader() -> BatchReader { @@ -509,11 +512,27 @@ mod tests { let mock = TestNextBatchProvider::new(vec![]); let fetcher = TestL2ChainProvider::default(); let mut bq = BatchQueue::new(cfg, mock, fetcher); - let parent = L2BlockInfo::default(); - let sb = SingleBatch::default(); - bq.next_spans.push(sb.clone()); + let first = SingleBatch { timestamp: 2, ..Default::default() }; + let second = SingleBatch { timestamp: 4, ..Default::default() }; + let parent = L2BlockInfo { + block_info: BlockInfo { + hash: b256!("0101010101010101010101010101010101010101010101010101010101010101"), + ..Default::default() + }, + ..Default::default() + }; + bq.next_spans.push_back(first.clone()); + bq.next_spans.push_back(second.clone()); + let next = bq.pop_next_batch(parent).unwrap(); - assert_eq!(next, sb); + + assert_eq!(next.timestamp, first.timestamp); + assert_eq!(next.parent_hash, parent.block_info.hash); + assert_eq!(bq.next_spans.front(), Some(&second)); + + let next = bq.pop_next_batch(parent).unwrap(); + assert_eq!(next.timestamp, second.timestamp); + assert_eq!(next.parent_hash, parent.block_info.hash); assert!(bq.next_spans.is_empty()); } @@ -524,7 +543,7 @@ mod tests { let fetcher = TestL2ChainProvider::default(); let mut bq = BatchQueue::new(Arc::clone(&cfg), mock, fetcher); bq.l1_blocks.push(BlockInfo::default()); - bq.next_spans.push(SingleBatch::default()); + bq.next_spans.push_back(SingleBatch::default()); bq.batches.push(BatchWithInclusionBlock { inclusion_block: BlockInfo::default(), batch: Batch::Single(SingleBatch::default()), @@ -545,7 +564,7 @@ mod tests { let fetcher = TestL2ChainProvider::default(); let mut bq = BatchQueue::new(Arc::clone(&cfg), mock, fetcher); bq.l1_blocks.push(BlockInfo::default()); - bq.next_spans.push(SingleBatch::default()); + bq.next_spans.push_back(SingleBatch::default()); bq.batches.push(BatchWithInclusionBlock { inclusion_block: BlockInfo::default(), batch: Batch::Single(SingleBatch::default()), @@ -864,10 +883,7 @@ mod tests { #[tokio::test] async fn test_holocene_derive_next_batch_future() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); // Construct a future single batch. let cfg = Arc::new(RollupConfig { @@ -933,7 +949,7 @@ mod tests { let fetcher = TestL2ChainProvider::default(); let mut bq = BatchQueue::new(cfg, mock, fetcher); let sb = SingleBatch::default(); - bq.next_spans.push(sb.clone()); + bq.next_spans.push_back(sb.clone()); let next = bq.next_batch(L2BlockInfo::default()).await.unwrap(); assert_eq!(next, sb); assert!(bq.next_spans.is_empty()); @@ -952,7 +968,7 @@ mod tests { let fetcher = TestL2ChainProvider::default(); let mut bq = BatchQueue::new(cfg, mock, fetcher); let sb = SingleBatch::default(); - bq.next_spans.push(sb.clone()); + bq.next_spans.push_back(sb.clone()); let res = bq.next_batch(L2BlockInfo::default()).await.unwrap_err(); assert_eq!(res, PipelineError::NotEnoughData.temp()); assert!(bq.is_last_in_span()); @@ -993,10 +1009,7 @@ mod tests { #[tokio::test] async fn test_next_batch_missing_origin() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let mut reader = new_batch_reader(); let payload_block_hash = diff --git a/crates/consensus/derive/src/stages/batch/batch_stream.rs b/crates/consensus/derive/src/stages/batch/batch_stream.rs index e8ff5d411e..42fb0c954a 100644 --- a/crates/consensus/derive/src/stages/batch/batch_stream.rs +++ b/crates/consensus/derive/src/stages/batch/batch_stream.rs @@ -260,12 +260,11 @@ mod tests { use base_common_consensus::BaseBlock; use base_common_genesis::{ChainGenesis, HardForkConfig, SystemConfig}; use base_protocol::{SingleBatch, SpanBatchElement}; - use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use super::*; use crate::{ StageReset, - test_utils::{CollectingLayer, TestBatchStreamProvider, TestL2ChainProvider, TraceStorage}, + test_utils::{TestBatchStreamProvider, TestL2ChainProvider}, }; #[tokio::test] @@ -323,9 +322,7 @@ mod tests { #[tokio::test] async fn test_batch_stream_inactive() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - tracing_subscriber::Registry::default().with(layer).init(); + let (trace_store, _guard) = base_protocol::capture_traces!(); let data = vec![Ok(Batch::Single(SingleBatch::default()))]; let config = Arc::new(RollupConfig { @@ -426,9 +423,7 @@ mod tests { #[tokio::test] async fn test_span_batch_extraction_error_flushes_stage() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - tracing_subscriber::Registry::default().with(layer).init(); + let (trace_store, _guard) = base_protocol::capture_traces!(); let parent_hash = b256!("1111111111111111111111111111111111111111000000000000000000000000"); let l1_block_hash = diff --git a/crates/consensus/derive/src/stages/batch/batch_validator.rs b/crates/consensus/derive/src/stages/batch/batch_validator.rs index c4945da89a..94c215aa91 100644 --- a/crates/consensus/derive/src/stages/batch/batch_validator.rs +++ b/crates/consensus/derive/src/stages/batch/batch_validator.rs @@ -329,12 +329,11 @@ mod tests { use base_common_genesis::{HardForkConfig, RollupConfig, SystemConfig}; use base_protocol::{Batch, BlockInfo, L2BlockInfo, SingleBatch, SpanBatch}; use tracing::Level; - use tracing_subscriber::layer::SubscriberExt; use crate::{ AttributesProvider, BatchValidator, NextBatchProvider, OriginAdvancer, PipelineError, PipelineErrorKind, PipelineResult, ResetError, StageReset, - test_utils::{CollectingLayer, TestNextBatchProvider, TraceStorage}, + test_utils::TestNextBatchProvider, }; #[tokio::test] @@ -526,10 +525,7 @@ mod tests { #[tokio::test] async fn test_batch_validator_next_batch_sequence_window_expired() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let cfg = Arc::new(RollupConfig { seq_window_size: 5, ..Default::default() }); let mut mock = TestNextBatchProvider::new(vec![]); @@ -562,10 +558,7 @@ mod tests { #[tokio::test] async fn test_batch_validator_next_batch_sequence_window_expired_advance_epoch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let cfg = Arc::new(RollupConfig { seq_window_size: 5, ..Default::default() }); let mut mock = TestNextBatchProvider::new(vec![]); diff --git a/crates/consensus/derive/src/stages/channel/channel_assembler.rs b/crates/consensus/derive/src/stages/channel/channel_assembler.rs index c568e08858..0b7d14f512 100644 --- a/crates/consensus/derive/src/stages/channel/channel_assembler.rs +++ b/crates/consensus/derive/src/stages/channel/channel_assembler.rs @@ -234,20 +234,13 @@ mod tests { use base_common_genesis::{HardForkConfig, RollupConfig}; use base_protocol::BlockInfo; use tracing::Level; - use tracing_subscriber::layer::SubscriberExt; use super::ChannelAssembler; - use crate::{ - ChannelReaderProvider, PipelineError, - test_utils::{CollectingLayer, TestNextFrameProvider, TraceStorage}, - }; + use crate::{ChannelReaderProvider, PipelineError, test_utils::TestNextFrameProvider}; #[tokio::test] async fn test_assembler_channel_timeout() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let frames = [ crate::frame!(0xFF, 0, vec![0xDD; 50], false), @@ -307,10 +300,7 @@ mod tests { #[tokio::test] async fn test_assembler_already_built() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let frames = [ crate::frame!(0xFF, 0, vec![0xDD; 50], false), @@ -373,10 +363,7 @@ mod tests { #[tokio::test] async fn test_assembler_size_limit_exceeded_bedrock() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let mut frames = [ crate::frame!(0xFF, 0, vec![0xDD; 50], false), @@ -408,10 +395,7 @@ mod tests { #[tokio::test] async fn test_assembler_size_limit_exceeded_fjord() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let mut frames = [ crate::frame!(0xFF, 0, vec![0xDD; 50], false), diff --git a/crates/consensus/derive/src/stages/channel/channel_bank.rs b/crates/consensus/derive/src/stages/channel/channel_bank.rs index f503f914e5..a7b811ef7c 100644 --- a/crates/consensus/derive/src/stages/channel/channel_bank.rs +++ b/crates/consensus/derive/src/stages/channel/channel_bank.rs @@ -266,10 +266,9 @@ mod tests { use alloy_eips::BlockNumHash; use base_common_genesis::{HardForkConfig, SystemConfig}; use tracing::Level; - use tracing_subscriber::layer::SubscriberExt; use super::*; - use crate::test_utils::{CollectingLayer, TestNextFrameProvider, TraceStorage}; + use crate::test_utils::TestNextFrameProvider; #[test] fn test_try_read_channel_at_index_missing_channel() { @@ -472,10 +471,7 @@ mod tests { #[test] fn test_ingest_invalid_frame() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let mock = TestNextFrameProvider::new(vec![]); let mut channel_bank = ChannelBank::new(Arc::new(RollupConfig::default()), mock); @@ -560,14 +556,11 @@ mod tests { #[tokio::test] async fn test_channel_timeout() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = base_protocol::capture_traces!(); let configs: [RollupConfig; 2] = [ - base_common_chains::Registry::rollup_config(8453).cloned().unwrap(), - base_common_chains::Registry::rollup_config(84532).cloned().unwrap(), + base_common_chains::rollup_config!(base_common_chains::ChainConfig::MAINNET), + base_common_chains::rollup_config!(base_common_chains::ChainConfig::SEPOLIA), ]; for cfg in configs { diff --git a/crates/consensus/derive/src/test_utils/mod.rs b/crates/consensus/derive/src/test_utils/mod.rs index 2b4989c477..cb0932829b 100644 --- a/crates/consensus/derive/src/test_utils/mod.rs +++ b/crates/consensus/derive/src/test_utils/mod.rs @@ -37,11 +37,9 @@ mod channel_reader; pub use channel_reader::TestChannelReaderProvider; mod frame_queue; +pub use base_protocol::test_utils::{CollectingLayer, TraceStorage}; pub use frame_queue::TestFrameQueueProvider; -mod tracing; -pub use tracing::{CollectingLayer, TraceStorage}; - mod sys_config_fetcher; pub use sys_config_fetcher::{TestSystemConfigL2Fetcher, TestSystemConfigL2FetcherError}; diff --git a/crates/consensus/derive/src/test_utils/tracing.rs b/crates/consensus/derive/src/test_utils/tracing.rs deleted file mode 100644 index fd2b567b33..0000000000 --- a/crates/consensus/derive/src/test_utils/tracing.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! This module contains a subscriber layer for `tracing-subscriber` that collects traces and their -//! log levels. - -use alloc::{format, string::String, sync::Arc, vec::Vec}; - -use spin::Mutex; -use tracing::{Event, Level, Subscriber}; -use tracing_subscriber::{Layer, layer::Context}; - -/// The storage for the collected traces. -#[derive(Debug, Default, Clone)] -pub struct TraceStorage(pub Arc>>); - -impl TraceStorage { - /// Returns the items in the storage that match the specified level. - pub fn get_by_level(&self, level: Level) -> Vec { - self.0 - .lock() - .iter() - .filter_map(|(l, message)| if *l == level { Some(message.clone()) } else { None }) - .collect() - } - - /// Locks the storage and returns the items. - pub fn lock(&self) -> spin::MutexGuard<'_, Vec<(Level, String)>> { - self.0.lock() - } - - /// Returns if the storage is empty. - pub fn is_empty(&self) -> bool { - self.0.lock().is_empty() - } -} - -/// A subscriber layer that collects traces and their log levels. -#[derive(Debug, Default)] -pub struct CollectingLayer { - /// The storage for the collected traces. - pub storage: TraceStorage, -} - -impl CollectingLayer { - /// Creates a new collecting layer with the specified storage. - pub const fn new(storage: TraceStorage) -> Self { - Self { storage } - } -} - -impl Layer for CollectingLayer { - fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { - let metadata = event.metadata(); - let level = *metadata.level(); - let message = format!("{event:?}"); - - let mut storage = self.storage.0.lock(); - storage.push((level, message)); - } -} diff --git a/crates/consensus/engine/README.md b/crates/consensus/engine/README.md index df21c5722c..fad8ba4708 100644 --- a/crates/consensus/engine/README.md +++ b/crates/consensus/engine/README.md @@ -10,12 +10,11 @@ The `base-consensus-engine` crate provides a task-based engine client for intera ## Key Components -- **[`Engine`](crate::Engine)** - Main task queue processor that executes engine operations atomically +- **[`Engine`](crate::Engine)** - Main engine state owner that executes engine operations atomically - **[`EngineClient`](crate::EngineClient)** - HTTP client for Engine API communication with JWT authentication - **[`EngineState`](crate::EngineState)** - Tracks the current state of the execution layer - **Task Types** - Specialized tasks for different engine operations: - [`InsertTask`](crate::InsertTask) - Insert new payloads into the execution engine - - [`BuildTask`](crate::BuildTask) - Build new payloads with automatic forkchoice synchronization - [`ConsolidateTask`](crate::ConsolidateTask) - Consolidate unsafe payloads to advance the safe chain - [`FinalizeTask`](crate::FinalizeTask) - Finalize safe payloads on L1 confirmation - [`SynchronizeTask`](crate::SynchronizeTask) - Internal task for execution layer forkchoice synchronization @@ -37,7 +36,7 @@ The engine implements a task-driven architecture where operations are queued and └─────────────┘ └──────────────┘ └─────────────┘ ``` -- **Automatic Forkchoice Handling**: The [`BuildTask`](crate::BuildTask) automatically performs forkchoice updates during block building, eliminating the need for explicit forkchoice management in user code. +- **Automatic Forkchoice Handling**: [`Engine::build`](crate::Engine::build) automatically performs forkchoice updates during block building, eliminating the need for explicit forkchoice management in user code. - **Internal Synchronization**: [`SynchronizeTask`](crate::SynchronizeTask) handles internal execution layer synchronization and is primarily used by other tasks rather than directly by users. - **Priority-Based Execution**: Tasks are executed in priority order to ensure optimal sequencer performance and block processing efficiency. diff --git a/crates/consensus/engine/src/attributes.rs b/crates/consensus/engine/src/attributes.rs index 22f9541956..b3f86ede2b 100644 --- a/crates/consensus/engine/src/attributes.rs +++ b/crates/consensus/engine/src/attributes.rs @@ -423,7 +423,7 @@ mod tests { use alloy_primitives::{Bytes, FixedBytes, address, b256}; use alloy_rpc_types_eth::BlockTransactions; use arbitrary::{Arbitrary, Unstructured}; - use base_common_chains::Registry; + use base_common_chains::{ChainConfig, rollup_config}; use base_common_consensus::{HoloceneExtraData, JovianExtraData}; use base_common_rpc_types_engine::BasePayloadAttributes; use base_protocol::{BlockInfo, L2BlockInfo}; @@ -440,19 +440,14 @@ mod tests { } } - fn default_rollup_config() -> &'static RollupConfig { - let base_mainnet = 8453; - Registry::rollup_config(base_mainnet).expect("default rollup config should exist") - } - #[test] fn test_attributes_match_parent_hash_mismatch() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let attributes = default_attributes(); let mut block = Block::::default(); block.header.inner.parent_hash = b256!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::ParentHash( attributes.parent.block_info.hash, block.header.inner.parent_hash, @@ -464,11 +459,11 @@ mod tests { #[test] fn test_attributes_match_check_timestamp() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let attributes = default_attributes(); let mut block = Block::::default(); block.header.inner.timestamp = 1234567890; - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::Timestamp( attributes.attributes().payload_attributes.timestamp, block.header.inner.timestamp, @@ -480,12 +475,12 @@ mod tests { #[test] fn test_attributes_match_check_prev_randao() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let attributes = default_attributes(); let mut block = Block::::default(); block.header.inner.mix_hash = b256!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::PrevRandao( attributes.attributes().payload_attributes.prev_randao, block.header.inner.mix_hash, @@ -497,11 +492,11 @@ mod tests { #[test] fn test_attributes_match_missing_gas_limit() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let attributes = default_attributes(); let mut block = Block::::default(); block.header.inner.gas_limit = 123456; - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::MissingAttributesGasLimit.into(); assert_eq!(check, expected); assert!(check.is_mismatch()); @@ -509,12 +504,12 @@ mod tests { #[test] fn test_attributes_match_check_gas_limit() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let mut attributes = default_attributes(); attributes.attributes.gas_limit = Some(123457); let mut block = Block::::default(); block.header.inner.gas_limit = 123456; - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::GasLimit( attributes.attributes().gas_limit.unwrap_or_default(), block.header.inner.gas_limit, @@ -526,13 +521,13 @@ mod tests { #[test] fn test_attributes_match_check_parent_beacon_block_root() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let mut attributes = default_attributes(); attributes.attributes.gas_limit = Some(0); attributes.attributes.payload_attributes.parent_beacon_block_root = Some(b256!("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")); let block = Block::::default(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::ParentBeaconBlockRoot( attributes.attributes().payload_attributes.parent_beacon_block_root, block.header.inner.parent_beacon_block_root, @@ -544,12 +539,12 @@ mod tests { #[test] fn test_attributes_match_check_fee_recipient() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let mut attributes = default_attributes(); attributes.attributes.gas_limit = Some(0); let mut block = Block::::default(); block.header.inner.beneficiary = address!("1234567890abcdef1234567890abcdef12345678"); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); let expected: AttributesMatch = AttributesMismatch::FeeRecipient( attributes.attributes().payload_attributes.suggested_fee_recipient, block.header.inner.beneficiary, @@ -604,15 +599,15 @@ mod tests { #[test] fn test_attributes_match_check_transactions() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (attributes, block) = test_transactions_match_helper(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, AttributesMatch::Match); } #[test] fn test_attributes_mismatch_check_transactions_len() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (mut attributes, block) = test_transactions_match_helper(); attributes.attributes = BasePayloadAttributes { transactions: attributes.attributes.transactions.map(|mut txs| { @@ -627,14 +622,14 @@ mod tests { let expected: AttributesMatch = AttributesMismatch::TransactionLen(block_txs_len - 1, block_txs_len).into(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, expected); assert!(check.is_mismatch()); } #[test] fn test_attributes_mismatch_check_transaction_content() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (attributes, mut block) = test_transactions_match_helper(); let BlockTransactions::Full(block_txs) = &mut block.transactions else { unreachable!("The helper should build a full list of transactions") @@ -653,7 +648,7 @@ mod tests { let expected: AttributesMatch = AttributesMismatch::TransactionContent(last_tx_hash, first_tx_hash).into(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, expected); assert!(check.is_mismatch()); } @@ -661,7 +656,7 @@ mod tests { /// Checks the edge case where the attributes array is empty. #[test] fn test_attributes_mismatch_empty_tx_attributes() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (mut attributes, block) = test_transactions_match_helper(); attributes.attributes = BasePayloadAttributes { transactions: None, ..attributes.attributes }; @@ -670,7 +665,7 @@ mod tests { let expected: AttributesMatch = AttributesMismatch::TransactionLen(0, block_txs_len).into(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, expected); assert!(check.is_mismatch()); } @@ -679,13 +674,13 @@ mod tests { /// format. #[test] fn test_block_transactions_wrong_format() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (attributes, mut block) = test_transactions_match_helper(); block.transactions = BlockTransactions::Uncle; let expected: AttributesMatch = AttributesMismatch::MalformedBlockTransactions.into(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, expected); assert!(check.is_mismatch()); } @@ -694,7 +689,7 @@ mod tests { /// format. #[test] fn test_attributes_transactions_wrong_format() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (mut attributes, block) = test_transactions_match_helper(); let txs = attributes.attributes.transactions.as_mut().unwrap(); let first_tx_bytes = txs.first_mut().unwrap(); @@ -702,7 +697,7 @@ mod tests { let expected: AttributesMatch = AttributesMismatch::MalformedAttributesTransaction.into(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, expected); assert!(check.is_mismatch()); } @@ -711,7 +706,7 @@ mod tests { // `Some(vec![])`, ie an empty vector inside a `Some` option. #[test] fn test_attributes_and_block_transactions_empty() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (mut attributes, mut block) = test_transactions_match_helper(); attributes.attributes = @@ -719,7 +714,7 @@ mod tests { block.transactions = BlockTransactions::Full(vec![]); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, AttributesMatch::Match); // Edge case: if the block transactions and the payload attributes are empty, we can also @@ -728,7 +723,7 @@ mod tests { BasePayloadAttributes { transactions: None, ..attributes.attributes }; block.transactions = BlockTransactions::Hashes(vec![]); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, AttributesMatch::Match); } @@ -736,7 +731,7 @@ mod tests { // use the hash format. #[test] fn test_attributes_and_block_transactions_empty_hash_format() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (mut attributes, mut block) = test_transactions_match_helper(); attributes.attributes = @@ -744,14 +739,14 @@ mod tests { block.transactions = BlockTransactions::Hashes(vec![]); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, AttributesMatch::Match); } // Test that the check fails if the block format is incorrect and the attributes are empty #[test] fn test_attributes_empty_and_block_uncle() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let (mut attributes, mut block) = test_transactions_match_helper(); attributes.attributes = @@ -761,12 +756,12 @@ mod tests { let expected: AttributesMatch = AttributesMismatch::MalformedBlockTransactions.into(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, expected); } fn eip1559_test_setup() -> (RollupConfig, AttributesWithParent, Block) { - let mut cfg = default_rollup_config().clone(); + let mut cfg = rollup_config!(ChainConfig::MAINNET); // We need to activate holocene to make sure it works! We set the activation time to zero to // make sure that it is activated by default. @@ -1017,11 +1012,11 @@ mod tests { #[test] fn test_attributes_match() { - let cfg = default_rollup_config(); + let cfg = rollup_config!(ChainConfig::MAINNET); let mut attributes = default_attributes(); attributes.attributes.gas_limit = Some(0); let block = Block::::default(); - let check = AttributesMatch::check(cfg, &attributes, &block); + let check = AttributesMatch::check(&cfg, &attributes, &block); assert_eq!(check, AttributesMatch::Match); assert!(check.is_match()); } diff --git a/crates/consensus/engine/src/lib.rs b/crates/consensus/engine/src/lib.rs index 65f183bb82..ebc3ccee1d 100644 --- a/crates/consensus/engine/src/lib.rs +++ b/crates/consensus/engine/src/lib.rs @@ -11,11 +11,10 @@ extern crate tracing; mod task_queue; pub use task_queue::{ - BuildTask, BuildTaskError, ConsolidateInput, ConsolidateTask, ConsolidateTaskError, - DelegatedForkchoiceTask, DelegatedForkchoiceTaskError, DelegatedForkchoiceUpdate, Engine, + BuildTaskError, ConsolidateInput, ConsolidateTask, ConsolidateTaskError, Engine, EngineBuildError, EngineResetError, EngineTask, EngineTaskError, EngineTaskErrorSeverity, - EngineTaskErrors, EngineTaskExt, FinalizeTask, FinalizeTaskError, GetPayloadTask, - InsertPayloadSafety, InsertTask, InsertTaskError, SealTask, SealTaskError, SynchronizeTask, + EngineTaskErrors, EngineTaskExt, FinalizeTask, FinalizeTaskError, InsertPayloadSafety, + InsertTask, InsertTaskError, InsertTaskResult, SealTask, SealTaskError, SynchronizeTask, SynchronizeTaskError, }; diff --git a/crates/consensus/engine/src/state/core.rs b/crates/consensus/engine/src/state/core.rs index 63a5a19893..0c57323ac1 100644 --- a/crates/consensus/engine/src/state/core.rs +++ b/crates/consensus/engine/src/state/core.rs @@ -146,7 +146,7 @@ impl EngineState { /// /// [Consolidation] is only performed by a rollup node when the unsafe head /// is ahead of the safe head. When the two are equal, consolidation isn't - /// required and the [`crate::BuildTask`] can be used to build the block. + /// required and [`crate::Engine::build`] can be used to build the block. /// /// [Consolidation]: https://specs.base.org/protocol/consensus/derivation#l1-consolidation-payload-attributes-matching pub fn needs_consolidation(&self) -> bool { diff --git a/crates/consensus/engine/src/sync/checkpoint.rs b/crates/consensus/engine/src/sync/checkpoint.rs index 5c34477f3d..c6061f9592 100644 --- a/crates/consensus/engine/src/sync/checkpoint.rs +++ b/crates/consensus/engine/src/sync/checkpoint.rs @@ -1,5 +1,7 @@ //! Forkchoice checkpoint interfaces for sync start recovery. +use std::fmt; + use async_trait::async_trait; use base_protocol::L2BlockInfo; use thiserror::Error; @@ -23,6 +25,12 @@ impl ForkchoiceCheckpointLabel { } } +impl fmt::Display for ForkchoiceCheckpointLabel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + /// Error returned when reading forkchoice checkpoints. #[derive(Debug, Error)] pub enum ForkchoiceCheckpointError { diff --git a/crates/consensus/engine/src/sync/error.rs b/crates/consensus/engine/src/sync/error.rs index 879a5020e0..4306bfd981 100644 --- a/crates/consensus/engine/src/sync/error.rs +++ b/crates/consensus/engine/src/sync/error.rs @@ -6,7 +6,7 @@ use alloy_transport::{RpcError, TransportErrorKind}; use base_protocol::FromBlockError; use thiserror::Error; -use super::checkpoint::ForkchoiceCheckpointError; +use super::{ForkchoiceCheckpointLabel, checkpoint::ForkchoiceCheckpointError}; /// An error that can occur during the sync start process. #[derive(Error, Debug)] @@ -19,12 +19,12 @@ pub enum SyncStartError { /// [`L2BlockInfo`]: base_protocol::L2BlockInfo #[error(transparent)] FromBlock(#[from] FromBlockError), - /// An error occurred while reading a forkchoice checkpoint. - #[error(transparent)] - ForkchoiceCheckpoint(#[from] ForkchoiceCheckpointError), /// A block could not be found. #[error("Block not found: {0}")] BlockNotFound(BlockId), + /// An error occurred while reading a forkchoice checkpoint. + #[error(transparent)] + ForkchoiceCheckpoint(#[from] ForkchoiceCheckpointError), /// Invalid L1 genesis hash. #[error("Invalid L1 genesis hash. Expected {0}, Got {1}")] InvalidL1GenesisHash(B256, B256), @@ -43,4 +43,25 @@ pub enum SyncStartError { /// Inconsistent sequence number. #[error("Inconsistent sequence number; Must monotonically increase.")] InconsistentSequenceNumber, + /// The on-disk forkchoice checkpoint did not match the reth-labeled head block. + /// + /// Surfaced instead of [`SyncStartError::FromBlock`] when the underlying + /// [`FromBlockError::MissingL1InfoDeposit`] was caused by a stale or otherwise + /// inconsistent checkpoint, so operators see "checkpoint mismatch" in logs rather + /// than the misleading "missing L1 info deposit". + #[error( + "forkchoice checkpoint mismatch for {label}: reth labeled block {reth_number} ({reth_hash}), checkpoint {checkpoint_number} ({checkpoint_hash})" + )] + CheckpointMismatch { + /// Which labeled head (safe / finalized) the mismatch was observed on. + label: ForkchoiceCheckpointLabel, + /// Block number reth returned for the label. + reth_number: u64, + /// Block hash reth returned for the label. + reth_hash: B256, + /// Block number recorded in the on-disk checkpoint. + checkpoint_number: u64, + /// Block hash recorded in the on-disk checkpoint. + checkpoint_hash: B256, + }, } diff --git a/crates/consensus/engine/src/sync/forkchoice.rs b/crates/consensus/engine/src/sync/forkchoice.rs index 633725a33e..8041023e7b 100644 --- a/crates/consensus/engine/src/sync/forkchoice.rs +++ b/crates/consensus/engine/src/sync/forkchoice.rs @@ -12,7 +12,10 @@ use base_common_rpc_types::Transaction; use base_protocol::{BlockInfo, FromBlockError, L2BlockInfo}; use tracing::warn; -use crate::{EngineClient, ForkchoiceCheckpointLabel, ForkchoiceCheckpointReader, SyncStartError}; +use crate::{ + EngineClient, ForkchoiceCheckpointLabel, ForkchoiceCheckpointReader, + NoopForkchoiceCheckpointReader, SyncStartError, +}; /// An unsafe, safe, and finalized [`L2BlockInfo`] returned by the [`crate::find_starting_forkchoice`] /// function. @@ -52,16 +55,12 @@ impl L2ForkchoiceState { cfg: &RollupConfig, engine_client: &EngineClient_, ) -> Result { - Self::current_with_checkpoint_reader( - cfg, - engine_client, - &crate::NoopForkchoiceCheckpointReader, - ) - .await + Self::current_with_checkpoint_reader(cfg, engine_client, &NoopForkchoiceCheckpointReader) + .await } - /// Fetches the current forkchoice state of the L2 execution layer, using checkpoints only when - /// reth reports a safe or finalized block whose body has been pruned. + /// Like [`Self::current`], but falls back to `checkpoint_reader` for safe / finalized labels + /// when reth has pruned the L1 info deposit transaction body. pub async fn current_with_checkpoint_reader< EngineClient_: EngineClient, CheckpointReader: ForkchoiceCheckpointReader + ?Sized, @@ -161,7 +160,6 @@ async fn block_info_from_reth_or_checkpoint< let Some(checkpoint) = checkpoint_reader.checkpoint(label).await? else { return Err(err.into()); }; - if checkpoint.block_info != header { warn!( target: "sync_start", @@ -176,9 +174,14 @@ async fn block_info_from_reth_or_checkpoint< checkpoint_timestamp = checkpoint.block_info.timestamp, "forkchoice checkpoint does not match reth labeled block header" ); - return Err(err.into()); + return Err(SyncStartError::CheckpointMismatch { + label, + reth_number: header.number, + reth_hash: header.hash, + checkpoint_number: checkpoint.block_info.number, + checkpoint_hash: checkpoint.block_info.hash, + }); } - warn!( target: "sync_start", label = label.as_str(), diff --git a/crates/consensus/engine/src/sync/mod.rs b/crates/consensus/engine/src/sync/mod.rs index 4b6c3ed59e..9ebd27ed27 100644 --- a/crates/consensus/engine/src/sync/mod.rs +++ b/crates/consensus/engine/src/sync/mod.rs @@ -3,12 +3,6 @@ use base_common_genesis::RollupConfig; use base_protocol::L2BlockInfo; -mod checkpoint; -pub use checkpoint::{ - ForkchoiceCheckpointError, ForkchoiceCheckpointLabel, ForkchoiceCheckpointReader, - NoopForkchoiceCheckpointReader, -}; - mod forkchoice; pub use forkchoice::L2ForkchoiceState; @@ -16,6 +10,12 @@ mod error; pub use error::SyncStartError; use tracing::info; +mod checkpoint; +pub use checkpoint::{ + ForkchoiceCheckpointError, ForkchoiceCheckpointLabel, ForkchoiceCheckpointReader, + NoopForkchoiceCheckpointReader, +}; + use crate::EngineClient; /// Searches for the latest [`L2ForkchoiceState`] that we can use to start the sync process with. @@ -42,9 +42,8 @@ pub async fn find_starting_forkchoice( .await } -/// Searches for the latest [`L2ForkchoiceState`] that we can use to start the sync process with, -/// using the supplied checkpoint reader only when reth returns a labeled safe/finalized block whose -/// body is pruned. +/// Like [`find_starting_forkchoice`], but consults `checkpoint_reader` when reth-labeled blocks +/// cannot be hydrated because their bodies have been pruned (see [`ForkchoiceCheckpointReader`]). pub async fn find_starting_forkchoice_with_checkpoint_reader< EngineClient_: EngineClient, CheckpointReader: ForkchoiceCheckpointReader + ?Sized, diff --git a/crates/consensus/engine/src/task_queue/core.rs b/crates/consensus/engine/src/task_queue/core.rs index 8fac36cf5c..455e446efd 100644 --- a/crates/consensus/engine/src/task_queue/core.rs +++ b/crates/consensus/engine/src/task_queue/core.rs @@ -1,17 +1,22 @@ -//! The [`Engine`] is a task queue that receives and executes [`EngineTask`]s. +//! The [`Engine`] owns execution-layer state and drains queued [`EngineTask`]s. -use std::{cmp::Reverse, collections::BinaryHeap, sync::Arc}; +use std::{cmp::Reverse, collections::BinaryHeap, sync::Arc, time::Instant}; +use alloy_rpc_types_engine::{ + ExecutionPayload, INVALID_FORK_CHOICE_STATE_ERROR, PayloadId, PayloadStatusEnum, +}; use base_common_genesis::RollupConfig; -use base_protocol::{BaseBlockConversionError, L2BlockInfo}; +use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; +use base_protocol::{AttributesWithParent, BaseBlockConversionError, L2BlockInfo}; use thiserror::Error; use tokio::sync::watch::Sender; use super::EngineTaskExt; use crate::{ - EngineClient, EngineState, EngineSyncStateUpdate, EngineTask, EngineTaskError, + BuildTaskError, EngineBuildError, EngineClient, EngineForkchoiceVersion, + EngineGetPayloadVersion, EngineState, EngineSyncStateUpdate, EngineTask, EngineTaskError, EngineTaskErrorSeverity, ForkchoiceCheckpointReader, Metrics, NoopForkchoiceCheckpointReader, - SyncStartError, SynchronizeTask, SynchronizeTaskError, + SealTaskError, SyncStartError, SynchronizeTask, SynchronizeTaskError, find_starting_forkchoice_with_checkpoint_reader, task_queue::EngineTaskErrors, }; @@ -75,6 +80,312 @@ impl Engine { self.task_queue_length.subscribe() } + /// Starts a block build directly against the execution layer. + pub async fn build( + &mut self, + client: Arc, + config: Arc, + attributes: AttributesWithParent, + ) -> Result { + let _task_timer = + base_metrics::timed!(Metrics::engine_task_duration(Metrics::BUILD_TASK_LABEL)); + + match Self::build_with_state(&self.state, client.as_ref(), config.as_ref(), attributes) + .await + { + Ok(payload_id) => { + Metrics::engine_task_count(Metrics::BUILD_TASK_LABEL).increment(1); + Ok(payload_id) + } + Err(err) => { + let severity = err.severity(); + Metrics::engine_task_failure(Metrics::BUILD_TASK_LABEL, severity.as_label()) + .increment(1); + + match severity { + EngineTaskErrorSeverity::Temporary => { + trace!(target: "engine", error = %err, "Temporary engine error"); + } + EngineTaskErrorSeverity::Critical => { + error!(target: "engine", error = %err, "Critical engine error"); + } + EngineTaskErrorSeverity::Reset => { + warn!(target: "engine", "Engine requested derivation reset"); + } + EngineTaskErrorSeverity::Flush => { + warn!(target: "engine", "Engine requested derivation flush"); + } + } + + Err(err) + } + } + } + + /// Starts a block build using the provided engine state. + pub async fn build_with_state( + state: &EngineState, + engine_client: &EngineClient_, + cfg: &RollupConfig, + attributes_envelope: AttributesWithParent, + ) -> Result { + debug!( + target: "engine_builder", + txs = attributes_envelope + .attributes() + .transactions + .as_ref() + .map_or(0, |txs| txs.len()), + is_deposits = attributes_envelope.is_deposits_only(), + "Starting new build job" + ); + + let fcu_start_time = Instant::now(); + let payload_id = Self::start_build(state, engine_client, cfg, attributes_envelope).await?; + let fcu_duration = fcu_start_time.elapsed(); + + info!( + target: "engine_builder", + fcu_duration = ?fcu_duration, + "block build started" + ); + + Ok(payload_id) + } + + /// Fetches a sealed payload from the execution layer without inserting it. + pub async fn get_payload( + &mut self, + client: Arc, + config: Arc, + payload_id: PayloadId, + attributes: AttributesWithParent, + ) -> Result { + let _task_timer = + base_metrics::timed!(Metrics::engine_task_duration(Metrics::GET_PAYLOAD_TASK_LABEL)); + + let result = Self::get_payload_with_state( + &self.state, + client.as_ref(), + config.as_ref(), + payload_id, + &attributes, + ) + .await; + + match result { + Ok(envelope) => { + Metrics::engine_task_count(Metrics::GET_PAYLOAD_TASK_LABEL).increment(1); + Ok(envelope) + } + Err(err) => { + Metrics::engine_task_failure( + Metrics::GET_PAYLOAD_TASK_LABEL, + err.severity().as_label(), + ) + .increment(1); + Err(err) + } + } + } + + /// Fetches a sealed payload using the provided engine state. + pub async fn get_payload_with_state( + state: &EngineState, + engine: &EngineClient_, + cfg: &RollupConfig, + payload_id: PayloadId, + payload_attrs: &AttributesWithParent, + ) -> Result { + debug!( + target: "engine", + "Starting new get-payload job" + ); + + let unsafe_block_info = state.sync_state.unsafe_head().block_info; + let parent_block_info = payload_attrs.parent.block_info; + + if unsafe_block_info.hash != parent_block_info.hash + || unsafe_block_info.number != parent_block_info.number + { + error!( + target: "engine", + unsafe_block_info = ?unsafe_block_info, + parent_block_info = ?parent_block_info, + "GetPayload attributes parent does not match unsafe head, returning rebuild error" + ); + Metrics::sequencer_unsafe_head_changed_total().increment(1); + return Err(SealTaskError::UnsafeHeadChangedSinceBuild); + } + + Self::fetch_payload(cfg, engine, payload_id, payload_attrs).await + } + + /// Validates a forkchoice update status returned while starting a build. + pub fn validate_forkchoice_status(status: PayloadStatusEnum) -> Result<(), BuildTaskError> { + match status { + PayloadStatusEnum::Valid => Ok(()), + PayloadStatusEnum::Invalid { validation_error } => { + error!(target: "engine_builder", error = %validation_error, "Forkchoice update failed"); + Err(BuildTaskError::EngineBuildError(EngineBuildError::InvalidPayload( + validation_error, + ))) + } + PayloadStatusEnum::Syncing => { + warn!(target: "engine_builder", "Forkchoice update failed temporarily: EL is syncing"); + Err(BuildTaskError::EngineBuildError(EngineBuildError::EngineSyncing)) + } + PayloadStatusEnum::Accepted => Err(BuildTaskError::EngineBuildError( + EngineBuildError::UnexpectedPayloadStatus(status), + )), + } + } + + /// Sends the forkchoice update that starts an execution-layer build job. + pub async fn start_build( + state: &EngineState, + engine_client: &EngineClient_, + cfg: &RollupConfig, + attributes_envelope: AttributesWithParent, + ) -> Result { + if state.sync_state.unsafe_head().block_info.number + < state.sync_state.finalized_head().block_info.number + { + return Err(BuildTaskError::EngineBuildError( + EngineBuildError::FinalizedAheadOfUnsafe( + state.sync_state.unsafe_head().block_info.number, + state.sync_state.finalized_head().block_info.number, + ), + )); + } + + let new_forkchoice = state + .sync_state + .apply_update(EngineSyncStateUpdate { + unsafe_head: Some(attributes_envelope.parent), + ..Default::default() + }) + .create_forkchoice_state(); + + let forkchoice_version = EngineForkchoiceVersion::from_cfg( + cfg, + attributes_envelope.attributes.payload_attributes.timestamp, + ); + let attrs = attributes_envelope.attributes; + let update = match forkchoice_version { + EngineForkchoiceVersion::V3 => { + engine_client.fork_choice_updated_v3(new_forkchoice, Some(attrs)).await + } + EngineForkchoiceVersion::V2 => { + engine_client.fork_choice_updated_v2(new_forkchoice, Some(attrs)).await + } + } + .map_err(|e| { + error!(target: "engine_builder", error = %e, "Forkchoice update failed"); + let error = e + .as_error_resp() + .and_then(|e| { + (e.code == INVALID_FORK_CHOICE_STATE_ERROR as i64) + .then_some(EngineBuildError::ForkchoiceStateInvalid) + }) + .unwrap_or_else(|| EngineBuildError::AttributesInsertionFailed(e)); + + BuildTaskError::EngineBuildError(error) + })?; + + Self::validate_forkchoice_status(update.payload_status.status)?; + + debug!( + target: "engine_builder", + unsafe_hash = new_forkchoice.head_block_hash.to_string(), + safe_hash = new_forkchoice.safe_block_hash.to_string(), + finalized_hash = new_forkchoice.finalized_block_hash.to_string(), + "Forkchoice update with attributes successful" + ); + + update + .payload_id + .ok_or(BuildTaskError::EngineBuildError(EngineBuildError::MissingPayloadId)) + } + + /// Fetches the payload from the execution layer using the payload timestamp for versioning. + pub async fn fetch_payload( + cfg: &RollupConfig, + engine: &EngineClient_, + payload_id: PayloadId, + payload_attrs: &AttributesWithParent, + ) -> Result { + let payload_timestamp = payload_attrs.attributes().payload_attributes.timestamp; + + debug!( + target: "engine", + payload_id = payload_id.to_string(), + l2_time = payload_timestamp, + "Fetching payload" + ); + + let get_payload_version = EngineGetPayloadVersion::from_cfg(cfg, payload_timestamp); + let payload_envelope = match get_payload_version { + EngineGetPayloadVersion::V5 => { + let payload = engine.get_payload_v5(payload_id).await.map_err(|e| { + error!(target: "engine", error = %e, "Payload fetch failed"); + SealTaskError::GetPayloadFailed(e) + })?; + + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: payload_attrs + .attributes() + .payload_attributes + .parent_beacon_block_root, + execution_payload: BaseExecutionPayload::V4(payload.execution_payload), + } + } + EngineGetPayloadVersion::V4 => { + let payload = engine.get_payload_v4(payload_id).await.map_err(|e| { + error!(target: "engine", error = %e, "Payload fetch failed"); + SealTaskError::GetPayloadFailed(e) + })?; + + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: Some(payload.parent_beacon_block_root), + execution_payload: BaseExecutionPayload::V4(payload.execution_payload), + } + } + EngineGetPayloadVersion::V3 => { + let payload = engine.get_payload_v3(payload_id).await.map_err(|e| { + error!(target: "engine", error = %e, "Payload fetch failed"); + SealTaskError::GetPayloadFailed(e) + })?; + + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: Some(payload.parent_beacon_block_root), + execution_payload: BaseExecutionPayload::V3(payload.execution_payload), + } + } + EngineGetPayloadVersion::V2 => { + let payload = engine.get_payload_v2(payload_id).await.map_err(|e| { + error!(target: "engine", error = %e, "Payload fetch failed"); + SealTaskError::GetPayloadFailed(e) + })?; + + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: match payload.execution_payload.into_payload() { + ExecutionPayload::V1(payload) => BaseExecutionPayload::V1(payload), + ExecutionPayload::V2(payload) => BaseExecutionPayload::V2(payload), + other => { + return Err(SealTaskError::UnexpectedPayloadVersion(format!( + "{other:?}" + ))); + } + }, + } + } + }; + + Ok(payload_envelope) + } + /// Enqueues a new [`EngineTask`] for execution. /// Updates the queue length and notifies listeners of the change. pub fn enqueue(&mut self, task: EngineTask) { @@ -87,7 +398,7 @@ impl Engine { } /// Resets the engine by finding a plausible sync starting point via - /// [`find_starting_forkchoice`]. The state will be updated to the starting point, and a + /// [`crate::find_starting_forkchoice`]. The state will be updated to the starting point, and a /// forkchoice update will be enqueued in order to reorg the execution layer. pub async fn reset( &mut self, @@ -97,8 +408,8 @@ impl Engine { self.reset_with_checkpoint_reader(client, config, &NoopForkchoiceCheckpointReader).await } - /// Resets the engine by finding a plausible sync starting point via - /// [`find_starting_forkchoice_with_checkpoint_reader`]. + /// Like [`Self::reset`], but consults `checkpoint_reader` when reth-labeled blocks cannot be + /// hydrated because their bodies have been pruned. pub async fn reset_with_checkpoint_reader( &mut self, client: Arc, @@ -268,8 +579,8 @@ mod tests { use tokio::sync::watch; use crate::{ - BuildTask, Engine, EngineState, EngineSyncStateUpdate, EngineTask, EngineTaskError, - EngineTaskErrorSeverity, EngineTaskErrors, GetPayloadTask, InsertPayloadSafety, SealTask, + Engine, EngineState, EngineSyncStateUpdate, EngineTask, EngineTaskError, + EngineTaskErrorSeverity, InsertPayloadSafety, SealTask, SealTaskError, test_utils::{ TestAttributesBuilder, TestEngineStateBuilder, test_block_info, test_engine_client_builder, @@ -315,53 +626,7 @@ mod tests { } #[test] - fn equal_priority_build_tasks_are_fifo() { - let client = Arc::new(test_engine_client_builder().build()); - let cfg = Arc::new(RollupConfig::default()); - let mut engine = test_engine(); - - let first_timestamp = 1; - let second_timestamp = 2; - - engine.enqueue(EngineTask::Build(Box::new(BuildTask::new( - Arc::clone(&client), - Arc::clone(&cfg), - TestAttributesBuilder::new().with_timestamp(first_timestamp).build(), - None, - )))); - engine.enqueue(EngineTask::Build(Box::new(BuildTask::new( - Arc::clone(&client), - Arc::clone(&cfg), - TestAttributesBuilder::new().with_timestamp(second_timestamp).build(), - None, - )))); - - let (first, _) = engine.tasks.pop().expect("first task should be queued"); - let (second, _) = engine.tasks.pop().expect("second task should be queued"); - - match first { - EngineTask::Build(task) => { - assert_eq!( - task.attributes.attributes().payload_attributes.timestamp, - first_timestamp - ); - } - other => panic!("expected first build task, got {other:?}"), - } - - match second { - EngineTask::Build(task) => { - assert_eq!( - task.attributes.attributes().payload_attributes.timestamp, - second_timestamp - ); - } - other => panic!("expected second build task, got {other:?}"), - } - } - - #[test] - fn equal_priority_seal_and_get_payload_tasks_are_fifo() { + fn equal_priority_seal_tasks_are_fifo() { let client = Arc::new(test_engine_client_builder().build()); let cfg = Arc::new(RollupConfig::default()); let attributes = TestAttributesBuilder::new().build(); @@ -377,11 +642,12 @@ mod tests { InsertPayloadSafety::Unsafe, None, )))); - engine.enqueue(EngineTask::GetPayload(Box::new(GetPayloadTask::new( + engine.enqueue(EngineTask::Seal(Box::new(SealTask::new( Arc::clone(&client), Arc::clone(&cfg), second_payload_id, attributes, + InsertPayloadSafety::Unsafe, None, )))); @@ -396,10 +662,10 @@ mod tests { } match second { - EngineTask::GetPayload(task) => { + EngineTask::Seal(task) => { assert_eq!(task.payload_id, second_payload_id); } - other => panic!("expected second get-payload task, got {other:?}"), + other => panic!("expected second seal task, got {other:?}"), } } @@ -411,10 +677,12 @@ mod tests { let cfg = Arc::new(RollupConfig::default()); let mut engine = Engine::new(EngineState::default(), state_tx, queue_tx); - engine.enqueue(EngineTask::Build(Box::new(BuildTask::new( + engine.enqueue(EngineTask::Seal(Box::new(SealTask::new( client, cfg, + PayloadId::new([1; 8]), TestAttributesBuilder::new().build(), + InsertPayloadSafety::Unsafe, None, )))); assert_eq!(*queue_rx.borrow(), 1); @@ -424,6 +692,76 @@ mod tests { assert_eq!(*queue_rx.borrow(), 0); } + fn valid_fcu_with_payload(payload_id: PayloadId) -> ForkchoiceUpdated { + ForkchoiceUpdated { + payload_status: PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: Some(FixedBytes([2u8; 32])), + }, + payload_id: Some(payload_id), + } + } + + #[tokio::test] + async fn build_with_state_returns_payload_id() { + let payload_id = PayloadId::new([1u8; 8]); + let parent_block = test_block_info(0); + let unsafe_block = test_block_info(1); + let cfg = RollupConfig::default(); + let client = test_engine_client_builder() + .with_fork_choice_updated_v2_response(valid_fcu_with_payload(payload_id)) + .build(); + let attributes = TestAttributesBuilder::new().with_parent(parent_block).build(); + let state = TestEngineStateBuilder::new() + .with_unsafe_head(unsafe_block) + .with_safe_head(parent_block) + .with_finalized_head(parent_block) + .build(); + + let result = Engine::build_with_state(&state, &client, &cfg, attributes) + .await + .expect("build should return payload id"); + + assert_eq!(result, payload_id); + } + + #[tokio::test] + async fn get_payload_with_state_rejects_parent_mismatch() { + let attributes = TestAttributesBuilder::new().build(); + let mismatched_unsafe_head = test_block_info(2); + let state = TestEngineStateBuilder::new().with_unsafe_head(mismatched_unsafe_head).build(); + let client = test_engine_client_builder().build(); + + let result = Engine::get_payload_with_state( + &state, + &client, + &RollupConfig::default(), + PayloadId::default(), + &attributes, + ) + .await; + + assert!(matches!(result, Err(SealTaskError::UnsafeHeadChangedSinceBuild))); + } + + #[tokio::test] + async fn get_payload_with_state_propagates_fetch_error() { + let attributes = TestAttributesBuilder::new().build(); + let state = TestEngineStateBuilder::new().with_unsafe_head(attributes.parent).build(); + let client = test_engine_client_builder().build(); + + let result = Engine::get_payload_with_state( + &state, + &client, + &RollupConfig::default(), + PayloadId::default(), + &attributes, + ) + .await; + + assert!(matches!(result, Err(SealTaskError::GetPayloadFailed(_)))); + } + #[tokio::test] async fn probe_el_sync_valid_sets_el_sync_finished_and_advances_state() { let head = test_block_info(100); @@ -514,12 +852,10 @@ mod tests { assert!(!engine.state().el_sync_finished); } - /// Regression test: a [`BuildTask`] whose attr-bearing FCU returns - /// `PayloadStatusEnum::Invalid` must surface as [`EngineTaskErrorSeverity::Flush`] and the - /// poisoned task must be popped from the head of the queue, otherwise the engine processor - /// would re-execute the same task on every drain and starve every later request behind it. + /// Regression test: an attr-bearing FCU that returns `PayloadStatusEnum::Invalid` must + /// surface as [`EngineTaskErrorSeverity::Flush`] from the direct build path. #[tokio::test] - async fn drain_pops_head_on_flush_severity() { + async fn direct_build_invalid_payload_returns_flush() { let parent_block = test_block_info(0); let unsafe_block = test_block_info(1); let attributes_timestamp = unsafe_block.block_info.timestamp; @@ -550,55 +886,12 @@ mod tests { let (queue_tx, queue_rx) = watch::channel(0usize); let mut engine = Engine::new(initial_state, state_tx, queue_tx); - // Head: poisoned build task. Tail: a follow-up build task that should remain queued so - // we can also assert that drain only removes the failing head, not the rest of the - // queue (queued tasks may still be valid since they were enqueued from independent - // requests). - engine.enqueue(EngineTask::Build(Box::new(BuildTask::new( - Arc::clone(&client), - Arc::clone(&cfg), - attributes.clone(), - None, - )))); - engine.enqueue(EngineTask::Build(Box::new(BuildTask::new( - Arc::clone(&client), - Arc::clone(&cfg), - attributes, - None, - )))); - assert_eq!(*queue_rx.borrow(), 2); - - let err = engine.drain().await.expect_err("invalid FCU must fail drain"); - match &err { - EngineTaskErrors::Build(build_err) => assert_eq!( - build_err.severity(), - EngineTaskErrorSeverity::Flush, - "InvalidPayload must surface as Flush" - ), - other => panic!("expected Build error, got {other:?}"), - } + let err = engine + .build(Arc::clone(&client), Arc::clone(&cfg), attributes) + .await + .expect_err("invalid FCU must fail build"); assert_eq!(err.severity(), EngineTaskErrorSeverity::Flush); - - // Poisoned head popped, follow-up still in queue, metrics watch updated. - assert_eq!(engine.tasks.len(), 1, "only the poisoned head should be popped on Flush"); - assert_eq!(*queue_rx.borrow(), 1, "queue length watch must be republished after pop"); - - // Now flip the EL's response to a valid FCU and re-drain. This proves that drain - // *resumes* after a flush — the surviving follow-up task must execute and be popped, - // emptying the queue. Without the head-pop fix, this second drain would re-execute the - // poisoned task forever and never reach the follow-up. - client - .set_fork_choice_updated_v3_response(ForkchoiceUpdated { - payload_status: PayloadStatus { - status: PayloadStatusEnum::Valid, - latest_valid_hash: Some(FixedBytes([3u8; 32])), - }, - payload_id: Some(PayloadId::new([7u8; 8])), - }) - .await; - - engine.drain().await.expect("drain must succeed once the EL accepts the follow-up"); - assert_eq!(engine.tasks.len(), 0, "follow-up task should drain to completion"); - assert_eq!(*queue_rx.borrow(), 0, "queue length watch must reflect empty queue"); + assert_eq!(engine.tasks.len(), 0, "direct build must not enqueue poisoned work"); + assert_eq!(*queue_rx.borrow(), 0, "queue length watch must remain unchanged"); } } diff --git a/crates/consensus/engine/src/task_queue/tasks/build/error.rs b/crates/consensus/engine/src/task_queue/tasks/build/error.rs index c158fddb2f..69e606ce92 100644 --- a/crates/consensus/engine/src/task_queue/tasks/build/error.rs +++ b/crates/consensus/engine/src/task_queue/tasks/build/error.rs @@ -1,16 +1,15 @@ -//! Contains error types for the [`crate::SynchronizeTask`]. +//! Contains error types for direct engine build operations. -use alloy_rpc_types_engine::{PayloadId, PayloadStatusEnum}; +use alloy_rpc_types_engine::PayloadStatusEnum; use alloy_transport::{RpcError, TransportErrorKind}; use thiserror::Error; -use tokio::sync::mpsc; use crate::{EngineTaskError, task_queue::tasks::task::EngineTaskErrorSeverity}; /// An error that occurs during payload building within the engine. /// /// This error type is specific to the block building process and represents failures -/// that can occur during the automatic forkchoice update phase of [`BuildTask`]. +/// that can occur during the automatic forkchoice update phase of [`crate::Engine::build`]. /// Unlike [`BuildTaskError`], which handles higher-level build orchestration errors, /// `EngineBuildError` focuses on low-level engine API communication failures. /// @@ -20,7 +19,6 @@ use crate::{EngineTaskError, task_queue::tasks::task::EngineTaskErrorSeverity}; /// - **Engine Communication**: RPC failures during forkchoice updates /// - **Payload Validation**: Invalid payload status responses from the execution layer /// -/// [`BuildTask`]: crate::BuildTask #[derive(Debug, Error)] pub enum EngineBuildError { /// The finalized head is ahead of the unsafe head. @@ -46,15 +44,12 @@ pub enum EngineBuildError { EngineSyncing, } -/// An error that occurs when running the [`crate::BuildTask`]. +/// An error that occurs when starting an execution-layer build. #[derive(Debug, Error)] pub enum BuildTaskError { /// An error occurred when building the payload attributes in the engine. #[error("An error occurred when building the payload attributes to the engine.")] EngineBuildError(EngineBuildError), - /// Error sending the built payload envelope. - #[error(transparent)] - MpscSend(#[from] Box>), } impl EngineTaskError for BuildTaskError { @@ -84,7 +79,6 @@ impl EngineTaskError for BuildTaskError { Self::EngineBuildError(EngineBuildError::ForkchoiceStateInvalid) => { EngineTaskErrorSeverity::Reset } - Self::MpscSend(_) => EngineTaskErrorSeverity::Critical, } } } diff --git a/crates/consensus/engine/src/task_queue/tasks/build/mod.rs b/crates/consensus/engine/src/task_queue/tasks/build/mod.rs index 8b4b322e1a..2a6617e293 100644 --- a/crates/consensus/engine/src/task_queue/tasks/build/mod.rs +++ b/crates/consensus/engine/src/task_queue/tasks/build/mod.rs @@ -1,10 +1,4 @@ -//! Task and its associated types for building and importing a new block. - -mod task; -pub use task::BuildTask; +//! Errors for starting an execution-layer block build. mod error; pub use error::{BuildTaskError, EngineBuildError}; - -#[cfg(test)] -mod task_test; diff --git a/crates/consensus/engine/src/task_queue/tasks/build/task.rs b/crates/consensus/engine/src/task_queue/tasks/build/task.rs deleted file mode 100644 index 67d8fa703d..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/build/task.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! A task for building a new block and importing it. -use std::{sync::Arc, time::Instant}; - -use alloy_rpc_types_engine::{INVALID_FORK_CHOICE_STATE_ERROR, PayloadId, PayloadStatusEnum}; -use async_trait::async_trait; -use base_common_genesis::RollupConfig; -use base_protocol::AttributesWithParent; -use derive_more::Constructor; -use tokio::sync::mpsc; - -use super::BuildTaskError; -use crate::{ - EngineClient, EngineForkchoiceVersion, EngineState, EngineTaskExt, - state::EngineSyncStateUpdate, task_queue::tasks::build::error::EngineBuildError, -}; - -/// Task for building new blocks with automatic forkchoice synchronization. -/// -/// The [`BuildTask`] only performs the `engine_forkchoiceUpdated` call within the block building -/// workflow. It makes this call with the provided attributes to initiate block building on the -/// execution layer and, if successful, sends the new [`PayloadId`] via the configured sender. -/// -/// ## Error Handling -/// -/// The task uses [`EngineBuildError`] for build-specific failures during the forkchoice update -/// phase. -/// -/// [`EngineBuildError`]: crate::EngineBuildError -#[derive(Debug, Clone, Constructor)] -pub struct BuildTask { - /// The engine API client. - pub engine: Arc, - /// The [`RollupConfig`]. - pub cfg: Arc, - /// The [`AttributesWithParent`] to instruct the execution layer to build. - pub attributes: AttributesWithParent, - /// The optional sender through which [`PayloadId`] will be sent after the - /// block build has been started. - pub payload_id_tx: Option>, -} - -impl BuildTask { - /// Validates the provided [`PayloadStatusEnum`] according to the rules listed below. - /// - /// ## Observed [`PayloadStatusEnum`] Variants - /// - `VALID`: Returns Ok(()) - forkchoice update was successful - /// - `INVALID`: Returns error with validation details - /// - `SYNCING`: Returns temporary error - EL is syncing - /// - Other: Returns error for unexpected status codes - fn validate_forkchoice_status(status: PayloadStatusEnum) -> Result<(), BuildTaskError> { - match status { - PayloadStatusEnum::Valid => Ok(()), - PayloadStatusEnum::Invalid { validation_error } => { - error!(target: "engine_builder", error = %validation_error, "Forkchoice update failed"); - Err(BuildTaskError::EngineBuildError(EngineBuildError::InvalidPayload( - validation_error, - ))) - } - PayloadStatusEnum::Syncing => { - warn!(target: "engine_builder", "Forkchoice update failed temporarily: EL is syncing"); - Err(BuildTaskError::EngineBuildError(EngineBuildError::EngineSyncing)) - } - PayloadStatusEnum::Accepted => { - // Other codes are never returned by `engine_forkchoiceUpdate` - Err(BuildTaskError::EngineBuildError(EngineBuildError::UnexpectedPayloadStatus( - status, - ))) - } - } - } - - /// Starts the block building process by sending an initial `engine_forkchoiceUpdate` call with - /// the payload attributes to build. - /// - /// ### Success (`VALID`) - /// If the build is successful, the [`PayloadId`] is returned for sealing and the successful - /// forkchoice update identifier is relayed via the stored `payload_id_tx` sender. - /// - /// ### Failure (`INVALID`) - /// If the forkchoice update fails, the [`BuildTaskError`]. - /// - /// ### Syncing (`SYNCING`) - /// If the EL is syncing, the payload attributes are buffered and the function returns early. - /// This is a temporary state, and the function should be called again later. - /// - /// Note: This is `pub(super)` to allow testing via the `tests` submodule. - pub(super) async fn start_build( - &self, - state: &EngineState, - engine_client: &EngineClient_, - attributes_envelope: AttributesWithParent, - ) -> Result { - // Sanity check if the head is behind the finalized head. If it is, this is a critical - // error. - if state.sync_state.unsafe_head().block_info.number - < state.sync_state.finalized_head().block_info.number - { - return Err(BuildTaskError::EngineBuildError( - EngineBuildError::FinalizedAheadOfUnsafe( - state.sync_state.unsafe_head().block_info.number, - state.sync_state.finalized_head().block_info.number, - ), - )); - } - - // When inserting a payload, we advertise the parent's unsafe head as the current unsafe - // head to build on top of. - let new_forkchoice = state - .sync_state - .apply_update(EngineSyncStateUpdate { - unsafe_head: Some(attributes_envelope.parent), - ..Default::default() - }) - .create_forkchoice_state(); - - let forkchoice_version = EngineForkchoiceVersion::from_cfg( - &self.cfg, - attributes_envelope.attributes.payload_attributes.timestamp, - ); - let attrs = attributes_envelope.attributes; - let update = match forkchoice_version { - EngineForkchoiceVersion::V3 => { - engine_client.fork_choice_updated_v3(new_forkchoice, Some(attrs)).await - } - EngineForkchoiceVersion::V2 => { - engine_client.fork_choice_updated_v2(new_forkchoice, Some(attrs)).await - } - } - .map_err(|e| { - error!(target: "engine_builder", error = %e, "Forkchoice update failed"); - let error = e - .as_error_resp() - .and_then(|e| { - (e.code == INVALID_FORK_CHOICE_STATE_ERROR as i64) - .then_some(EngineBuildError::ForkchoiceStateInvalid) - }) - .unwrap_or_else(|| EngineBuildError::AttributesInsertionFailed(e)); - - BuildTaskError::EngineBuildError(error) - })?; - - Self::validate_forkchoice_status(update.payload_status.status)?; - - debug!( - target: "engine_builder", - unsafe_hash = new_forkchoice.head_block_hash.to_string(), - safe_hash = new_forkchoice.safe_block_hash.to_string(), - finalized_hash = new_forkchoice.finalized_block_hash.to_string(), - "Forkchoice update with attributes successful" - ); - - // Fetch the payload ID from the FCU. If no payload ID was returned, something went wrong - - // the block building job on the EL should have been initiated. - update - .payload_id - .ok_or(BuildTaskError::EngineBuildError(EngineBuildError::MissingPayloadId)) - } -} - -#[async_trait] -impl EngineTaskExt for BuildTask { - type Output = PayloadId; - - type Error = BuildTaskError; - - async fn execute(&self, state: &mut EngineState) -> Result { - debug!( - target: "engine_builder", - txs = self.attributes.attributes().transactions.as_ref().map_or(0, |txs| txs.len()), - is_deposits = self.attributes.is_deposits_only(), - "Starting new build job" - ); - - // Start the build by sending an FCU call with the current forkchoice and the input - // payload attributes. - let fcu_start_time = Instant::now(); - let payload_id = self.start_build(state, &self.engine, self.attributes.clone()).await?; - let fcu_duration = fcu_start_time.elapsed(); - - info!( - target: "engine_builder", - fcu_duration = ?fcu_duration, - "block build started" - ); - - // If a channel was provided, send the payload ID to it. - if let Some(tx) = &self.payload_id_tx { - tx.send(payload_id).await.map_err(Box::new)?; - } - - Ok(payload_id) - } -} diff --git a/crates/consensus/engine/src/task_queue/tasks/build/task_test.rs b/crates/consensus/engine/src/task_queue/tasks/build/task_test.rs deleted file mode 100644 index 41f336e6f4..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/build/task_test.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Tests for `BuildTask::execute` - -use std::sync::Arc; - -use alloy_json_rpc::ErrorPayload; -use alloy_primitives::FixedBytes; -use alloy_rpc_types_engine::{ - ForkchoiceUpdated, INVALID_FORK_CHOICE_STATE_ERROR, PayloadId, PayloadStatus, PayloadStatusEnum, -}; -use base_common_genesis::RollupConfig; -use rstest::rstest; -use thiserror::Error; -use tokio::sync::mpsc; - -use crate::{ - BuildTask, BuildTaskError, EngineBuildError, EngineClient, EngineForkchoiceVersion, - EngineState, EngineTaskError, EngineTaskErrorSeverity, EngineTaskExt, - test_utils::{ - MockEngineClientBuilder, TestAttributesBuilder, TestEngineStateBuilder, test_block_info, - test_engine_client_builder, - }, -}; - -fn fcu_for_payload(payload_id: Option, status: PayloadStatusEnum) -> ForkchoiceUpdated { - ForkchoiceUpdated { - payload_status: PayloadStatus { status, latest_valid_hash: Some(FixedBytes([2u8; 32])) }, - payload_id, - } -} - -fn configure_fcu( - b: MockEngineClientBuilder, - fcu_version: EngineForkchoiceVersion, - fcu_response: ForkchoiceUpdated, - cfg: &mut RollupConfig, - attributes_timestamp: u64, -) -> MockEngineClientBuilder { - match fcu_version { - EngineForkchoiceVersion::V2 => { - // Ecotone not yet active - cfg.hardforks.ecotone_time = Some(attributes_timestamp + 1); - b.with_fork_choice_updated_v2_response(fcu_response) - } - EngineForkchoiceVersion::V3 => { - // Ecotone is active - cfg.hardforks.ecotone_time = Some(attributes_timestamp); - b.with_fork_choice_updated_v3_response(fcu_response) - } - } -} - -#[derive(Debug, Error, PartialEq, Eq)] -enum TestErr { - #[error("AttributesInsertionFailed.")] - AttributesInsertionFailed, - #[error("EngineSyncing.")] - EngineSyncing, - #[error("FinalizedAheadOfUnsafe.")] - FinalizedAheadOfUnsafe, - #[error("ForkchoiceStateInvalid.")] - ForkchoiceStateInvalid, - #[error("InvalidPayload.")] - InvalidPayload, - #[error("MissingPayloadId.")] - MissingPayloadId, - #[error("UnexpectedPayloadStatus.")] - Unexpected, - #[error("MpscSend.")] - MpscSend, -} - -// Wraps real errors, ignoring details so we can easily match on results. -async fn wrapped_execute( - task: &BuildTask, - state: &mut EngineState, -) -> Result { - match task.execute(state).await { - Ok(payload_id) => Ok(payload_id), - Err(BuildTaskError::EngineBuildError(e)) => match e { - EngineBuildError::AttributesInsertionFailed(_) => { - Err(TestErr::AttributesInsertionFailed) - } - EngineBuildError::EngineSyncing => Err(TestErr::EngineSyncing), - EngineBuildError::FinalizedAheadOfUnsafe(_, _) => Err(TestErr::FinalizedAheadOfUnsafe), - EngineBuildError::ForkchoiceStateInvalid => Err(TestErr::ForkchoiceStateInvalid), - EngineBuildError::InvalidPayload(_) => Err(TestErr::InvalidPayload), - EngineBuildError::MissingPayloadId => Err(TestErr::MissingPayloadId), - EngineBuildError::UnexpectedPayloadStatus(_) => Err(TestErr::Unexpected), - }, - Err(BuildTaskError::MpscSend(_)) => Err(TestErr::MpscSend), - } -} - -#[rstest] -#[case::success(Some(PayloadStatusEnum::Valid), true, None)] -#[case::missing_id(Some(PayloadStatusEnum::Valid), false, Some(TestErr::MissingPayloadId))] -#[case::fcu_fail(None, false, Some(TestErr::AttributesInsertionFailed))] -#[case::fcu_status_fail(Some(PayloadStatusEnum::Invalid{validation_error: String::new()}), false, Some(TestErr::InvalidPayload))] -#[case::fcu_status_fail(Some(PayloadStatusEnum::Syncing), false, Some(TestErr::EngineSyncing))] -#[case::fcu_status_fail(Some(PayloadStatusEnum::Accepted), false, Some(TestErr::Unexpected))] -#[tokio::test] -async fn test_execute_variants( - // NB: none = failure - #[case] fcu_status: Option, - // NB: none = failure - #[case] payload_id_present: bool, - // NB: none = success - #[case] expected_err: Option, - #[values(true, false)] with_channel: bool, - #[values(EngineForkchoiceVersion::V2, EngineForkchoiceVersion::V3)] - fcu_version: EngineForkchoiceVersion, -) { - let payload_id = if payload_id_present { Some(PayloadId::new([1u8; 8])) } else { None }; - - let parent_block = test_block_info(0); - let unsafe_block = test_block_info(1); - let attributes_timestamp = unsafe_block.block_info.timestamp; - - let mut cfg = RollupConfig::default(); - - // Configure client with FCU response. If none, it will err on call, which is also a test case. - let engine_client = fcu_status - .map_or_else(test_engine_client_builder, |status| { - configure_fcu( - test_engine_client_builder(), - fcu_version, - fcu_for_payload(payload_id, status), - &mut cfg, - attributes_timestamp, - ) - }) - .build(); - - let attributes = TestAttributesBuilder::new() - .with_parent(parent_block) - .with_timestamp(attributes_timestamp) - .build(); - - let (tx, mut rx) = mpsc::channel(1); - - let task = BuildTask::new( - Arc::new(engine_client.clone()), - Arc::new(cfg), - attributes.clone(), - if with_channel { Some(tx) } else { None }, - ); - - let mut state = TestEngineStateBuilder::new() - .with_unsafe_head(unsafe_block) - .with_safe_head(parent_block) - .with_finalized_head(parent_block) - .build(); - - // Execute: Call execute - let result = wrapped_execute(&task, &mut state).await; - - if expected_err.is_some() { - assert_eq!(expected_err, result.err()); - } else { - assert!(result.is_ok()); - assert!(payload_id.is_some(), "Payload id none when it should be some."); - assert_eq!(result.unwrap(), payload_id.unwrap(), "Should return the correct payload ID"); - - // test channel payload send - if task.payload_id_tx.is_some() { - let res = rx.recv().await; - assert!(res.is_some(), "channel result is None"); - assert_eq!( - res.unwrap(), - payload_id.unwrap(), - "channel should have received correct payload id" - ); - } - } -} - -fn configure_fcu_error( - b: MockEngineClientBuilder, - fcu_version: EngineForkchoiceVersion, - error: ErrorPayload, - cfg: &mut RollupConfig, - attributes_timestamp: u64, -) -> MockEngineClientBuilder { - match fcu_version { - EngineForkchoiceVersion::V2 => { - cfg.hardforks.ecotone_time = Some(attributes_timestamp + 1); - b.with_fork_choice_updated_v2_error(error) - } - EngineForkchoiceVersion::V3 => { - cfg.hardforks.ecotone_time = Some(attributes_timestamp); - b.with_fork_choice_updated_v3_error(error) - } - } -} - -#[rstest] -#[tokio::test] -async fn test_invalid_forkchoice_state_triggers_reset( - #[values(EngineForkchoiceVersion::V2, EngineForkchoiceVersion::V3)] - fcu_version: EngineForkchoiceVersion, -) { - let parent_block = test_block_info(0); - let unsafe_block = test_block_info(1); - let attributes_timestamp = unsafe_block.block_info.timestamp; - - let mut cfg = RollupConfig::default(); - - let error = ErrorPayload { - code: INVALID_FORK_CHOICE_STATE_ERROR as i64, - message: "Invalid fork choice state".into(), - data: None, - }; - - let engine_client = configure_fcu_error( - test_engine_client_builder(), - fcu_version, - error, - &mut cfg, - attributes_timestamp, - ) - .build(); - - let attributes = TestAttributesBuilder::new() - .with_parent(parent_block) - .with_timestamp(attributes_timestamp) - .build(); - - let task = BuildTask::new(Arc::new(engine_client), Arc::new(cfg), attributes, None); - - let mut state = TestEngineStateBuilder::new() - .with_unsafe_head(unsafe_block) - .with_safe_head(parent_block) - .with_finalized_head(parent_block) - .build(); - - let result = wrapped_execute(&task, &mut state).await; - - assert_eq!(result, Err(TestErr::ForkchoiceStateInvalid)); - - let err = BuildTaskError::EngineBuildError(EngineBuildError::ForkchoiceStateInvalid); - assert_eq!(err.severity(), EngineTaskErrorSeverity::Reset); -} diff --git a/crates/consensus/engine/src/task_queue/tasks/consolidate/task_test.rs b/crates/consensus/engine/src/task_queue/tasks/consolidate/task_test.rs index 7bee7759bd..6927ad744f 100644 --- a/crates/consensus/engine/src/task_queue/tasks/consolidate/task_test.rs +++ b/crates/consensus/engine/src/task_queue/tasks/consolidate/task_test.rs @@ -31,6 +31,7 @@ fn rpc_transaction(tx: BaseTxEnvelope, block_number: u64) -> BaseTransaction { inner: Recovered::new_unchecked(tx, Address::ZERO), block_hash: None, block_number: Some(block_number), + block_timestamp: None, effective_gas_price: Some(0), transaction_index: Some(0), }, @@ -45,7 +46,7 @@ fn rpc_transaction(tx: BaseTxEnvelope, block_number: u64) -> BaseTransaction { /// Previously, `SealTask` compared `state.sync_state.unsafe_head()` (the chain /// tip, e.g. block 76) against `attributes.parent` (the safe head, e.g. block 34) /// and returned `UnsafeHeadChangedSinceBuild` with Critical severity, crashing the -/// engine. Op-node has no such check — the `BuildTask` already FCU'd the EL to the +/// engine. Op-node has no such check; the build step already FCU'd the EL to the /// correct parent, so the comparison is invalid. /// /// After the fix the reconcile path proceeds to `seal_and_canonicalize_block` @@ -78,7 +79,7 @@ async fn consolidate_does_not_crash_when_safe_behind_unsafe_and_attributes_misma b256!("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); // Mock client: return the mismatched block at number 35, and a Valid FCU - // with a payload_id (needed by BuildTask inside the reconcile path). + // with a payload_id needed by the build step inside the reconcile path. let valid_fcu = ForkchoiceUpdated { payload_status: PayloadStatus { status: PayloadStatusEnum::Valid, diff --git a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/error.rs b/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/error.rs deleted file mode 100644 index 45844c893c..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/error.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Error types for the delegated forkchoice task. - -use thiserror::Error; - -use crate::{ - ConsolidateTaskError, EngineTaskError, FinalizeTaskError, - task_queue::tasks::task::EngineTaskErrorSeverity, -}; - -/// An error returned by the delegated follow-node forkchoice task. -#[derive(Debug, Error)] -pub enum DelegatedForkchoiceTaskError { - /// Consolidation failed while applying the delegated safe head. - #[error(transparent)] - Consolidate(#[from] ConsolidateTaskError), - /// Finalization failed while advancing the delegated finalized head. - #[error(transparent)] - Finalize(#[from] FinalizeTaskError), -} - -impl EngineTaskError for DelegatedForkchoiceTaskError { - fn severity(&self) -> EngineTaskErrorSeverity { - match self { - Self::Consolidate(inner) => inner.severity(), - Self::Finalize(inner) => inner.severity(), - } - } -} diff --git a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/mod.rs b/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/mod.rs deleted file mode 100644 index e551edb399..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Follow-node delegated forkchoice task and its associated types. - -mod error; -pub use error::DelegatedForkchoiceTaskError; - -mod task; -pub use task::{DelegatedForkchoiceTask, DelegatedForkchoiceUpdate}; - -#[cfg(test)] -mod task_test; diff --git a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/task.rs b/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/task.rs deleted file mode 100644 index 87eac6b878..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/task.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! A follow-node task that applies delegated safe and finalized labels together. - -use std::sync::Arc; - -use async_trait::async_trait; -use base_common_genesis::RollupConfig; -use base_protocol::L2BlockInfo; -use derive_more::Constructor; - -use crate::{ - ConsolidateInput, ConsolidateTask, DelegatedForkchoiceTaskError, EngineClient, EngineState, - EngineTaskExt, FinalizeTask, -}; - -/// Delegated forkchoice labels from a remote follow source. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DelegatedForkchoiceUpdate { - /// The delegated safe L2 block. - pub safe_l2: L2BlockInfo, - /// The delegated finalized L2 block number, if available. - pub finalized_l2_number: Option, -} - -/// Applies delegated safe and finalized labels in engine-state order. -#[derive(Debug, Clone, Constructor)] -pub struct DelegatedForkchoiceTask { - /// The engine client. - pub client: Arc, - /// The rollup config. - pub cfg: Arc, - /// The delegated labels to apply. - pub update: DelegatedForkchoiceUpdate, -} - -#[async_trait] -impl EngineTaskExt for DelegatedForkchoiceTask { - type Output = (); - type Error = DelegatedForkchoiceTaskError; - - async fn execute(&self, state: &mut EngineState) -> Result<(), Self::Error> { - ConsolidateTask::new( - Arc::clone(&self.client), - Arc::clone(&self.cfg), - ConsolidateInput::BlockInfo(self.update.safe_l2), - ) - .execute(state) - .await?; - - let actual_safe = state.sync_state.safe_head().block_info.number; - let Some(remote_finalized) = self.update.finalized_l2_number else { - return Ok(()); - }; - - let finalized_target = remote_finalized.min(actual_safe); - let current_finalized = state.sync_state.finalized_head().block_info.number; - if finalized_target <= current_finalized { - debug!( - target: "engine", - actual_safe, - current_finalized, - finalized_target, - "Skipping delegated finalized update" - ); - return Ok(()); - } - - FinalizeTask::new(Arc::clone(&self.client), Arc::clone(&self.cfg), finalized_target) - .execute(state) - .await?; - - Ok(()) - } -} diff --git a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/task_test.rs b/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/task_test.rs deleted file mode 100644 index 36852806c0..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/delegated_forkchoice/task_test.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Tests for [`DelegatedForkchoiceTask::execute`]. - -use std::sync::Arc; - -use alloy_eips::BlockNumberOrTag; -use alloy_primitives::B256; -use alloy_rpc_types_engine::{ForkchoiceUpdated, PayloadStatus, PayloadStatusEnum}; -use alloy_rpc_types_eth::Block as RpcBlock; -use base_common_genesis::RollupConfig; -use base_common_rpc_types::Transaction as BaseTransaction; -use base_protocol::{BlockInfo, L2BlockInfo}; - -use crate::{ - DelegatedForkchoiceTask, DelegatedForkchoiceUpdate, EngineTaskExt, - test_utils::{TestEngineStateBuilder, test_block_info, test_engine_client_builder}, -}; - -fn syncing_fcu() -> ForkchoiceUpdated { - ForkchoiceUpdated { - payload_status: PayloadStatus { - status: PayloadStatusEnum::Syncing, - latest_valid_hash: None, - }, - payload_id: None, - } -} - -fn block_with_hash(number: u64, hash: B256) -> RpcBlock { - let mut block = RpcBlock::::default(); - block.header.hash = hash; - block.header.inner.number = number; - block.header.inner.timestamp = number * 2; - block -} - -#[tokio::test] -async fn syncing_safe_update_skips_finalization_beyond_actual_safe() { - let delegated_safe_number = 80; - let delegated_safe_hash = B256::from([0x11; 32]); - let delegated_safe = L2BlockInfo { - block_info: BlockInfo { - hash: delegated_safe_hash, - number: delegated_safe_number, - ..Default::default() - }, - ..Default::default() - }; - - let client = Arc::new( - test_engine_client_builder() - .with_l2_block_by_label( - BlockNumberOrTag::Number(delegated_safe_number), - block_with_hash(delegated_safe_number, delegated_safe_hash), - ) - .with_fork_choice_updated_v3_response(syncing_fcu()) - .build(), - ); - - let mut state = TestEngineStateBuilder::new() - .with_unsafe_head(test_block_info(100)) - .with_safe_head(L2BlockInfo::default()) - .with_finalized_head(L2BlockInfo::default()) - .with_el_sync_finished(false) - .build(); - - let task = DelegatedForkchoiceTask::new( - client, - Arc::new(RollupConfig::default()), - DelegatedForkchoiceUpdate { - safe_l2: delegated_safe, - finalized_l2_number: Some(delegated_safe_number), - }, - ); - - task.execute(&mut state).await.expect("delegated forkchoice should not fail"); - - assert_eq!( - state.sync_state.safe_head(), - L2BlockInfo::default(), - "safe head must remain unchanged when safe FCU returns Syncing", - ); - assert_eq!( - state.sync_state.finalized_head(), - L2BlockInfo::default(), - "finalized head must not advance past the actual safe head", - ); -} diff --git a/crates/consensus/engine/src/task_queue/tasks/get_payload/mod.rs b/crates/consensus/engine/src/task_queue/tasks/get_payload/mod.rs deleted file mode 100644 index 47d8ae4917..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/get_payload/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! A task for fetching a sealed payload from the engine without inserting it. - -mod task; -pub use task::GetPayloadTask; - -#[cfg(test)] -mod task_test; diff --git a/crates/consensus/engine/src/task_queue/tasks/get_payload/task.rs b/crates/consensus/engine/src/task_queue/tasks/get_payload/task.rs deleted file mode 100644 index d52eb953c0..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/get_payload/task.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! A task for fetching a sealed payload from the engine without inserting it. -use std::sync::Arc; - -use alloy_rpc_types_engine::{ExecutionPayload, PayloadId}; -use async_trait::async_trait; -use base_common_genesis::RollupConfig; -use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; -use base_protocol::AttributesWithParent; -use derive_more::Constructor; -use tokio::sync::mpsc; - -use super::super::SealTaskError; -use crate::{EngineClient, EngineGetPayloadVersion, EngineState, EngineTaskExt, Metrics}; - -/// Task for fetching a sealed payload from the engine without inserting it. -/// -/// Unlike [`SealTask`], this task only performs the `engine_getPayload` step and -/// sends the resulting [`BaseExecutionPayloadEnvelope`] back to the caller. It does -/// NOT import the payload into the engine (no `new_payload` or FCU calls). -/// -/// This enables the sequencer to commit to the conductor before engine insertion. -/// -/// [`SealTask`]: crate::SealTask -#[derive(Debug, Clone, Constructor)] -pub struct GetPayloadTask { - /// The engine API client. - pub engine: Arc, - /// The [`RollupConfig`]. - pub cfg: Arc, - /// The [`PayloadId`] to fetch. - pub payload_id: PayloadId, - /// The [`AttributesWithParent`] used for version selection and parent validation. - pub attributes: AttributesWithParent, - /// An optional sender to convey the sealed [`BaseExecutionPayloadEnvelope`] - /// or the [`SealTaskError`] that occurred during fetching. - pub result_tx: Option>>, -} - -impl GetPayloadTask { - /// Fetches the execution payload from the EL, returning the execution envelope. - /// - /// This is the same version-dispatch logic as [`SealTask::seal_payload`] but without - /// any insertion step. - async fn get_payload( - &self, - cfg: &RollupConfig, - engine: &EngineClient_, - payload_id: PayloadId, - payload_attrs: &AttributesWithParent, - ) -> Result { - let payload_timestamp = payload_attrs.attributes().payload_attributes.timestamp; - - debug!( - target: "engine", - payload_id = payload_id.to_string(), - l2_time = payload_timestamp, - "Fetching payload" - ); - - let get_payload_version = EngineGetPayloadVersion::from_cfg(cfg, payload_timestamp); - let payload_envelope = match get_payload_version { - EngineGetPayloadVersion::V5 => { - let payload = engine.get_payload_v5(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: payload_attrs - .attributes() - .payload_attributes - .parent_beacon_block_root, - execution_payload: BaseExecutionPayload::V4(payload.execution_payload), - } - } - EngineGetPayloadVersion::V4 => { - let payload = engine.get_payload_v4(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: Some(payload.parent_beacon_block_root), - execution_payload: BaseExecutionPayload::V4(payload.execution_payload), - } - } - EngineGetPayloadVersion::V3 => { - let payload = engine.get_payload_v3(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: Some(payload.parent_beacon_block_root), - execution_payload: BaseExecutionPayload::V3(payload.execution_payload), - } - } - EngineGetPayloadVersion::V2 => { - let payload = engine.get_payload_v2(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: None, - execution_payload: match payload.execution_payload.into_payload() { - ExecutionPayload::V1(payload) => BaseExecutionPayload::V1(payload), - ExecutionPayload::V2(payload) => BaseExecutionPayload::V2(payload), - _ => unreachable!("the response should be a V1 or V2 payload"), - }, - } - } - }; - - Ok(payload_envelope) - } - - /// Sends the provided result via the `result_tx` sender if one exists, returning the - /// appropriate error if it does not. - async fn send_channel_result_or_get_error( - &self, - res: Result, - ) -> Result<(), SealTaskError> { - if let Some(tx) = &self.result_tx { - tx.send(res).await.map_err(|e| SealTaskError::MpscSend(Box::new(e)))?; - } else if let Err(x) = res { - return Err(x); - } - - Ok(()) - } -} - -#[async_trait] -impl EngineTaskExt for GetPayloadTask { - type Output = (); - - type Error = SealTaskError; - - async fn execute(&self, state: &mut EngineState) -> Result<(), SealTaskError> { - debug!( - target: "engine", - "Starting new get-payload job" - ); - - let unsafe_block_info = state.sync_state.unsafe_head().block_info; - let parent_block_info = self.attributes.parent.block_info; - - let res = if unsafe_block_info.hash != parent_block_info.hash - || unsafe_block_info.number != parent_block_info.number - { - error!( - target: "engine", - unsafe_block_info = ?unsafe_block_info, - parent_block_info = ?parent_block_info, - "GetPayload attributes parent does not match unsafe head, returning rebuild error" - ); - Metrics::sequencer_unsafe_head_changed_total().increment(1); - Err(SealTaskError::UnsafeHeadChangedSinceBuild) - } else { - self.get_payload(&self.cfg, &self.engine, self.payload_id, &self.attributes).await - }; - - self.send_channel_result_or_get_error(res).await?; - - Ok(()) - } -} diff --git a/crates/consensus/engine/src/task_queue/tasks/get_payload/task_test.rs b/crates/consensus/engine/src/task_queue/tasks/get_payload/task_test.rs deleted file mode 100644 index 8fd3c5d1b8..0000000000 --- a/crates/consensus/engine/src/task_queue/tasks/get_payload/task_test.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Tests for [`GetPayloadTask::execute`]. - -use std::sync::Arc; - -use alloy_primitives::{Address, B256, Bloom, Bytes, U256}; -use alloy_rpc_types_engine::{ - BlobsBundleV2, ExecutionPayloadEnvelopeV2, ExecutionPayloadFieldV2, ExecutionPayloadV1, - ExecutionPayloadV2, ExecutionPayloadV3, PayloadId, -}; -use base_common_genesis::{HardForkConfig, HardforkConfig, RollupConfig}; -use base_common_rpc_types_engine::{ - BaseExecutionPayload, BaseExecutionPayloadEnvelopeV5, BaseExecutionPayloadV4, -}; -use rstest::rstest; -use tokio::sync::mpsc; - -use crate::{ - EngineTaskExt, GetPayloadTask, SealTaskError, - test_utils::{TestAttributesBuilder, TestEngineStateBuilder, test_engine_client_builder}, -}; - -/// A non-zero `ExecutionPayloadEnvelopeV2` for testing. -fn v2_envelope() -> ExecutionPayloadEnvelopeV2 { - ExecutionPayloadEnvelopeV2 { - execution_payload: ExecutionPayloadFieldV2::V1(ExecutionPayloadV1 { - parent_hash: B256::repeat_byte(0x11), - fee_recipient: Address::repeat_byte(0x22), - state_root: B256::repeat_byte(0x33), - receipts_root: B256::repeat_byte(0x44), - logs_bloom: Bloom::ZERO, - prev_randao: B256::repeat_byte(0x55), - block_number: 1, - gas_limit: 30_000_000, - gas_used: 21_000, - timestamp: 100, - extra_data: Bytes::new(), - base_fee_per_gas: U256::from(1_000_000_000u64), - block_hash: B256::repeat_byte(0x66), - transactions: vec![], - }), - block_value: U256::from(500_000_000_000u64), - } -} - -/// A non-zero [`BaseExecutionPayloadEnvelopeV5`] for Osaka / Base Azul testing. -fn v5_envelope() -> BaseExecutionPayloadEnvelopeV5 { - BaseExecutionPayloadEnvelopeV5 { - execution_payload: BaseExecutionPayloadV4 { - payload_inner: ExecutionPayloadV3 { - payload_inner: ExecutionPayloadV2 { - payload_inner: ExecutionPayloadV1 { - parent_hash: B256::repeat_byte(0xAA), - fee_recipient: Address::repeat_byte(0xBB), - state_root: B256::repeat_byte(0xCC), - receipts_root: B256::repeat_byte(0xDD), - logs_bloom: Bloom::ZERO, - prev_randao: B256::repeat_byte(0xEE), - block_number: 42, - gas_limit: 30_000_000, - gas_used: 100_000, - timestamp: 2000, - extra_data: Bytes::new(), - base_fee_per_gas: U256::from(7_000_000_000u64), - block_hash: B256::repeat_byte(0xFF), - transactions: vec![], - }, - withdrawals: vec![], - }, - blob_gas_used: 0, - excess_blob_gas: 0, - }, - withdrawals_root: B256::repeat_byte(0x77), - }, - block_value: U256::from(1_000_000_000_000u64), - blobs_bundle: BlobsBundleV2 { commitments: vec![], proofs: vec![], blobs: vec![] }, - should_override_builder: false, - execution_requests: vec![], - } -} - -/// When the engine's unsafe head does not match the attributes parent, `GetPayloadTask` must -/// short-circuit and return [`SealTaskError::UnsafeHeadChangedSinceBuild`] without touching the -/// engine API. -#[tokio::test] -async fn test_parent_mismatch_returns_unsafe_head_changed_error() { - let attributes = TestAttributesBuilder::new().build(); - - // Build engine state whose unsafe head hash/number differ from the attributes parent. - // test_block_info(2) produces block number 2 while the default attributes parent is block 0. - let client = test_engine_client_builder().build(); - let mismatched_unsafe_head = crate::test_utils::test_block_info(2); - let mut state = TestEngineStateBuilder::new().with_unsafe_head(mismatched_unsafe_head).build(); - - let task = GetPayloadTask::new( - Arc::new(client), - Arc::new(RollupConfig::default()), - PayloadId::default(), - attributes, - None, - ); - - let result = task.execute(&mut state).await; - - assert!( - matches!(result, Err(SealTaskError::UnsafeHeadChangedSinceBuild)), - "expected UnsafeHeadChangedSinceBuild, got {result:?}" - ); -} - -/// When the unsafe head matches the attributes parent and the engine returns a valid payload, -/// `GetPayloadTask` must succeed and deliver the envelope — either via the result channel -/// (when one is provided) or as the direct task return value. -#[rstest] -#[tokio::test] -async fn test_get_payload_v2_success(#[values(true, false)] with_channel: bool) { - let attributes = TestAttributesBuilder::new().build(); - let parent = attributes.parent; - - // RollupConfig::default() has no ecotone_time set → get_payload_v2 is selected. - let client = test_engine_client_builder().with_execution_payload_v2(v2_envelope()).build(); - - let mut state = TestEngineStateBuilder::new().with_unsafe_head(parent).build(); - - let (tx, mut rx) = mpsc::channel(1); - let task = GetPayloadTask::new( - Arc::new(client), - Arc::new(RollupConfig::default()), - PayloadId::default(), - attributes, - if with_channel { Some(tx) } else { None }, - ); - - let result = task.execute(&mut state).await; - - assert!(result.is_ok(), "task should succeed, got {result:?}"); - - if with_channel { - let channel_result = rx.recv().await.expect("channel should have a result"); - assert!(channel_result.is_ok(), "channel result should be Ok, got {channel_result:?}"); - } -} - -/// When the unsafe head matches the attributes parent and the engine returns a valid V5 payload -/// (Osaka / Base Azul), `GetPayloadTask` must call `get_payload_v5`, wrap the inner -/// [`BaseExecutionPayloadV4`] as an [`BaseExecutionPayload::V4`] variant, and source -/// `parent_beacon_block_root` from the attributes rather than the payload envelope. -#[rstest] -#[tokio::test] -async fn test_get_payload_v5_success(#[values(true, false)] with_channel: bool) { - let attributes = TestAttributesBuilder::new().build(); - let parent = attributes.parent; - - // Activate Base Azul (Osaka) at the default attributes timestamp (2000) so that - // `EngineGetPayloadVersion::V5` is selected. - let cfg = Arc::new(RollupConfig { - hardforks: HardForkConfig { - base: HardforkConfig { azul: Some(2000), beryl: None }, - ..Default::default() - }, - ..Default::default() - }); - - let client = test_engine_client_builder().with_execution_payload_v5(v5_envelope()).build(); - let mut state = TestEngineStateBuilder::new().with_unsafe_head(parent).build(); - - let (tx, mut rx) = mpsc::channel(1); - let task = GetPayloadTask::new( - Arc::new(client), - cfg, - PayloadId::default(), - attributes, - if with_channel { Some(tx) } else { None }, - ); - - let result = task.execute(&mut state).await; - assert!(result.is_ok(), "task should succeed, got {result:?}"); - - if with_channel { - let channel_result = rx.recv().await.expect("channel should have a result"); - assert!(channel_result.is_ok(), "channel result should be Ok, got {channel_result:?}"); - let envelope = channel_result.unwrap(); - // V5 wraps the execution payload as the V4 variant inside BaseExecutionPayload. - assert!( - matches!(envelope.execution_payload, BaseExecutionPayload::V4(_)), - "V5 get_payload should produce a V4 execution payload variant, got {:?}", - envelope.execution_payload - ); - // V5 omits parent_beacon_block_root from the response envelope; the task sources - // it from the attributes. TestAttributesBuilder::new() defaults to Some(B256::ZERO). - assert_eq!( - envelope.parent_beacon_block_root, - Some(B256::ZERO), - "parent_beacon_block_root should be sourced from attributes for V5 payloads" - ); - } -} - -/// When the engine returns an error (no payload configured in the mock), `GetPayloadTask` must -/// surface the error — either by sending it via the result channel or by returning it from -/// `execute` when no channel is provided. -#[rstest] -#[tokio::test] -async fn test_get_payload_failure_propagates(#[values(true, false)] with_channel: bool) { - let attributes = TestAttributesBuilder::new().build(); - let parent = attributes.parent; - - // No payload configured → mock returns a transport error. - let client = test_engine_client_builder().build(); - let mut state = TestEngineStateBuilder::new().with_unsafe_head(parent).build(); - - let (tx, mut rx) = mpsc::channel(1); - let task = GetPayloadTask::new( - Arc::new(client), - Arc::new(RollupConfig::default()), - PayloadId::default(), - attributes, - if with_channel { Some(tx) } else { None }, - ); - - let result = task.execute(&mut state).await; - - if with_channel { - // With a channel the task itself returns Ok(()); the error goes into the channel. - assert!(result.is_ok(), "task should return Ok when a channel absorbs the error"); - let channel_result = rx.recv().await.expect("channel should have a result"); - assert!( - matches!(channel_result, Err(SealTaskError::GetPayloadFailed(_))), - "channel should contain GetPayloadFailed, got {channel_result:?}" - ); - } else { - // Without a channel the task propagates the error directly. - assert!( - matches!(result, Err(SealTaskError::GetPayloadFailed(_))), - "expected GetPayloadFailed, got {result:?}" - ); - } -} diff --git a/crates/consensus/engine/src/task_queue/tasks/insert/error.rs b/crates/consensus/engine/src/task_queue/tasks/insert/error.rs index 96c79d56a0..1479914442 100644 --- a/crates/consensus/engine/src/task_queue/tasks/insert/error.rs +++ b/crates/consensus/engine/src/task_queue/tasks/insert/error.rs @@ -31,6 +31,9 @@ pub enum InsertTaskError { /// The forkchoice update call to consolidate the block into the engine state failed. #[error(transparent)] ForkchoiceUpdateFailed(#[from] SynchronizeTaskError), + /// The forkchoice update completed without advancing the unsafe head to the inserted payload. + #[error("Forkchoice update did not advance to the inserted payload")] + ForkchoiceUpdateDidNotAdvance, } impl EngineTaskError for InsertTaskError { @@ -39,9 +42,9 @@ impl EngineTaskError for InsertTaskError { Self::FromBlockError(_) | Self::L2BlockInfoConstruction(_) => { EngineTaskErrorSeverity::Critical } - Self::InsertFailed(_) | Self::UnexpectedPayloadStatus(_) => { - EngineTaskErrorSeverity::Temporary - } + Self::InsertFailed(_) + | Self::UnexpectedPayloadStatus(_) + | Self::ForkchoiceUpdateDidNotAdvance => EngineTaskErrorSeverity::Temporary, Self::ForkchoiceUpdateFailed(inner) => inner.severity(), } } diff --git a/crates/consensus/engine/src/task_queue/tasks/insert/mod.rs b/crates/consensus/engine/src/task_queue/tasks/insert/mod.rs index d5d2d74e88..f1df94b0c1 100644 --- a/crates/consensus/engine/src/task_queue/tasks/insert/mod.rs +++ b/crates/consensus/engine/src/task_queue/tasks/insert/mod.rs @@ -1,7 +1,7 @@ //! Task to insert a payload into the execution engine. mod task; -pub use task::{InsertPayloadSafety, InsertTask}; +pub use task::{InsertPayloadSafety, InsertTask, InsertTaskResult}; mod error; pub use error::InsertTaskError; diff --git a/crates/consensus/engine/src/task_queue/tasks/insert/task.rs b/crates/consensus/engine/src/task_queue/tasks/insert/task.rs index 5d05eeb2fb..eabf89dde1 100644 --- a/crates/consensus/engine/src/task_queue/tasks/insert/task.rs +++ b/crates/consensus/engine/src/task_queue/tasks/insert/task.rs @@ -13,12 +13,16 @@ use base_common_rpc_types_engine::{ BaseExecutionPayload, BaseExecutionPayloadEnvelope, BaseExecutionPayloadSidecar, }; use base_protocol::L2BlockInfo; +use tokio::sync::mpsc; use crate::{ EngineClient, EngineState, EngineTaskExt, InsertTaskError, SynchronizeTask, state::EngineSyncStateUpdate, }; +/// Result sent to callers waiting for payload insertion acknowledgement. +pub type InsertTaskResult = Result; + /// Whether inserting a payload should advance the safe head. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InsertPayloadSafety { @@ -54,6 +58,8 @@ pub struct InsertTask { envelope: BaseExecutionPayloadEnvelope, /// Whether the inserted payload should advance the safe head. payload_safety: InsertPayloadSafety, + /// Optional response channel used by callers that need insertion acknowledgement. + result_tx: Option>, } impl InsertTask { @@ -64,7 +70,7 @@ impl InsertTask { envelope: BaseExecutionPayloadEnvelope, payload_safety: InsertPayloadSafety, ) -> Self { - Self { client, rollup_config, envelope, payload_safety } + Self { client, rollup_config, envelope, payload_safety, result_tx: None } } /// Creates a new task to insert an unsafe payload. @@ -76,6 +82,22 @@ impl InsertTask { Self::new(client, rollup_config, envelope, InsertPayloadSafety::Unsafe) } + /// Creates a new task to insert an unsafe payload and send insertion acknowledgement. + pub const fn unsafe_payload_with_result( + client: Arc, + rollup_config: Arc, + envelope: BaseExecutionPayloadEnvelope, + result_tx: mpsc::Sender, + ) -> Self { + Self { + client, + rollup_config, + envelope, + payload_safety: InsertPayloadSafety::Unsafe, + result_tx: Some(result_tx), + } + } + /// Creates a new task to insert a safe payload. pub const fn safe_payload( client: Arc, @@ -139,15 +161,8 @@ impl InsertTask { true } -} - -#[async_trait] -impl EngineTaskExt for InsertTask { - type Output = (); - - type Error = InsertTaskError; - async fn execute(&self, state: &mut EngineState) -> Result<(), InsertTaskError> { + async fn insert_payload(&self, state: &mut EngineState) -> InsertTaskResult { let time_start = Instant::now(); // Form a block ref before insertion so stale unsafe payloads can be dropped before import. @@ -178,7 +193,7 @@ impl EngineTaskExt for InsertTask { .map_err(InsertTaskError::L2BlockInfoConstruction)?; if !self.is_unsafe_payload_applicable(state, &new_block_ref) { - return Ok(()); + return Ok(state.sync_state.unsafe_head()); } // Insert the new payload. @@ -237,6 +252,10 @@ impl EngineTaskExt for InsertTask { .execute(state) .await?; + if self.result_tx.is_some() && state.sync_state.unsafe_head() != new_block_ref { + return Err(InsertTaskError::ForkchoiceUpdateDidNotAdvance); + } + let total_duration = time_start.elapsed(); info!( @@ -249,7 +268,31 @@ impl EngineTaskExt for InsertTask { "Inserted new payload" ); - Ok(()) + Ok(new_block_ref) + } + + async fn send_channel_result(&self, result: InsertTaskResult) { + let Some(result_tx) = &self.result_tx else { return }; + if result_tx.send(result).await.is_err() { + warn!(target: "engine", "Sending insert result failed"); + } + } +} + +#[async_trait] +impl EngineTaskExt for InsertTask { + type Output = (); + + type Error = InsertTaskError; + + async fn execute(&self, state: &mut EngineState) -> Result<(), InsertTaskError> { + let result = self.insert_payload(state).await; + if self.result_tx.is_some() { + self.send_channel_result(result).await; + Ok(()) + } else { + result.map(|_| ()) + } } } diff --git a/crates/consensus/engine/src/task_queue/tasks/mod.rs b/crates/consensus/engine/src/task_queue/tasks/mod.rs index 6e4b07e6fd..2d0e72397b 100644 --- a/crates/consensus/engine/src/task_queue/tasks/mod.rs +++ b/crates/consensus/engine/src/task_queue/tasks/mod.rs @@ -9,25 +9,17 @@ mod synchronize; pub use synchronize::{SynchronizeTask, SynchronizeTaskError}; mod insert; -pub use insert::{InsertPayloadSafety, InsertTask, InsertTaskError}; +pub use insert::{InsertPayloadSafety, InsertTask, InsertTaskError, InsertTaskResult}; mod build; -pub use build::{BuildTask, BuildTaskError, EngineBuildError}; +pub use build::{BuildTaskError, EngineBuildError}; mod seal; pub use seal::{SealTask, SealTaskError}; -mod get_payload; -pub use get_payload::GetPayloadTask; - mod consolidate; pub use consolidate::{ConsolidateInput, ConsolidateTask, ConsolidateTaskError}; -mod delegated_forkchoice; -pub use delegated_forkchoice::{ - DelegatedForkchoiceTask, DelegatedForkchoiceTaskError, DelegatedForkchoiceUpdate, -}; - mod finalize; pub use finalize::{FinalizeTask, FinalizeTaskError}; diff --git a/crates/consensus/engine/src/task_queue/tasks/seal/error.rs b/crates/consensus/engine/src/task_queue/tasks/seal/error.rs index f6b43e3789..62a2e419fe 100644 --- a/crates/consensus/engine/src/task_queue/tasks/seal/error.rs +++ b/crates/consensus/engine/src/task_queue/tasks/seal/error.rs @@ -50,6 +50,10 @@ pub enum SealTaskError { /// this should not happen and is a critical error. #[error("Unsafe head changed between build and seal")] UnsafeHeadChangedSinceBuild, + /// The execution layer returned a payload version that does not match the requested + /// get-payload method. + #[error("Unexpected payload version from get_payload: {0}")] + UnexpectedPayloadVersion(String), } impl SealTaskError { @@ -71,13 +75,14 @@ impl SealTaskError { } InsertTaskError::FromBlockError(_) | InsertTaskError::L2BlockInfoConstruction(_) => true, - InsertTaskError::InsertFailed(_) | InsertTaskError::UnexpectedPayloadStatus(_) => { - false - } + InsertTaskError::InsertFailed(_) + | InsertTaskError::UnexpectedPayloadStatus(_) + | InsertTaskError::ForkchoiceUpdateDidNotAdvance => false, }, Self::GetPayloadFailed(_) | Self::HoloceneInvalidFlush - | Self::UnsafeHeadChangedSinceBuild => false, + | Self::UnsafeHeadChangedSinceBuild + | Self::UnexpectedPayloadVersion(_) => false, Self::DepositOnlyPayloadFailed | Self::DepositOnlyPayloadReattemptFailed | Self::FromBlock(_) @@ -91,7 +96,9 @@ impl EngineTaskError for SealTaskError { fn severity(&self) -> EngineTaskErrorSeverity { match self { Self::PayloadInsertionFailed(inner) => inner.severity(), - Self::GetPayloadFailed(_) => EngineTaskErrorSeverity::Temporary, + Self::GetPayloadFailed(_) | Self::UnexpectedPayloadVersion(_) => { + EngineTaskErrorSeverity::Temporary + } Self::HoloceneInvalidFlush => EngineTaskErrorSeverity::Flush, Self::UnsafeHeadChangedSinceBuild => EngineTaskErrorSeverity::Reset, Self::DepositOnlyPayloadReattemptFailed @@ -118,6 +125,10 @@ mod tests { #[rstest] #[case::get_payload_failed(SealTaskError::GetPayloadFailed(rpc_error()), false)] + #[case::unexpected_payload_version( + SealTaskError::UnexpectedPayloadVersion("V3".to_string()), + false + )] #[case::holocene_invalid_flush(SealTaskError::HoloceneInvalidFlush, false)] #[case::unsafe_head_changed(SealTaskError::UnsafeHeadChangedSinceBuild, false)] #[case::deposit_only_failed(SealTaskError::DepositOnlyPayloadFailed, true)] diff --git a/crates/consensus/engine/src/task_queue/tasks/seal/task.rs b/crates/consensus/engine/src/task_queue/tasks/seal/task.rs index e4bd9314d8..3ba2354154 100644 --- a/crates/consensus/engine/src/task_queue/tasks/seal/task.rs +++ b/crates/consensus/engine/src/task_queue/tasks/seal/task.rs @@ -1,18 +1,17 @@ //! A task for importing a block that has already been started. use std::{sync::Arc, time::Instant}; -use alloy_rpc_types_engine::{ExecutionPayload, PayloadId}; +use alloy_rpc_types_engine::PayloadId; use async_trait::async_trait; use base_common_genesis::RollupConfig; -use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; +use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; use base_protocol::{AttributesWithParent, L2BlockInfo}; use derive_more::Constructor; use tokio::sync::mpsc; use super::SealTaskError; use crate::{ - EngineClient, EngineGetPayloadVersion, EngineState, EngineTaskExt, InsertPayloadSafety, - InsertTask, + Engine, EngineClient, EngineState, EngineTaskExt, InsertPayloadSafety, InsertTask, InsertTaskError::{self}, task_queue::build_and_seal, }; @@ -66,73 +65,7 @@ impl SealTask { payload_id: PayloadId, payload_attrs: AttributesWithParent, ) -> Result { - let payload_timestamp = payload_attrs.attributes().payload_attributes.timestamp; - - debug!( - target: "engine", - payload_id = payload_id.to_string(), - l2_time = payload_timestamp, - "Sealing payload" - ); - - let get_payload_version = EngineGetPayloadVersion::from_cfg(cfg, payload_timestamp); - let payload_envelope = match get_payload_version { - EngineGetPayloadVersion::V5 => { - let payload = engine.get_payload_v5(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - // V5 drops parent_beacon_block_root from the get_payload response; source it - // from the attributes instead so InsertTask can still pass it to new_payload. - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: payload_attrs - .attributes() - .payload_attributes - .parent_beacon_block_root, - execution_payload: BaseExecutionPayload::V4(payload.execution_payload), - } - } - EngineGetPayloadVersion::V4 => { - let payload = engine.get_payload_v4(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: Some(payload.parent_beacon_block_root), - execution_payload: BaseExecutionPayload::V4(payload.execution_payload), - } - } - EngineGetPayloadVersion::V3 => { - let payload = engine.get_payload_v3(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: Some(payload.parent_beacon_block_root), - execution_payload: BaseExecutionPayload::V3(payload.execution_payload), - } - } - EngineGetPayloadVersion::V2 => { - let payload = engine.get_payload_v2(payload_id).await.map_err(|e| { - error!(target: "engine", error = %e, "Payload fetch failed"); - SealTaskError::GetPayloadFailed(e) - })?; - - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: None, - execution_payload: match payload.execution_payload.into_payload() { - ExecutionPayload::V1(payload) => BaseExecutionPayload::V1(payload), - ExecutionPayload::V2(payload) => BaseExecutionPayload::V2(payload), - _ => unreachable!("the response should be a V1 or V2 payload"), - }, - } - } - }; - - Ok(payload_envelope) + Engine::::fetch_payload(cfg, engine, payload_id, &payload_attrs).await } /// Inserts a payload into the engine with Holocene fallback support. @@ -290,7 +223,7 @@ impl EngineTaskExt for SealTask { ); // NOTE: the reference node does not compare the current unsafe head against the - // attributes parent before sealing. The BuildTask already sent an FCU + // attributes parent before sealing. The build step already sent an FCU // with `attributes.parent` as the head, so the EL is building on the // correct parent regardless of where the engine's in-memory unsafe head // sits. During consolidation the safe head is intentionally behind the diff --git a/crates/consensus/engine/src/task_queue/tasks/synchronize/task.rs b/crates/consensus/engine/src/task_queue/tasks/synchronize/task.rs index 6c7e9fe117..933fee71ef 100644 --- a/crates/consensus/engine/src/task_queue/tasks/synchronize/task.rs +++ b/crates/consensus/engine/src/task_queue/tasks/synchronize/task.rs @@ -29,13 +29,12 @@ use crate::{ /// ## Automatic Integration /// /// Unlike the legacy `ForkchoiceTask`, forkchoice updates during block building are now -/// explicitly handled within [`BuildTask`], eliminating the need for explicit +/// explicitly handled within direct build processing, eliminating the need for explicit /// forkchoice management in most user scenarios. /// /// [`InsertTask`]: crate::InsertTask /// [`ConsolidateTask`]: crate::ConsolidateTask /// [`FinalizeTask`]: crate::FinalizeTask -/// [`BuildTask`]: crate::BuildTask #[derive(Debug, Clone, Constructor)] pub struct SynchronizeTask { /// The engine client. diff --git a/crates/consensus/engine/src/task_queue/tasks/task.rs b/crates/consensus/engine/src/task_queue/tasks/task.rs index 1cbc704b22..6be590f726 100644 --- a/crates/consensus/engine/src/task_queue/tasks/task.rs +++ b/crates/consensus/engine/src/task_queue/tasks/task.rs @@ -9,12 +9,10 @@ use derive_more::Display; use thiserror::Error; use tokio::task::yield_now; -use super::{ - BuildTask, ConsolidateTask, DelegatedForkchoiceTask, FinalizeTask, GetPayloadTask, InsertTask, -}; +use super::{ConsolidateTask, FinalizeTask, InsertTask}; use crate::{ - BuildTaskError, ConsolidateTaskError, DelegatedForkchoiceTaskError, EngineClient, EngineState, - FinalizeTaskError, InsertTaskError, Metrics, + BuildTaskError, ConsolidateTaskError, EngineClient, EngineState, FinalizeTaskError, + InsertTaskError, Metrics, task_queue::{SealTask, SealTaskError}, }; @@ -74,9 +72,6 @@ pub enum EngineTaskErrors { /// An error that occurred while consolidating the engine state. #[error(transparent)] Consolidate(#[from] ConsolidateTaskError), - /// An error that occurred while applying delegated follow-node forkchoice labels. - #[error(transparent)] - DelegatedForkchoice(#[from] DelegatedForkchoiceTaskError), /// An error that occurred while finalizing an L2 block. #[error(transparent)] Finalize(#[from] FinalizeTaskError), @@ -101,7 +96,6 @@ impl EngineTaskError for EngineTaskErrors { Self::Build(inner) => inner.severity(), Self::Seal(inner) => inner.severity(), Self::Consolidate(inner) => inner.severity(), - Self::DelegatedForkchoice(inner) => inner.severity(), Self::Finalize(inner) => inner.severity(), } } @@ -114,18 +108,12 @@ impl EngineTaskError for EngineTaskErrors { pub enum EngineTask { /// Inserts a payload into the execution engine. Insert(Box>), - /// Begins building a new block with the given attributes, producing a new payload ID. - Build(Box>), /// Seals the block with the given payload ID and attributes, inserting it into the execution /// engine. Seal(Box>), - /// Fetches a sealed payload from the engine without inserting it. - GetPayload(Box>), /// Performs consolidation on the engine state, reverting to payload attribute processing - /// via the [`BuildTask`] if consolidation fails. + /// via the direct build-and-seal fallback if consolidation fails. Consolidate(Box>), - /// Applies delegated safe and finalized labels for follow mode. - DelegatedForkchoice(Box>), /// Finalizes an L2 block Finalize(Box>), } @@ -136,13 +124,8 @@ impl EngineTask { match self { Self::Insert(task) => task.execute(state).await?, Self::Seal(task) => task.execute(state).await?, - Self::GetPayload(task) => task.execute(state).await?, Self::Consolidate(task) => task.execute(state).await?, - Self::DelegatedForkchoice(task) => task.execute(state).await?, Self::Finalize(task) => task.execute(state).await?, - Self::Build(task) => { - task.execute(state).await?; - } }; Ok(()) @@ -152,13 +135,19 @@ impl EngineTask { match self { Self::Insert(_) => Metrics::INSERT_TASK_LABEL, Self::Consolidate(_) => Metrics::CONSOLIDATE_TASK_LABEL, - Self::DelegatedForkchoice(_) => Metrics::DELEGATED_FORKCHOICE_TASK_LABEL, - Self::Build(_) => Metrics::BUILD_TASK_LABEL, Self::Seal(_) => Metrics::SEAL_TASK_LABEL, - Self::GetPayload(_) => Metrics::GET_PAYLOAD_TASK_LABEL, Self::Finalize(_) => Metrics::FINALIZE_TASK_LABEL, } } + + const fn task_priority(&self) -> u8 { + match self { + Self::Seal(_) => 4, + Self::Insert(_) => 3, + Self::Consolidate(_) => 2, + Self::Finalize(_) => 1, + } + } } impl PartialEq for EngineTask { @@ -166,11 +155,8 @@ impl PartialEq for EngineTask { matches!( (self, other), (Self::Insert(_), Self::Insert(_)) - | (Self::Build(_), Self::Build(_)) | (Self::Seal(_), Self::Seal(_)) - | (Self::GetPayload(_), Self::GetPayload(_)) | (Self::Consolidate(_), Self::Consolidate(_)) - | (Self::DelegatedForkchoice(_), Self::DelegatedForkchoice(_)) | (Self::Finalize(_), Self::Finalize(_)) ) } @@ -186,56 +172,7 @@ impl PartialOrd for EngineTask { impl Ord for EngineTask { fn cmp(&self, other: &Self) -> Ordering { - // Order (descending): BuildBlock -> Insert -> Consolidate -> Finalize - // - // https://specs.base.org/protocol/consensus/derivation#forkchoice-synchronization - // - // - Block building jobs are prioritized above all other tasks, to give priority to the - // sequencer. BuildTask handles forkchoice updates automatically. - // - Insert tasks are prioritized over Consolidate tasks, to ensure direct payload imports - // are handled promptly. - // - Consolidate tasks are prioritized over Finalize tasks, as they advance the safe chain - // via derivation. - // - Finalize tasks have the lowest priority, as they only update finalized status. - match (self, other) { - // Same variant cases - (Self::Insert(_), Self::Insert(_)) - | (Self::Consolidate(_), Self::Consolidate(_)) - | (Self::DelegatedForkchoice(_), Self::DelegatedForkchoice(_)) - | (Self::Build(_), Self::Build(_)) - | (Self::Seal(_), Self::Seal(_)) - | (Self::GetPayload(_), Self::GetPayload(_)) - | (Self::Finalize(_), Self::Finalize(_)) => Ordering::Equal, - - // Seal and GetPayload share equal priority (sequencer critical path); must be checked - // before the wildcard arms below to satisfy Ord antisymmetry. - (Self::Seal(_) | Self::GetPayload(_), Self::Seal(_) | Self::GetPayload(_)) => { - Ordering::Equal - } - - // Seal and GetPayload tasks are prioritized over all others (sequencer critical path) - (Self::Seal(_) | Self::GetPayload(_), _) => Ordering::Greater, - (_, Self::Seal(_) | Self::GetPayload(_)) => Ordering::Less, - - // BuildBlock tasks are prioritized over Insert and Consolidate tasks - (Self::Build(_), _) => Ordering::Greater, - (_, Self::Build(_)) => Ordering::Less, - - // Insert tasks are prioritized over Consolidate and Finalize tasks - (Self::Insert(_), _) => Ordering::Greater, - (_, Self::Insert(_)) => Ordering::Less, - - // Consolidate-style tasks are prioritized over Finalize tasks. - (Self::Consolidate(_) | Self::DelegatedForkchoice(_), Self::Finalize(_)) => { - Ordering::Greater - } - (Self::Finalize(_), Self::Consolidate(_) | Self::DelegatedForkchoice(_)) => { - Ordering::Less - } - - // Consolidate and delegated forkchoice share equal priority. - (Self::Consolidate(_) | Self::DelegatedForkchoice(_), _) => Ordering::Equal, - } + self.task_priority().cmp(&other.task_priority()) } } diff --git a/crates/consensus/engine/src/task_queue/tasks/util.rs b/crates/consensus/engine/src/task_queue/tasks/util.rs index 9308382365..e61dabd3cf 100644 --- a/crates/consensus/engine/src/task_queue/tasks/util.rs +++ b/crates/consensus/engine/src/task_queue/tasks/util.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use base_common_genesis::RollupConfig; use base_protocol::AttributesWithParent; -use super::{BuildTask, BuildTaskError, EngineTaskExt, SealTask, SealTaskError}; -use crate::{EngineClient, EngineState, InsertPayloadSafety}; +use super::{BuildTaskError, EngineTaskExt, SealTask, SealTaskError}; +use crate::{Engine, EngineClient, EngineState, InsertPayloadSafety}; /// Error type for build and seal operations. #[derive(Debug, thiserror::Error)] @@ -22,7 +22,7 @@ pub(in crate::task_queue) enum BuildAndSealError { /// Builds and seals a payload in sequence. /// /// This is a utility function that: -/// 1. Creates and executes a [`BuildTask`] to initiate block building +/// 1. Starts an execution-layer build /// 2. Creates and executes a [`SealTask`] to seal the block, referencing the initiated payload /// /// This pattern is commonly used for Holocene deposits-only fallback and other scenarios @@ -42,14 +42,12 @@ pub(in crate::task_queue) async fn build_and_seal( attributes: AttributesWithParent, payload_safety: InsertPayloadSafety, ) -> Result<(), BuildAndSealError> { - // Execute the build task - let payload_id = BuildTask::new( - Arc::clone(&engine), - Arc::clone(&cfg), + let payload_id = Engine::::build_with_state( + state, + engine.as_ref(), + cfg.as_ref(), attributes.clone(), - None, // Build task doesn't send the payload yet ) - .execute(state) .await?; // Execute the seal task with the payload ID from the build diff --git a/crates/consensus/engine/src/test_utils/attributes.rs b/crates/consensus/engine/src/test_utils/attributes.rs index 72748bf039..7f4bbff5e7 100644 --- a/crates/consensus/engine/src/test_utils/attributes.rs +++ b/crates/consensus/engine/src/test_utils/attributes.rs @@ -87,6 +87,7 @@ impl TestAttributesBuilder { suggested_fee_recipient: self.suggested_fee_recipient, withdrawals: self.withdrawals, parent_beacon_block_root: self.parent_beacon_block_root, + slot_number: None, }, transactions: self.transactions, no_tx_pool: self.no_tx_pool, diff --git a/crates/consensus/protocol/src/batch/single.rs b/crates/consensus/protocol/src/batch/single.rs index 7ade31413d..c6cbdfacf3 100644 --- a/crates/consensus/protocol/src/batch/single.rs +++ b/crates/consensus/protocol/src/batch/single.rs @@ -196,10 +196,8 @@ mod tests { use base_common_consensus::{BaseTxEnvelope, TxDeposit}; use base_common_genesis::HardForkConfig; use tracing::Level; - use tracing_subscriber::layer::SubscriberExt; use super::*; - use crate::test_utils::{CollectingLayer, TraceStorage}; #[test] fn test_empty_l1_blocks() { @@ -599,10 +597,7 @@ mod tests { #[test] #[cfg(feature = "std")] fn test_check_batch_drop_non_empty_jovian_transition() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); // Gather a few test transactions for the batch. let transactions = example_transactions(); diff --git a/crates/consensus/protocol/src/batch/span.rs b/crates/consensus/protocol/src/batch/span.rs index 8a96a35745..87800fff16 100644 --- a/crates/consensus/protocol/src/batch/span.rs +++ b/crates/consensus/protocol/src/batch/span.rs @@ -760,10 +760,9 @@ mod tests { use base_common_consensus::BaseBlock; use base_common_genesis::{ChainGenesis, HardForkConfig}; use tracing::Level; - use tracing_subscriber::layer::SubscriberExt; use super::*; - use crate::test_utils::{CollectingLayer, TestBatchValidator, TraceStorage}; + use crate::test_utils::TestBatchValidator; fn gen_l1_blocks( start_num: u64, @@ -869,10 +868,7 @@ mod tests { #[tokio::test] async fn test_check_batch_missing_l1_block_input() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig::default(); let l1_blocks = vec![]; @@ -891,10 +887,7 @@ mod tests { #[tokio::test] async fn test_check_batches_is_empty() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig::default(); let l1_blocks = vec![BlockInfo::default()]; @@ -949,10 +942,7 @@ mod tests { #[tokio::test] async fn test_eager_block_missing_origins() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig::default(); let block = BlockInfo { number: 9, ..Default::default() }; @@ -977,10 +967,7 @@ mod tests { #[tokio::test] async fn test_check_batch_delta_inactive() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(10), ..Default::default() }, @@ -1009,10 +996,7 @@ mod tests { #[tokio::test] async fn test_check_batch_out_of_order() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1042,10 +1026,7 @@ mod tests { #[tokio::test] async fn test_check_batch_no_new_blocks() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1073,10 +1054,7 @@ mod tests { #[tokio::test] async fn test_check_batch_overlapping_blocks_tx_count_mismatch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1139,10 +1117,7 @@ mod tests { #[tokio::test] async fn test_check_batch_overlapping_blocks_tx_mismatch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1218,10 +1193,7 @@ mod tests { #[tokio::test] async fn test_check_batch_block_timestamp_lt_l1_origin() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1256,10 +1228,7 @@ mod tests { #[tokio::test] async fn test_check_batch_misaligned_timestamp() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1288,10 +1257,7 @@ mod tests { #[tokio::test] async fn test_check_batch_misaligned_without_overlap() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1320,10 +1286,7 @@ mod tests { #[tokio::test] async fn test_check_batch_failed_to_fetch_l2_block() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1355,10 +1318,7 @@ mod tests { #[tokio::test] async fn test_check_batch_parent_hash_fail() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1407,10 +1367,7 @@ mod tests { #[tokio::test] async fn test_check_sequence_window_expired() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { hardforks: HardForkConfig { delta_time: Some(0), ..Default::default() }, @@ -1455,10 +1412,7 @@ mod tests { #[tokio::test] async fn test_starting_epoch_too_far_ahead() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1510,10 +1464,7 @@ mod tests { #[tokio::test] async fn test_check_batch_epoch_hash_mismatch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1569,10 +1520,7 @@ mod tests { #[tokio::test] async fn test_need_more_l1_blocks() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1623,10 +1571,7 @@ mod tests { #[tokio::test] async fn test_drop_batch_epoch_too_old() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1680,10 +1625,7 @@ mod tests { #[tokio::test] async fn test_check_batch_exceeds_max_seq_drif() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1731,12 +1673,7 @@ mod tests { #[tokio::test] async fn test_continuing_with_empty_batch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default() - .with(layer) - .with(tracing_subscriber::fmt::layer()); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1791,10 +1728,7 @@ mod tests { #[tokio::test] async fn test_check_batch_exceeds_sequencer_time_drift() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1855,10 +1789,7 @@ mod tests { #[tokio::test] async fn test_check_batch_empty_txs() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1923,10 +1854,7 @@ mod tests { #[tokio::test] async fn test_check_batch_with_deposit_tx() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -1985,10 +1913,7 @@ mod tests { #[tokio::test] async fn test_check_batch_with_eip7702_tx() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -2051,10 +1976,7 @@ mod tests { #[tokio::test] async fn test_check_batch_failed_to_fetch_payload() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -2107,10 +2029,7 @@ mod tests { #[tokio::test] async fn test_check_batch_failed_to_extract_l2_block_info() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let cfg = RollupConfig { seq_window_size: 100, @@ -2176,10 +2095,7 @@ mod tests { #[tokio::test] async fn test_overlapped_blocks_origin_mismatch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let payload_block_hash = b256!("0e2ee9abe94ee4514b170d7039d8151a7469d434a8575dbab5bd4187a27732dd"); @@ -2247,10 +2163,7 @@ mod tests { #[tokio::test] async fn test_overlapped_blocks_origin_outdated() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let parent_hash = b256!("1111111111111111111111111111111111111111000000000000000000000000"); let cfg = RollupConfig { @@ -2317,10 +2230,7 @@ mod tests { #[tokio::test] async fn test_check_batch_valid_with_genesis_epoch() { - let trace_store: TraceStorage = Default::default(); - let layer = CollectingLayer::new(trace_store.clone()); - let subscriber = tracing_subscriber::Registry::default().with(layer); - let _guard = tracing::subscriber::set_default(subscriber); + let (trace_store, _guard) = crate::capture_traces!(); let payload_block_hash = b256!("0e2ee9abe94ee4514b170d7039d8151a7469d434a8575dbab5bd4187a27732dd"); diff --git a/crates/consensus/protocol/src/block.rs b/crates/consensus/protocol/src/block.rs index dc1266012c..ba1fab3075 100644 --- a/crates/consensus/protocol/src/block.rs +++ b/crates/consensus/protocol/src/block.rs @@ -297,6 +297,7 @@ mod tests { ), block_hash: None, block_number: Some(1), + block_timestamp: None, effective_gas_price: Some(1), transaction_index: Some(0), }; diff --git a/crates/consensus/protocol/src/test_utils.rs b/crates/consensus/protocol/src/test_utils.rs index 31bc3ed835..434bb033e2 100644 --- a/crates/consensus/protocol/src/test_utils.rs +++ b/crates/consensus/protocol/src/test_utils.rs @@ -100,6 +100,11 @@ impl TraceStorage { .collect() } + /// Locks the storage and returns the collected traces. + pub fn lock(&self) -> spin::MutexGuard<'_, Vec<(Level, String)>> { + self.0.lock() + } + /// Returns if the storage is empty. pub fn is_empty(&self) -> bool { self.0.lock().is_empty() @@ -130,3 +135,18 @@ impl Layer for CollectingLayer { storage.push((level, message)); } } + +/// Installs a temporary tracing subscriber that captures events into [`TraceStorage`]. +#[macro_export] +macro_rules! capture_traces { + () => {{ + let trace_store = $crate::test_utils::TraceStorage::default(); + let layer = $crate::test_utils::CollectingLayer::new(trace_store.clone()); + let subscriber = ::tracing_subscriber::layer::SubscriberExt::with( + ::tracing_subscriber::Registry::default(), + layer, + ); + let guard = ::tracing::subscriber::set_default(subscriber); + (trace_store, guard) + }}; +} diff --git a/crates/consensus/rpc/src/jsonrpsee.rs b/crates/consensus/rpc/src/jsonrpsee.rs index 121ce59d51..62b6cd72de 100644 --- a/crates/consensus/rpc/src/jsonrpsee.rs +++ b/crates/consensus/rpc/src/jsonrpsee.rs @@ -16,9 +16,80 @@ use jsonrpsee::{ core::{RpcResult, SubscriptionResult}, proc_macros::rpc, }; +use serde::{Deserialize, Serialize}; use crate::{OutputResponse, health::HealthzResponse}; +/// Live Raft cluster membership snapshot returned by `conductor_clusterMembership`. +/// +/// Mirrors the upstream op-conductor `ClusterMembership` struct. +/// See: +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClusterMembership { + /// All servers currently known to the Raft configuration. + pub servers: Vec, + /// Raft configuration index — increments on every membership change. + pub version: u64, +} + +/// A single member of the Raft cluster. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerInfo { + /// Stable Raft server identifier (e.g. `"sequencer-1"`). + /// + /// Matches the `server_id` operators configure per node and the parameter + /// expected by `conductor_transferLeaderToServer`. + pub id: String, + /// Raft binary-protocol address (e.g. `"op-conductor-1:5051"`). + /// + /// **NOT** the JSON-RPC URL — the Raft consensus port is distinct from the + /// HTTP RPC port. Callers that need to talk JSON-RPC to a peer must derive + /// the RPC URL separately (typically by extracting the host and applying a + /// port template from local configuration). + pub addr: String, + /// Whether this server can vote in Raft elections. + pub suffrage: ServerSuffrage, +} + +/// Raft suffrage (voting eligibility) for a cluster member. +/// +/// Wire format is an integer (`0` = voter, `1` = nonvoter), matching the +/// upstream `ServerSuffrage` enum in op-conductor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "u8", into = "u8")] +pub enum ServerSuffrage { + /// Server is eligible to vote in Raft elections. + Voter, + /// Server is a non-voting member (replica only). + Nonvoter, +} + +impl TryFrom for ServerSuffrage { + type Error = UnknownServerSuffrage; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Self::Voter), + 1 => Ok(Self::Nonvoter), + other => Err(UnknownServerSuffrage(other)), + } + } +} + +impl From for u8 { + fn from(value: ServerSuffrage) -> Self { + match value { + ServerSuffrage::Voter => 0, + ServerSuffrage::Nonvoter => 1, + } + } +} + +/// Error returned when an unknown `ServerSuffrage` discriminant is decoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("unknown server suffrage discriminant: {0}")] +pub struct UnknownServerSuffrage(pub u8); + /// Base rollup node RPC interface. /// /// https://docs.optimism.io/builders/node-operators/json-rpc @@ -250,6 +321,30 @@ pub trait ConductorApi { server_id: String, raft_addr: String, ) -> RpcResult<()>; + + /// Pauses the conductor control loop. + #[method(name = "pause")] + async fn conductor_pause(&self) -> RpcResult<()>; + + /// Resumes the conductor control loop. + #[method(name = "resume")] + async fn conductor_resume(&self) -> RpcResult<()>; + + /// Returns true if the conductor control loop is paused. + #[method(name = "paused")] + async fn conductor_paused(&self) -> RpcResult; + + /// Returns true if the conductor process is stopped. + #[method(name = "stopped")] + async fn conductor_stopped(&self) -> RpcResult; + + /// Returns true if the sequencer this conductor manages reports healthy. + #[method(name = "sequencerHealthy")] + async fn conductor_sequencer_healthy(&self) -> RpcResult; + + /// Returns the live Raft cluster membership snapshot. + #[method(name = "clusterMembership")] + async fn conductor_cluster_membership(&self) -> RpcResult; } #[cfg(test)] @@ -272,8 +367,8 @@ mod tests { use rstest::rstest; use super::{ - AdminApiServer, BaseP2PApiServer, ConductorApiServer, DevEngineApiServer, HealthzApiServer, - RollupNodeApiServer, WsServer, + AdminApiServer, BaseP2PApiServer, ClusterMembership, ConductorApiServer, + DevEngineApiServer, HealthzApiServer, RollupNodeApiServer, ServerSuffrage, WsServer, }; use crate::{OutputResponse, health::HealthzResponse}; @@ -585,6 +680,30 @@ mod tests { async fn conductor_transfer_leader_to_server(&self, _: String, _: String) -> RpcResult<()> { unimplemented!() } + + async fn conductor_pause(&self) -> RpcResult<()> { + unimplemented!() + } + + async fn conductor_resume(&self) -> RpcResult<()> { + unimplemented!() + } + + async fn conductor_paused(&self) -> RpcResult { + unimplemented!() + } + + async fn conductor_stopped(&self) -> RpcResult { + unimplemented!() + } + + async fn conductor_sequencer_healthy(&self) -> RpcResult { + unimplemented!() + } + + async fn conductor_cluster_membership(&self) -> RpcResult { + unimplemented!() + } } #[rstest] @@ -594,9 +713,47 @@ mod tests { #[case("conductor_overrideLeader")] #[case("conductor_transferLeader")] #[case("conductor_transferLeaderToServer")] + #[case("conductor_pause")] + #[case("conductor_resume")] + #[case("conductor_paused")] + #[case("conductor_stopped")] + #[case("conductor_sequencerHealthy")] + #[case("conductor_clusterMembership")] fn conductor_api_wire_names(#[case] expected: &str) { let module = StubConductorApi.into_rpc(); let names: Vec<&str> = module.method_names().collect(); assert!(names.contains(&expected), "missing method {expected}, got: {names:?}"); } + + #[test] + fn server_suffrage_wire_format_is_numeric() { + assert_eq!(serde_json::to_string(&ServerSuffrage::Voter).unwrap(), "0"); + assert_eq!(serde_json::to_string(&ServerSuffrage::Nonvoter).unwrap(), "1"); + assert_eq!(serde_json::from_str::("0").unwrap(), ServerSuffrage::Voter); + assert_eq!(serde_json::from_str::("1").unwrap(), ServerSuffrage::Nonvoter); + assert!(serde_json::from_str::("2").is_err()); + } + + #[test] + fn cluster_membership_round_trips_upstream_wire_format() { + let json = r#"{ + "servers": [ + {"id": "sequencer-1", "addr": "10.0.1.10:50050", "suffrage": 0}, + {"id": "sequencer-2", "addr": "10.0.1.11:50050", "suffrage": 0}, + {"id": "sequencer-3", "addr": "10.0.1.12:50050", "suffrage": 1} + ], + "version": 42 + }"#; + let membership: ClusterMembership = serde_json::from_str(json).unwrap(); + assert_eq!(membership.version, 42); + assert_eq!(membership.servers.len(), 3); + assert_eq!(membership.servers[0].id, "sequencer-1"); + assert_eq!(membership.servers[0].addr, "10.0.1.10:50050"); + assert_eq!(membership.servers[0].suffrage, ServerSuffrage::Voter); + assert_eq!(membership.servers[2].suffrage, ServerSuffrage::Nonvoter); + + let reserialized = serde_json::to_value(&membership).unwrap(); + let original: serde_json::Value = serde_json::from_str(json).unwrap(); + assert_eq!(reserialized, original); + } } diff --git a/crates/consensus/rpc/src/lib.rs b/crates/consensus/rpc/src/lib.rs index 04a2921ffa..d02ae150ed 100644 --- a/crates/consensus/rpc/src/lib.rs +++ b/crates/consensus/rpc/src/lib.rs @@ -29,8 +29,9 @@ mod jsonrpsee; #[cfg(feature = "client")] pub use jsonrpsee::{AdminApiClient, BaseP2PApiClient, ConductorApiClient, RollupNodeApiClient}; pub use jsonrpsee::{ - AdminApiServer, BaseP2PApiServer, ConductorApiServer, DevEngineApiServer, HealthzApiServer, - RollupNodeApiServer, WsServer, + AdminApiServer, BaseP2PApiServer, ClusterMembership, ConductorApiServer, DevEngineApiServer, + HealthzApiServer, RollupNodeApiServer, ServerInfo, ServerSuffrage, UnknownServerSuffrage, + WsServer, }; mod l1_watcher; diff --git a/crates/consensus/service/Cargo.toml b/crates/consensus/service/Cargo.toml index 386d64f98c..d60d8aea34 100644 --- a/crates/consensus/service/Cargo.toml +++ b/crates/consensus/service/Cargo.toml @@ -73,6 +73,10 @@ derive_more = { workspace = true, features = ["debug", "eq"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } jsonrpsee = { workspace = true, features = ["server", "http-client"] } +# Binary conductor commit endpoint +ethereum_ssz.workspace = true +reqwest.workspace = true + # metrics metrics = { workspace = true, optional = true } diff --git a/crates/consensus/service/README.md b/crates/consensus/service/README.md index 92bc3e1af7..94c09814d9 100644 --- a/crates/consensus/service/README.md +++ b/crates/consensus/service/README.md @@ -117,7 +117,7 @@ The `GossipTransport` trait abstracts the transport backend. The production impl The L1 watcher actor is the service's source of truth for L1 chain state. It runs two concurrent streams: `head_stream` polls `eth_getBlockByNumber("latest")` every four seconds and `finalized_stream` polls `eth_getBlockByNumber("finalized")` at the interval configured in `L1Config`. Both streams are deduplicated — they only emit when the block changes. -On each new head, the watcher computes the confirmation-delayed block number as `head.number - verifier_l1_confs`. If the delayed number is reachable it fetches that block by number via `AlloyL1BlockFetcher::get_block()` and sends it to the derivation actor as a `ProcessL1HeadUpdateRequest`. It also broadcasts the real head through the `watch::Sender>` and stores the real head number in the shared `Arc` so the `ConfDepthProvider` used by the derivation pipeline can gate its own L1 lookups. +On each new head, the watcher computes the confirmation-delayed block number as `head.number - verifier_l1_confs`. If the delayed number is reachable it fetches that block by number via `AlloyL1BlockFetcher::get_block()` and sends it to the derivation actor as a `ProcessL1HeadUpdateRequest`. It also broadcasts the real head through the `watch::Sender>` and stores the real head number in the shared `Arc` so the `ConfDepthProvider` used by the derivation pipeline can gate its own L1 lookups. The derivation actor publishes the pipeline's L1 origin separately, and `optimism_syncStatus.current_l1` is served from that derivation-origin signal rather than from the raw L1 head. Log fetching runs on the same head-update path. The watcher calls `AlloyL1BlockFetcher::get_logs()` with a filter for `SystemConfigLog` events from the rollup config's L1 system config address. If the logs contain a `SystemConfigUpdate::UnsafeBlockSigner` event it extracts the new signer address and sends it to the network actor via the `block_signer_sender` channel. The log fetch retries up to ten times with exponential backoff from 50 ms to 500 ms before the actor returns an error. diff --git a/crates/consensus/service/src/actors/checkpoint/db.rs b/crates/consensus/service/src/actors/checkpoint/db.rs index 6b4a2c22e9..58227acfa0 100644 --- a/crates/consensus/service/src/actors/checkpoint/db.rs +++ b/crates/consensus/service/src/actors/checkpoint/db.rs @@ -10,7 +10,42 @@ use tokio::task; use super::CheckpointError; +/// On-disk size of a [`B256`] hash. +const B256_LEN: usize = 32; +/// On-disk size of a `u64`. +const U64_LEN: usize = 8; + +/// Byte offsets of each [`L2BlockInfo`] field within the encoded payload, derived from the +/// field order in [`CheckpointDB::encode`]. +/// +/// Centralising the offsets here means a field change is a single-point edit: extend or +/// reorder the chain, and every reader and writer (and the layout-pinning test) is updated +/// in lockstep instead of through a fan-out of hand-counted magic numbers. +const HASH_OFFSET: usize = 0; +const NUMBER_OFFSET: usize = HASH_OFFSET + B256_LEN; +const PARENT_HASH_OFFSET: usize = NUMBER_OFFSET + U64_LEN; +const TIMESTAMP_OFFSET: usize = PARENT_HASH_OFFSET + B256_LEN; +const L1_ORIGIN_NUMBER_OFFSET: usize = TIMESTAMP_OFFSET + U64_LEN; +const L1_ORIGIN_HASH_OFFSET: usize = L1_ORIGIN_NUMBER_OFFSET + U64_LEN; +const SEQ_NUM_OFFSET: usize = L1_ORIGIN_HASH_OFFSET + B256_LEN; +const PAYLOAD_END: usize = SEQ_NUM_OFFSET + U64_LEN; + +/// Encoded checkpoint value length. Fixed at 128 bytes so the on-disk schema is identical +/// to the original layout shipped in #2698; any existing databases continue to round-trip +/// without migration. +/// +/// The current payload (`PAYLOAD_END`) fills this slot exactly — there is no spare capacity. +/// Any new field appended to [`L2BlockInfo`] will require expanding this constant, which +/// changes the redb table type (`&[u8; 128]`) and is an on-disk-breaking migration that +/// must be handled deliberately. const CHECKPOINT_VALUE_LEN: usize = 128; + +const _: () = assert!( + PAYLOAD_END <= CHECKPOINT_VALUE_LEN, + "L2BlockInfo encoding overflows the redb value slot; expanding CHECKPOINT_VALUE_LEN \ + is a breaking on-disk change" +); + const CHECKPOINTS: TableDefinition<'_, u8, &[u8; CHECKPOINT_VALUE_LEN]> = TableDefinition::new("checkpoints"); @@ -85,29 +120,29 @@ impl CheckpointDB { fn encode(block: L2BlockInfo) -> [u8; Self::VALUE_LEN] { let mut bytes = [0; Self::VALUE_LEN]; - put_b256(&mut bytes, 0, block.block_info.hash); - put_u64(&mut bytes, 32, block.block_info.number); - put_b256(&mut bytes, 40, block.block_info.parent_hash); - put_u64(&mut bytes, 72, block.block_info.timestamp); - put_u64(&mut bytes, 80, block.l1_origin.number); - put_b256(&mut bytes, 88, block.l1_origin.hash); - put_u64(&mut bytes, 120, block.seq_num); + put_b256(&mut bytes, HASH_OFFSET, block.block_info.hash); + put_u64(&mut bytes, NUMBER_OFFSET, block.block_info.number); + put_b256(&mut bytes, PARENT_HASH_OFFSET, block.block_info.parent_hash); + put_u64(&mut bytes, TIMESTAMP_OFFSET, block.block_info.timestamp); + put_u64(&mut bytes, L1_ORIGIN_NUMBER_OFFSET, block.l1_origin.number); + put_b256(&mut bytes, L1_ORIGIN_HASH_OFFSET, block.l1_origin.hash); + put_u64(&mut bytes, SEQ_NUM_OFFSET, block.seq_num); bytes } fn decode(bytes: &[u8; Self::VALUE_LEN]) -> L2BlockInfo { L2BlockInfo { block_info: BlockInfo { - hash: get_b256(bytes, 0), - number: get_u64(bytes, 32), - parent_hash: get_b256(bytes, 40), - timestamp: get_u64(bytes, 72), + hash: get_b256(bytes, HASH_OFFSET), + number: get_u64(bytes, NUMBER_OFFSET), + parent_hash: get_b256(bytes, PARENT_HASH_OFFSET), + timestamp: get_u64(bytes, TIMESTAMP_OFFSET), }, l1_origin: alloy_eips::BlockNumHash { - number: get_u64(bytes, 80), - hash: get_b256(bytes, 88), + number: get_u64(bytes, L1_ORIGIN_NUMBER_OFFSET), + hash: get_b256(bytes, L1_ORIGIN_HASH_OFFSET), }, - seq_num: get_u64(bytes, 120), + seq_num: get_u64(bytes, SEQ_NUM_OFFSET), } } } @@ -120,19 +155,19 @@ const fn label_key(label: ForkchoiceCheckpointLabel) -> u8 { } fn put_b256(bytes: &mut [u8; CheckpointDB::VALUE_LEN], offset: usize, value: B256) { - bytes[offset..offset + 32].copy_from_slice(value.as_slice()); + bytes[offset..offset + B256_LEN].copy_from_slice(value.as_slice()); } fn put_u64(bytes: &mut [u8; CheckpointDB::VALUE_LEN], offset: usize, value: u64) { - bytes[offset..offset + 8].copy_from_slice(&value.to_be_bytes()); + bytes[offset..offset + U64_LEN].copy_from_slice(&value.to_be_bytes()); } fn get_b256(bytes: &[u8; CheckpointDB::VALUE_LEN], offset: usize) -> B256 { - B256::from_slice(&bytes[offset..offset + 32]) + B256::from_slice(&bytes[offset..offset + B256_LEN]) } fn get_u64(bytes: &[u8; CheckpointDB::VALUE_LEN], offset: usize) -> u64 { - u64::from_be_bytes(bytes[offset..offset + 8].try_into().expect("slice length is 8")) + u64::from_be_bytes(bytes[offset..offset + U64_LEN].try_into().expect("slice length is 8")) } #[cfg(test)] @@ -144,11 +179,8 @@ mod tests { use super::CheckpointDB; - #[tokio::test] - async fn checkpoint_survives_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("checkpoint.redb"); - let checkpoint = L2BlockInfo { + fn sample_checkpoint() -> L2BlockInfo { + L2BlockInfo { block_info: BlockInfo { hash: B256::with_last_byte(1), number: 10, @@ -157,7 +189,30 @@ mod tests { }, l1_origin: BlockNumHash { number: 4, hash: B256::with_last_byte(5) }, seq_num: 6, - }; + } + } + + /// Hand-constructed bytes of the v0.13 / #2698 layout. Any encoder change that perturbs + /// these bytes \u2014 or any decoder change that fails to reconstruct + /// [`sample_checkpoint`] from them \u2014 will fail the round-trip tests below and surface + /// loudly instead of silently producing wrong records on already-deployed databases. + fn legacy_encoded_bytes() -> [u8; CheckpointDB::VALUE_LEN] { + let mut bytes = [0u8; CheckpointDB::VALUE_LEN]; + bytes[31] = 1; + bytes[32..40].copy_from_slice(&10u64.to_be_bytes()); + bytes[71] = 2; + bytes[72..80].copy_from_slice(&30u64.to_be_bytes()); + bytes[80..88].copy_from_slice(&4u64.to_be_bytes()); + bytes[119] = 5; + bytes[120..128].copy_from_slice(&6u64.to_be_bytes()); + bytes + } + + #[tokio::test] + async fn checkpoint_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("checkpoint.redb"); + let checkpoint = sample_checkpoint(); { let db = CheckpointDB::open(&path).unwrap(); @@ -168,4 +223,19 @@ mod tests { let stored = db.checkpoint(ForkchoiceCheckpointLabel::Safe).await.unwrap(); assert_eq!(stored, Some(checkpoint)); } + + #[test] + fn encode_matches_pinned_layout() { + assert_eq!( + CheckpointDB::encode(sample_checkpoint()), + legacy_encoded_bytes(), + "L2BlockInfo on-disk encoding has changed; databases written by earlier builds \ + will be mis-decoded" + ); + } + + #[test] + fn decode_round_trips_pinned_layout() { + assert_eq!(CheckpointDB::decode(&legacy_encoded_bytes()), sample_checkpoint()); + } } diff --git a/crates/consensus/service/src/actors/derivation/actor.rs b/crates/consensus/service/src/actors/derivation/actor.rs index 06931ee9ea..2773907433 100644 --- a/crates/consensus/service/src/actors/derivation/actor.rs +++ b/crates/consensus/service/src/actors/derivation/actor.rs @@ -11,7 +11,10 @@ use base_consensus_derive::{ use base_consensus_safedb::SafeHeadListener; use base_protocol::{AttributesWithParent, BlockInfo}; use thiserror::Error; -use tokio::{select, sync::mpsc}; +use tokio::{ + select, + sync::{mpsc, watch}, +}; use tokio_util::sync::{CancellationToken, WaitForCancellationFuture}; use crate::{ @@ -40,6 +43,8 @@ where /// The derivation pipeline. pipeline: PipelineSignalReceiver, + /// Publishes the L1 origin the derivation pipeline has advanced to. + derivation_origin_tx: watch::Sender>, /// The state machine controlling when derivation can occur. derivation_state_machine: DerivationStateMachine, /// The [`L2Finalizer`] tracks derived L2 blocks awaiting finalization. @@ -81,10 +86,12 @@ where inbound_request_rx: mpsc::Receiver, pipeline: PipelineSignalReceiver, safe_head_listener: Arc, + derivation_origin_tx: watch::Sender>, ) -> Self { Self { cancellation_token, pipeline, + derivation_origin_tx, inbound_request_rx, engine_client, derivation_state_machine: DerivationStateMachine::default(), @@ -94,6 +101,10 @@ where } } + fn publish_derivation_origin(&self) { + self.derivation_origin_tx.send_replace(self.pipeline.origin()); + } + /// Handles a [`Signal`] received over the derivation signal receiver channel. async fn signal(&mut self, signal: Signal) { if let Signal::Reset(ResetSignal { l2_safe_head: _reset_safe_head }) = signal { @@ -123,7 +134,10 @@ where } match self.pipeline.signal(signal).await { - Ok(_) => info!(target: "derivation", ?signal, "[SIGNAL] Executed Successfully"), + Ok(_) => { + self.publish_derivation_origin(); + info!(target: "derivation", ?signal, "[SIGNAL] Executed Successfully"); + } Err(e) => { error!(target: "derivation", ?e, ?signal, "Failed to signal derivation pipeline") } @@ -147,10 +161,11 @@ where StepResult::PreparedAttributes => { /* continue; attributes will be sent off. */ } StepResult::AdvancedOrigin => { let origin = - self.pipeline.origin().ok_or(PipelineError::MissingOrigin.crit())?.number; + self.pipeline.origin().ok_or(PipelineError::MissingOrigin.crit())?; - Metrics::derivation_l1_origin().absolute(origin); - debug!(target: "derivation", l1_block = origin, "Advanced L1 origin"); + Metrics::derivation_l1_origin().absolute(origin.number); + self.derivation_origin_tx.send_replace(Some(origin)); + debug!(target: "derivation", l1_block = origin.number, "Advanced L1 origin"); } StepResult::OriginAdvanceErr(e) | StepResult::StepFailed(e) => { match e { diff --git a/crates/consensus/service/src/actors/derivation/delegate_l2/actor.rs b/crates/consensus/service/src/actors/derivation/delegate_l2/actor.rs deleted file mode 100644 index f62a416dba..0000000000 --- a/crates/consensus/service/src/actors/derivation/delegate_l2/actor.rs +++ /dev/null @@ -1,772 +0,0 @@ -use std::sync::Arc; - -use alloy_eips::BlockNumberOrTag; -use alloy_provider::{Provider, RootProvider}; -use async_trait::async_trait; -use base_common_network::Base; -use base_consensus_engine::DelegatedForkchoiceUpdate; -use base_protocol::L2BlockInfo; -use futures::future::OptionFuture; -use serde::Deserialize; -use tokio::{select, sync::mpsc, task::JoinHandle, time}; -use tokio_util::sync::{CancellationToken, WaitForCancellationFuture}; -use tracing::{debug, error, info, warn}; - -use crate::{ - CancellableContext, DerivationActorRequest, DerivationEngineClient, EngineActorRequest, - NodeActor, - actors::derivation::{DerivationError, delegate_l2::L2SourceClient}, -}; - -const DEFAULT_PROOFS_MAX_BLOCKS_AHEAD: u64 = 512; - -#[derive(Debug, Deserialize)] -struct ProofsSyncStatus { - latest: Option, -} - -/// The [`NodeActor`] for the L2 delegate derivation sub-routine. -/// -/// Polls a source L2 execution layer node for new blocks and drives the local -/// engine via `ProcessUnsafeL2BlockRequest` (`NewPayload` + FCU) rather than -/// running the full derivation pipeline. -/// -/// Safe and finalized head updates are forwarded together as delegated labels. -#[derive(Debug)] -pub struct DelegateL2DerivationActor -where - DerivationEngineClient_: DerivationEngineClient, - L2Source: L2SourceClient, -{ - cancellation_token: CancellationToken, - inbound_request_rx: mpsc::Receiver, - engine_client: Arc, - engine_actor_request_tx: mpsc::Sender, - local_l2_provider: RootProvider, - l2_source: Arc, - sent_head: u64, - proofs_enabled: bool, - proofs_max_blocks_ahead: u64, -} - -impl CancellableContext - for DelegateL2DerivationActor -where - DerivationEngineClient_: DerivationEngineClient, - L2Source: L2SourceClient, -{ - fn cancelled(&self) -> WaitForCancellationFuture<'_> { - self.cancellation_token.cancelled() - } -} - -impl DelegateL2DerivationActor -where - DerivationEngineClient_: DerivationEngineClient, - L2Source: L2SourceClient, -{ - /// Creates a new [`DelegateL2DerivationActor`]. - pub fn new( - engine_client: DerivationEngineClient_, - engine_actor_request_tx: mpsc::Sender, - cancellation_token: CancellationToken, - inbound_request_rx: mpsc::Receiver, - local_l2_provider: RootProvider, - l2_source: L2Source, - ) -> Self { - Self { - cancellation_token, - inbound_request_rx, - engine_client: Arc::new(engine_client), - engine_actor_request_tx, - local_l2_provider, - l2_source: Arc::new(l2_source), - sent_head: 0, - proofs_enabled: false, - proofs_max_blocks_ahead: DEFAULT_PROOFS_MAX_BLOCKS_AHEAD, - } - } - - /// Enables proofs sync gating. When enabled, sync will not advance beyond - /// `proofs_latest + proofs_max_blocks_ahead` to prevent proofs from - /// falling too far behind. - pub const fn with_proofs(mut self, enabled: bool) -> Self { - self.proofs_enabled = enabled; - self - } - - /// Sets the maximum number of blocks the node may advance beyond the - /// proofs `ExEx` head. - pub const fn with_proofs_max_blocks_ahead(mut self, max_blocks_ahead: u64) -> Self { - self.proofs_max_blocks_ahead = max_blocks_ahead; - self - } -} - -#[async_trait] -impl NodeActor - for DelegateL2DerivationActor -where - DerivationEngineClient_: DerivationEngineClient + 'static, - L2Source: L2SourceClient + 'static, -{ - type Error = DerivationError; - type StartData = (); - - async fn start(mut self, _: Self::StartData) -> Result<(), Self::Error> { - self.run().await - } -} - -impl DelegateL2DerivationActor -where - DerivationEngineClient_: DerivationEngineClient + 'static, - L2Source: L2SourceClient + 'static, -{ - const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); - - async fn run(mut self) -> Result<(), DerivationError> { - if self.sent_head == 0 { - let head = self - .local_l2_provider - .get_block_number() - .await - .map_err(|e| DerivationError::Sender(Box::new(e)))?; - self.sent_head = head; - } - - info!(target: "derivation", head = self.sent_head, "Starting L2 delegate derivation"); - let mut ticker = time::interval(Self::POLL_INTERVAL); - ticker.set_missed_tick_behavior(time::MissedTickBehavior::Skip); - - let mut sync_task: Option>> = None; - - loop { - select! { - biased; - - _ = self.cancellation_token.cancelled() => { - info!(target: "derivation", "Received shutdown signal. Exiting L2 delegate derivation."); - return Ok(()); - } - req = self.inbound_request_rx.recv() => { - let Some(request_type) = req else { - error!(target: "derivation", "DelegateL2DerivationActor inbound request receiver closed unexpectedly"); - self.cancellation_token.cancel(); - return Err(DerivationError::RequestReceiveFailed); - }; - self.handle_request(request_type).await?; - } - // Poll the sync task for completion without blocking. - // `OptionFuture<&mut JoinHandle>` resolves immediately to - // `None` when no task is in flight, letting us fall through - // to spawn a new one. - Some(result) = OptionFuture::from(sync_task.as_mut()) => { - sync_task = None; - match result { - Err(join_error) => { - error!(target: "derivation", error = %join_error, "Sync task panicked or was cancelled"); - } - Ok(Err(derivation_error)) => { - warn!(target: "derivation", error = %derivation_error, "Sync from source failed"); - } - Ok(Ok(new_sent_head)) => { - self.sent_head = new_sent_head; - } - } - } - _ = ticker.tick() => { - if sync_task.is_some() { - debug!(target: "derivation", "Sync already in progress, skipping tick"); - continue; - } - - let target_block = match self.determine_target_block().await { - Ok(Some(target)) => target, - Ok(None) => { - warn!(target: "derivation", sent_head = self.sent_head, "Target is behind already sent head, skipping sync"); - continue; - }, - Err(e) => { - warn!(target: "derivation", error = %e, "Failed to determine target block"); - continue; - } - }; - info!(target: "derivation", target_block, sent_head = self.sent_head, "Starting sync from L2 source"); - - let cancellation_token = self.cancellation_token.clone(); - let l2_source = Arc::clone(&self.l2_source); - let engine_client = Arc::clone(&self.engine_client); - let engine_actor_request_tx = self.engine_actor_request_tx.clone(); - let local_l2_provider = self.local_l2_provider.clone(); - let sent_head = self.sent_head; - - sync_task = Some(tokio::spawn(async move { - SyncFromSourceTask::new( - engine_client, - engine_actor_request_tx, - cancellation_token, - local_l2_provider, - sent_head, - target_block, - l2_source, - ) - .sync_from_source() - .await - })); - } - } - } - } - - async fn determine_target_block(&self) -> Result, DerivationError> { - let remote_head = self - .l2_source - .get_block_number(BlockNumberOrTag::Latest) - .await - .map_err(|e| DerivationError::Sender(Box::new(e)))?; - - let sync_limit = if self.proofs_enabled { - match self - .local_l2_provider - .raw_request::<_, ProofsSyncStatus>("debug_proofsSyncStatus".into(), ()) - .await - { - Ok(status) => { - // default to 0 if proofs not available since user intends to avoid syncing past proofs head which is unknown - let latest = status.latest.unwrap_or(0); - let cap = latest + self.proofs_max_blocks_ahead; - debug!( - target: "derivation", - proofs_latest = latest, - cap, - "Proofs sync gate active" - ); - cap - } - Err(e) => { - warn!(target: "derivation", error = %e, "Failed to fetch proofs sync status, skipping sync"); - return Ok(None); - } - } - } else { - u64::MAX - }; - - let target = remote_head.min(sync_limit); - - if target != remote_head { - info!( - target: "derivation", - sync_limit, - remote_head, - "Remote head is ahead of proofs sync limit, capping sync" - ); - } - - if target <= self.sent_head { - return Ok(None); - } - - Ok(Some(target)) - } - - async fn handle_request( - &self, - request_type: DerivationActorRequest, - ) -> Result<(), DerivationError> { - match request_type { - DerivationActorRequest::ProcessEngineSafeHeadUpdateRequest(safe_head) => { - debug!( - target: "derivation", - safe_head = ?*safe_head, - "Ignoring engine safe head update in L2 delegate mode" - ); - } - DerivationActorRequest::ProcessEngineSyncCompletionRequest(safe_head) => { - info!( - target: "derivation", - head = safe_head.block_info.number, - "Ignoring engine sync completion in L2 delegate mode" - ); - } - DerivationActorRequest::ProcessEngineSignalRequest(_) - | DerivationActorRequest::ProcessFinalizedL1Block(_) - | DerivationActorRequest::ProcessL1HeadUpdateRequest(_) => { - debug!(target: "derivation", request_type = ?request_type, "Ignoring request in L2 delegate mode"); - } - } - Ok(()) - } -} - -pub(super) struct SyncFromSourceTask { - engine_client: Arc, - engine_actor_request_tx: mpsc::Sender, - cancellation_token: CancellationToken, - local_l2_provider: RootProvider, - sent_head: u64, - target_block: u64, - l2_source: Arc, -} - -impl SyncFromSourceTask -where - DerivationEngineClient_: DerivationEngineClient, - L2Source: L2SourceClient, -{ - pub(super) const fn new( - engine_client: Arc, - engine_actor_request_tx: mpsc::Sender, - cancellation_token: CancellationToken, - local_l2_provider: RootProvider, - sent_head: u64, - target_block: u64, - l2_source: Arc, - ) -> Self { - Self { - engine_client, - engine_actor_request_tx, - cancellation_token, - local_l2_provider, - sent_head, - target_block, - l2_source, - } - } - - /// Syncs blocks from the L2 source up to the pre-determined `target_block`. - /// - /// Returns the updated `sent_head` on success. - async fn sync_from_source(&mut self) -> Result { - if self.target_block <= self.sent_head { - return Ok(self.sent_head); - } - - for block_num in (self.sent_head + 1)..=self.target_block { - if self.cancellation_token.is_cancelled() { - info!(target: "derivation", block = block_num, "Sync interrupted by shutdown"); - return Ok(self.sent_head); - } - - let payload = self - .l2_source - .get_payload_by_number(block_num) - .await - .map_err(|e| DerivationError::Sender(Box::new(e)))?; - - debug!( - target: "derivation", - block = block_num, - "Inserting block from L2 source" - ); - - self.engine_actor_request_tx - .send(EngineActorRequest::ProcessUnsafeL2BlockRequest(Box::new(payload))) - .await - .map_err(|_| { - DerivationError::Sender(Box::new(std::io::Error::new( - std::io::ErrorKind::BrokenPipe, - "engine actor request channel closed", - ))) - })?; - - self.sent_head = block_num; - } - - self.update_safe_and_finalized().await?; - - Ok(self.sent_head) - } - - async fn update_safe_and_finalized(&self) -> Result<(), DerivationError> { - let Ok(safe_number) = self.l2_source.get_block_number(BlockNumberOrTag::Safe).await else { - return Ok(()); - }; - // Delegated labels must never point past blocks we have already forwarded to the local - // engine, but they must not be clamped to the engine's current safe head. On a fresh - // follow node the engine safe head starts at genesis, and clamping to it would pin both - // delegated safe and finalized labels at block 0 forever. - let local_tip = self.sent_head; - let clamped_safe = safe_number.min(local_tip); - let Ok(safe_payload) = self.l2_source.get_payload_by_number(clamped_safe).await else { - return Ok(()); - }; - - let source_hash = safe_payload.execution_payload.block_hash(); - - // Detect hash mismatch between source and local EL for the delegated safe block. - if let Ok(Some(local_block)) = - self.local_l2_provider.get_block_by_number(clamped_safe.into()).await - && local_block.header.hash != source_hash - { - warn!( - target: "derivation", - block_number = clamped_safe, - local_hash = %local_block.header.hash, - source_hash = %source_hash, - "Delegated safe block hash mismatch between source and local EL" - ); - } - - let safe_l2 = L2BlockInfo { - block_info: base_protocol::BlockInfo { - hash: source_hash, - number: clamped_safe, - ..Default::default() - }, - ..Default::default() - }; - let finalized_l2_number = self - .l2_source - .get_block_number(BlockNumberOrTag::Finalized) - .await - .ok() - .map(|number| number.min(local_tip)); - - let _ = self - .engine_client - .send_delegated_forkchoice_update(DelegatedForkchoiceUpdate { - safe_l2, - finalized_l2_number, - }) - .await; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use alloy_eips::BlockNumberOrTag; - use alloy_primitives::B256; - use alloy_rpc_types_engine::ExecutionPayloadV1; - use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; - use base_protocol::{BlockInfo, L2BlockInfo}; - use mockall::{Sequence, predicate::*}; - use tokio::sync::mpsc; - use tokio_util::sync::CancellationToken; - - use super::*; - use crate::actors::derivation::{ - delegate_l2::client::{DelegateL2ClientError, MockL2SourceClient}, - engine_client::MockDerivationEngineClient, - }; - - fn dummy_l2_block_info(number: u64) -> L2BlockInfo { - L2BlockInfo { - block_info: BlockInfo { - number, - hash: B256::from([number as u8; 32]), - ..Default::default() - }, - ..Default::default() - } - } - - fn dummy_payload_envelope(block_number: u64) -> BaseExecutionPayloadEnvelope { - let payload = ExecutionPayloadV1 { - parent_hash: B256::ZERO, - fee_recipient: alloy_primitives::Address::ZERO, - state_root: B256::ZERO, - receipts_root: B256::ZERO, - logs_bloom: alloy_primitives::Bloom::ZERO, - prev_randao: B256::ZERO, - block_number, - gas_limit: 0, - gas_used: 0, - timestamp: 0, - extra_data: alloy_primitives::Bytes::new(), - base_fee_per_gas: alloy_primitives::U256::ZERO, - block_hash: B256::from([block_number as u8; 32]), - transactions: vec![], - }; - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: None, - execution_payload: BaseExecutionPayload::V1(payload), - } - } - - fn make_actor( - engine_client: MockDerivationEngineClient, - l2_source: MockL2SourceClient, - ) -> ( - DelegateL2DerivationActor, - mpsc::Sender, - mpsc::Receiver, - CancellationToken, - ) { - let cancel = CancellationToken::new(); - let (deriv_tx, deriv_rx) = mpsc::channel(16); - let (engine_tx, engine_rx) = mpsc::channel(16); - - let local_l2_provider = - RootProvider::::new_http("http://localhost:1234".parse().unwrap()); - - let actor = DelegateL2DerivationActor::new( - engine_client, - engine_tx, - cancel.clone(), - deriv_rx, - local_l2_provider, - l2_source, - ); - - (actor, deriv_tx, engine_rx, cancel) - } - - fn make_sync_task( - engine_client: MockDerivationEngineClient, - l2_source: MockL2SourceClient, - sent_head: u64, - target_block: u64, - ) -> ( - SyncFromSourceTask, - mpsc::Receiver, - CancellationToken, - ) { - let cancel = CancellationToken::new(); - let (engine_tx, engine_rx) = mpsc::channel(16); - - let local_l2_provider = - RootProvider::::new_http("http://localhost:1234".parse().unwrap()); - - let task = SyncFromSourceTask::new( - Arc::new(engine_client), - engine_tx, - cancel.clone(), - local_l2_provider, - sent_head, - target_block, - Arc::new(l2_source), - ); - - (task, engine_rx, cancel) - } - - #[tokio::test] - async fn handle_sync_completion_enables_sync() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - let (actor, _, _, _) = make_actor(engine_client, l2_source); - - let safe_head = dummy_l2_block_info(42); - actor - .handle_request(DerivationActorRequest::ProcessEngineSyncCompletionRequest(Box::new( - safe_head, - ))) - .await - .unwrap(); - } - - #[tokio::test] - async fn handle_safe_head_update_sets_local_head() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - let (actor, _, _, _) = make_actor(engine_client, l2_source); - - let safe_head = dummy_l2_block_info(100); - actor - .handle_request(DerivationActorRequest::ProcessEngineSafeHeadUpdateRequest(Box::new( - safe_head, - ))) - .await - .unwrap(); - } - - #[tokio::test] - async fn handle_irrelevant_requests_noop() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - let (actor, _, _, _) = make_actor(engine_client, l2_source); - - actor - .handle_request(DerivationActorRequest::ProcessL1HeadUpdateRequest(Box::default())) - .await - .unwrap(); - - actor - .handle_request(DerivationActorRequest::ProcessFinalizedL1Block(Box::default())) - .await - .unwrap(); - } - - #[tokio::test] - async fn sync_noop_when_target_behind() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - - let (mut task, _, _) = make_sync_task(engine_client, l2_source, 10, 5); - - let new_head = task.sync_from_source().await.unwrap(); - assert_eq!(new_head, 10); - } - - #[tokio::test] - async fn sync_fetches_and_inserts_blocks() { - let mut engine_client = MockDerivationEngineClient::new(); - let mut l2_source = MockL2SourceClient::new(); - - l2_source - .expect_get_payload_by_number() - .with(eq(1)) - .returning(|n| Ok(dummy_payload_envelope(n))); - l2_source - .expect_get_payload_by_number() - .with(eq(2)) - .returning(|n| Ok(dummy_payload_envelope(n))); - l2_source - .expect_get_payload_by_number() - .with(eq(3)) - .returning(|n| Ok(dummy_payload_envelope(n))); - - l2_source.expect_get_block_number().with(eq(BlockNumberOrTag::Safe)).returning(|_| Ok(2)); - l2_source - .expect_get_payload_by_number() - .with(eq(2)) - .returning(|n| Ok(dummy_payload_envelope(n))); - l2_source - .expect_get_block_number() - .with(eq(BlockNumberOrTag::Finalized)) - .returning(|_| Ok(1)); - - engine_client.expect_send_delegated_forkchoice_update().returning(|update| { - assert_eq!(update.safe_l2.block_info.number, 2); - assert_eq!(update.finalized_l2_number, Some(1)); - Ok(()) - }); - - let (mut task, mut engine_rx, _) = make_sync_task(engine_client, l2_source, 0, 3); - - let new_head = task.sync_from_source().await.unwrap(); - assert_eq!(new_head, 3); - - for expected_num in 1..=3 { - let req = engine_rx.try_recv().unwrap(); - match req { - EngineActorRequest::ProcessUnsafeL2BlockRequest(envelope) => { - assert_eq!(envelope.execution_payload.block_number(), expected_num); - } - other => panic!("Expected ProcessUnsafeL2BlockRequest, got {other:?}"), - } - } - } - - #[tokio::test] - async fn delegated_forkchoice_uses_inserted_head_when_engine_safe_head_is_zero() { - let mut engine_client = MockDerivationEngineClient::new(); - let mut l2_source = MockL2SourceClient::new(); - - l2_source - .expect_get_payload_by_number() - .with(eq(1)) - .returning(|n| Ok(dummy_payload_envelope(n))); - l2_source - .expect_get_payload_by_number() - .with(eq(2)) - .returning(|n| Ok(dummy_payload_envelope(n))); - l2_source - .expect_get_payload_by_number() - .with(eq(3)) - .returning(|n| Ok(dummy_payload_envelope(n))); - - l2_source.expect_get_block_number().with(eq(BlockNumberOrTag::Safe)).returning(|_| Ok(2)); - l2_source - .expect_get_payload_by_number() - .with(eq(2)) - .returning(|n| Ok(dummy_payload_envelope(n))); - l2_source - .expect_get_block_number() - .with(eq(BlockNumberOrTag::Finalized)) - .returning(|_| Ok(1)); - - engine_client.expect_send_delegated_forkchoice_update().returning(|update| { - assert_eq!(update.safe_l2.block_info.number, 2); - assert_eq!(update.finalized_l2_number, Some(1)); - Ok(()) - }); - - let (mut task, _engine_rx, _) = make_sync_task(engine_client, l2_source, 0, 3); - - let new_head = task.sync_from_source().await.unwrap(); - assert_eq!(new_head, 3); - } - - #[tokio::test] - async fn delegated_forkchoice_not_sent_when_safe_payload_unavailable() { - let mut engine_client = MockDerivationEngineClient::new(); - let mut l2_source = MockL2SourceClient::new(); - let mut sequence = Sequence::new(); - - l2_source - .expect_get_payload_by_number() - .with(eq(1)) - .times(1) - .in_sequence(&mut sequence) - .returning(|n| Ok(dummy_payload_envelope(n))); - - l2_source.expect_get_block_number().with(eq(BlockNumberOrTag::Safe)).returning(|_| Ok(1)); - l2_source - .expect_get_payload_by_number() - .with(eq(1)) - .times(1) - .in_sequence(&mut sequence) - .returning(|n| Err(DelegateL2ClientError::BlockNotFound(format!("{n}")))); - - engine_client.expect_send_delegated_forkchoice_update().times(0); - engine_client.expect_send_safe_l2_signal().times(0); - engine_client.expect_send_finalized_l2_block().times(0); - - let (mut task, mut engine_rx, _) = make_sync_task(engine_client, l2_source, 0, 1); - - let new_head = task.sync_from_source().await.unwrap(); - assert_eq!(new_head, 1); - - let req = engine_rx.try_recv().unwrap(); - assert!( - matches!(req, EngineActorRequest::ProcessUnsafeL2BlockRequest(_)), - "expected ProcessUnsafeL2BlockRequest, got {req:?}" - ); - assert!(engine_rx.is_empty(), "unexpected extra engine requests"); - } - - #[tokio::test] - async fn sync_aborts_on_cancellation() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - - let (mut task, engine_rx, cancel) = make_sync_task(engine_client, l2_source, 0, 100); - - cancel.cancel(); - let new_head = task.sync_from_source().await.unwrap(); - - assert_eq!(new_head, 0); - assert!(engine_rx.is_empty()); - } - - #[tokio::test] - async fn run_loop_stops_on_cancellation() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - let (mut actor, _deriv_tx, _engine_rx, cancel) = make_actor(engine_client, l2_source); - - actor.sent_head = 10; - cancel.cancel(); - - let result = actor.run().await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn run_loop_errors_on_channel_close() { - let engine_client = MockDerivationEngineClient::new(); - let l2_source = MockL2SourceClient::new(); - let (mut actor, deriv_tx, _engine_rx, _cancel) = make_actor(engine_client, l2_source); - - actor.sent_head = 10; - drop(deriv_tx); - - let result = actor.run().await; - assert!(result.is_err()); - } -} diff --git a/crates/consensus/service/src/actors/derivation/delegate_l2/mod.rs b/crates/consensus/service/src/actors/derivation/delegate_l2/mod.rs deleted file mode 100644 index 9c26718d8e..0000000000 --- a/crates/consensus/service/src/actors/derivation/delegate_l2/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! L2 delegation derivation actor and its RPC client. - -mod actor; -pub use actor::DelegateL2DerivationActor; - -mod client; -pub use client::{DelegateL2Client, DelegateL2ClientError, L2SourceClient}; diff --git a/crates/consensus/service/src/actors/derivation/delegated/actor.rs b/crates/consensus/service/src/actors/derivation/delegated/actor.rs index 5a14db0ec2..3f94174ba5 100644 --- a/crates/consensus/service/src/actors/derivation/delegated/actor.rs +++ b/crates/consensus/service/src/actors/derivation/delegated/actor.rs @@ -2,9 +2,13 @@ use alloy_primitives::BlockHash; use async_trait::async_trait; use base_consensus_derive::ChainProvider; use base_consensus_providers::AlloyChainProvider; -use base_protocol::{L2BlockInfo, SyncStatus}; +use base_protocol::{BlockInfo, L2BlockInfo, SyncStatus}; use thiserror::Error; -use tokio::{select, sync::mpsc, time}; +use tokio::{ + select, + sync::{mpsc, watch}, + time, +}; use tokio_util::sync::{CancellationToken, WaitForCancellationFuture}; use crate::{ @@ -37,6 +41,8 @@ where derivation_delegate_provider: DerivationDelegateClient, /// L1 provider for validating L1 info for derivation delegation. l1_provider: AlloyChainProvider, + /// Publishes the delegate-reported L1 derivation cursor. + derivation_origin_tx: watch::Sender>, /// The engine's L2 safe head, according to updates from the Engine. engine_l2_safe_head: L2BlockInfo, @@ -65,6 +71,7 @@ where inbound_request_rx: mpsc::Receiver, derivation_delegate_provider: DerivationDelegateClient, l1_provider: AlloyChainProvider, + derivation_origin_tx: watch::Sender>, ) -> Self { Self { cancellation_token, @@ -72,6 +79,7 @@ where engine_client, derivation_delegate_provider, l1_provider, + derivation_origin_tx, engine_l2_safe_head: L2BlockInfo::default(), has_engine_sync_completed: false, } @@ -167,6 +175,8 @@ where return Ok(()); } + self.derivation_origin_tx.send_replace(Some(sync_status.current_l1)); + self.engine_client .send_safe_l2_signal(sync_status.safe_l2.into()) .await diff --git a/crates/consensus/service/src/actors/derivation/engine_client.rs b/crates/consensus/service/src/actors/derivation/engine_client.rs index a0bb4ea781..fd5ae7dbc3 100644 --- a/crates/consensus/service/src/actors/derivation/engine_client.rs +++ b/crates/consensus/service/src/actors/derivation/engine_client.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; use async_trait::async_trait; -use base_consensus_engine::{ConsolidateInput, DelegatedForkchoiceUpdate}; +use base_consensus_engine::ConsolidateInput; use derive_more::Constructor; use tokio::sync::mpsc; @@ -14,14 +14,6 @@ pub trait DerivationEngineClient: Debug + Send + Sync { /// Resets the engine's forkchoice. async fn reset_engine_forkchoice(&self) -> EngineClientResult<()>; - /// Sends follow-node delegated safe/finalized labels to the engine. - /// - /// Note: This does not wait for the engine to process the request. - async fn send_delegated_forkchoice_update( - &self, - update: DelegatedForkchoiceUpdate, - ) -> EngineClientResult<()>; - /// Sends a request to finalize the L2 block at the provided block number. /// Note: This does not wait for the engine to process it. async fn send_finalized_l2_block(&self, block_number: u64) -> EngineClientResult<()>; @@ -64,24 +56,6 @@ impl DerivationEngineClient for QueuedDerivationEngineClient { })? } - async fn send_delegated_forkchoice_update( - &self, - update: DelegatedForkchoiceUpdate, - ) -> EngineClientResult<()> { - trace!( - target: "derivation", - safe_number = update.safe_l2.block_info.number, - finalized_number = ?update.finalized_l2_number, - "Sending delegated forkchoice update to engine" - ); - self.engine_actor_request_tx - .send(EngineActorRequest::ProcessDelegatedForkchoiceUpdateRequest(Box::new(update))) - .await - .map_err(|_| EngineClientError::RequestError("request channel closed.".to_string()))?; - - Ok(()) - } - async fn send_finalized_l2_block(&self, block_number: u64) -> EngineClientResult<()> { trace!(target: "derivation", block_number, "Sending finalized L2 block number to engine."); self.engine_actor_request_tx diff --git a/crates/consensus/service/src/actors/derivation/mod.rs b/crates/consensus/service/src/actors/derivation/mod.rs index e29b76e84d..6cc6f71853 100644 --- a/crates/consensus/service/src/actors/derivation/mod.rs +++ b/crates/consensus/service/src/actors/derivation/mod.rs @@ -8,11 +8,6 @@ pub use delegated::{ DelegateDerivationActor, DerivationDelegateClient, DerivationDelegateClientError, }; -mod delegate_l2; -pub use delegate_l2::{ - DelegateL2Client, DelegateL2ClientError, DelegateL2DerivationActor, L2SourceClient, -}; - mod engine_client; pub use engine_client::{DerivationEngineClient, QueuedDerivationEngineClient}; diff --git a/crates/consensus/service/src/actors/engine/actor.rs b/crates/consensus/service/src/actors/engine/actor.rs index c3aa8ab417..1a40a8eca2 100644 --- a/crates/consensus/service/src/actors/engine/actor.rs +++ b/crates/consensus/service/src/actors/engine/actor.rs @@ -10,8 +10,7 @@ use tokio_util::{ }; use crate::{ - EngineActorRequest, EngineError, EngineProcessingRequest, EngineRequestReceiver, NodeActor, - actors::CancellableContext, + EngineActorRequest, EngineError, EngineRequestReceiver, NodeActor, actors::CancellableContext, }; /// The [`EngineActor`] is an intermediary that receives [`EngineActorRequest`] and delegates: @@ -86,7 +85,7 @@ where .then(handle_task_result("Engine processing", processing_cancellation.clone())); // Helper to send processing requests with error handling. - let send_engine_processing_request = |req: EngineProcessingRequest| async { + let send_engine_processing_request = |req: EngineActorRequest| async { engine_processing_tx.send(req).await.map_err(|_| { error!(target: "engine", "Engine processing channel closed unexpectedly"); self.cancellation_token.clone().cancel(); @@ -111,36 +110,7 @@ where return Err(EngineError::ChannelClosed); }; - // Route the request to the appropriate channel. - match request { - EngineActorRequest::BuildRequest(build_req) => { - send_engine_processing_request(EngineProcessingRequest::Build(build_req)).await?; - } - EngineActorRequest::ProcessSafeL2SignalRequest(signal) => { - send_engine_processing_request(EngineProcessingRequest::ProcessSafeL2Signal(signal)).await?; - } - EngineActorRequest::ProcessDelegatedForkchoiceUpdateRequest(update) => { - send_engine_processing_request(EngineProcessingRequest::ProcessDelegatedForkchoiceUpdate(update)).await?; - } - EngineActorRequest::ProcessFinalizedL2BlockNumberRequest(block_number) => { - send_engine_processing_request(EngineProcessingRequest::ProcessFinalizedL2BlockNumber(block_number)).await?; - } - EngineActorRequest::ProcessUnsafeL2BlockRequest(envelope) => { - send_engine_processing_request(EngineProcessingRequest::ProcessUnsafeL2Block(envelope)).await?; - } - EngineActorRequest::ProcessLocalUnsafeL2BlockRequest(envelope) => { - send_engine_processing_request(EngineProcessingRequest::ProcessLocalUnsafeL2Block(envelope)).await?; - } - EngineActorRequest::ResetRequest(reset_req) => { - send_engine_processing_request(EngineProcessingRequest::Reset(reset_req)).await?; - } - EngineActorRequest::GetPayloadRequest(get_payload_req) => { - send_engine_processing_request(EngineProcessingRequest::GetPayload(get_payload_req)).await?; - } - EngineActorRequest::SealRequest(seal_req) => { - send_engine_processing_request(EngineProcessingRequest::Seal(seal_req)).await?; - } - } + send_engine_processing_request(request).await?; } } } diff --git a/crates/consensus/service/src/actors/engine/engine_request_processor.rs b/crates/consensus/service/src/actors/engine/engine_request_processor.rs index f1b86f47fa..ee00da6401 100644 --- a/crates/consensus/service/src/actors/engine/engine_request_processor.rs +++ b/crates/consensus/service/src/actors/engine/engine_request_processor.rs @@ -5,11 +5,10 @@ use base_common_genesis::RollupConfig; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; use base_consensus_derive::{ResetSignal, Signal}; use base_consensus_engine::{ - BuildTask, ConsolidateInput, ConsolidateTask, DelegatedForkchoiceTask, - DelegatedForkchoiceUpdate, Engine, EngineClient, EngineSyncStateUpdate, EngineTask, - EngineTaskError, EngineTaskErrorSeverity, FinalizeTask, ForkchoiceCheckpointLabel, - ForkchoiceCheckpointReader, GetPayloadTask, InsertPayloadSafety, InsertTask, - Metrics as EngineMetrics, NoopForkchoiceCheckpointReader, SealTask, + ConsolidateTask, Engine, EngineClient, EngineSyncStateUpdate, EngineTask, EngineTaskError, + EngineTaskErrorSeverity, EngineTaskErrors, FinalizeTask, ForkchoiceCheckpointLabel, + ForkchoiceCheckpointReader, InsertTask, InsertTaskResult, Metrics as EngineMetrics, + NoopForkchoiceCheckpointReader, SealTaskError, }; use base_protocol::L2BlockInfo; use tokio::{ @@ -18,44 +17,22 @@ use tokio::{ }; use crate::{ - BuildRequest, CheckpointWriter, Conductor, EngineClientError, EngineDerivationClient, - EngineError, GetPayloadRequest, NodeMode, NoopCheckpointWriter, ResetRequest, SealRequest, + BuildRequest, CheckpointWriter, Conductor, EngineActorRequest, EngineClientError, + EngineDerivationClient, EngineError, GetPayloadRequest, InsertUnsafePayloadRequest, NodeMode, + NoopCheckpointWriter, }; -/// Requires that the implementor handles [`EngineProcessingRequest`]s via the provided channel. +/// Requires that the implementor handles engine requests via the provided channel. /// Note: this exists to facilitate unit testing rather than consolidate multiple implementations /// under a well-thought-out interface. pub trait EngineRequestReceiver: Send + Sync { /// Starts a task to handle engine processing requests. fn start( self, - request_channel: mpsc::Receiver, + request_channel: mpsc::Receiver, ) -> JoinHandle>; } -/// A request to process engine tasks. -#[derive(Debug)] -pub enum EngineProcessingRequest { - /// Request to start building a block. - Build(Box), - /// Request to fetch a sealed payload without inserting it. - GetPayload(Box), - /// Request to process a Safe signal, which can be derived attributes or delegated block info. - ProcessSafeL2Signal(ConsolidateInput), - /// Request to apply delegated safe/finalized labels together for follow mode. - ProcessDelegatedForkchoiceUpdate(Box), - /// Request to process the finalized L2 block with the provided block number. - ProcessFinalizedL2BlockNumber(Box), - /// Request to process a received unsafe L2 block. - ProcessUnsafeL2Block(Box), - /// Request to process a locally produced sequencer unsafe L2 block. - ProcessLocalUnsafeL2Block(Box), - /// Request to reset the forkchoice. - Reset(Box), - /// Request to seal a block. - Seal(Box), -} - /// Classifies the bootstrap behavior for the [`EngineProcessor`]. /// /// Determined once at startup from the node's configuration and (if applicable) @@ -130,8 +107,8 @@ where last_safe_head_checkpointed: L2BlockInfo, /// The last finalized head checkpoint written. last_finalized_head_checkpointed: L2BlockInfo, - /// The [`RollupConfig`] . /// A channel to use to relay the current unsafe head. + /// /// ## Note /// This is `Some` when the node is in sequencer mode, and `None` when the node is in validator /// mode. @@ -210,7 +187,8 @@ where /// Resets the inner [`Engine`] and propagates the reset to the derivation actor. async fn reset(&mut self) -> Result<(), EngineError> { - // Reset the engine. + // Reset the engine, consulting the checkpoint reader if reth has pruned the labeled + // safe / finalized block bodies (so the L1 info deposit cannot be reconstructed). let l2_safe_head = self .engine .reset_with_checkpoint_reader( @@ -220,7 +198,7 @@ where ) .await?; - self.checkpoint_forkchoice_state_if_updated().await?; + self.checkpoint_forkchoice_state_if_updated().await; // Signal the derivation actor to reset. let signal = ResetSignal { l2_safe_head }; @@ -237,6 +215,95 @@ where Ok(()) } + async fn checkpoint_forkchoice_state_if_updated(&mut self) { + let safe_head = self.engine.state().sync_state.safe_head(); + if safe_head != L2BlockInfo::default() && safe_head != self.last_safe_head_checkpointed { + match self + .checkpoint_writer + .update_checkpoint(ForkchoiceCheckpointLabel::Safe, safe_head) + .await + { + Ok(()) => self.last_safe_head_checkpointed = safe_head, + Err(err) => warn!( + target: "engine", + error = %err, + block_number = safe_head.block_info.number, + block_hash = %safe_head.block_info.hash, + "failed to persist safe head checkpoint; continuing without it" + ), + } + } + + let finalized_head = self.engine.state().sync_state.finalized_head(); + if finalized_head != L2BlockInfo::default() + && finalized_head != self.last_finalized_head_checkpointed + { + match self + .checkpoint_writer + .update_checkpoint(ForkchoiceCheckpointLabel::Finalized, finalized_head) + .await + { + Ok(()) => self.last_finalized_head_checkpointed = finalized_head, + Err(err) => warn!( + target: "engine", + error = %err, + block_number = finalized_head.block_info.number, + block_hash = %finalized_head.block_info.hash, + "failed to persist finalized head checkpoint; continuing without it" + ), + } + } + } + + /// Handles an [`EngineTaskErrors`] according to its severity. + async fn handle_engine_task_error(&mut self, err: EngineTaskErrors) -> Result<(), EngineError> { + let severity = err.severity(); + if severity == EngineTaskErrorSeverity::Critical { + error!(target: "engine", ?err, "Critical engine task error"); + return Err(err.into()); + } + + self.handle_engine_task_error_severity(severity, format!("{err:?}")).await + } + + async fn handle_engine_task_error_severity( + &mut self, + severity: EngineTaskErrorSeverity, + error: String, + ) -> Result<(), EngineError> { + match severity { + EngineTaskErrorSeverity::Critical => { + error!(target: "engine", %error, "Critical engine task error"); + Err(EngineError::CriticalEngineTask(error)) + } + EngineTaskErrorSeverity::Reset => { + warn!(target: "engine", %error, "Received reset request"); + self.reset().await + } + EngineTaskErrorSeverity::Flush => { + // This error is encountered when the payload is marked INVALID + // by the engine api. Post-holocene, the payload is replaced by + // a "deposits-only" block and re-executed. At the same time, + // the channel and any remaining buffered batches are flushed. + warn!(target: "engine", %error, "Invalid payload, Flushing derivation pipeline."); + match self.derivation_client.send_signal(Signal::FlushChannel).await { + Ok(_) => { + debug!(target: "engine", "Sent flush signal to derivation actor"); + Ok(()) + } + Err(err) => { + error!(target: "engine", ?err, "Failed to send flush signal to the derivation actor."); + Err(EngineError::ChannelClosed) + } + } + } + EngineTaskErrorSeverity::Temporary => { + trace!(target: "engine", %error, "Temporary engine task error"); + Ok(()) + } + } + } + /// Drains the inner [`Engine`] task queue and attempts to update the safe head. async fn drain(&mut self) -> Result<(), EngineError> { match self.engine.drain().await { @@ -244,39 +311,11 @@ where trace!(target: "engine", "[ENGINE] tasks drained"); } Err(err) => { - match err.severity() { - EngineTaskErrorSeverity::Critical => { - error!(target: "engine", ?err, "Critical error draining engine tasks"); - return Err(err.into()); - } - EngineTaskErrorSeverity::Reset => { - warn!(target: "engine", ?err, "Received reset request"); - self.reset().await?; - } - EngineTaskErrorSeverity::Flush => { - // This error is encountered when the payload is marked INVALID - // by the engine api. Post-holocene, the payload is replaced by - // a "deposits-only" block and re-executed. At the same time, - // the channel and any remaining buffered batches are flushed. - warn!(target: "engine", ?err, "Invalid payload, Flushing derivation pipeline."); - match self.derivation_client.send_signal(Signal::FlushChannel).await { - Ok(_) => { - debug!(target: "engine", "Sent flush signal to derivation actor") - } - Err(err) => { - error!(target: "engine", ?err, "Failed to send flush signal to the derivation actor."); - return Err(EngineError::ChannelClosed); - } - } - } - EngineTaskErrorSeverity::Temporary => { - trace!(target: "engine", ?err, "Temporary error draining engine tasks"); - } - } + self.handle_engine_task_error(err).await?; } } - self.checkpoint_forkchoice_state_if_updated().await?; + self.checkpoint_forkchoice_state_if_updated().await; self.send_derivation_actor_safe_head_if_updated().await?; if !self.el_sync_complete && self.engine.state().el_sync_finished { @@ -286,35 +325,27 @@ where Ok(()) } - async fn checkpoint_forkchoice_state_if_updated(&mut self) -> Result<(), EngineError> { - let safe_head = self.engine.state().sync_state.safe_head(); - if safe_head != L2BlockInfo::default() && safe_head != self.last_safe_head_checkpointed { - self.checkpoint_writer - .update_checkpoint(ForkchoiceCheckpointLabel::Safe, safe_head) - .await?; - self.last_safe_head_checkpointed = safe_head; - } - - let finalized_head = self.engine.state().sync_state.finalized_head(); - if finalized_head != L2BlockInfo::default() - && finalized_head != self.last_finalized_head_checkpointed - { - self.checkpoint_writer - .update_checkpoint(ForkchoiceCheckpointLabel::Finalized, finalized_head) - .await?; - self.last_finalized_head_checkpointed = finalized_head; - } - - Ok(()) - } - - fn enqueue_unsafe_payload_insert(&mut self, envelope: BaseExecutionPayloadEnvelope) { + fn enqueue_unsafe_payload_insert( + &mut self, + envelope: BaseExecutionPayloadEnvelope, + result_tx: Option>, + ) { self.log_follower_upgrade_activation(&envelope); - let task = EngineTask::Insert(Box::new(InsertTask::unsafe_payload( - Arc::clone(&self.client), - Arc::clone(&self.rollup), - envelope, - ))); + let task = match result_tx { + Some(result_tx) => { + EngineTask::Insert(Box::new(InsertTask::unsafe_payload_with_result( + Arc::clone(&self.client), + Arc::clone(&self.rollup), + envelope, + result_tx, + ))) + } + None => EngineTask::Insert(Box::new(InsertTask::unsafe_payload( + Arc::clone(&self.client), + Arc::clone(&self.rollup), + envelope, + ))), + }; self.engine.enqueue(task); } @@ -331,7 +362,7 @@ where parent_hash = %envelope.execution_payload.parent_hash(), "Validator enqueuing external unsafe payload" ); - self.enqueue_unsafe_payload_insert(envelope); + self.enqueue_unsafe_payload_insert(envelope, None); return; } @@ -348,7 +379,7 @@ where max_external_unsafe_gap = EngineProcessorOptions::MAX_SEQUENCER_EXTERNAL_UNSAFE_GAP, "Sequencer enqueuing external unsafe payload within gap limit" ); - self.enqueue_unsafe_payload_insert(envelope); + self.enqueue_unsafe_payload_insert(envelope, None); return; } @@ -365,7 +396,8 @@ where ); } - fn handle_local_unsafe_l2_block(&mut self, envelope: BaseExecutionPayloadEnvelope) { + fn handle_local_unsafe_l2_block(&mut self, request: InsertUnsafePayloadRequest) { + let InsertUnsafePayloadRequest { envelope, result_tx } = request; debug!( target: "engine", block_number = envelope.execution_payload.block_number(), @@ -373,7 +405,7 @@ where parent_hash = %envelope.execution_payload.parent_hash(), "Enqueuing local sequencer unsafe payload" ); - self.enqueue_unsafe_payload_insert(envelope); + self.enqueue_unsafe_payload_insert(envelope, result_tx); } async fn mark_el_sync_complete_and_notify_derivation_actor( @@ -637,7 +669,7 @@ where { fn start( mut self, - mut request_channel: mpsc::Receiver, + mut request_channel: mpsc::Receiver, ) -> JoinHandle> { tokio::spawn(async move { // Bootstrap: pre-populate the unsafe_head_tx watch channel so that external callers @@ -711,29 +743,53 @@ where }; match request { - EngineProcessingRequest::Build(build_request) => { + EngineActorRequest::BuildRequest(build_request) => { let BuildRequest { attributes, result_tx } = *build_request; - let task = EngineTask::Build(Box::new(BuildTask::new( - Arc::clone(&self.client), - Arc::clone(&self.rollup), - attributes, - Some(result_tx), - ))); - self.engine.enqueue(task); + match self + .engine + .build(Arc::clone(&self.client), Arc::clone(&self.rollup), attributes) + .await + { + Ok(payload_id) => { + result_tx + .send(Ok(payload_id)) + .await + .map_err(|_| EngineError::ChannelClosed)?; + } + Err(err) => { + let severity = err.severity(); + let error = format!("{err:?}"); + result_tx + .send(Err(err)) + .await + .map_err(|_| EngineError::ChannelClosed)?; + self.handle_engine_task_error_severity(severity, error).await?; + } + } } - EngineProcessingRequest::GetPayload(get_payload_request) => { + EngineActorRequest::GetPayloadRequest(get_payload_request) => { let GetPayloadRequest { payload_id, attributes, result_tx } = *get_payload_request; - let task = EngineTask::GetPayload(Box::new(GetPayloadTask::new( - Arc::clone(&self.client), - Arc::clone(&self.rollup), - payload_id, - attributes, - Some(result_tx), - ))); - self.engine.enqueue(task); + let result = self + .engine + .get_payload( + Arc::clone(&self.client), + Arc::clone(&self.rollup), + payload_id, + attributes, + ) + .await; + + let error = + result.as_ref().err().map(|err| (err.severity(), format!("{err:?}"))); + result_tx.send(result).await.map_err(|err| { + EngineTaskErrors::Seal(SealTaskError::MpscSend(Box::new(err))) + })?; + if let Some((severity, error)) = error { + self.handle_engine_task_error_severity(severity, error).await?; + } } - EngineProcessingRequest::ProcessSafeL2Signal(safe_signal) => { + EngineActorRequest::ProcessSafeL2SignalRequest(safe_signal) => { let task = EngineTask::Consolidate(Box::new(ConsolidateTask::new( Arc::clone(&self.client), Arc::clone(&self.rollup), @@ -741,17 +797,7 @@ where ))); self.engine.enqueue(task); } - EngineProcessingRequest::ProcessDelegatedForkchoiceUpdate(update) => { - let task = EngineTask::DelegatedForkchoice(Box::new( - DelegatedForkchoiceTask::new( - Arc::clone(&self.client), - Arc::clone(&self.rollup), - *update, - ), - )); - self.engine.enqueue(task); - } - EngineProcessingRequest::ProcessFinalizedL2BlockNumber( + EngineActorRequest::ProcessFinalizedL2BlockNumberRequest( finalized_l2_block_number, ) => { // Finalize the L2 block at the provided block number. @@ -762,13 +808,13 @@ where ))); self.engine.enqueue(task); } - EngineProcessingRequest::ProcessUnsafeL2Block(envelope) => { + EngineActorRequest::ProcessUnsafeL2BlockRequest(envelope) => { self.handle_external_unsafe_l2_block(*envelope); } - EngineProcessingRequest::ProcessLocalUnsafeL2Block(envelope) => { + EngineActorRequest::ProcessLocalUnsafeL2BlockRequest(envelope) => { self.handle_local_unsafe_l2_block(*envelope); } - EngineProcessingRequest::Reset(reset_request) => { + EngineActorRequest::ResetRequest(reset_request) => { // Do not reset the engine while the EL is still syncing. A Reset sends a // forkchoice_updated to reth pointing at the sync-start block, which will // return Valid and cause reth to set that stale block as canonical, @@ -802,18 +848,6 @@ where reset_res?; } } - EngineProcessingRequest::Seal(seal_request) => { - let SealRequest { payload_id, attributes, result_tx } = *seal_request; - let task = EngineTask::Seal(Box::new(SealTask::new( - Arc::clone(&self.client), - Arc::clone(&self.rollup), - payload_id, - attributes, - InsertPayloadSafety::Unsafe, - Some(result_tx), - ))); - self.engine.enqueue(task); - } } } }) @@ -824,12 +858,13 @@ where mod tests { use std::sync::Arc; + use alloy_consensus::transaction::Recovered; use alloy_eips::{BlockId, BlockNumHash, BlockNumberOrTag, NumHash, eip2718::Encodable2718}; - use alloy_primitives::{Address, B256, Bloom, U256}; + use alloy_primitives::{Address, B256, Bloom, Sealed, U256}; use alloy_rpc_types_engine::{ ExecutionPayloadV1, ForkchoiceUpdated, PayloadStatus, PayloadStatusEnum, }; - use alloy_rpc_types_eth::Block as RpcBlock; + use alloy_rpc_types_eth::{Block as RpcBlock, BlockTransactions}; use async_trait::async_trait; use base_common_consensus::{BaseTxEnvelope, TxDeposit}; use base_common_genesis::{ChainGenesis, RollupConfig, SystemConfig}; @@ -837,8 +872,8 @@ mod tests { use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; use base_consensus_derive::Signal; use base_consensus_engine::{ - Engine, EngineState, ForkchoiceCheckpointError, ForkchoiceCheckpointLabel, - ForkchoiceCheckpointReader, + Engine, EngineState, EngineTaskError, EngineTaskErrorSeverity, ForkchoiceCheckpointError, + ForkchoiceCheckpointLabel, ForkchoiceCheckpointReader, test_utils::{ TestAttributesBuilder, TestEngineStateBuilder, test_block_info, test_engine_client_builder, @@ -849,11 +884,35 @@ mod tests { use tokio::sync::{mpsc, watch}; use crate::{ - BuildRequest, EngineClientError, EngineProcessingRequest, EngineProcessor, - EngineProcessorOptions, EngineRequestReceiver, MockConductor, NodeMode, - NoopCheckpointWriter, ResetRequest, actors::engine::client::MockEngineDerivationClient, + BuildRequest, EngineActorRequest, EngineClientError, EngineProcessor, + EngineProcessorOptions, EngineRequestReceiver, InsertUnsafePayloadRequest, MockConductor, + NodeMode, NoopCheckpointWriter, ResetRequest, + actors::engine::client::MockEngineDerivationClient, }; + /// Test-only [`ForkchoiceCheckpointReader`] that returns pre-seeded safe/finalized heads. + /// + /// Used by the validator-restart regression test to simulate the on-disk checkpoint state + /// that survives a process restart even when reth has pruned the corresponding block body. + #[derive(Debug)] + struct TestCheckpointReader { + safe: Option, + finalized: Option, + } + + #[async_trait] + impl ForkchoiceCheckpointReader for TestCheckpointReader { + async fn checkpoint( + &self, + label: ForkchoiceCheckpointLabel, + ) -> Result, ForkchoiceCheckpointError> { + Ok(match label { + ForkchoiceCheckpointLabel::Safe => self.safe, + ForkchoiceCheckpointLabel::Finalized => self.finalized, + }) + } + } + /// Returns a default all-zero L2 block and its canonical hash. /// /// Use the returned hash as `genesis.l2.hash` in the test rollup config so that @@ -891,33 +950,6 @@ mod tests { } } - #[derive(Debug)] - struct TestCheckpointReader { - safe: Option, - finalized: Option, - } - - #[async_trait] - impl ForkchoiceCheckpointReader for TestCheckpointReader { - async fn checkpoint( - &self, - label: ForkchoiceCheckpointLabel, - ) -> Result, ForkchoiceCheckpointError> { - Ok(match label { - ForkchoiceCheckpointLabel::Safe => self.safe, - ForkchoiceCheckpointLabel::Finalized => self.finalized, - }) - } - } - - fn l1_info_deposit_tx() -> Vec { - BaseTxEnvelope::from(TxDeposit { - input: L1BlockInfoBedrock::default().encode_calldata(), - ..Default::default() - }) - .encoded_2718() - } - fn unsafe_payload( block_number: u64, parent_hash: B256, @@ -944,177 +976,6 @@ mod tests { } } - fn unsafe_payload_with_l1_info( - block_number: u64, - parent_hash: B256, - block_hash: B256, - ) -> BaseExecutionPayloadEnvelope { - BaseExecutionPayloadEnvelope { - parent_beacon_block_root: None, - execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { - parent_hash, - fee_recipient: Address::ZERO, - state_root: B256::ZERO, - receipts_root: B256::ZERO, - logs_bloom: Bloom::ZERO, - prev_randao: B256::ZERO, - block_number, - gas_limit: 30_000_000, - gas_used: 0, - timestamp: block_number, - extra_data: Default::default(), - base_fee_per_gas: U256::ZERO, - block_hash, - transactions: vec![l1_info_deposit_tx().into()], - }), - } - } - - fn pruned_reth_l2_block(number: u64, parent_hash: B256) -> RpcBlock { - RpcBlock:: { - header: alloy_rpc_types_eth::Header { - inner: alloy_consensus::Header { - number, - parent_hash, - timestamp: number, - ..Default::default() - }, - ..Default::default() - }, - transactions: alloy_rpc_types_eth::BlockTransactions::Full(vec![]), - ..Default::default() - } - } - - fn l1_info_rpc_transaction(block_number: u64) -> BaseTransaction { - let tx = alloy_rpc_types_eth::Transaction { - inner: alloy_consensus::transaction::Recovered::new_unchecked( - BaseTxEnvelope::Deposit(alloy_primitives::Sealed::new(TxDeposit { - input: L1BlockInfoBedrock::default().encode_calldata(), - ..Default::default() - })), - Default::default(), - ), - block_hash: None, - block_number: Some(block_number), - effective_gas_price: Some(1), - transaction_index: Some(0), - }; - - BaseTransaction { inner: tx, deposit_nonce: None, deposit_receipt_version: None } - } - - fn full_reth_l2_block_with_l1_info( - number: u64, - parent_hash: B256, - ) -> RpcBlock { - RpcBlock:: { - header: alloy_rpc_types_eth::Header { - inner: alloy_consensus::Header { - number, - parent_hash, - timestamp: number, - ..Default::default() - }, - ..Default::default() - }, - transactions: alloy_rpc_types_eth::BlockTransactions::Full(vec![ - l1_info_rpc_transaction(number), - ]), - ..Default::default() - } - } - - #[tokio::test] - async fn validator_restart_does_not_crash_when_reth_safe_block_body_is_pruned() { - let (genesis_block, genesis_hash) = make_genesis_block(); - let cfg = Arc::new(RollupConfig { - genesis: ChainGenesis { - l2: BlockNumHash { number: 0, hash: genesis_hash }, - l1: BlockNumHash { number: 0, hash: B256::ZERO }, - system_config: Some(SystemConfig::default()), - ..Default::default() - }, - ..Default::default() - }); - let mut reth_latest = L2BlockInfo { - block_info: BlockInfo { - number: 44_343_433, - hash: B256::with_last_byte(0x42), - parent_hash: B256::with_last_byte(0x41), - timestamp: 44_343_433, - }, - ..Default::default() - }; - let pruned_safe = pruned_reth_l2_block(44_343_433, reth_latest.block_info.parent_hash); - let safe_checkpoint = L2BlockInfo { - block_info: BlockInfo::from( - &pruned_safe - .clone() - .into_consensus() - .map_transactions(|tx| tx.inner.inner.into_inner()), - ), - ..Default::default() - }; - reth_latest.block_info.hash = safe_checkpoint.block_info.hash; - let next_hash = B256::with_last_byte(0x43); - let full_latest = full_reth_l2_block_with_l1_info( - reth_latest.block_info.number + 1, - reth_latest.block_info.hash, - ); - - let client = Arc::new( - test_engine_client_builder() - .with_config(Arc::clone(&cfg)) - .with_l2_block(BlockId::Number(BlockNumberOrTag::Finalized), genesis_block) - .with_l2_block(BlockId::Number(BlockNumberOrTag::Safe), pruned_safe) - .with_l2_block(BlockId::Number(BlockNumberOrTag::Latest), full_latest) - .with_l1_block(BlockId::from(B256::ZERO), RpcBlock::default()) - .with_new_payload_v2_response(PayloadStatus { - status: PayloadStatusEnum::Valid, - latest_valid_hash: Some(next_hash), - }) - .with_fork_choice_updated_v3_response(valid_fcu()) - .build(), - ); - - let mut mock_derivation = MockEngineDerivationClient::new(); - mock_derivation.expect_send_signal().returning(|_| Ok(())); - mock_derivation.expect_send_new_engine_safe_head().returning(|_| Ok(())); - mock_derivation.expect_notify_sync_completed().returning(|_| Ok(())); - - let (state_tx, _) = watch::channel(EngineState::default()); - let (queue_tx, _) = watch::channel(0usize); - let engine = Engine::new(EngineState::default(), state_tx, queue_tx); - - let mut processor = EngineProcessor::new_with_checkpoint( - Arc::clone(&client), - cfg, - mock_derivation, - engine, - EngineProcessorOptions { - node_mode: NodeMode::Validator, - unsafe_head_tx: None, - conductor: None, - sequencer_stopped: false, - }, - Arc::new(TestCheckpointReader { safe: Some(safe_checkpoint), finalized: None }), - Arc::new(NoopCheckpointWriter), - ); - - processor.bootstrap_validator(Some(reth_latest)).await; - processor.handle_external_unsafe_l2_block(unsafe_payload_with_l1_info( - reth_latest.block_info.number + 1, - reth_latest.block_info.hash, - next_hash, - )); - - processor - .drain() - .await - .expect("validator restart must not crash when reth pruned historical block bodies"); - } - fn unsafe_payload_processor( node_mode: NodeMode, el_sync_finished: bool, @@ -1285,7 +1146,10 @@ mod tests { unsafe_payload_processor(node_mode, el_sync_finished, unsafe_head, safe_head); if local_payload { - processor.handle_local_unsafe_l2_block(envelope); + processor.handle_local_unsafe_l2_block(InsertUnsafePayloadRequest { + envelope, + result_tx: None, + }); } else { processor.handle_external_unsafe_l2_block(envelope); } @@ -1422,7 +1286,7 @@ mod tests { // Send a Reset — the ELSyncing guard must fire and return ELSyncing. let (result_tx, mut result_rx) = mpsc::channel(1); req_tx - .send(EngineProcessingRequest::Reset(Box::new(ResetRequest { result_tx }))) + .send(EngineActorRequest::ResetRequest(Box::new(ResetRequest { result_tx }))) .await .expect("failed to send reset request"); @@ -1882,6 +1746,182 @@ mod tests { ); } + fn l1_info_deposit_tx_bytes() -> Vec { + BaseTxEnvelope::from(TxDeposit { + input: L1BlockInfoBedrock::default().encode_calldata(), + ..Default::default() + }) + .encoded_2718() + } + + fn unsafe_payload_with_l1_info( + block_number: u64, + parent_hash: B256, + block_hash: B256, + ) -> BaseExecutionPayloadEnvelope { + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + parent_hash, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + prev_randao: B256::ZERO, + block_number, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: block_number, + extra_data: Default::default(), + base_fee_per_gas: U256::ZERO, + block_hash, + transactions: vec![l1_info_deposit_tx_bytes().into()], + }), + } + } + + fn pruned_reth_l2_block(number: u64, parent_hash: B256) -> RpcBlock { + let mut block = RpcBlock::::default(); + block.header.inner.number = number; + block.header.inner.parent_hash = parent_hash; + block.header.inner.timestamp = number; + block.transactions = BlockTransactions::Full(vec![]); + block + } + + fn l1_info_rpc_transaction(block_number: u64) -> BaseTransaction { + let envelope = BaseTxEnvelope::Deposit(Sealed::new_unchecked( + TxDeposit { + input: L1BlockInfoBedrock::default().encode_calldata(), + ..Default::default() + }, + B256::ZERO, + )); + BaseTransaction { + inner: alloy_rpc_types_eth::Transaction { + inner: Recovered::new_unchecked(envelope, Address::ZERO), + block_hash: None, + block_number: Some(block_number), + block_timestamp: None, + effective_gas_price: Some(0), + transaction_index: Some(0), + }, + deposit_nonce: None, + deposit_receipt_version: None, + } + } + + fn full_reth_l2_block_with_l1_info( + number: u64, + parent_hash: B256, + ) -> RpcBlock { + let mut block = RpcBlock::::default(); + block.header.inner.number = number; + block.header.inner.parent_hash = parent_hash; + block.header.inner.timestamp = number; + block.transactions = BlockTransactions::Full(vec![l1_info_rpc_transaction(number)]); + block + } + + /// Regression test for the validator-restart crash when reth has pruned the body of + /// the last safe block. + /// + /// Before the fix, `EngineProcessor::new` would seed safe/finalized heads from + /// `L2ForkchoiceState::current(client)`, which calls + /// `client.l2_block_info_by_label(BlockNumberOrTag::Safe)` and `Finalized`. If reth had + /// pruned the safe block's body, that call returned `None` and the processor lost the + /// checkpoint, producing zeroed safe/finalized heads. Any subsequent unsafe payload + /// insertion then panicked because the engine's invariant "`safe_head.number` <= + /// `unsafe_head.number`" was satisfied trivially but `attributes.parent` mismatches led + /// to a `CriticalEngineTask` and the processor crashed during `drain()`. + /// + /// After the fix, `new_with_checkpoint` consults the persisted checkpoint reader, which + /// returns the previously checkpointed safe head even when reth has pruned the block + /// body. The validator can then accept the next unsafe payload and drain cleanly. + #[tokio::test] + async fn validator_restart_does_not_crash_when_reth_safe_block_body_is_pruned() { + let (genesis_block, genesis_hash) = make_genesis_block(); + let cfg = Arc::new(RollupConfig { + genesis: ChainGenesis { + l2: BlockNumHash { number: 0, hash: genesis_hash }, + l1: BlockNumHash { number: 0, hash: B256::ZERO }, + system_config: Some(SystemConfig::default()), + ..Default::default() + }, + ..Default::default() + }); + + let parent_hash = B256::with_last_byte(0x41); + let pruned_safe = pruned_reth_l2_block(44_343_433, parent_hash); + let safe_hash = pruned_safe.header.inner.hash_slow(); + let safe_checkpoint = L2BlockInfo { + block_info: BlockInfo { + number: 44_343_433, + hash: safe_hash, + parent_hash, + timestamp: 44_343_433, + }, + ..Default::default() + }; + let reth_latest = safe_checkpoint; + let next_hash = B256::with_last_byte(0x43); + let full_latest = full_reth_l2_block_with_l1_info( + reth_latest.block_info.number + 1, + reth_latest.block_info.hash, + ); + + let client = Arc::new( + test_engine_client_builder() + .with_config(Arc::clone(&cfg)) + .with_l2_block(BlockId::Number(BlockNumberOrTag::Finalized), genesis_block) + .with_l2_block(BlockId::Number(BlockNumberOrTag::Safe), pruned_safe) + .with_l2_block(BlockId::Number(BlockNumberOrTag::Latest), full_latest) + .with_l1_block(BlockId::from(B256::ZERO), RpcBlock::default()) + .with_new_payload_v2_response(PayloadStatus { + status: PayloadStatusEnum::Valid, + latest_valid_hash: Some(next_hash), + }) + .with_fork_choice_updated_v3_response(valid_fcu()) + .build(), + ); + + let mut mock_derivation = MockEngineDerivationClient::new(); + mock_derivation.expect_send_signal().returning(|_| Ok(())); + mock_derivation.expect_send_new_engine_safe_head().returning(|_| Ok(())); + mock_derivation.expect_notify_sync_completed().returning(|_| Ok(())); + + let (state_tx, _state_rx) = watch::channel(EngineState::default()); + let (queue_tx, _queue_rx) = watch::channel(0usize); + let engine = Engine::new(EngineState::default(), state_tx, queue_tx); + + let mut processor = EngineProcessor::new_with_checkpoint( + Arc::clone(&client), + Arc::clone(&cfg), + mock_derivation, + engine, + EngineProcessorOptions { + node_mode: NodeMode::Validator, + unsafe_head_tx: None, + conductor: None, + sequencer_stopped: false, + }, + Arc::new(TestCheckpointReader { safe: Some(safe_checkpoint), finalized: None }), + Arc::new(NoopCheckpointWriter), + ); + + processor.bootstrap_validator(Some(reth_latest)).await; + processor.handle_external_unsafe_l2_block(unsafe_payload_with_l1_info( + reth_latest.block_info.number + 1, + reth_latest.block_info.hash, + next_hash, + )); + + processor + .drain() + .await + .expect("validator restart must not crash when reth pruned historical block bodies"); + } + /// Regression test: when a `Build` request fails with an `InvalidPayload` (the EL rejects /// the derived attributes), the processor must dispatch exactly one /// [`Signal::FlushChannel`] to the derivation actor and resume servicing requests rather @@ -1959,15 +1999,25 @@ mod tests { .with_parent(parent_block) .with_timestamp(attributes_timestamp) .build(); - let (build_result_tx, _build_result_rx) = mpsc::channel(1); + let (build_result_tx, mut build_result_rx) = mpsc::channel(1); req_tx - .send(EngineProcessingRequest::Build(Box::new(BuildRequest { + .send(EngineActorRequest::BuildRequest(Box::new(BuildRequest { attributes, result_tx: build_result_tx, }))) .await .expect("failed to send build request"); + let build_result = + tokio::time::timeout(std::time::Duration::from_secs(5), build_result_rx.recv()) + .await + .expect("timed out waiting for build result") + .expect("build result channel closed before response"); + assert!(matches!( + build_result, + Err(err) if err.severity() == EngineTaskErrorSeverity::Flush + )); + let received = tokio::time::timeout(std::time::Duration::from_secs(5), signal_rx.recv()) .await .expect("timed out waiting for FlushChannel signal") diff --git a/crates/consensus/service/src/actors/engine/error.rs b/crates/consensus/service/src/actors/engine/error.rs index 801784a994..4fe2b28a5e 100644 --- a/crates/consensus/service/src/actors/engine/error.rs +++ b/crates/consensus/service/src/actors/engine/error.rs @@ -4,8 +4,6 @@ use base_consensus_engine::{EngineResetError, EngineTaskErrors}; -use crate::CheckpointError; - /// An error from the [`EngineActor`]. /// /// [`EngineActor`]: super::EngineActor @@ -20,7 +18,7 @@ pub enum EngineError { /// Engine task error. #[error(transparent)] EngineTask(#[from] EngineTaskErrors), - /// Checkpoint error. - #[error(transparent)] - Checkpoint(#[from] CheckpointError), + /// A critical engine task error was already forwarded to the request caller. + #[error("critical engine task error: {0}")] + CriticalEngineTask(String), } diff --git a/crates/consensus/service/src/actors/engine/mod.rs b/crates/consensus/service/src/actors/engine/mod.rs index dc69efd865..99f48f2ec5 100644 --- a/crates/consensus/service/src/actors/engine/mod.rs +++ b/crates/consensus/service/src/actors/engine/mod.rs @@ -15,15 +15,14 @@ pub use error::EngineError; mod request; pub use request::{ BuildRequest, EngineActorRequest, EngineClientError, EngineClientResult, EngineRpcRequest, - GetPayloadRequest, ResetRequest, SealRequest, + GetPayloadRequest, InsertUnsafePayloadRequest, ResetRequest, }; mod engine_request_processor; #[cfg(test)] pub use client::MockEngineDerivationClient; pub use engine_request_processor::{ - BootstrapRole, EngineProcessingRequest, EngineProcessor, EngineProcessorOptions, - EngineRequestReceiver, + BootstrapRole, EngineProcessor, EngineProcessorOptions, EngineRequestReceiver, }; mod rpc_request_processor; diff --git a/crates/consensus/service/src/actors/engine/request.rs b/crates/consensus/service/src/actors/engine/request.rs index 90ba7f56f3..f50b1fb0e1 100644 --- a/crates/consensus/service/src/actors/engine/request.rs +++ b/crates/consensus/service/src/actors/engine/request.rs @@ -1,9 +1,9 @@ use alloy_rpc_types_engine::PayloadId; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; use base_consensus_engine::{ - BuildTaskError, ConsolidateInput, DelegatedForkchoiceUpdate, EngineQueries, SealTaskError, + BuildTaskError, ConsolidateInput, EngineQueries, InsertTaskError, SealTaskError, }; -use base_protocol::AttributesWithParent; +use base_protocol::{AttributesWithParent, L2BlockInfo}; use thiserror::Error; use tokio::sync::mpsc; @@ -30,6 +30,10 @@ pub enum EngineClientError { #[error(transparent)] SealError(#[from] SealTaskError), + /// An error occurred inserting an unsafe block. + #[error(transparent)] + InsertError(#[from] InsertTaskError), + /// An error occurred performing the reset. #[error("An error occurred performing the reset: {0}.")] ResetForkchoiceError(String), @@ -49,18 +53,14 @@ pub enum EngineActorRequest { /// Request to consolidate using a safe L2 signal from attributes or delegated safe-block /// derivation ProcessSafeL2SignalRequest(ConsolidateInput), - /// Request to apply delegated follow-node safe/finalized labels together. - ProcessDelegatedForkchoiceUpdateRequest(Box), /// Request to finalize the L2 block at the provided block number. ProcessFinalizedL2BlockNumberRequest(Box), /// Request to insert the provided external unsafe block. ProcessUnsafeL2BlockRequest(Box), /// Request to insert a locally produced sequencer unsafe block. - ProcessLocalUnsafeL2BlockRequest(Box), + ProcessLocalUnsafeL2BlockRequest(Box), /// Request to reset engine forkchoice. ResetRequest(Box), - /// Request to seal the block with the provided details. - SealRequest(Box), } /// RPC Request for the engine to handle. @@ -77,7 +77,7 @@ pub struct BuildRequest { /// The [`AttributesWithParent`] from which the block build should be started. pub attributes: AttributesWithParent, /// The channel on which the result, successful or not, will be sent. - pub result_tx: mpsc::Sender, + pub result_tx: mpsc::Sender>, } /// A request to reset the engine forkchoice. @@ -89,16 +89,13 @@ pub struct ResetRequest { pub result_tx: mpsc::Sender>, } -/// A request to seal and canonicalize a payload. -/// Contains the `PayloadId`, attributes, and a channel to send back the result. +/// A request to insert a local unsafe payload. #[derive(Debug)] -pub struct SealRequest { - /// The `PayloadId` to seal and canonicalize. - pub payload_id: PayloadId, - /// The attributes necessary for the seal operation. - pub attributes: AttributesWithParent, - /// The channel on which the result, successful or not, will be sent. - pub result_tx: mpsc::Sender>, +pub struct InsertUnsafePayloadRequest { + /// The payload envelope to insert. + pub envelope: BaseExecutionPayloadEnvelope, + /// Optional response channel used by the sequencer to wait for actual insertion. + pub result_tx: Option>>, } /// A request to get the sealed payload without inserting it into the engine. diff --git a/crates/consensus/service/src/actors/l1_watcher/query_processor.rs b/crates/consensus/service/src/actors/l1_watcher/query_processor.rs index 1fd0b678d3..38671f2c3d 100644 --- a/crates/consensus/service/src/actors/l1_watcher/query_processor.rs +++ b/crates/consensus/service/src/actors/l1_watcher/query_processor.rs @@ -28,8 +28,8 @@ where rollup_config: Arc, /// The L1 provider used for live block lookups. l1_provider: Arc, - /// Receiver for the most recent L1 head observed by the watcher actor. - latest_head: watch::Receiver>, + /// Receiver for the most recent L1 origin reached by derivation. + derivation_origin: watch::Receiver>, } impl Clone for L1WatcherQueryExecutor @@ -40,7 +40,7 @@ where Self { rollup_config: Arc::clone(&self.rollup_config), l1_provider: Arc::clone(&self.l1_provider), - latest_head: self.latest_head.clone(), + derivation_origin: self.derivation_origin.clone(), } } } @@ -53,9 +53,9 @@ where pub const fn new( rollup_config: Arc, l1_provider: Arc, - latest_head: watch::Receiver>, + derivation_origin: watch::Receiver>, ) -> Self { - Self { rollup_config, l1_provider, latest_head } + Self { rollup_config, l1_provider, derivation_origin } } /// Executes a single query. @@ -102,7 +102,7 @@ where query_started_at: Instant, sender: oneshot::Sender, ) { - let current_l1 = *self.latest_head.borrow(); + let current_l1 = *self.derivation_origin.borrow(); let (head_l1, finalized_l1, safe_l1) = tokio::join!( self.query_block(BlockId::latest(), "latest"), self.query_block(BlockId::finalized(), "finalized"), @@ -182,14 +182,14 @@ where rollup_config: Arc, l1_provider: L1Provider, inbound_queries: mpsc::Receiver, - latest_head: watch::Receiver>, + derivation_origin: watch::Receiver>, cancellation: CancellationToken, ) -> Self { Self { executor: L1WatcherQueryExecutor::new( rollup_config, Arc::new(l1_provider), - latest_head, + derivation_origin, ), inbound_queries, cancellation, @@ -334,19 +334,19 @@ mod tests { fn executor( fetcher: MockFetcher, - current_l1: Option, + derivation_origin: Option, ) -> L1WatcherQueryExecutor { - let (_latest_head_tx, latest_head_rx) = watch::channel(current_l1); + let (_derivation_origin_tx, derivation_origin_rx) = watch::channel(derivation_origin); L1WatcherQueryExecutor::new( Arc::new(RollupConfig::default()), Arc::new(fetcher), - latest_head_rx, + derivation_origin_rx, ) } #[tokio::test] - async fn l1_state_query_returns_live_state() { - let current_l1 = Some(MockFetcher::block_info(11)); + async fn l1_state_query_uses_derivation_origin_for_current_l1() { + let current_l1 = Some(MockFetcher::block_info(7)); let executor = executor(MockFetcher::with_delay(Duration::ZERO), current_l1); let (sender, receiver) = oneshot::channel(); @@ -375,14 +375,14 @@ mod tests { #[tokio::test] async fn query_processor_handles_multiple_queries_concurrently() { let fetcher = MockFetcher::with_delay(Duration::from_millis(20)); - let (_latest_head_tx, latest_head_rx) = watch::channel(None); + let (_derivation_origin_tx, derivation_origin_rx) = watch::channel(None); let (query_tx, query_rx) = mpsc::channel(16); let cancellation = CancellationToken::new(); let processor = L1WatcherQueryProcessor::new( Arc::new(RollupConfig::default()), fetcher, query_rx, - latest_head_rx, + derivation_origin_rx, cancellation.clone(), ) .with_query_concurrency(2); diff --git a/crates/consensus/service/src/actors/mod.rs b/crates/consensus/service/src/actors/mod.rs index a8156815ab..419f184eca 100644 --- a/crates/consensus/service/src/actors/mod.rs +++ b/crates/consensus/service/src/actors/mod.rs @@ -16,23 +16,24 @@ mod engine; pub use engine::MockEngineDerivationClient; pub use engine::{ BootstrapRole, BuildRequest, EngineActor, EngineActorRequest, EngineClientError, - EngineClientResult, EngineConfig, EngineDerivationClient, EngineError, EngineProcessingRequest, - EngineProcessor, EngineProcessorOptions, EngineRequestReceiver, EngineRpcProcessor, - EngineRpcRequest, GetPayloadRequest, QueuedEngineDerivationClient, ResetRequest, SealRequest, + EngineClientResult, EngineConfig, EngineDerivationClient, EngineError, EngineProcessor, + EngineProcessorOptions, EngineRequestReceiver, EngineRpcProcessor, EngineRpcRequest, + GetPayloadRequest, InsertUnsafePayloadRequest, QueuedEngineDerivationClient, ResetRequest, }; mod rpc; +pub(crate) use rpc::launch_rpc_server; pub use rpc::{ QueuedEngineRpcClient, QueuedSequencerAdminAPIClient, RpcActor, RpcActorError, RpcContext, }; mod derivation; pub use derivation::{ - DelegateDerivationActor, DelegateL2Client, DelegateL2ClientError, DelegateL2DerivationActor, - DerivationActor, DerivationActorRequest, DerivationClientError, DerivationClientResult, - DerivationDelegateClient, DerivationDelegateClientError, DerivationEngineClient, - DerivationError, DerivationState, DerivationStateMachine, DerivationStateTransitionError, - DerivationStateUpdate, L2Finalizer, L2SourceClient, QueuedDerivationEngineClient, + DelegateDerivationActor, DerivationActor, DerivationActorRequest, DerivationClientError, + DerivationClientResult, DerivationDelegateClient, DerivationDelegateClientError, + DerivationEngineClient, DerivationError, DerivationState, DerivationStateMachine, + DerivationStateTransitionError, DerivationStateUpdate, L2Finalizer, + QueuedDerivationEngineClient, }; mod l1_watcher; @@ -57,8 +58,9 @@ pub use sequencer::{ Conductor, ConductorClient, ConductorError, DelayedL1OriginSelectorProvider, L1OriginSelector, L1OriginSelectorError, L1OriginSelectorProvider, OriginSelector, PayloadBuilder, PayloadSealer, PendingStopSender, PoolActivation, QueuedSequencerEngineClient, RecoveryModeGuard, - ScheduledTicker, SealState, SealStepError, SequencerActor, SequencerActorError, - SequencerAdminQuery, SequencerConfig, SequencerEngineClient, UnsealedPayloadHandle, + ScheduledTicker, SealState, SealStepError, SealStepOutcome, SequencerActor, + SequencerActorError, SequencerAdminQuery, SequencerConfig, SequencerEngineClient, + UnsealedPayloadHandle, }; #[cfg(test)] pub use sequencer::{MockConductor, MockOriginSelector, MockSequencerEngineClient}; diff --git a/crates/consensus/service/src/actors/rpc/actor.rs b/crates/consensus/service/src/actors/rpc/actor.rs index 533f751145..cf71231cc8 100644 --- a/crates/consensus/service/src/actors/rpc/actor.rs +++ b/crates/consensus/service/src/actors/rpc/actor.rs @@ -64,7 +64,7 @@ impl CancellableContext for RpcContext { /// ## Errors /// /// - [`std::io::Error`] if the server fails to start. -async fn launch( +pub(crate) async fn launch_rpc_server( config: &RpcBuilder, module: RpcModule<()>, ) -> Result { @@ -144,12 +144,12 @@ where let restarts = self.config.restart_count(); - let mut handle = launch(&self.config, modules.clone()).await?; + let mut handle = launch_rpc_server(&self.config, modules.clone()).await?; for _ in 0..=restarts { tokio::select! { _ = handle.clone().stopped() => { - match launch(&self.config, modules.clone()).await { + match launch_rpc_server(&self.config, modules.clone()).await { Ok(h) => handle = h, Err(err) => { error!(target: "rpc", ?err, "Failed to launch rpc server"); @@ -191,7 +191,7 @@ mod tests { http_timeout: Duration::from_secs(60), max_concurrent_requests: NonZeroUsize::new(1024).expect("nonzero"), }; - let result = launch(&launcher, RpcModule::new(())).await; + let result = launch_rpc_server(&launcher, RpcModule::new(())).await; assert!(result.is_ok()); } @@ -213,7 +213,7 @@ mod tests { modules.merge(RpcModule::new(())).expect("module merge"); modules.merge(RpcModule::new(())).expect("module merge"); - let result = launch(&launcher, modules).await; + let result = launch_rpc_server(&launcher, modules).await; assert!(result.is_ok()); } } diff --git a/crates/consensus/service/src/actors/rpc/mod.rs b/crates/consensus/service/src/actors/rpc/mod.rs index 9408117e33..3204f17a35 100644 --- a/crates/consensus/service/src/actors/rpc/mod.rs +++ b/crates/consensus/service/src/actors/rpc/mod.rs @@ -1,6 +1,7 @@ //! RPC actor and engine/sequencer RPC client wrappers. mod actor; +pub(crate) use actor::launch_rpc_server; pub use actor::{RpcActor, RpcContext}; mod engine_rpc_client; diff --git a/crates/consensus/service/src/actors/sequencer/actor.rs b/crates/consensus/service/src/actors/sequencer/actor.rs index da02877340..0ef737e698 100644 --- a/crates/consensus/service/src/actors/sequencer/actor.rs +++ b/crates/consensus/service/src/actors/sequencer/actor.rs @@ -10,7 +10,6 @@ use async_trait::async_trait; use base_common_genesis::RollupConfig; use base_consensus_derive::AttributesBuilder; use base_consensus_rpc::SequencerAdminAPIError; -use base_protocol::L2BlockInfo; use tokio::{ select, sync::{mpsc, oneshot}, @@ -29,7 +28,7 @@ use crate::{ error::SequencerActorError, origin_selector::OriginSelector, recovery::RecoveryModeGuard, - seal::PayloadSealer, + seal::{PayloadSealer, SealStepOutcome}, }, }, }; @@ -66,14 +65,6 @@ pub struct SequencerActor< pub engine_client: Arc, /// Whether the sequencer is active. pub is_active: bool, - /// Expected [`L2BlockInfo`] parent for the next build. - /// - /// Set in the ticker arm when a seal succeeds (derived from the sealed envelope). Consumed - /// in the `Ok(true)` sealer arm via [`PayloadBuilder::build_on`], which is called after - /// `insert_unsafe_payload` has already been fire-and-forgot to the engine. This ordering - /// guarantees the engine's `InsertTask` is queued before `BuildTask`, so the EL always - /// builds on the correct (just-inserted) parent instead of the stale watch value. - pub next_build_parent: Option, /// Shared recovery mode flag. pub recovery_mode: RecoveryModeGuard, /// The rollup configuration. @@ -318,7 +309,7 @@ where } } => { match result { - Ok(true) => { + Ok(SealStepOutcome::Inserted(inserted_head)) => { if let Some(sealer) = self.sealer.take() { Metrics::sequencer_seal_pipeline_duration() .record(sealer.started_at.elapsed()); @@ -339,32 +330,14 @@ where warn!(target: "sequencer", "Failed to send deferred stop_sequencer response"); } } - // Build the next payload on the correct parent now that - // insert_unsafe_payload has already been fire-and-forgot to the engine. - // next_build_parent was computed from the sealed envelope in the ticker - // arm; using it here ensures InsertTask is enqueued before BuildTask so - // the EL builds on the just-inserted block instead of its grandparent. if self.is_active { - next_payload_to_seal = - if let Some(parent) = self.next_build_parent.take() { - let result = self.builder.build_on(parent).await?; - // If the build returned None (the just-inserted parent block - // is not yet indexed by the L2 provider — insert_unsafe_payload - // is fire-and-forgot), restore next_build_parent so the - // immediate ticker retry uses build_on with the known correct - // parent rather than the potentially stale watch head, which - // could cause the wrong block to be built. - if result.is_none() { - self.next_build_parent = Some(parent); - build_ticker.reset_immediately(); - } - result - } else { - self.builder.build().await? - }; + next_payload_to_seal = self.builder.build_on(inserted_head).await?; + if next_payload_to_seal.is_none() { + build_ticker.reset_immediately(); + } } } - Ok(false) => {} + Ok(SealStepOutcome::Pending) => {} Err(err) => { let step = self.sealer.as_ref().map(|s| s.state.label()).unwrap_or("unknown"); warn!(target: "sequencer", error = ?err, step, "Seal step failed, will retry"); @@ -380,11 +353,6 @@ where _ = build_ticker.tick(), if self.is_active && self.sealer.is_none() => { if let Some(handle) = next_payload_to_seal.take() { // Extract data needed after try_seal_handle consumes the handle. - let parent_beacon_root = handle - .attributes_with_parent - .attributes() - .payload_attributes - .parent_beacon_block_root; let handle_timestamp = handle .attributes_with_parent .attributes() @@ -393,25 +361,6 @@ where match self.try_seal_handle(handle).await? { Some((new_sealer, dur)) => { last_seal_duration = dur; - // Stash the expected parent for the next build. This is consumed - // in the Ok(true) arm after insert_unsafe_payload is queued, - // ensuring BuildTask is enqueued after InsertTask in the engine. - self.next_build_parent = match L2BlockInfo::from_payload_and_genesis( - new_sealer.envelope.execution_payload.clone(), - parent_beacon_root, - &self.rollup_config.genesis, - ) { - Ok(parent) => Some(parent), - Err(err) => { - warn!( - target: "sequencer", - error = ?err, - "Failed to derive L2BlockInfo from sealed payload; \ - next build will fall back to unsafe head watch channel" - ); - None - } - }; self.sealer = Some(new_sealer); // Schedule the next tick for the next block's target seal time. // Use the just-sealed block's timestamp; the next block's @@ -422,9 +371,8 @@ where + Duration::from_secs(next_block_seconds) - last_seal_duration; build_ticker.reset_at(next_block_time); - // Do not call build() here. The next payload is built in the - // Ok(true) arm after insert_unsafe_payload has been queued, - // so InsertTask always precedes BuildTask in the engine queue. + // Do not call build() here. The next payload is built after the + // engine acknowledges insertion of the sealed payload. } None => { // Stale build or non-fatal seal error: rebuild immediately on @@ -447,22 +395,7 @@ where } } } else { - // No pre-built payload: bootstrap on first tick, or retry after the - // Ok(true) arm's build_on failed due to the parent block not yet being - // indexed (insert_unsafe_payload is fire-and-forgot). If next_build_parent - // is set, use build_on with the known correct parent rather than reading - // the potentially stale watch head, which could cause the wrong block to - // be built. On failure restore next_build_parent and reset_immediately so - // we retry as soon as the engine indexes the block. - next_payload_to_seal = if let Some(parent) = self.next_build_parent.take() { - let result = self.builder.build_on(parent).await?; - if result.is_none() { - self.next_build_parent = Some(parent); - } - result - } else { - self.builder.build().await? - }; + next_payload_to_seal = self.builder.build().await?; if let Some(ref payload) = next_payload_to_seal { let next_block_seconds = payload .attributes_with_parent diff --git a/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs b/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs index d48e5b4353..7384dee139 100644 --- a/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs +++ b/crates/consensus/service/src/actors/sequencer/admin_api_impl.rs @@ -180,8 +180,8 @@ where /// Stops the sequencer. If a seal pipeline is in-flight, the response is deferred /// until the pipeline completes so the returned hash reflects the fully inserted head. /// - /// Any pre-built payload and stashed `next_build_parent` are discarded so that a subsequent - /// restart always builds on a fresh, accurate head rather than a potentially stale one. + /// Any pre-built payload is discarded so that a subsequent restart always builds on a fresh, + /// accurate head. pub(super) async fn stop_sequencer( &mut self, next_payload: &mut Option, @@ -189,10 +189,9 @@ where ) { info!(target: "sequencer", "Stopping sequencer"); self.is_active = false; - // Discard any pre-built payload and stashed parent so a subsequent start_sequencer - // always builds on a fresh, accurate head rather than a potentially stale one. + // Discard any pre-built payload so a subsequent start_sequencer always builds on a fresh, + // accurate head. next_payload.take(); - self.next_build_parent = None; self.update_metrics(); if self.sealer.is_some() { diff --git a/crates/consensus/service/src/actors/sequencer/build.rs b/crates/consensus/service/src/actors/sequencer/build.rs index 753e69f436..3979cc9493 100644 --- a/crates/consensus/service/src/actors/sequencer/build.rs +++ b/crates/consensus/service/src/actors/sequencer/build.rs @@ -66,9 +66,8 @@ impl PayloadB /// Starts building the next L2 block on top of an explicit `parent`, returning a handle to /// the in-flight payload. /// - /// Unlike [`Self::build`], this bypasses the watch channel and uses the provided - /// `parent` directly. Call this when the correct parent is already known (e.g., the - /// block just sealed) to avoid racing against the engine's internal state update. + /// Use this when the caller already knows the correct parent, such as after an acknowledged + /// local insert. That avoids racing the unsafe-head watch channel publication path. /// /// Returns `Ok(None)` for temporary or reset conditions that should be retried on the /// next tick. diff --git a/crates/consensus/service/src/actors/sequencer/conductor.rs b/crates/consensus/service/src/actors/sequencer/conductor.rs index 8aa62fe8c1..7cf4658068 100644 --- a/crates/consensus/service/src/actors/sequencer/conductor.rs +++ b/crates/consensus/service/src/actors/sequencer/conductor.rs @@ -1,4 +1,4 @@ -use std::fmt::Debug; +use std::{fmt::Debug, time::Duration}; use async_trait::async_trait; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; @@ -7,8 +7,16 @@ use jsonrpsee::{ core::ClientError, http_client::{HttpClient, HttpClientBuilder}, }; +use ssz::Encode; use url::Url; +/// HTTP route on the conductor that accepts SSZ-encoded payload envelopes. +/// Mirrors `CommitUnsafePayloadPath` in op-conductor. +const COMMIT_UNSAFE_PAYLOAD_PATH: &str = "/commit-unsafe-payload"; + +/// Content-Type the conductor expects on the binary commit endpoint. +const SSZ_CONTENT_TYPE: &str = "application/octet-stream"; + /// Trait for interacting with the conductor service. /// /// The conductor service is responsible for coordinating sequencer behavior @@ -33,10 +41,17 @@ pub trait Conductor: Debug + Send + Sync { } /// A client for communicating with the conductor service via RPC. +/// +/// Always uses jsonrpsee for `leader`, `active`, and `override_leader`. For +/// `commit_unsafe_payload`, dispatches to the SSZ-binary endpoint when +/// `binary_commit` is set on construction; otherwise uses the JSON-RPC method. #[derive(Debug, Clone)] pub struct ConductorClient { - /// The inner HTTP client. + /// The inner JSON-RPC HTTP client. inner: HttpClient, + /// The reqwest client + endpoint URL for the binary commit path. `None` + /// means use JSON-RPC for commits. + binary: Option, } #[async_trait] @@ -53,6 +68,9 @@ impl Conductor for ConductorClient { &self, payload: &BaseExecutionPayloadEnvelope, ) -> Result<(), ConductorError> { + if let Some(bin) = &self.binary { + return bin.commit_unsafe_payload(payload).await; + } Ok(self.inner.conductor_commit_unsafe_payload(payload.clone()).await?) } @@ -62,10 +80,70 @@ impl Conductor for ConductorClient { } impl ConductorClient { - /// Creates a new conductor client using HTTP transport. - pub fn new_http(url: Url) -> Result { - let inner = HttpClientBuilder::default().build(url)?; - Ok(Self { inner }) + /// Creates a new conductor client using HTTP transport (JSON-RPC for all + /// methods). + pub fn new_http(url: Url, timeout: Duration) -> Result { + let inner = HttpClientBuilder::default().request_timeout(timeout).build(url)?; + Ok(Self { inner, binary: None }) + } + + /// Creates a new conductor client where `commit_unsafe_payload` uses the + /// SSZ-binary endpoint at `/commit-unsafe-payload` and the other RPCs + /// stay on JSON-RPC. The conductor must be running with the binary + /// endpoint enabled. + pub fn new_http_with_binary_commit( + url: Url, + timeout: Duration, + ) -> Result { + let inner = HttpClientBuilder::default().request_timeout(timeout).build(url.clone())?; + let binary = BinaryCommitClient::new(url, timeout)?; + Ok(Self { inner, binary: Some(binary) }) + } +} + +/// Thin reqwest wrapper for the conductor's SSZ-binary commit endpoint. +/// +/// Wire format (matches op-conductor `BinaryCommitHandler`): +/// ```text +/// POST /commit-unsafe-payload +/// Content-Type: application/octet-stream +/// Body: SSZ-encoded BaseExecutionPayloadEnvelope (raw bytes, no length +/// prefix; for V3+ payloads the parent_beacon_block_root is the first +/// 32 bytes per ``). +/// ``` +/// Returns `Ok(())` on 200, `ConductorError::BinaryRejected` on non-success status codes, +/// or `ConductorError::BinaryRequest` on transport failures. +#[derive(Debug, Clone)] +struct BinaryCommitClient { + http: reqwest::Client, + endpoint: Url, +} + +impl BinaryCommitClient { + fn new(base_url: Url, timeout: Duration) -> Result { + let endpoint = base_url.join(COMMIT_UNSAFE_PAYLOAD_PATH)?; + let http = reqwest::Client::builder().timeout(timeout).build()?; + Ok(Self { http, endpoint }) + } + + async fn commit_unsafe_payload( + &self, + payload: &BaseExecutionPayloadEnvelope, + ) -> Result<(), ConductorError> { + let body = payload.as_ssz_bytes(); + let resp = self + .http + .post(self.endpoint.clone()) + .header(reqwest::header::CONTENT_TYPE, SSZ_CONTENT_TYPE) + .body(body) + .send() + .await?; + if resp.status().is_success() { + return Ok(()); + } + let status = resp.status(); + let body = resp.text().await.unwrap_or_default().trim().to_string(); + Err(ConductorError::BinaryRejected { status, body }) } } @@ -78,4 +156,19 @@ pub enum ConductorError { /// The conductor rejected the payload because this node is not the leader. #[error("not the conductor leader")] NotLeader, + /// A transport-level error on the binary commit endpoint (connection refused, timeout, TLS, + /// client construction failure, etc.). + #[error("binary commit request failed")] + BinaryRequest(#[from] reqwest::Error), + /// The conductor's binary commit endpoint returned a non-success HTTP status. + #[error("binary commit rejected: {status}")] + BinaryRejected { + /// HTTP status code returned by the conductor. + status: reqwest::StatusCode, + /// Response body, typically an error message from the conductor. + body: String, + }, + /// The conductor URL could not be parsed into a valid endpoint. + #[error("invalid conductor url: {0}")] + InvalidUrl(#[from] url::ParseError), } diff --git a/crates/consensus/service/src/actors/sequencer/config.rs b/crates/consensus/service/src/actors/sequencer/config.rs index ae171ee139..35b5887147 100644 --- a/crates/consensus/service/src/actors/sequencer/config.rs +++ b/crates/consensus/service/src/actors/sequencer/config.rs @@ -2,12 +2,17 @@ //! //! [`SequencerActor`]: super::SequencerActor +use std::time::Duration; + use url::Url; +/// Default conductor RPC timeout (1 second), matching the CLI default. +const DEFAULT_CONDUCTOR_RPC_TIMEOUT: Duration = Duration::from_secs(1); + /// Configuration for the [`SequencerActor`]. /// /// [`SequencerActor`]: super::SequencerActor -#[derive(Default, Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SequencerConfig { /// Whether or not the sequencer is enabled at startup. pub sequencer_stopped: bool, @@ -15,6 +20,30 @@ pub struct SequencerConfig { pub sequencer_recovery_mode: bool, /// The [`Url`] for the conductor RPC endpoint. If [`Some`], enables the conductor service. pub conductor_rpc_url: Option, + /// Use the conductor's SSZ-binary commit endpoint (`POST /commit-unsafe-payload`) + /// instead of the JSON-RPC `conductor_commitUnsafePayload` method. Avoids the + /// JSON encode/decode round trip on the leader's RPC handler — ~6–11x faster + /// commit latency for typical mainnet payloads, and a prerequisite for blocks + /// larger than the conductor's 5 `MiB` JSON-RPC body limit. + /// + /// Requires conductor with binary endpoint support + /// (). + pub conductor_binary_commit: bool, + /// Request timeout for conductor RPC calls (both JSON-RPC and binary commit). + pub conductor_rpc_timeout: Duration, /// The confirmation delay for the sequencer. pub l1_conf_delay: u64, } + +impl Default for SequencerConfig { + fn default() -> Self { + Self { + sequencer_stopped: false, + sequencer_recovery_mode: false, + conductor_rpc_url: None, + conductor_binary_commit: false, + conductor_rpc_timeout: DEFAULT_CONDUCTOR_RPC_TIMEOUT, + l1_conf_delay: 0, + } + } +} diff --git a/crates/consensus/service/src/actors/sequencer/engine_client.rs b/crates/consensus/service/src/actors/sequencer/engine_client.rs index 8ad58d79e9..ac195aa88d 100644 --- a/crates/consensus/service/src/actors/sequencer/engine_client.rs +++ b/crates/consensus/service/src/actors/sequencer/engine_client.rs @@ -9,7 +9,10 @@ use tokio::sync::{mpsc, watch}; use crate::{ EngineClientError, EngineClientResult, - actors::engine::{BuildRequest, EngineActorRequest, GetPayloadRequest, ResetRequest}, + actors::engine::{ + BuildRequest, EngineActorRequest, GetPayloadRequest, InsertUnsafePayloadRequest, + ResetRequest, + }, }; /// Trait to be used by the Sequencer to interact with the engine, abstracting communication @@ -37,12 +40,12 @@ pub trait SequencerEngineClient: Debug + Send + Sync { attributes: AttributesWithParent, ) -> EngineClientResult; - /// Fire-and-forget: submits the sealed payload to the engine for insertion (`new_payload` + FCU). - /// Call this after a successful conductor commit. + /// Submits the sealed payload to the engine for insertion (`new_payload` + FCU), returning the + /// inserted unsafe head after the engine acknowledges insertion. async fn insert_unsafe_payload( &self, payload: BaseExecutionPayloadEnvelope, - ) -> EngineClientResult<()>; + ) -> EngineClientResult; /// Returns the current unsafe head [`L2BlockInfo`]. async fn get_unsafe_head(&self) -> EngineClientResult; @@ -77,7 +80,7 @@ impl SequencerEngineClient for Arc { async fn insert_unsafe_payload( &self, payload: BaseExecutionPayloadEnvelope, - ) -> EngineClientResult<()> { + ) -> EngineClientResult { (**self).insert_unsafe_payload(payload).await } @@ -140,13 +143,20 @@ impl SequencerEngineClient for QueuedSequencerEngineClient { return Err(EngineClientError::RequestError("request channel closed.".to_string())); } - payload_id_rx.recv() - .await - .inspect(|payload_id| trace!(target: "sequencer", ?payload_id, "Start build request successfully.")) - .ok_or_else(|| { - error!(target: "block_engine", "Failed to receive payload for initiated block build"); - EngineClientError::ResponseError("response channel closed.".to_string()) - }) + match payload_id_rx.recv().await { + Some(Ok(payload_id)) => { + trace!(target: "sequencer", ?payload_id, "Start build request successfully."); + Ok(payload_id) + } + Some(Err(err)) => { + info!(target: "sequencer", ?err, "Start build request failed."); + Err(EngineClientError::StartBuildError(err)) + } + None => { + error!(target: "block_engine", "Failed to receive payload for initiated block build"); + Err(EngineClientError::ResponseError("response channel closed.".to_string())) + } + } } async fn get_sealed_payload( @@ -185,11 +195,101 @@ impl SequencerEngineClient for QueuedSequencerEngineClient { async fn insert_unsafe_payload( &self, payload: BaseExecutionPayloadEnvelope, - ) -> EngineClientResult<()> { + ) -> EngineClientResult { + let (result_tx, mut result_rx) = mpsc::channel(1); + trace!(target: "sequencer", "Sending insert unsafe payload request to engine."); self.engine_actor_request_tx - .send(EngineActorRequest::ProcessLocalUnsafeL2BlockRequest(Box::new(payload))) + .send(EngineActorRequest::ProcessLocalUnsafeL2BlockRequest(Box::new( + InsertUnsafePayloadRequest { envelope: payload, result_tx: Some(result_tx) }, + ))) .await - .map_err(|_| EngineClientError::RequestError("request channel closed.".to_string())) + .map_err(|_| EngineClientError::RequestError("request channel closed.".to_string()))?; + + let inserted_head = match result_rx.recv().await { + Some(Ok(inserted_head)) => inserted_head, + Some(Err(err)) => { + info!(target: "sequencer", error = ?err, "Insert unsafe payload failed"); + return Err(EngineClientError::InsertError(err)); + } + None => { + error!(target: "block_engine", "Failed to receive insert unsafe payload result"); + return Err(EngineClientError::ResponseError( + "response channel closed.".to_string(), + )); + } + }; + + trace!( + target: "sequencer", + block_number = inserted_head.block_info.number, + block_hash = %inserted_head.block_info.hash, + "Insert unsafe payload acknowledged" + ); + + Ok(inserted_head) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Address, B256, Bloom, U256}; + use alloy_rpc_types_engine::ExecutionPayloadV1; + use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; + use base_protocol::{BlockInfo, L2BlockInfo}; + use tokio::sync::{mpsc, watch}; + + use super::{QueuedSequencerEngineClient, SequencerEngineClient}; + use crate::EngineActorRequest; + + fn dummy_envelope() -> BaseExecutionPayloadEnvelope { + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + prev_randao: B256::ZERO, + block_number: 1, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 1, + extra_data: Default::default(), + base_fee_per_gas: U256::ZERO, + block_hash: B256::with_last_byte(1), + transactions: vec![], + }), + } + } + + fn l2_head(number: u64) -> L2BlockInfo { + L2BlockInfo { + block_info: BlockInfo::new(B256::with_last_byte(number as u8), number, B256::ZERO, 1), + ..Default::default() + } + } + + #[tokio::test] + async fn insert_unsafe_payload_returns_engine_ack() { + let (request_tx, mut request_rx) = mpsc::channel(1); + let (_, unsafe_head_rx) = watch::channel(L2BlockInfo::default()); + let inserted_head = l2_head(1); + let client = QueuedSequencerEngineClient::new(request_tx, unsafe_head_rx); + + let insert_handle = + tokio::spawn(async move { client.insert_unsafe_payload(dummy_envelope()).await }); + + let request = request_rx.recv().await.expect("insert request"); + let EngineActorRequest::ProcessLocalUnsafeL2BlockRequest(request) = request else { + panic!("expected local unsafe insert request"); + }; + let result_tx = request.result_tx.expect("insert result sender"); + result_tx.send(Ok(inserted_head)).await.expect("send insert result"); + + let result = insert_handle.await.expect("insert task"); + + assert_eq!(result.expect("insert result"), inserted_head); } } diff --git a/crates/consensus/service/src/actors/sequencer/mod.rs b/crates/consensus/service/src/actors/sequencer/mod.rs index 3e59ca81fc..265de4254f 100644 --- a/crates/consensus/service/src/actors/sequencer/mod.rs +++ b/crates/consensus/service/src/actors/sequencer/mod.rs @@ -18,7 +18,7 @@ mod recovery; pub use recovery::RecoveryModeGuard; mod seal; -pub use seal::{PayloadSealer, SealState, SealStepError}; +pub use seal::{PayloadSealer, SealState, SealStepError, SealStepOutcome}; mod ticker; pub use ticker::ScheduledTicker; diff --git a/crates/consensus/service/src/actors/sequencer/seal.rs b/crates/consensus/service/src/actors/sequencer/seal.rs index 7099d26a8e..f734002485 100644 --- a/crates/consensus/service/src/actors/sequencer/seal.rs +++ b/crates/consensus/service/src/actors/sequencer/seal.rs @@ -6,6 +6,7 @@ use std::time::Instant; use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; +use base_protocol::L2BlockInfo; use crate::{ Metrics, UnsafePayloadGossipClient, @@ -23,6 +24,15 @@ pub enum SealState { Gossiped, } +/// Result from one seal pipeline step. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SealStepOutcome { + /// The current step completed and the pipeline has more work to do. + Pending, + /// The sealed payload has been inserted and acknowledged by the engine. + Inserted(L2BlockInfo), +} + impl SealState { /// Returns a static label string for metrics (matches the metric label values). pub const fn label(&self) -> &'static str { @@ -40,7 +50,7 @@ impl SealState { /// based on the current [`SealState`]. On success the state advances; on /// failure the state is unchanged so the same step is retried on the next call. /// -/// Once insertion succeeds, `step` returns `Ok(true)` and the caller should +/// Once insertion succeeds, `step` returns [`SealStepOutcome::Inserted`] and the caller should /// remove the sealer (the pipeline is complete). #[derive(Debug)] pub struct PayloadSealer { @@ -62,15 +72,15 @@ impl PayloadSealer { /// Performs one step of the seal pipeline. /// - /// Returns `Ok(true)` when the pipeline is complete (payload inserted). - /// Returns `Ok(false)` when the step succeeded but more steps remain. + /// Returns [`SealStepOutcome::Inserted`] when the pipeline is complete. + /// Returns [`SealStepOutcome::Pending`] when the step succeeded but more steps remain. /// Returns `Err` when the step failed — state is unchanged for retry. pub async fn step( &mut self, conductor: &Option, gossip_client: &G, engine_client: &E, - ) -> Result + ) -> Result where C: Conductor, G: UnsafePayloadGossipClient, @@ -88,7 +98,7 @@ impl PayloadSealer { .map_err(SealStepError::Conductor)?; } self.state = SealState::Committed; - Ok(false) + Ok(SealStepOutcome::Pending) } SealState::Committed => { gossip_client @@ -96,14 +106,14 @@ impl PayloadSealer { .await .map_err(SealStepError::Gossip)?; self.state = SealState::Gossiped; - Ok(false) + Ok(SealStepOutcome::Pending) } SealState::Gossiped => { - engine_client + let inserted_head = engine_client .insert_unsafe_payload(self.envelope.clone()) .await .map_err(SealStepError::Insert)?; - Ok(true) + Ok(SealStepOutcome::Inserted(inserted_head)) } }; diff --git a/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs b/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs index 90b081757b..7ba3319641 100644 --- a/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs +++ b/crates/consensus/service/src/actors/sequencer/tests/actor_test.rs @@ -12,8 +12,8 @@ use jsonrpsee::core::ClientError; use rstest::rstest; use crate::{ - ConductorError, SealState, SealStepError, SequencerActorError, UnsafePayloadGossipClientError, - UnsealedPayloadHandle, + ConductorError, SealState, SealStepError, SealStepOutcome, SequencerActorError, + UnsafePayloadGossipClientError, UnsealedPayloadHandle, actors::{ MockConductor, MockOriginSelector, MockSequencerEngineClient, MockUnsafePayloadGossipClient, @@ -288,7 +288,7 @@ async fn test_sealer_full_pipeline_no_conductor() { gossip.expect_schedule_execution_payload_gossip().times(1).return_once(|_| Ok(())); let mut engine = MockSequencerEngineClient::new(); - engine.expect_insert_unsafe_payload().times(1).return_once(|_| Ok(())); + engine.expect_insert_unsafe_payload().times(1).return_once(|_| Ok(L2BlockInfo::default())); let conductor: Option = None; let mut sealer = PayloadSealer::new(envelope); @@ -296,15 +296,15 @@ async fn test_sealer_full_pipeline_no_conductor() { assert_eq!(sealer.state, SealState::Sealed); let result = sealer.step(&conductor, &gossip, &engine).await; - assert!(!result.unwrap()); + assert_eq!(result.unwrap(), SealStepOutcome::Pending); assert_eq!(sealer.state, SealState::Committed); let result = sealer.step(&conductor, &gossip, &engine).await; - assert!(!result.unwrap()); + assert_eq!(result.unwrap(), SealStepOutcome::Pending); assert_eq!(sealer.state, SealState::Gossiped); let result = sealer.step(&conductor, &gossip, &engine).await; - assert!(result.unwrap()); + assert_eq!(result.unwrap(), SealStepOutcome::Inserted(L2BlockInfo::default())); } #[tokio::test] @@ -318,21 +318,21 @@ async fn test_sealer_full_pipeline_with_conductor() { gossip.expect_schedule_execution_payload_gossip().times(1).return_once(|_| Ok(())); let mut engine = MockSequencerEngineClient::new(); - engine.expect_insert_unsafe_payload().times(1).return_once(|_| Ok(())); + engine.expect_insert_unsafe_payload().times(1).return_once(|_| Ok(L2BlockInfo::default())); let conductor = Some(conductor); let mut sealer = PayloadSealer::new(envelope); let result = sealer.step(&conductor, &gossip, &engine).await; - assert!(!result.unwrap()); + assert_eq!(result.unwrap(), SealStepOutcome::Pending); assert_eq!(sealer.state, SealState::Committed); let result = sealer.step(&conductor, &gossip, &engine).await; - assert!(!result.unwrap()); + assert_eq!(result.unwrap(), SealStepOutcome::Pending); assert_eq!(sealer.state, SealState::Gossiped); let result = sealer.step(&conductor, &gossip, &engine).await; - assert!(result.unwrap()); + assert_eq!(result.unwrap(), SealStepOutcome::Inserted(L2BlockInfo::default())); } #[tokio::test] diff --git a/crates/consensus/service/src/actors/sequencer/tests/test_util.rs b/crates/consensus/service/src/actors/sequencer/tests/test_util.rs index cb11bb09b0..5bb8bf175c 100644 --- a/crates/consensus/service/src/actors/sequencer/tests/test_util.rs +++ b/crates/consensus/service/src/actors/sequencer/tests/test_util.rs @@ -46,6 +46,5 @@ pub(super) fn test_actor() -> SequencerActor< unsafe_payload_gossip_client: MockUnsafePayloadGossipClient::new(), sealer: None, pending_stop: None, - next_build_parent: None, } } diff --git a/crates/consensus/service/src/follow/engine.rs b/crates/consensus/service/src/follow/engine.rs new file mode 100644 index 0000000000..e3ebdb001d --- /dev/null +++ b/crates/consensus/service/src/follow/engine.rs @@ -0,0 +1,200 @@ +use std::{fmt::Debug, sync::Arc}; + +use async_trait::async_trait; +use base_common_genesis::RollupConfig; +use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; +use base_consensus_engine::{ + EngineClient, EngineState, EngineSyncStateUpdate, EngineTask, EngineTaskExt, InsertTask, + SynchronizeTask, +}; +use base_protocol::L2BlockInfo; +use tokio::sync::Mutex; + +use crate::follow::error::FollowError; + +#[async_trait] +pub(super) trait FollowEngine: Debug + Send + Sync { + async fn insert_payload( + &self, + envelope: BaseExecutionPayloadEnvelope, + ) -> Result<(), FollowError>; + + async fn update_safe_finalized_blocks( + &self, + safe: Option, + finalized: Option, + ) -> Result<(), FollowError>; +} + +#[derive(Debug)] +pub(super) struct EngineApiFollowEngine { + client: Arc, + rollup_config: Arc, + state: Mutex, +} + +impl EngineApiFollowEngine { + pub(super) fn new( + client: Arc, + rollup_config: Arc, + latest: L2BlockInfo, + safe: L2BlockInfo, + finalized: L2BlockInfo, + ) -> Self { + let mut state = EngineState::default(); + state.sync_state = state.sync_state.apply_update(EngineSyncStateUpdate { + unsafe_head: Some(latest), + local_safe_head: Some(safe), + safe_head: Some(safe), + finalized_head: Some(finalized), + }); + Self { client, rollup_config, state: Mutex::new(state) } + } +} + +#[async_trait] +impl FollowEngine for EngineApiFollowEngine { + async fn insert_payload( + &self, + envelope: BaseExecutionPayloadEnvelope, + ) -> Result<(), FollowError> { + let task = InsertTask::unsafe_payload( + Arc::clone(&self.client), + Arc::clone(&self.rollup_config), + envelope, + ); + EngineTask::Insert(Box::new(task)) + .execute(&mut *self.state.lock().await) + .await + .map_err(FollowError::engine_task) + } + + async fn update_safe_finalized_blocks( + &self, + safe: Option, + finalized: Option, + ) -> Result<(), FollowError> { + if safe.is_none() && finalized.is_none() { + return Ok(()); + } + + let task = SynchronizeTask::new( + Arc::clone(&self.client), + Arc::clone(&self.rollup_config), + EngineSyncStateUpdate { + local_safe_head: safe, + safe_head: safe, + finalized_head: finalized, + ..Default::default() + }, + ); + task.execute(&mut *self.state.lock().await).await.map_err(FollowError::engine_task) + } +} + +#[cfg(test)] +mod tests { + use std::{sync::Arc, time::Duration}; + + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Address, B256, Bloom, U256}; + use alloy_rpc_types_engine::{ + ExecutionPayloadV1, ForkchoiceUpdated, PayloadStatus, PayloadStatusEnum, + }; + use base_common_consensus::{BaseTxEnvelope, TxDeposit}; + use base_common_genesis::RollupConfig; + use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; + use base_consensus_engine::test_utils::test_engine_client_builder; + use base_protocol::{BlockInfo, L1BlockInfoBedrock, L2BlockInfo}; + use tokio::time::{self, Instant}; + + use super::{EngineApiFollowEngine, FollowEngine}; + + fn valid_payload_status() -> PayloadStatus { + PayloadStatus { status: PayloadStatusEnum::Valid, latest_valid_hash: Some(B256::ZERO) } + } + + fn valid_forkchoice_updated() -> ForkchoiceUpdated { + ForkchoiceUpdated { payload_status: valid_payload_status(), payload_id: None } + } + + fn l1_info_deposit_tx() -> Vec { + BaseTxEnvelope::from(TxDeposit { + input: L1BlockInfoBedrock::default().encode_calldata(), + ..Default::default() + }) + .encoded_2718() + } + + fn l2_block_info(number: u64) -> L2BlockInfo { + L2BlockInfo { + block_info: BlockInfo { + hash: B256::with_last_byte(number as u8), + number, + ..Default::default() + }, + ..Default::default() + } + } + + fn payload(number: u64) -> BaseExecutionPayloadEnvelope { + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + parent_hash: B256::with_last_byte(number.saturating_sub(1) as u8), + fee_recipient: Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + prev_randao: B256::ZERO, + block_number: number, + gas_limit: 30_000_000, + gas_used: 0, + timestamp: 1, + extra_data: Default::default(), + base_fee_per_gas: U256::ZERO, + block_hash: B256::with_last_byte(number as u8), + transactions: vec![l1_info_deposit_tx().into()], + }), + } + } + + #[tokio::test] + async fn insert_payload_retries_temporary_engine_errors() { + let rollup_config = Arc::new(RollupConfig::default()); + let client = Arc::new( + test_engine_client_builder() + .with_config(Arc::clone(&rollup_config)) + .with_fork_choice_updated_v3_response(valid_forkchoice_updated()) + .build(), + ); + let genesis = l2_block_info(0); + let engine = Arc::new(EngineApiFollowEngine::new( + Arc::clone(&client), + rollup_config, + genesis, + genesis, + genesis, + )); + + let insert_engine = Arc::clone(&engine); + let insert = tokio::spawn(async move { insert_engine.insert_payload(payload(1)).await }); + + let deadline = Instant::now() + Duration::from_secs(1); + while client.last_new_payload_v2().await.is_none() && Instant::now() < deadline { + time::sleep(Duration::from_millis(10)).await; + } + assert!( + client.last_new_payload_v2().await.is_some(), + "follow insert should attempt engine_newPayload before retrying" + ); + + client.set_new_payload_v2_response(valid_payload_status()).await; + + time::timeout(Duration::from_secs(1), insert) + .await + .expect("insert should finish after temporary error clears") + .expect("insert task should not panic") + .expect("temporary engine error should be retried"); + } +} diff --git a/crates/consensus/service/src/follow/error.rs b/crates/consensus/service/src/follow/error.rs new file mode 100644 index 0000000000..d283467952 --- /dev/null +++ b/crates/consensus/service/src/follow/error.rs @@ -0,0 +1,97 @@ +use alloy_eips::BlockNumberOrTag; +use alloy_primitives::B256; +use base_consensus_engine::{EngineTaskError, EngineTaskErrorSeverity}; +use base_protocol::FromBlockError; +use thiserror::Error; + +use crate::follow::source::RemoteL2ClientError; + +/// Error returned by follow-mode runtime, client, engine, and RPC operations. +#[derive(Debug, Error)] +pub enum FollowError { + /// The local L2 node did not return a block for the requested tag. + #[error("local L2 block unavailable at {0:?}")] + LocalBlockUnavailable(BlockNumberOrTag), + + /// Fetching a block from the local L2 node failed. + #[error("failed to fetch local L2 block at {tag:?}: {source}")] + LocalBlockFetch { + /// Requested local block tag. + tag: BlockNumberOrTag, + /// Underlying transport error. + source: alloy_transport::TransportError, + }, + + /// Converting a local L2 block into block info failed. + #[error("failed to build local L2 block info: {0}")] + LocalBlockInfo(#[from] FromBlockError), + + /// Fetching the local proofs sync status failed. + #[error("failed to fetch proofs sync status: {0}")] + ProofsStatus(alloy_transport::TransportError), + + /// Fetching data from the remote L2 source failed. + #[error(transparent)] + Remote(#[from] RemoteL2ClientError), + + /// The source and local L2 nodes returned different hashes for the same block number. + #[error("source block hash {remote} does not match local block hash {local} at block {number}")] + SourceBlockHashMismatch { + /// Block number compared across the source and local nodes. + number: u64, + /// Hash returned by the local L2 node. + local: B256, + /// Hash returned by the source L2 node. + remote: B256, + }, + + /// The local engine rejected a follow-mode task. + #[error("engine task failed with {severity} severity: {error}")] + EngineTask { + /// Engine task error severity. + severity: EngineTaskErrorSeverity, + /// Engine task error message. + error: String, + }, + + /// Starting or restarting the follow RPC server failed. + #[error("follow RPC server failed: {0}")] + RpcServer(String), + + /// Building the follow RPC module failed. + #[error("follow RPC module failed: {0}")] + RpcModule(String), + + /// Stopping the follow RPC server failed. + #[error("follow RPC server stop failed: {0}")] + RpcStop(String), + + /// The follow RPC server exceeded its restart limit. + #[error("follow RPC server stopped too many times")] + RpcRestartLimit, + + /// The insert loop lost its payload producer. + #[error("blocks-to-insert channel closed")] + BlocksToInsertChannelClosed, + + /// The insert loop received a payload for the wrong block number. + #[error("prefetcher returned block {actual}, expected {expected}")] + OutOfOrderPayload { + /// Payload block number received from the prefetcher. + actual: u64, + /// Block number the insert loop expected next. + expected: u64, + }, + + /// Joining a follow-mode task failed. + #[error("follow task join failed: {0}")] + TaskJoin(#[from] tokio::task::JoinError), +} + +impl FollowError { + /// Builds a follow error from an engine task error while preserving severity. + pub fn engine_task(error: impl EngineTaskError + ToString) -> Self { + let severity = error.severity(); + Self::EngineTask { severity, error: error.to_string() } + } +} diff --git a/crates/consensus/service/src/follow/local.rs b/crates/consensus/service/src/follow/local.rs new file mode 100644 index 0000000000..a782157e37 --- /dev/null +++ b/crates/consensus/service/src/follow/local.rs @@ -0,0 +1,68 @@ +use std::{fmt::Debug, sync::Arc}; + +use alloy_eips::BlockNumberOrTag; +use alloy_provider::{Provider, RootProvider}; +use async_trait::async_trait; +use base_common_genesis::RollupConfig; +use base_common_network::Base; +use base_protocol::L2BlockInfo; +use serde::Deserialize; + +use crate::follow::error::FollowError; + +#[derive(Debug, Deserialize)] +struct ProofsSyncStatus { + latest: Option, +} + +#[cfg_attr(test, mockall::automock)] +#[async_trait] +pub(super) trait FollowLocalClient: Debug + Send + Sync { + async fn block_info(&self, tag: BlockNumberOrTag) -> Result, FollowError>; + + async fn proofs_latest(&self) -> Result, FollowError>; +} + +#[derive(Clone, Debug)] +pub(super) struct LocalL2Client { + provider: RootProvider, + rollup_config: Arc, +} + +impl LocalL2Client { + pub(super) const fn new( + provider: RootProvider, + rollup_config: Arc, + ) -> Self { + Self { provider, rollup_config } + } +} + +#[async_trait] +impl FollowLocalClient for LocalL2Client { + async fn block_info(&self, tag: BlockNumberOrTag) -> Result, FollowError> { + let block = self + .provider + .get_block_by_number(tag) + .full() + .await + .map_err(|source| FollowError::LocalBlockFetch { tag, source })?; + let Some(block) = block else { + return Ok(None); + }; + L2BlockInfo::from_block_and_genesis( + &block.into_consensus().map_transactions(|tx| tx.inner.inner.into_inner()), + &self.rollup_config.genesis, + ) + .map(Some) + .map_err(FollowError::from) + } + + async fn proofs_latest(&self) -> Result, FollowError> { + self.provider + .raw_request::<_, ProofsSyncStatus>("debug_proofsSyncStatus".into(), ()) + .await + .map(|status| status.latest) + .map_err(FollowError::ProofsStatus) + } +} diff --git a/crates/consensus/service/src/follow/mod.rs b/crates/consensus/service/src/follow/mod.rs new file mode 100644 index 0000000000..ca2e10ea8f --- /dev/null +++ b/crates/consensus/service/src/follow/mod.rs @@ -0,0 +1,19 @@ +//! Follow-mode runtime, clients, and RPC surface. + +mod engine; +mod error; +pub use error::FollowError; + +mod local; +mod node; +pub use node::{FollowNode, FollowNodeConfig}; + +mod prefetcher; +mod proof_gate; +mod rpc; +mod runtime; +mod source; + +#[cfg(test)] +pub use source::MockRemoteClient; +pub use source::{RemoteClient, RemoteL2Client, RemoteL2ClientError}; diff --git a/crates/consensus/service/src/follow/node.rs b/crates/consensus/service/src/follow/node.rs new file mode 100644 index 0000000000..10560e8717 --- /dev/null +++ b/crates/consensus/service/src/follow/node.rs @@ -0,0 +1,163 @@ +use std::{fmt::Debug, sync::Arc, time::Duration}; + +use alloy_eips::BlockNumberOrTag; +use alloy_provider::RootProvider; +use base_common_genesis::RollupConfig; +use base_common_network::Base; +use base_consensus_engine::{BaseEngineClient, EngineClient}; +use base_consensus_rpc::RpcBuilder; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +use crate::{ + NodeActor, ShutdownSignal, + follow::{ + engine::EngineApiFollowEngine, + error::FollowError, + local::{FollowLocalClient, LocalL2Client}, + proof_gate::{ActiveProofGate, NoopProofGate, ProofGate}, + rpc::FollowRpcActor, + runtime::FollowRuntime, + source::RemoteL2Client, + }, +}; + +/// A lightweight node that follows another L2 node by fetching source L2 +/// payloads and inserting them into the local execution engine. +#[derive(Debug)] +pub struct FollowNode>> +where + E: EngineClient + Debug + 'static, +{ + config: Arc, + engine_client: Arc, + local_l2_provider: RootProvider, + l2_source: RemoteL2Client, + proofs_enabled: bool, + proofs_max_blocks_ahead: u64, + insert_delay: Duration, + rpc_builder: Option, +} + +/// Runtime dependencies and options for a [`FollowNode`]. +#[derive(Debug)] +pub struct FollowNodeConfig>> +where + E: EngineClient + Debug + 'static, +{ + /// The rollup configuration for the L2 chain. + pub rollup_config: Arc, + /// Client used to insert payloads into the local execution engine. + pub engine_client: Arc, + /// Provider for reading local L2 state. + pub local_l2_provider: RootProvider, + /// Source L2 client used to fetch payloads to follow. + pub l2_source: RemoteL2Client, + /// Optional RPC server configuration. + pub rpc_builder: Option, + /// Whether to gate sync behind proofs progress. + pub proofs_enabled: bool, + /// Maximum blocks the follow node may advance beyond proofs progress. + pub proofs_max_blocks_ahead: u64, + /// Delay after each successful source payload insert. + pub insert_delay: Duration, +} + +impl FollowNode +where + E: EngineClient + Debug + 'static, +{ + /// Creates a new [`FollowNode`]. + pub fn new(config: FollowNodeConfig) -> Self { + Self { + config: config.rollup_config, + engine_client: config.engine_client, + local_l2_provider: config.local_l2_provider, + l2_source: config.l2_source, + rpc_builder: config.rpc_builder, + proofs_enabled: config.proofs_enabled, + proofs_max_blocks_ahead: config.proofs_max_blocks_ahead, + insert_delay: config.insert_delay, + } + } + + /// Starts the follow node. + pub async fn start(&self) -> Result<(), FollowError> { + let cancellation = CancellationToken::new(); + let local = + Arc::new(LocalL2Client::new(self.local_l2_provider.clone(), Arc::clone(&self.config))); + let latest = local + .block_info(BlockNumberOrTag::Latest) + .await? + .ok_or(FollowError::LocalBlockUnavailable(BlockNumberOrTag::Latest))?; + let safe = local.block_info(BlockNumberOrTag::Safe).await?.unwrap_or_default(); + let finalized = local.block_info(BlockNumberOrTag::Finalized).await?.unwrap_or_default(); + let engine = Arc::new(EngineApiFollowEngine::new( + Arc::clone(&self.engine_client), + Arc::clone(&self.config), + latest, + safe, + finalized, + )); + let rpc = self + .rpc_builder + .clone() + .map(|rpc_builder| FollowRpcActor::new(rpc_builder, Arc::clone(&local))); + + if self.proofs_enabled { + let proof_gate = + ActiveProofGate::new(Arc::clone(&local), self.proofs_max_blocks_ahead).await?; + self.start_runtime(local, engine, latest, proof_gate, rpc, cancellation).await + } else { + self.start_runtime(local, engine, latest, NoopProofGate, rpc, cancellation).await + } + } + + async fn start_runtime( + &self, + local: Arc, + engine: Arc>, + latest: base_protocol::L2BlockInfo, + proof_gate: Gate, + rpc: Option>, + cancellation: CancellationToken, + ) -> Result<(), FollowError> + where + Gate: ProofGate + 'static, + { + let runtime = FollowRuntime::new( + Arc::clone(&local), + Arc::new(self.l2_source.clone()), + engine, + cancellation.clone(), + latest, + proof_gate, + self.insert_delay, + ); + + let mut tasks = JoinSet::new(); + tasks.spawn(runtime.start()); + if let Some(rpc) = rpc { + tasks.spawn(rpc.start(cancellation.clone())); + } + + tokio::select! { + result = tasks.join_next() => { + cancellation.cancel(); + if let Some(result) = result { + result??; + } + while let Some(result) = tasks.join_next().await { + result??; + } + } + _ = ShutdownSignal::wait() => { + cancellation.cancel(); + while let Some(result) = tasks.join_next().await { + result??; + } + } + } + Ok(()) + } +} diff --git a/crates/consensus/service/src/follow/prefetcher.rs b/crates/consensus/service/src/follow/prefetcher.rs new file mode 100644 index 0000000000..4adb85c84d --- /dev/null +++ b/crates/consensus/service/src/follow/prefetcher.rs @@ -0,0 +1,108 @@ +use std::{fmt::Debug, sync::Arc, time::Duration}; + +use alloy_eips::BlockNumberOrTag; +use base_common_rpc_types_engine::BaseExecutionPayloadEnvelope; +use tokio::{sync::mpsc, time}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +use crate::follow::{error::FollowError, source::RemoteClient}; + +/// Number of source L2 payloads to keep prefetched ahead of the insert loop. +pub(super) const PREFETCH_WINDOW: usize = 50; +const SOURCE_HEAD_BACKOFF: Duration = Duration::from_secs(1); +const PREFETCH_FAILURE_WARN_INTERVAL: u64 = 5; + +/// A fetched source payload. +pub(super) type PrefetchedPayload = BaseExecutionPayloadEnvelope; + +/// Fetches source L2 payloads ahead of the insert loop. +#[derive(Debug)] +pub(super) struct PayloadPrefetcher { + source: Arc, + cancellation: CancellationToken, + blocks_to_insert_tx: mpsc::Sender, +} + +impl PayloadPrefetcher +where + Remote: RemoteClient + 'static, +{ + /// Creates a payload prefetcher. + pub(super) const fn new( + source: Arc, + cancellation: CancellationToken, + blocks_to_insert_tx: mpsc::Sender, + ) -> Self { + Self { source, cancellation, blocks_to_insert_tx } + } + + /// Starts fetching from the local node head and pushes payloads through a + /// bounded channel. + pub(super) async fn run(self, start_from_local_head: u64) -> Result<(), FollowError> { + let mut next_fetch = start_from_local_head.saturating_add(1); + let mut source_latest = start_from_local_head; + let mut consecutive_payload_failures = 0; + + loop { + if self.cancellation.is_cancelled() { + return Ok(()); + } + + if next_fetch > source_latest { + source_latest = self.refresh_source_latest(source_latest).await; + if next_fetch > source_latest { + self.backoff_at_source_head().await; + continue; + } + } + + let payload = self.source.get_payload_by_number(next_fetch).await; + + match payload { + Ok(payload) => { + if self.blocks_to_insert_tx.send(payload).await.is_err() { + return Ok(()); + } + consecutive_payload_failures = 0; + next_fetch = next_fetch.saturating_add(1); + } + Err(e) => { + consecutive_payload_failures += 1; + if consecutive_payload_failures % PREFETCH_FAILURE_WARN_INTERVAL == 0 { + warn!( + target: "follow", + block = next_fetch, + attempts = consecutive_payload_failures, + error = %e, + "Repeatedly failed to prefetch source payload" + ); + } else { + debug!( + target: "follow", + block = next_fetch, + attempts = consecutive_payload_failures, + error = %e, + "Failed to prefetch source payload" + ); + } + self.backoff_at_source_head().await; + } + } + } + } + + async fn refresh_source_latest(&self, current: u64) -> u64 { + match self.source.get_block_number(BlockNumberOrTag::Latest).await { + Ok(latest) => latest, + Err(e) => { + debug!(target: "follow", error = %e, "Failed to fetch source latest head"); + current + } + } + } + + async fn backoff_at_source_head(&self) { + time::sleep(SOURCE_HEAD_BACKOFF).await; + } +} diff --git a/crates/consensus/service/src/follow/proof_gate.rs b/crates/consensus/service/src/follow/proof_gate.rs new file mode 100644 index 0000000000..b1c9af658d --- /dev/null +++ b/crates/consensus/service/src/follow/proof_gate.rs @@ -0,0 +1,64 @@ +use std::{fmt::Debug, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use tokio::time; + +use crate::follow::{error::FollowError, local::FollowLocalClient}; + +const PROOF_GATE_RETRY_INTERVAL: Duration = Duration::from_millis(250); + +#[async_trait] +pub(super) trait ProofGate: Debug + Send { + async fn wait_til_ready(&mut self, current_block: u64) -> Result<(), FollowError>; +} + +#[derive(Debug)] +pub(super) struct ActiveProofGate { + local: Arc, + max_blocks_ahead: u64, + cap: u64, +} + +impl ActiveProofGate +where + Local: FollowLocalClient + 'static, +{ + pub(super) async fn new(local: Arc, max_blocks_ahead: u64) -> Result { + let mut gate = Self { local, max_blocks_ahead, cap: 0 }; + gate.refresh().await?; + Ok(gate) + } + + async fn refresh(&mut self) -> Result<(), FollowError> { + let latest = self.local.proofs_latest().await?.unwrap_or(0); + self.cap = latest.saturating_add(self.max_blocks_ahead); + debug!(target: "follow", proofs_latest = latest, cap = self.cap, "Proof gate refreshed"); + Ok(()) + } +} + +#[async_trait] +impl ProofGate for ActiveProofGate +where + Local: FollowLocalClient + 'static, +{ + async fn wait_til_ready(&mut self, current_block: u64) -> Result<(), FollowError> { + while current_block > self.cap { + self.refresh().await?; + if current_block > self.cap { + time::sleep(PROOF_GATE_RETRY_INTERVAL).await; + } + } + Ok(()) + } +} + +#[derive(Debug, Default)] +pub(super) struct NoopProofGate; + +#[async_trait] +impl ProofGate for NoopProofGate { + async fn wait_til_ready(&mut self, _current_block: u64) -> Result<(), FollowError> { + Ok(()) + } +} diff --git a/crates/consensus/service/src/follow/rpc.rs b/crates/consensus/service/src/follow/rpc.rs new file mode 100644 index 0000000000..4dc3effd20 --- /dev/null +++ b/crates/consensus/service/src/follow/rpc.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use alloy_eips::BlockNumberOrTag; +use async_trait::async_trait; +use base_consensus_rpc::{HealthzApiServer, HealthzRpc, RpcBuilder, SyncStatusApiServer}; +use base_protocol::{L2BlockInfo, SyncStatus}; +use jsonrpsee::{ + RpcModule, + core::RpcResult, + server::ServerHandle, + types::{ErrorCode, ErrorObject}, +}; +use tokio_util::sync::CancellationToken; + +use crate::{ + NodeActor, + actors::launch_rpc_server, + follow::{error::FollowError, local::FollowLocalClient}, +}; + +#[derive(Debug)] +struct FollowSyncStatusRpc { + local: Arc, +} + +impl FollowSyncStatusRpc { + const fn new(local: Arc) -> Self { + Self { local } + } + + async fn block_or_default(&self, tag: BlockNumberOrTag) -> RpcResult + where + L: FollowLocalClient, + { + self.local + .block_info(tag) + .await + .map_err(|e| { + ErrorObject::owned(ErrorCode::InternalError.code(), e.to_string(), None::<()>) + }) + .map(|block| block.unwrap_or_default()) + } +} + +#[async_trait] +impl SyncStatusApiServer for FollowSyncStatusRpc +where + L: FollowLocalClient + 'static, +{ + async fn sync_status(&self) -> RpcResult { + let unsafe_l2 = self.block_or_default(BlockNumberOrTag::Latest).await?; + let safe_l2 = self.block_or_default(BlockNumberOrTag::Safe).await?; + let finalized_l2 = self.block_or_default(BlockNumberOrTag::Finalized).await?; + + Ok(SyncStatus { + unsafe_l2, + local_safe_l2: safe_l2, + safe_l2, + finalized_l2, + ..Default::default() + }) + } +} + +#[derive(Debug)] +pub(super) struct FollowRpcActor { + config: RpcBuilder, + local: Arc, +} + +impl FollowRpcActor { + pub(super) const fn new(config: RpcBuilder, local: Arc) -> Self { + Self { config, local } + } + + async fn launch(&self, module: RpcModule<()>) -> Result { + launch_rpc_server(&self.config, module) + .await + .map_err(|e| FollowError::RpcServer(e.to_string())) + } +} + +#[async_trait] +impl NodeActor for FollowRpcActor +where + L: FollowLocalClient + 'static, +{ + type Error = FollowError; + type StartData = CancellationToken; + + async fn start(self, cancellation: Self::StartData) -> Result<(), Self::Error> { + let mut modules = RpcModule::new(()); + modules + .merge(HealthzApiServer::into_rpc(HealthzRpc {})) + .map_err(|e| FollowError::RpcModule(e.to_string()))?; + modules + .merge(FollowSyncStatusRpc::new(Arc::clone(&self.local)).into_rpc()) + .map_err(|e| FollowError::RpcModule(e.to_string()))?; + + let restarts = self.config.restart_count(); + let mut handle = self.launch(modules.clone()).await?; + + for _ in 0..=restarts { + tokio::select! { + _ = handle.clone().stopped() => { + handle = self.launch(modules.clone()).await?; + } + _ = cancellation.cancelled() => { + handle.stop().map_err(|e| FollowError::RpcStop(format!("{e:?}")))?; + return Ok(()); + } + } + } + + cancellation.cancel(); + Err(FollowError::RpcRestartLimit) + } +} diff --git a/crates/consensus/service/src/follow/runtime.rs b/crates/consensus/service/src/follow/runtime.rs new file mode 100644 index 0000000000..43124a2c6a --- /dev/null +++ b/crates/consensus/service/src/follow/runtime.rs @@ -0,0 +1,681 @@ +use std::{fmt::Debug, sync::Arc, time::Duration}; + +use alloy_eips::BlockNumberOrTag; +use base_protocol::{BlockInfo, L2BlockInfo}; +use tokio::{ + sync::mpsc, + time::{self, MissedTickBehavior}, +}; +use tokio_util::sync::CancellationToken; + +use crate::follow::{ + engine::FollowEngine, + error::FollowError, + local::FollowLocalClient, + prefetcher::{PREFETCH_WINDOW, PayloadPrefetcher, PrefetchedPayload}, + proof_gate::ProofGate, + source::RemoteClient, +}; + +const SAFETY_POLL_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug)] +pub(super) struct FollowRuntime { + local: Arc, + source: Arc, + engine: Arc, + cancellation: CancellationToken, + follow_from_block: L2BlockInfo, + proof_gate: Gate, + insert_delay: Duration, +} + +impl FollowRuntime +where + Local: FollowLocalClient + 'static, + Remote: RemoteClient + 'static, + Gate: ProofGate + 'static, +{ + pub(super) fn new( + local: Arc, + source: Arc, + engine: Arc, + cancellation: CancellationToken, + follow_from_block: L2BlockInfo, + proof_gate: Gate, + insert_delay: Duration, + ) -> Self { + Self { local, source, engine, cancellation, follow_from_block, proof_gate, insert_delay } + } + + async fn run_ordered_insert_loop( + engine: Arc, + cancellation: CancellationToken, + mut blocks_to_insert_rx: mpsc::Receiver, + start_block: u64, + proof_gate: &mut GateInner, + insert_delay: Duration, + ) -> Result<(), FollowError> { + let mut current_block = start_block; + + loop { + if cancellation.is_cancelled() { + return Ok(()); + } + + proof_gate.wait_til_ready(current_block).await?; + + let Some(payload) = blocks_to_insert_rx.recv().await else { + return Err(FollowError::BlocksToInsertChannelClosed); + }; + let block_number = payload.execution_payload.block_number(); + if block_number != current_block { + return Err(FollowError::OutOfOrderPayload { + actual: block_number, + expected: current_block, + }); + } + + info!(target: "follow", block = current_block, "Inserting source payload"); + engine.insert_payload(payload).await?; + if !insert_delay.is_zero() { + debug!( + target: "follow", + block = current_block, + delay = ?insert_delay, + "Sleeping after source payload insert" + ); + time::sleep(insert_delay).await; + } + current_block = current_block.saturating_add(1); + } + } + + async fn run_update_safe_finalized_heads_loop( + local: Arc, + source: Arc, + engine: Arc, + cancellation: CancellationToken, + ) -> Result<(), FollowError> { + let mut ticker = time::interval(SAFETY_POLL_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + if cancellation.is_cancelled() { + return Ok(()); + } + + ticker.tick().await; + if let Err(e) = Self::update_safe_and_finalized( + Arc::clone(&local), + Arc::clone(&source), + Arc::clone(&engine), + ) + .await + { + warn!(target: "follow", error = %e, "Failed to update safe/finalized labels"); + } + } + } + + async fn update_safe_and_finalized( + local: Arc, + source: Arc, + engine: Arc, + ) -> Result<(), FollowError> { + let latest = local + .block_info(BlockNumberOrTag::Latest) + .await? + .ok_or(FollowError::LocalBlockUnavailable(BlockNumberOrTag::Latest))?; + let Some(local_safe) = local.block_info(BlockNumberOrTag::Safe).await? else { + debug!(target: "follow", "Skipping safe/finalized update because local safe label is unavailable"); + return Ok(()); + }; + let local_finalized = local.block_info(BlockNumberOrTag::Finalized).await?; + + let source_safe = + source.get_block_number(BlockNumberOrTag::Safe).await?.min(latest.block_info.number); + let safe = if source_safe >= local_safe.block_info.number { + Self::verified_local_block_at(&local, &source, source_safe).await? + } else { + None + }; + + let safe_limit = safe.as_ref().unwrap_or(&local_safe).block_info.number; + let source_finalized = source + .get_block_number(BlockNumberOrTag::Finalized) + .await? + .min(latest.block_info.number) + .min(safe_limit); + let local_finalized_number = local_finalized.map(|block| block.block_info.number); + let should_update_finalized = + local_finalized_number.map(|number| source_finalized >= number).unwrap_or(true); + let finalized = if should_update_finalized { + Self::verified_local_block_at(&local, &source, source_finalized).await? + } else { + None + }; + + engine.update_safe_finalized_blocks(safe, finalized).await + } + + async fn verified_local_block_at( + local: &Local, + source: &Remote, + number: u64, + ) -> Result, FollowError> { + let Some(local_block) = local.block_info(number.into()).await? else { + return Ok(None); + }; + let source_block = source.get_block_info(number.into()).await?; + Self::ensure_same_hash(local_block, source_block)?; + Ok(Some(local_block)) + } + + fn ensure_same_hash( + local_block: L2BlockInfo, + source_block: BlockInfo, + ) -> Result<(), FollowError> { + if local_block.block_info.hash != source_block.hash { + return Err(FollowError::SourceBlockHashMismatch { + number: local_block.block_info.number, + local: local_block.block_info.hash, + remote: source_block.hash, + }); + } + Ok(()) + } +} + +impl FollowRuntime +where + Local: FollowLocalClient + 'static, + Remote: RemoteClient + 'static, + Gate: ProofGate + 'static, +{ + pub(super) async fn start(mut self) -> Result<(), FollowError> { + let next_insert = self.follow_from_block.block_info.number.saturating_add(1); + let (blocks_to_insert_tx, blocks_to_insert_rx) = mpsc::channel(PREFETCH_WINDOW); + let prefetcher = PayloadPrefetcher::new( + Arc::clone(&self.source), + self.cancellation.clone(), + blocks_to_insert_tx, + ); + let fetch_loop = prefetcher.run(self.follow_from_block.block_info.number); + let insert_loop = Self::run_ordered_insert_loop( + Arc::clone(&self.engine), + self.cancellation.clone(), + blocks_to_insert_rx, + next_insert, + &mut self.proof_gate, + self.insert_delay, + ); + let safety_loop = Self::run_update_safe_finalized_heads_loop( + Arc::clone(&self.local), + Arc::clone(&self.source), + Arc::clone(&self.engine), + self.cancellation.clone(), + ); + + tokio::select! { + result = fetch_loop => result, + result = insert_loop => result, + result = safety_loop => result, + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, + }; + + use alloy_primitives::B256; + use alloy_rpc_types_engine::ExecutionPayloadV1; + use async_trait::async_trait; + use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; + use base_protocol::L2BlockInfo; + use mockall::predicate::eq; + use tokio::{sync::Mutex, time}; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::{ + MockRemoteClient, + follow::{ + engine::FollowEngine, + local::MockFollowLocalClient, + proof_gate::{ActiveProofGate, NoopProofGate}, + }, + }; + + const DEFAULT_PROOFS_MAX_BLOCKS_AHEAD: u64 = 16; + + #[derive(Debug)] + struct RecordingEngine { + inserted: Mutex>, + labels: Mutex, Option)>>, + delay: Duration, + } + + #[derive(Debug)] + struct DelayedSource { + latest: u64, + fetch_delay: Duration, + } + + #[async_trait] + impl RemoteClient for DelayedSource { + async fn get_block_number( + &self, + tag: BlockNumberOrTag, + ) -> Result { + match tag { + BlockNumberOrTag::Latest => Ok(self.latest), + BlockNumberOrTag::Number(number) => Ok(number), + _ => Ok(0), + } + } + + async fn get_block_info( + &self, + tag: BlockNumberOrTag, + ) -> Result { + Ok(match tag { + BlockNumberOrTag::Latest => source_block_info(self.latest), + BlockNumberOrTag::Number(number) => source_block_info(number), + _ => source_block_info(0), + }) + } + + async fn get_payload_by_number( + &self, + number: u64, + ) -> Result { + time::sleep(self.fetch_delay).await; + Ok(payload(number)) + } + } + + #[async_trait] + impl FollowEngine for RecordingEngine { + async fn insert_payload( + &self, + envelope: BaseExecutionPayloadEnvelope, + ) -> Result<(), FollowError> { + time::sleep(self.delay).await; + self.inserted.lock().await.push(envelope.execution_payload.block_number()); + Ok(()) + } + + async fn update_safe_finalized_blocks( + &self, + safe: Option, + finalized: Option, + ) -> Result<(), FollowError> { + self.labels + .lock() + .await + .push((safe.map(|v| v.block_info.number), finalized.map(|v| v.block_info.number))); + Ok(()) + } + } + + fn block_info(number: u64) -> L2BlockInfo { + L2BlockInfo { + block_info: base_protocol::BlockInfo { + number, + hash: B256::from([number as u8; 32]), + ..Default::default() + }, + ..Default::default() + } + } + + fn source_block_info(number: u64) -> BlockInfo { + BlockInfo { number, hash: B256::from([number as u8; 32]), ..Default::default() } + } + + fn payload(number: u64) -> BaseExecutionPayloadEnvelope { + BaseExecutionPayloadEnvelope { + parent_beacon_block_root: None, + execution_payload: BaseExecutionPayload::V1(ExecutionPayloadV1 { + parent_hash: B256::ZERO, + fee_recipient: alloy_primitives::Address::ZERO, + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: alloy_primitives::Bloom::ZERO, + prev_randao: B256::ZERO, + block_number: number, + gas_limit: 0, + gas_used: 0, + timestamp: 0, + extra_data: Default::default(), + base_fee_per_gas: Default::default(), + block_hash: B256::from([number as u8; 32]), + transactions: vec![], + }), + } + } + + fn local_client( + latest: u64, + safe: u64, + finalized: u64, + proofs_latest: u64, + ) -> MockFollowLocalClient { + let mut local = MockFollowLocalClient::new(); + local.expect_block_info().returning(move |tag| { + Ok(Some(match tag { + BlockNumberOrTag::Latest => block_info(latest), + BlockNumberOrTag::Safe => block_info(safe), + BlockNumberOrTag::Finalized => block_info(finalized), + BlockNumberOrTag::Number(number) => block_info(number), + _ => block_info(0), + })) + }); + local.expect_proofs_latest().returning(move || Ok(Some(proofs_latest))); + local + } + + #[tokio::test] + async fn ordered_insertion_consumes_channel_order() { + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let mut proof_gate = NoopProofGate; + let (blocks_to_insert_tx, blocks_to_insert_rx) = mpsc::channel(PREFETCH_WINDOW); + blocks_to_insert_tx.send(payload(1)).await.expect("send 1"); + blocks_to_insert_tx.send(payload(2)).await.expect("send 2"); + blocks_to_insert_tx.send(payload(3)).await.expect("send 3"); + drop(blocks_to_insert_tx); + + let engine_for_loop: Arc = Arc::::clone(&engine); + let error = FollowRuntime::::run_ordered_insert_loop( + engine_for_loop, + CancellationToken::new(), + blocks_to_insert_rx, + 1, + &mut proof_gate, + Duration::ZERO, + ) + .await + .expect_err("closed channel"); + + assert_eq!(*engine.inserted.lock().await, vec![1, 2, 3]); + assert!(matches!(error, FollowError::BlocksToInsertChannelClosed)); + } + + #[tokio::test] + async fn ordered_insertion_rejects_out_of_order_channel_input() { + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let mut proof_gate = NoopProofGate; + let (blocks_to_insert_tx, blocks_to_insert_rx) = mpsc::channel(PREFETCH_WINDOW); + blocks_to_insert_tx.send(payload(2)).await.expect("send 2"); + drop(blocks_to_insert_tx); + + let error = FollowRuntime::::run_ordered_insert_loop( + engine, + CancellationToken::new(), + blocks_to_insert_rx, + 1, + &mut proof_gate, + Duration::ZERO, + ) + .await + .expect_err("error"); + + assert!(matches!(error, FollowError::OutOfOrderPayload { actual: 2, expected: 1 })); + } + + #[tokio::test] + async fn ordered_insertion_applies_configured_insert_delay() { + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let mut proof_gate = NoopProofGate; + let (blocks_to_insert_tx, blocks_to_insert_rx) = mpsc::channel(PREFETCH_WINDOW); + blocks_to_insert_tx.send(payload(1)).await.expect("send 1"); + blocks_to_insert_tx.send(payload(2)).await.expect("send 2"); + drop(blocks_to_insert_tx); + + let engine_for_loop: Arc = Arc::::clone(&engine); + let started = Instant::now(); + let error = FollowRuntime::::run_ordered_insert_loop( + engine_for_loop, + CancellationToken::new(), + blocks_to_insert_rx, + 1, + &mut proof_gate, + Duration::from_millis(20), + ) + .await + .expect_err("closed channel"); + + assert!(matches!(error, FollowError::BlocksToInsertChannelClosed)); + assert_eq!(*engine.inserted.lock().await, vec![1, 2]); + assert!(started.elapsed() >= Duration::from_millis(40)); + } + + #[tokio::test] + async fn prefetch_backpressures_on_bounded_channel() { + let requests = Arc::new(AtomicU64::new(0)); + let mut source = MockRemoteClient::new(); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Latest)).returning(|_| Ok(100)); + let requests_for_mock = Arc::clone(&requests); + source.expect_get_payload_by_number().returning(move |number| { + requests_for_mock.fetch_max(number, Ordering::SeqCst); + Ok(payload(number)) + }); + let cancellation = CancellationToken::new(); + let (blocks_to_insert_tx, blocks_to_insert_rx) = mpsc::channel(PREFETCH_WINDOW); + let prefetcher = + PayloadPrefetcher::new(Arc::new(source), cancellation.clone(), blocks_to_insert_tx); + let handle = tokio::spawn(async move { prefetcher.run(0).await }); + + let deadline = Instant::now() + Duration::from_secs(1); + while blocks_to_insert_rx.len() < PREFETCH_WINDOW && Instant::now() < deadline { + time::sleep(Duration::from_millis(10)).await; + } + let fetched = blocks_to_insert_rx.len(); + cancellation.cancel(); + drop(blocks_to_insert_rx); + handle.await.expect("join").expect("prefetcher"); + + assert_eq!(fetched, PREFETCH_WINDOW); + assert!(requests.load(Ordering::SeqCst) <= PREFETCH_WINDOW as u64 + 1); + } + + #[tokio::test] + async fn proof_cap_pauses_until_proofs_advance() { + let proofs_latest = Arc::new(AtomicU64::new(0)); + let mut local = MockFollowLocalClient::new(); + local.expect_block_info().returning(|tag| { + Ok(Some(match tag { + BlockNumberOrTag::Number(number) => block_info(number), + _ => block_info(0), + })) + }); + let proofs_for_mock = Arc::clone(&proofs_latest); + local + .expect_proofs_latest() + .returning(move || Ok(Some(proofs_for_mock.load(Ordering::SeqCst)))); + let local = Arc::new(local); + + let mut source = MockRemoteClient::new(); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Latest)).returning(|_| Ok(20)); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Safe)).returning(|_| Ok(0)); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Finalized)).returning(|_| Ok(0)); + source.expect_get_block_info().returning(|tag| { + Ok(match tag { + BlockNumberOrTag::Number(number) => source_block_info(number), + _ => source_block_info(0), + }) + }); + source.expect_get_payload_by_number().returning(|number| Ok(payload(number))); + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let cancellation = CancellationToken::new(); + let proof_gate = ActiveProofGate::new(Arc::clone(&local), DEFAULT_PROOFS_MAX_BLOCKS_AHEAD) + .await + .expect("proof gate"); + let engine_for_runtime: Arc = Arc::::clone(&engine); + let runtime = FollowRuntime::new( + Arc::clone(&local), + Arc::new(source), + engine_for_runtime, + cancellation.clone(), + block_info(0), + proof_gate, + Duration::ZERO, + ); + let handle = tokio::spawn(async move { runtime.start().await }); + + time::sleep(Duration::from_millis(500)).await; + assert_eq!(engine.inserted.lock().await.len(), DEFAULT_PROOFS_MAX_BLOCKS_AHEAD as usize); + + proofs_latest.store(10, Ordering::SeqCst); + time::sleep(Duration::from_millis(500)).await; + cancellation.cancel(); + handle.await.expect("join").expect("insert loop"); + + assert!(engine.inserted.lock().await.len() > DEFAULT_PROOFS_MAX_BLOCKS_AHEAD as usize); + } + + #[tokio::test] + async fn safe_and_finalized_are_clamped_and_do_not_unwind() { + let local = Arc::new(local_client(10, 8, 7, 100)); + let mut source = MockRemoteClient::new(); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Safe)).returning(|_| Ok(20)); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Finalized)).returning(|_| Ok(6)); + source + .expect_get_block_info() + .with(eq(BlockNumberOrTag::Number(10))) + .returning(|_| Ok(source_block_info(10))); + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let engine_for_update: Arc = Arc::::clone(&engine); + FollowRuntime::::update_safe_and_finalized( + local, + Arc::new(source), + engine_for_update, + ) + .await + .expect("labels"); + + assert_eq!(*engine.labels.lock().await, vec![(Some(10), None)]); + } + + #[tokio::test] + async fn safe_and_finalized_update_skips_without_local_safe_label() { + let mut local = MockFollowLocalClient::new(); + local.expect_block_info().returning(|tag| { + Ok(match tag { + BlockNumberOrTag::Latest => Some(block_info(10)), + BlockNumberOrTag::Safe => None, + _ => panic!("unexpected local block lookup: {tag:?}"), + }) + }); + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let engine_for_update: Arc = Arc::::clone(&engine); + FollowRuntime::::update_safe_and_finalized( + Arc::new(local), + Arc::new(MockRemoteClient::new()), + engine_for_update, + ) + .await + .expect("skip update"); + + assert!(engine.labels.lock().await.is_empty()); + } + + #[tokio::test] + async fn safe_label_rejects_source_hash_mismatch() { + let local = Arc::new(local_client(10, 8, 7, 100)); + let mut source = MockRemoteClient::new(); + source.expect_get_block_number().with(eq(BlockNumberOrTag::Safe)).returning(|_| Ok(10)); + source.expect_get_block_info().with(eq(BlockNumberOrTag::Number(10))).returning(|_| { + Ok(BlockInfo { number: 10, hash: B256::from([99; 32]), ..Default::default() }) + }); + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::ZERO, + }); + let engine_for_update: Arc = Arc::::clone(&engine); + + let error = + FollowRuntime::::update_safe_and_finalized( + local, + Arc::new(source), + engine_for_update, + ) + .await + .expect_err("mismatched source hash"); + + assert!(matches!(error, FollowError::SourceBlockHashMismatch { number: 10, .. })); + assert!(engine.labels.lock().await.is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn insert_loop_benchmark_prefetches_source_fetch_latency() { + let local = Arc::new(local_client(0, 0, 0, 100)); + let source = DelayedSource { latest: 25, fetch_delay: Duration::from_millis(30) }; + let engine = Arc::new(RecordingEngine { + inserted: Mutex::new(Vec::new()), + labels: Mutex::new(Vec::new()), + delay: Duration::from_millis(50), + }); + let cancellation = CancellationToken::new(); + let engine_for_runtime: Arc = Arc::::clone(&engine); + let runtime = FollowRuntime::new( + local, + Arc::new(source), + engine_for_runtime, + cancellation.clone(), + block_info(0), + NoopProofGate, + Duration::ZERO, + ); + let started = Instant::now(); + let handle = tokio::spawn(async move { runtime.start().await }); + + loop { + if engine.inserted.lock().await.len() >= 20 { + cancellation.cancel(); + break; + } + time::sleep(Duration::from_millis(10)).await; + } + handle.await.expect("join").expect("insert loop"); + + let elapsed_per_block = started.elapsed() / 20; + assert!( + elapsed_per_block < Duration::from_millis(75), + "fetch latency appears serialized into insertion: {elapsed_per_block:?}" + ); + } +} diff --git a/crates/consensus/service/src/actors/derivation/delegate_l2/client.rs b/crates/consensus/service/src/follow/source.rs similarity index 63% rename from crates/consensus/service/src/actors/derivation/delegate_l2/client.rs rename to crates/consensus/service/src/follow/source.rs index f361a1444b..1a03e1fbd1 100644 --- a/crates/consensus/service/src/actors/derivation/delegate_l2/client.rs +++ b/crates/consensus/service/src/follow/source.rs @@ -7,12 +7,13 @@ use async_trait::async_trait; use base_common_consensus::BaseTxEnvelope; use base_common_network::Base; use base_common_rpc_types_engine::{BaseExecutionPayload, BaseExecutionPayloadEnvelope}; +use base_protocol::BlockInfo; use thiserror::Error; use url::Url; -/// Error type for [`DelegateL2Client`] operations. +/// Error type for [`RemoteL2Client`] operations. #[derive(Debug, Error)] -pub enum DelegateL2ClientError { +pub enum RemoteL2ClientError { /// Failed to fetch block from L2 EL. #[error("failed to fetch block at {tag}: {source}")] FetchBlock { @@ -27,29 +28,33 @@ pub enum DelegateL2ClientError { BlockNotFound(String), } -/// Trait for fetching L2 block data from a source node. +/// Trait for fetching L2 block data from the remote node. #[cfg_attr(test, mockall::automock)] #[async_trait] -pub trait L2SourceClient: Debug + Send + Sync { +pub trait RemoteClient: Debug + Send + Sync { /// Fetches the block number at the given tag. - async fn get_block_number(&self, tag: BlockNumberOrTag) -> Result; + async fn get_block_number(&self, tag: BlockNumberOrTag) -> Result; + + /// Fetches the block info at the given tag. + async fn get_block_info(&self, tag: BlockNumberOrTag) + -> Result; /// Fetches a block by number and converts it to an [`BaseExecutionPayloadEnvelope`]. async fn get_payload_by_number( &self, number: u64, - ) -> Result; + ) -> Result; } /// Client that polls a source L2 execution layer node for block data and /// converts blocks into [`BaseExecutionPayloadEnvelope`] for engine insertion. #[derive(Debug, Clone)] -pub struct DelegateL2Client { +pub struct RemoteL2Client { provider: RootProvider, } -impl DelegateL2Client { - /// Creates a new [`DelegateL2Client`] from a source L2 node URL. +impl RemoteL2Client { + /// Creates a new [`RemoteL2Client`] from a source L2 node URL. pub fn new(url: Url) -> Self { let provider = RootProvider::::new_http(url); Self { provider } @@ -57,29 +62,42 @@ impl DelegateL2Client { } #[async_trait] -impl L2SourceClient for DelegateL2Client { - async fn get_block_number(&self, tag: BlockNumberOrTag) -> Result { +impl RemoteClient for RemoteL2Client { + async fn get_block_number(&self, tag: BlockNumberOrTag) -> Result { + if matches!(tag, BlockNumberOrTag::Latest) { + return self.provider.get_block_number().await.map_err(|e| { + RemoteL2ClientError::FetchBlock { tag: format!("{tag:?}"), source: e } + }); + } + + self.get_block_info(tag).await.map(|block| block.number) + } + + async fn get_block_info( + &self, + tag: BlockNumberOrTag, + ) -> Result { let block = self .provider .get_block_by_number(tag) .await - .map_err(|e| DelegateL2ClientError::FetchBlock { tag: format!("{tag:?}"), source: e })? - .ok_or_else(|| DelegateL2ClientError::BlockNotFound(format!("{tag:?}")))?; + .map_err(|e| RemoteL2ClientError::FetchBlock { tag: format!("{tag:?}"), source: e })? + .ok_or_else(|| RemoteL2ClientError::BlockNotFound(format!("{tag:?}")))?; - Ok(block.header.number) + Ok(BlockInfo::from(&block)) } async fn get_payload_by_number( &self, number: u64, - ) -> Result { + ) -> Result { let rpc_block = self .provider .get_block_by_number(number.into()) .full() .await - .map_err(|e| DelegateL2ClientError::FetchBlock { tag: format!("{number}"), source: e })? - .ok_or_else(|| DelegateL2ClientError::BlockNotFound(format!("{number}")))?; + .map_err(|e| RemoteL2ClientError::FetchBlock { tag: format!("{number}"), source: e })? + .ok_or_else(|| RemoteL2ClientError::BlockNotFound(format!("{number}")))?; let block_hash = rpc_block.header.hash; let parent_beacon_block_root = rpc_block.header.parent_beacon_block_root; diff --git a/crates/consensus/service/src/lib.rs b/crates/consensus/service/src/lib.rs index 22f5704884..abc55bb176 100644 --- a/crates/consensus/service/src/lib.rs +++ b/crates/consensus/service/src/lib.rs @@ -11,35 +11,38 @@ extern crate tracing; mod service; pub use service::{ - DerivationDelegateConfig, FollowNode, HEAD_STREAM_POLL_INTERVAL, L1Config, L1ConfigBuilder, - NodeMode, RollupNode, RollupNodeBuilder, ShutdownSignal, + DerivationDelegateConfig, FollowNode, FollowNodeConfig, HEAD_STREAM_POLL_INTERVAL, L1Config, + L1ConfigBuilder, NodeMode, RollupNode, RollupNodeBuilder, ShutdownSignal, }; +mod follow; +pub use follow::{FollowError, RemoteClient, RemoteL2Client, RemoteL2ClientError}; + mod actors; pub use actors::{ AlloyL1BlockFetcher, BlockStream, BootstrapRole, BuildRequest, CancellableContext, CheckpointActor, CheckpointClient, CheckpointDB, CheckpointError, CheckpointRequest, CheckpointWriter, Conductor, ConductorClient, ConductorError, DelayedL1OriginSelectorProvider, - DelegateDerivationActor, DelegateL2Client, DelegateL2ClientError, DelegateL2DerivationActor, - DerivationActor, DerivationActorRequest, DerivationClientError, DerivationClientResult, - DerivationDelegateClient, DerivationDelegateClientError, DerivationEngineClient, - DerivationError, DerivationState, DerivationStateMachine, DerivationStateTransitionError, - DerivationStateUpdate, EngineActor, EngineActorRequest, EngineClientError, EngineClientResult, - EngineConfig, EngineDerivationClient, EngineError, EngineProcessingRequest, EngineProcessor, - EngineProcessorOptions, EngineRequestReceiver, EngineRpcProcessor, EngineRpcRequest, - GetPayloadRequest, GossipTransport, L1BlockFetcher, L1OriginSelector, L1OriginSelectorError, - L1OriginSelectorProvider, L1WatcherActor, L1WatcherActorError, L1WatcherDerivationClient, - L1WatcherQueryExecutor, L1WatcherQueryProcessor, L2Finalizer, L2SourceClient, LogRetrier, - NetworkActor, NetworkActorError, NetworkBuilder, NetworkBuilderError, NetworkConfig, - NetworkDriver, NetworkDriverError, NetworkEngineClient, NetworkHandler, NetworkInboundData, - NodeActor, NoopCheckpointWriter, OriginSelector, PayloadBuilder, PayloadSealer, - PendingStopSender, PoolActivation, QueuedDerivationEngineClient, QueuedEngineDerivationClient, - QueuedEngineRpcClient, QueuedL1WatcherDerivationClient, QueuedNetworkEngineClient, - QueuedSequencerAdminAPIClient, QueuedSequencerEngineClient, QueuedUnsafePayloadGossipClient, - RecoveryModeGuard, ResetRequest, RpcActor, RpcActorError, RpcContext, ScheduledTicker, - SealRequest, SealState, SealStepError, SequencerActor, SequencerActorError, - SequencerAdminQuery, SequencerConfig, SequencerEngineClient, UnsafePayloadGossipClient, - UnsafePayloadGossipClientError, UnsealedPayloadHandle, + DelegateDerivationActor, DerivationActor, DerivationActorRequest, DerivationClientError, + DerivationClientResult, DerivationDelegateClient, DerivationDelegateClientError, + DerivationEngineClient, DerivationError, DerivationState, DerivationStateMachine, + DerivationStateTransitionError, DerivationStateUpdate, EngineActor, EngineActorRequest, + EngineClientError, EngineClientResult, EngineConfig, EngineDerivationClient, EngineError, + EngineProcessor, EngineProcessorOptions, EngineRequestReceiver, EngineRpcProcessor, + EngineRpcRequest, GetPayloadRequest, GossipTransport, InsertUnsafePayloadRequest, + L1BlockFetcher, L1OriginSelector, L1OriginSelectorError, L1OriginSelectorProvider, + L1WatcherActor, L1WatcherActorError, L1WatcherDerivationClient, L1WatcherQueryExecutor, + L1WatcherQueryProcessor, L2Finalizer, LogRetrier, NetworkActor, NetworkActorError, + NetworkBuilder, NetworkBuilderError, NetworkConfig, NetworkDriver, NetworkDriverError, + NetworkEngineClient, NetworkHandler, NetworkInboundData, NodeActor, NoopCheckpointWriter, + OriginSelector, PayloadBuilder, PayloadSealer, PendingStopSender, PoolActivation, + QueuedDerivationEngineClient, QueuedEngineDerivationClient, QueuedEngineRpcClient, + QueuedL1WatcherDerivationClient, QueuedNetworkEngineClient, QueuedSequencerAdminAPIClient, + QueuedSequencerEngineClient, QueuedUnsafePayloadGossipClient, RecoveryModeGuard, ResetRequest, + RpcActor, RpcActorError, RpcContext, ScheduledTicker, SealState, SealStepError, + SealStepOutcome, SequencerActor, SequencerActorError, SequencerAdminQuery, SequencerConfig, + SequencerEngineClient, UnsafePayloadGossipClient, UnsafePayloadGossipClientError, + UnsealedPayloadHandle, }; mod metrics; @@ -48,4 +51,6 @@ pub use actors::{ MockConductor, MockEngineDerivationClient, MockOriginSelector, MockSequencerEngineClient, MockUnsafePayloadGossipClient, }; +#[cfg(test)] +pub use follow::MockRemoteClient; pub use metrics::Metrics; diff --git a/crates/consensus/service/src/service/follow.rs b/crates/consensus/service/src/service/follow.rs deleted file mode 100644 index 75b7257d47..0000000000 --- a/crates/consensus/service/src/service/follow.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::{ - sync::{Arc, atomic::AtomicU64}, - time::Duration, -}; - -use alloy_eips::BlockNumberOrTag; -use alloy_provider::RootProvider; -use base_common_genesis::RollupConfig; -use base_common_network::Base; -use base_consensus_engine::{Engine, EngineClient, EngineState}; -use base_consensus_rpc::RpcBuilder; -use base_consensus_safedb::{DisabledSafeDB, SafeDBReader}; -use tokio::sync::{mpsc, watch}; -use tokio_util::sync::CancellationToken; - -use crate::{ - AlloyL1BlockFetcher, BlockStream, DelegateL2Client, DelegateL2DerivationActor, EngineActor, - EngineActorRequest, EngineConfig, EngineProcessor, EngineProcessorOptions, EngineRpcProcessor, - L1Config, L1WatcherActor, L1WatcherQueryProcessor, NodeActor, NodeMode, - QueuedDerivationEngineClient, QueuedEngineDerivationClient, QueuedEngineRpcClient, - QueuedL1WatcherDerivationClient, RpcActor, RpcContext, - service::node::HEAD_STREAM_POLL_INTERVAL, -}; - -/// A lightweight node that follows another L2 node by polling its execution -/// layer RPC and driving the local engine via `NewPayload` + FCU. -/// -/// Runs only the [`EngineActor`] and [`DelegateL2DerivationActor`] — no derivation -/// pipeline, P2P, or sequencer. -#[derive(Debug)] -pub struct FollowNode { - config: Arc, - engine_config: EngineConfig, - local_l2_provider: RootProvider, - l2_source: DelegateL2Client, - proofs_enabled: bool, - proofs_max_blocks_ahead: u64, - l1_config: L1Config, - rpc_builder: Option, -} - -impl FollowNode { - /// Creates a new [`FollowNode`]. - pub const fn new( - config: Arc, - engine_config: EngineConfig, - local_l2_provider: RootProvider, - l2_source: DelegateL2Client, - rpc_builder: Option, - l1_config: L1Config, - ) -> Self { - Self { - config, - engine_config, - local_l2_provider, - l2_source, - rpc_builder, - l1_config, - proofs_enabled: false, - proofs_max_blocks_ahead: 512, - } - } - - /// Enables proofs sync gating via `debug_proofsSyncStatus`. - pub const fn with_proofs(mut self, enabled: bool) -> Self { - self.proofs_enabled = enabled; - self - } - - /// Sets the maximum number of blocks the node may advance beyond the - /// proofs `ExEx` head. - pub const fn with_proofs_max_blocks_ahead(mut self, max_blocks_ahead: u64) -> Self { - self.proofs_max_blocks_ahead = max_blocks_ahead; - self - } - - fn create_engine_actor( - &self, - engine_client: Arc, - cancellation_token: CancellationToken, - engine_request_rx: mpsc::Receiver, - derivation_client: QueuedEngineDerivationClient, - ) -> (EngineActor>, EngineRpcProcessor) - { - let engine_state = EngineState::default(); - let (engine_state_tx, engine_state_rx) = watch::channel(engine_state); - let (engine_queue_length_tx, engine_queue_length_rx) = watch::channel(0); - let engine = Engine::new(engine_state, engine_state_tx, engine_queue_length_tx); - - let engine_processor = EngineProcessor::new( - Arc::clone(&engine_client), - Arc::clone(&self.config), - derivation_client, - engine, - EngineProcessorOptions { - node_mode: NodeMode::Validator, - unsafe_head_tx: None, - conductor: None, - sequencer_stopped: false, - }, - ); - - let engine_rpc_processor = EngineRpcProcessor::new( - Arc::clone(&engine_client), - Arc::clone(&self.config), - engine_state_rx, - engine_queue_length_rx, - ); - - let engine_actor = - EngineActor::new(cancellation_token, engine_request_rx, engine_processor); - - (engine_actor, engine_rpc_processor) - } - - /// Starts the follow node. - pub async fn start(&self) -> Result<(), String> { - let engine_client = Arc::new( - self.engine_config.clone().build_engine_client().await.map_err(|e| e.to_string())?, - ); - self.start_inner(engine_client).await - } - - /// Starts the follow node with a pre-built engine client. - /// - /// Enables dependency injection of the engine client for testing scenarios. - pub async fn start_with_engine_client( - &self, - engine_client: Arc, - ) -> Result<(), String> { - self.start_inner(engine_client).await - } - - async fn start_inner( - &self, - engine_client: Arc, - ) -> Result<(), String> { - let cancellation = CancellationToken::new(); - - let (derivation_actor_request_tx, derivation_actor_request_rx) = mpsc::channel(1024); - let (engine_actor_request_tx, engine_actor_request_rx) = mpsc::channel(1024); - let (engine_rpc_request_tx, engine_rpc_request_rx) = mpsc::channel(1024); - - let (engine_actor, engine_rpc_processor) = self.create_engine_actor( - engine_client, - cancellation.clone(), - engine_actor_request_rx, - QueuedEngineDerivationClient::new(derivation_actor_request_tx.clone()), - ); - - let derivation = DelegateL2DerivationActor::<_>::new( - QueuedDerivationEngineClient { - engine_actor_request_tx: engine_actor_request_tx.clone(), - }, - engine_actor_request_tx.clone(), - cancellation.clone(), - derivation_actor_request_rx, - self.local_l2_provider.clone(), - self.l2_source.clone(), - ) - .with_proofs(self.proofs_enabled) - .with_proofs_max_blocks_ahead(self.proofs_max_blocks_ahead); - - // Create the RPC server actor if configured. - let rpc_builder = self.rpc_builder.clone(); - let engine_rpc_actor = rpc_builder - .as_ref() - .map(|_| (engine_rpc_processor, (cancellation.clone(), engine_rpc_request_rx))); - let rpc = rpc_builder.map(|b| { - // Follow nodes do not run derivation, so they never produce confirmed safe - // heads to record. Safe head tracking is disabled; the RPC endpoint returns - // an error if queried. - RpcActor::new( - b, - QueuedEngineRpcClient::new(engine_rpc_request_tx), - None::, - Arc::new(DisabledSafeDB) as Arc, - ) - }); - - let (l1_query_tx, l1_query_rx) = mpsc::channel(1024); - - let head_stream = BlockStream::new_as_stream( - self.l1_config.engine_provider.clone(), - BlockNumberOrTag::Latest, - Duration::from_secs(HEAD_STREAM_POLL_INTERVAL), - )?; - let finalized_stream = BlockStream::new_as_stream( - self.l1_config.engine_provider.clone(), - BlockNumberOrTag::Finalized, - self.l1_config.finalized_poll_interval, - )?; - - let (l1_head_updates_tx, _l1_head_updates_rx) = watch::channel(None); - // Create the [`L1WatcherActor`]. Previously known as the DA watcher actor. - let l1_watcher = L1WatcherActor::new( - Arc::clone(&self.config), - AlloyL1BlockFetcher(self.l1_config.engine_provider.clone()), - l1_head_updates_tx.clone(), - QueuedL1WatcherDerivationClient { derivation_actor_request_tx }, - None, - cancellation.clone(), - head_stream, - finalized_stream, - self.l1_config.verifier_l1_confs, - Arc::new(AtomicU64::new(0)), - ); - let l1_query_processor = L1WatcherQueryProcessor::new( - Arc::clone(&self.config), - AlloyL1BlockFetcher(self.l1_config.engine_provider.clone()), - l1_query_rx, - l1_head_updates_tx.subscribe(), - cancellation.clone(), - ); - - crate::service::spawn_and_wait!( - cancellation, - actors = [ - rpc.map(|r| ( - r, - RpcContext { - cancellation: cancellation.clone(), - p2p_network: None, - network_admin: None, - l1_watcher_queries: l1_query_tx, - } - )), - Some((derivation, ())), - Some((engine_actor, ())), - engine_rpc_actor, - Some((l1_watcher, ())), - Some((l1_query_processor, ())), - ] - ); - Ok(()) - } -} diff --git a/crates/consensus/service/src/service/mod.rs b/crates/consensus/service/src/service/mod.rs index 119fae6cce..b3767b31d7 100644 --- a/crates/consensus/service/src/service/mod.rs +++ b/crates/consensus/service/src/service/mod.rs @@ -6,8 +6,7 @@ mod builder; pub use builder::{DerivationDelegateConfig, L1ConfigBuilder, RollupNodeBuilder}; -mod follow; -pub use follow::FollowNode; +pub use crate::follow::{FollowNode, FollowNodeConfig}; mod mode; pub use mode::NodeMode; diff --git a/crates/consensus/service/src/service/node.rs b/crates/consensus/service/src/service/node.rs index 951f13db52..62c89fa3fe 100644 --- a/crates/consensus/service/src/service/node.rs +++ b/crates/consensus/service/src/service/node.rs @@ -299,11 +299,19 @@ impl RollupNode { /// finalizes `safe` blocks that it has derived when L1 finalized block updates are /// received. pub async fn start(&self) -> Result<(), String> { + self.start_with_cancellation(CancellationToken::new()).await + } + + /// Starts the rollup node service with a caller-provided cancellation token. + pub async fn start_with_cancellation( + &self, + cancellation: CancellationToken, + ) -> Result<(), String> { let l1_head_number: base_consensus_providers::L1HeadNumber = Arc::new(AtomicU64::new(0)); let pipeline = self.create_pipeline(Arc::clone(&l1_head_number)).await; let engine_client = Arc::new(self.engine_config().build_engine_client().await.map_err(|e| e.to_string())?); - self.start_inner(engine_client, pipeline, l1_head_number).await + self.start_inner(engine_client, pipeline, l1_head_number, cancellation).await } /// Starts the rollup node service with a pre-built derivation pipeline. @@ -328,7 +336,7 @@ impl RollupNode { let l1_head_number: base_consensus_providers::L1HeadNumber = Arc::new(AtomicU64::new(0)); let engine_client = Arc::new(self.engine_config().build_engine_client().await.map_err(|e| e.to_string())?); - self.start_inner(engine_client, pipeline, l1_head_number).await + self.start_inner(engine_client, pipeline, l1_head_number, CancellationToken::new()).await } /// Starts the rollup node with a pre-built engine client. @@ -342,7 +350,7 @@ impl RollupNode { ) -> Result<(), String> { let l1_head_number: base_consensus_providers::L1HeadNumber = Arc::new(AtomicU64::new(0)); let pipeline = self.create_pipeline(Arc::clone(&l1_head_number)).await; - self.start_inner(engine_client, pipeline, l1_head_number).await + self.start_inner(engine_client, pipeline, l1_head_number, CancellationToken::new()).await } async fn start_inner( @@ -350,6 +358,7 @@ impl RollupNode { engine_client: Arc, pipeline: P, l1_head_number: base_consensus_providers::L1HeadNumber, + cancellation: CancellationToken, ) -> Result<(), String> where E: EngineClient + 'static, @@ -357,8 +366,6 @@ impl RollupNode { DerivationActor: NodeActor, { - let cancellation = CancellationToken::new(); - // Build the safe head DB pair. Both actors share the same underlying DB via Arc. // // In delegate mode the local derivation actor is replaced by a `DelegateDerivationActor` @@ -389,6 +396,7 @@ impl RollupNode { let (engine_actor_request_tx, engine_actor_request_rx) = mpsc::channel(1024); let (engine_rpc_request_tx, engine_rpc_request_rx) = mpsc::channel(1024); let (unsafe_head_tx, unsafe_head_rx) = watch::channel(L2BlockInfo::default()); + let (derivation_origin_tx, derivation_origin_rx) = watch::channel(None); let (checkpoint_request_tx, checkpoint_request_rx) = mpsc::channel(1024); let checkpoint_db = CheckpointDB::open(&self.checkpoint_path) .map_err(|e| format!("failed to open checkpoint database: {e}"))?; @@ -397,11 +405,22 @@ impl RollupNode { // Create the conductor client early — the engine processor needs it for the // bootstrap leadership check and the sequencer actor needs it for block building. + // When `conductor_binary_commit` is set, commit_unsafe_payload uses the + // SSZ-binary endpoint; the other RPCs (leader, active, override_leader) + // continue to use JSON-RPC. + let binary_commit = self.sequencer_config.conductor_binary_commit; + let conductor_timeout = self.sequencer_config.conductor_rpc_timeout; let conductor: Option = self .sequencer_config .conductor_rpc_url .clone() - .map(ConductorClient::new_http) + .map(|url| { + if binary_commit { + ConductorClient::new_http_with_binary_commit(url, conductor_timeout) + } else { + ConductorClient::new_http(url, conductor_timeout) + } + }) .transpose() .map_err(|e| format!("Failed to create conductor client: {e}"))?; @@ -435,6 +454,7 @@ impl RollupNode { derivation_actor_request_rx, provider, l1_provider, + derivation_origin_tx, ))) } else { ConfiguredDerivationActor::Normal(Box::new(DerivationActor::<_, P>::new( @@ -445,6 +465,7 @@ impl RollupNode { derivation_actor_request_rx, pipeline, safe_head_listener, + derivation_origin_tx, ))) }; @@ -508,7 +529,7 @@ impl RollupNode { Arc::clone(&self.config), AlloyL1BlockFetcher(self.l1_config.engine_provider.clone()), l1_query_rx, - l1_head_updates_tx.subscribe(), + derivation_origin_rx, cancellation.clone(), ); @@ -546,7 +567,6 @@ impl RollupNode { unsafe_payload_gossip_client: queued_gossip_client, sealer: None, pending_stop: None, - next_build_parent: None, }), Some(QueuedSequencerAdminAPIClient::new(sequencer_admin_api_tx)), ) diff --git a/crates/consensus/service/tests/actors/engine.rs b/crates/consensus/service/tests/actors/engine.rs index 033905f7b1..a8da170549 100644 --- a/crates/consensus/service/tests/actors/engine.rs +++ b/crates/consensus/service/tests/actors/engine.rs @@ -8,75 +8,21 @@ use std::{ time::Duration, }; -use alloy_primitives::B256; -use alloy_rpc_types_engine::{ForkchoiceUpdated, PayloadId, PayloadStatus, PayloadStatusEnum}; -use alloy_rpc_types_eth::Block as RpcBlock; -use async_trait::async_trait; -use base_common_genesis::RollupConfig; -use base_common_rpc_types::Transaction as BaseTransaction; +use alloy_rpc_types_engine::PayloadId; use base_common_rpc_types_engine::BasePayloadAttributes; -use base_consensus_engine::{ - DelegatedForkchoiceUpdate, Engine, EngineQueries, - test_utils::{TestEngineStateBuilder, test_block_info, test_engine_client_builder}, -}; +use base_consensus_engine::EngineQueries; use base_consensus_node::{ - BuildRequest, EngineActor, EngineActorRequest, EngineDerivationClient, EngineError, - EngineProcessingRequest, EngineProcessor, EngineProcessorOptions, EngineRequestReceiver, - NodeActor, NodeMode, QueuedEngineRpcClient, + BuildRequest, EngineActor, EngineActorRequest, EngineError, EngineRequestReceiver, NodeActor, + QueuedEngineRpcClient, }; -use base_protocol::{AttributesWithParent, BlockInfo, L2BlockInfo}; +use base_protocol::{AttributesWithParent, L2BlockInfo}; use jsonrpsee::types::ErrorCode; use tokio::{ - sync::{mpsc, oneshot, watch}, + sync::{mpsc, oneshot}, task::JoinHandle, }; use tokio_util::sync::CancellationToken; -#[derive(Debug, Default)] -struct NoopDerivationClient; - -#[async_trait] -impl EngineDerivationClient for NoopDerivationClient { - async fn notify_sync_completed( - &self, - _: L2BlockInfo, - ) -> Result<(), base_consensus_node::DerivationClientError> { - Ok(()) - } - - async fn send_new_engine_safe_head( - &self, - _: L2BlockInfo, - ) -> Result<(), base_consensus_node::DerivationClientError> { - Ok(()) - } - - async fn send_signal( - &self, - _: base_consensus_derive::Signal, - ) -> Result<(), base_consensus_node::DerivationClientError> { - Ok(()) - } -} - -const fn syncing_fcu() -> ForkchoiceUpdated { - ForkchoiceUpdated { - payload_status: PayloadStatus { - status: PayloadStatusEnum::Syncing, - latest_valid_hash: None, - }, - payload_id: None, - } -} - -fn mismatched_block(number: u64) -> RpcBlock { - let mut block = RpcBlock::::default(); - block.header.hash = B256::from([0xabu8; 32]); - block.header.inner.number = number; - block.header.inner.timestamp = number * 2; - block -} - #[derive(Debug)] struct CountingEngineReceiver { builds_processed: Arc, @@ -85,7 +31,7 @@ struct CountingEngineReceiver { impl EngineRequestReceiver for CountingEngineReceiver { fn start( self, - mut request_channel: mpsc::Receiver, + mut request_channel: mpsc::Receiver, ) -> JoinHandle> { let builds_processed = self.builds_processed; tokio::spawn(async move { @@ -94,106 +40,16 @@ impl EngineRequestReceiver for CountingEngineReceiver { return Err(EngineError::ChannelClosed); }; - if let EngineProcessingRequest::Build(build_request) = request { + if let EngineActorRequest::BuildRequest(build_request) = request { builds_processed.fetch_add(1, Ordering::SeqCst); let payload_id = PayloadId::new([0x01; 8]); - let _ = build_request.result_tx.send(payload_id).await; + let _ = build_request.result_tx.send(Ok(payload_id)).await; } } }) } } -#[tokio::test(flavor = "multi_thread")] -async fn follow_restart_delegated_forkchoice_does_not_finalize_past_actual_safe_head() { - let unsafe_head = test_block_info(100); - let delegated_safe_number = 80; - - let initial_state = TestEngineStateBuilder::new() - .with_unsafe_head(unsafe_head) - .with_safe_head(L2BlockInfo::default()) - .with_finalized_head(L2BlockInfo::default()) - .with_el_sync_finished(false) - .build(); - - let client = Arc::new( - test_engine_client_builder() - .with_block_info_by_tag(alloy_eips::BlockNumberOrTag::Latest, unsafe_head) - .with_l2_block_by_label( - alloy_eips::BlockNumberOrTag::Number(delegated_safe_number), - mismatched_block(delegated_safe_number), - ) - .with_fork_choice_updated_v3_response(syncing_fcu()) - .build(), - ); - - let delegated_safe = L2BlockInfo { - block_info: BlockInfo { - number: delegated_safe_number, - hash: B256::from([0xcdu8; 32]), - ..Default::default() - }, - ..Default::default() - }; - - let (state_tx, state_rx) = watch::channel(initial_state); - let (queue_tx, _) = watch::channel(0usize); - let engine = Engine::new(initial_state, state_tx, queue_tx); - - let processor = EngineProcessor::new( - Arc::clone(&client), - Arc::new(RollupConfig::default()), - NoopDerivationClient, - engine, - EngineProcessorOptions { - node_mode: NodeMode::Validator, - unsafe_head_tx: None, - conductor: None, - sequencer_stopped: false, - }, - ); - - let (req_tx, req_rx) = mpsc::channel(8); - let handle = processor.start(req_rx); - - state_rx - .clone() - .wait_for(|state| { - state.sync_state.unsafe_head().block_info.number == unsafe_head.block_info.number - }) - .await - .expect("bootstrap did not seed unsafe head"); - - req_tx - .send(EngineProcessingRequest::ProcessDelegatedForkchoiceUpdate(Box::new( - DelegatedForkchoiceUpdate { - safe_l2: delegated_safe, - finalized_l2_number: Some(delegated_safe_number), - }, - ))) - .await - .expect("failed to send delegated forkchoice update"); - - drop(req_tx); - let result = handle.await.expect("processor task panicked"); - assert!( - matches!(result, Err(EngineError::ChannelClosed)), - "expected ChannelClosed after request channel shutdown, got {result:?}" - ); - - let state = *state_rx.borrow(); - assert_eq!( - state.sync_state.safe_head(), - L2BlockInfo::default(), - "safe head should remain unchanged when the delegated safe FCU returns Syncing", - ); - assert_eq!( - state.sync_state.finalized_head(), - L2BlockInfo::default(), - "finalized head must not advance past the actual engine safe head", - ); -} - #[tokio::test(flavor = "multi_thread")] async fn full_public_rpc_queue_does_not_block_engine_processing_requests() { let cancellation_token = CancellationToken::new(); @@ -238,7 +94,8 @@ async fn full_public_rpc_queue_does_not_block_engine_processing_requests() { let payload_id = tokio::time::timeout(Duration::from_secs(2), payload_id_rx.recv()) .await .expect("build request was blocked behind rpc backpressure") - .expect("build response channel closed"); + .expect("build response channel closed") + .expect("build request failed"); assert_eq!(payload_id, PayloadId::new([0x01; 8])); assert_eq!(builds_processed.load(Ordering::SeqCst), 1); diff --git a/crates/consensus/service/tests/actors/verifier_conf_depth.rs b/crates/consensus/service/tests/actors/verifier_conf_depth.rs index 1d7c28dda6..a04cc8711b 100644 --- a/crates/consensus/service/tests/actors/verifier_conf_depth.rs +++ b/crates/consensus/service/tests/actors/verifier_conf_depth.rs @@ -15,19 +15,22 @@ use std::{ }, }; +use alloy_consensus::Header; use alloy_eips::{BlockId, BlockNumberOrTag}; -use alloy_primitives::B256; -use alloy_rpc_types_eth::{Block, Filter, Log}; +use alloy_primitives::{B256, Bloom, U256}; +use alloy_rpc_types_eth::{Block, Filter, Header as RpcHeader, Log}; use async_trait::async_trait; use base_common_genesis::RollupConfig; use base_consensus_derive::{ChainProvider, PipelineErrorKind}; use base_consensus_node::{ - DerivationClientResult, L1BlockFetcher, L1WatcherActor, L1WatcherDerivationClient, NodeActor, + DerivationClientResult, L1BlockFetcher, L1WatcherActor, L1WatcherDerivationClient, + L1WatcherQueryExecutor, NodeActor, }; use base_consensus_providers::{AlloyChainProviderError, ConfDepthProvider, L1HeadNumber}; +use base_consensus_rpc::L1WatcherQueries; use base_protocol::BlockInfo; use futures::Stream; -use tokio::sync::watch; +use tokio::sync::{oneshot, watch}; use tokio_util::sync::CancellationToken; // --------------------------------------------------------------------------- @@ -45,6 +48,27 @@ impl MockL1Fetcher { fn with_blocks(blocks: impl IntoIterator) -> Self { Self { blocks: blocks.into_iter().map(|b| (b.number, b)).collect() } } + + fn block_info_for_id(&self, id: BlockId) -> Option { + match id { + BlockId::Number(BlockNumberOrTag::Number(number)) => self.blocks.get(&number).copied(), + BlockId::Number(BlockNumberOrTag::Latest) => { + self.blocks.values().max_by_key(|block| block.number).copied() + } + _ => None, + } + } + + fn block(block_info: BlockInfo) -> Block { + Block::empty(RpcHeader::new(Header { + parent_hash: block_info.parent_hash, + number: block_info.number, + timestamp: block_info.timestamp, + logs_bloom: Bloom::ZERO, + difficulty: U256::ZERO, + ..Default::default() + })) + } } #[async_trait] @@ -56,16 +80,7 @@ impl L1BlockFetcher for MockL1Fetcher { } async fn get_block(&self, id: BlockId) -> Result, Self::Error> { - match id { - BlockId::Number(BlockNumberOrTag::Number(number)) => { - if self.blocks.contains_key(&number) { - Ok(Some(Block::default())) - } else { - Ok(None) - } - } - _ => Ok(None), - } + Ok(self.block_info_for_id(id).map(Self::block)) } } @@ -240,6 +255,61 @@ async fn l1_head_atomic_holds_real_head_not_delayed() { // Meanwhile, derivation should have received delayed heads. let heads = derivation_client.heads.lock().unwrap().clone(); assert_eq!(heads.len(), 3, "all three heads should have been forwarded to derivation"); - // Each head is fetched as Block::default() which maps to block number 0. - // The important thing is that derivation received delayed blocks, not the real heads. + assert_eq!( + heads.iter().map(|head| head.number).collect::>(), + vec![10, 20, 40], + "derivation should receive heads delayed by verifier_l1_confs" + ); +} + +#[tokio::test] +async fn sync_status_reports_derivation_origin_separately_from_live_head_with_verifier_confs() { + let conf_depth: u64 = 4; + let l1_head_number: L1HeadNumber = Arc::new(AtomicU64::new(0)); + let blocks: Vec = (90..=100).map(block_at).collect(); + let fetcher = MockL1Fetcher::with_blocks(blocks.clone()); + + let derivation_client = RecordingDerivationClient::default(); + let (l1_head_tx, _l1_head_rx) = watch::channel(None); + let cancel = CancellationToken::new(); + let head_stream: BoxedBlockStream = Box::pin(futures::stream::iter(vec![block_at(100)])); + let finalized_stream: BoxedBlockStream = Box::pin(futures::stream::pending()); + + let actor = L1WatcherActor::new( + Arc::new(RollupConfig::default()), + fetcher, + l1_head_tx, + derivation_client.clone(), + None, + cancel, + head_stream, + finalized_stream, + conf_depth, + Arc::clone(&l1_head_number), + ); + let _ = actor.start(()).await; + + assert_eq!(l1_head_number.load(Ordering::Relaxed), 100); + let heads = derivation_client.heads.lock().unwrap().clone(); + let derivation_origin = heads.last().copied().expect("derivation should receive a head"); + assert_eq!(derivation_origin.number, 96); + + let (_derivation_origin_tx, derivation_origin_rx) = watch::channel(Some(derivation_origin)); + let executor = L1WatcherQueryExecutor::new( + Arc::new(RollupConfig::default()), + Arc::new(MockL1Fetcher::with_blocks(blocks)), + derivation_origin_rx, + ); + let (sender, receiver) = oneshot::channel(); + + executor.execute(L1WatcherQueries::L1State(sender)).await; + + let state = receiver.await.expect("state query should return a response"); + assert_eq!(state.current_l1.map(|block| block.number), Some(96)); + assert_eq!(state.head_l1.map(|block| block.number), Some(100)); + assert_ne!( + state.current_l1.map(|block| block.number), + state.head_l1.map(|block| block.number), + "verifier_l1_confs should make sync status expose derivation origin separately from live head" + ); } diff --git a/crates/execution/chainspec/src/basefee.rs b/crates/execution/chainspec/src/basefee.rs index 2e65730732..4fcdce5ffc 100644 --- a/crates/execution/chainspec/src/basefee.rs +++ b/crates/execution/chainspec/src/basefee.rs @@ -102,14 +102,12 @@ mod tests { base_sepolia_spec .hardforks .insert(BaseUpgrade::Jovian.boxed(), ForkCondition::Timestamp(JOVIAN_TIMESTAMP)); - Arc::new(BaseChainSpec { - inner: ChainSpec { - chain: base_sepolia_spec.chain, - genesis: base_sepolia_spec.genesis, - genesis_header: base_sepolia_spec.genesis_header, - ..Default::default() - }, - }) + Arc::new(BaseChainSpec::from(ChainSpec { + chain: base_sepolia_spec.chain, + genesis: base_sepolia_spec.genesis, + genesis_header: base_sepolia_spec.genesis_header, + ..Default::default() + })) } #[test] diff --git a/crates/execution/chainspec/src/builder.rs b/crates/execution/chainspec/src/builder.rs index d3c9bbc2f3..d055bb9a50 100644 --- a/crates/execution/chainspec/src/builder.rs +++ b/crates/execution/chainspec/src/builder.rs @@ -1,8 +1,8 @@ use alloy_chains::Chain; use alloy_genesis::Genesis; use alloy_hardforks::Hardfork; +use alloy_primitives::Address; use base_common_chains::BaseUpgrade; -use derive_more::From; use reth_chainspec::ChainSpecBuilder; use reth_ethereum_forks::{ChainHardforks, EthereumHardfork, ForkCondition}; use reth_primitives_traits::SealedHeader; @@ -10,10 +10,12 @@ use reth_primitives_traits::SealedHeader; use crate::BaseChainSpec; /// Chain spec builder for a Base chain. -#[derive(Debug, Default, From)] +#[derive(Debug, Default)] pub struct BaseChainSpecBuilder { /// [`ChainSpecBuilder`] inner: ChainSpecBuilder, + /// Activation registry admin address. + activation_admin_address: Option
, } impl BaseChainSpecBuilder { @@ -25,7 +27,7 @@ impl BaseChainSpecBuilder { .genesis(base_mainnet.genesis.clone()); let forks = base_mainnet.hardforks.clone(); inner = inner.with_forks(forks); - Self { inner } + Self { inner, activation_admin_address: base_mainnet.activation_admin_address } } /// Set the chain ID. @@ -52,6 +54,18 @@ impl BaseChainSpecBuilder { self } + /// Set the activation registry admin address. + pub const fn activation_admin_address(mut self, address: Address) -> Self { + self.activation_admin_address = Some(address); + self + } + + /// Set or clear the activation registry admin address. + pub const fn optional_activation_admin_address(mut self, address: Option
) -> Self { + self.activation_admin_address = address; + self + } + /// Remove the given fork from the spec. pub fn without_fork(mut self, fork: BaseUpgrade) -> Self { self.inner = self.inner.without_fork(fork); @@ -150,6 +164,6 @@ impl BaseChainSpecBuilder { &inner.genesis, &inner.hardforks, )); - BaseChainSpec { inner } + BaseChainSpec { inner, activation_admin_address: self.activation_admin_address } } } diff --git a/crates/execution/chainspec/src/spec.rs b/crates/execution/chainspec/src/spec.rs index 15cd9fb1c0..3c5d63eb95 100644 --- a/crates/execution/chainspec/src/spec.rs +++ b/crates/execution/chainspec/src/spec.rs @@ -5,7 +5,7 @@ use alloy_consensus::{BlockHeader, Header, proofs::storage_root_unhashed}; use alloy_eips::eip7840::BlobParams; use alloy_genesis::Genesis; use alloy_hardforks::Hardfork; -use alloy_primitives::{B256, U256}; +use alloy_primitives::{Address, B256, U256}; use base_common_chains::{BaseUpgrade, ChainConfig, Upgrades}; use base_common_consensus::Predeploys; use derive_more::{Constructor, Deref, Into}; @@ -72,7 +72,11 @@ impl GenesisInfo { #[derive(Debug, Clone, Deref, Into, Constructor, PartialEq, Eq)] pub struct BaseChainSpec { /// [`ChainSpec`]. + #[deref] pub inner: ChainSpec, + /// Activation registry admin address. + #[deref(ignore)] + pub activation_admin_address: Option
, } impl BaseChainSpec { @@ -179,6 +183,7 @@ impl TryFrom<&ChainConfig> for BaseChainSpec { prune_delete_limit: cfg.prune_delete_limit, ..Default::default() }, + activation_admin_address: cfg.activation_admin_address, }) } } @@ -281,12 +286,17 @@ impl Upgrades for BaseChainSpec { fn upgrade_activation(&self, fork: BaseUpgrade) -> ForkCondition { self.fork(fork) } + + fn activation_admin_address(&self) -> Option
{ + self.activation_admin_address + } } impl From for BaseChainSpec { fn from(genesis: Genesis) -> Self { let base_genesis_info = GenesisInfo::extract_from(&genesis); let genesis_info = base_genesis_info.base_chain_info.genesis_info.unwrap_or_default(); + let activation_admin_address = genesis_info.activation_admin_address; // Block-based hardforks in canonical fork ID order. let hardfork_opts = [ @@ -367,13 +377,14 @@ impl From for BaseChainSpec { base_fee_params: base_genesis_info.base_fee_params, ..Default::default() }, + activation_admin_address, } } } impl From for BaseChainSpec { fn from(value: ChainSpec) -> Self { - Self { inner: value } + Self { inner: value, activation_admin_address: None } } } @@ -389,7 +400,7 @@ mod tests { use alloy_consensus::proofs::storage_root_unhashed; use alloy_genesis::{ChainConfig as AlloyChainConfig, Genesis}; use alloy_hardforks::Hardfork; - use alloy_primitives::{B256, U256, b256}; + use alloy_primitives::{B256, U256, address, b256}; use base_common_chains::{BaseUpgrade, ChainConfig, Upgrades}; use base_common_rpc_types::FeeInfo; use reth_chainspec::{ @@ -583,6 +594,38 @@ mod tests { assert_eq!(base_fee, 980000000); } + #[test] + fn activation_admin_matches_chain_config() { + assert_eq!( + BaseChainSpec::mainnet().activation_admin_address(), + Some(address!("331C9d37BbcebBC9dfAf98FBE3C5B8A39Dd6E771")) + ); + assert_eq!( + BaseChainSpec::sepolia().activation_admin_address(), + Some(address!("5Be7Dd3678e999D5F7bC508c413db239F7D4Ac59")) + ); + } + + #[test] + fn activation_admin_is_unset_for_default_genesis() { + assert_eq!( + BaseChainSpec::from_genesis(Genesis::default()).activation_admin_address(), + None + ); + } + + #[test] + fn activation_admin_can_be_read_from_genesis() { + let mut genesis = Genesis::default(); + let admin = address!("0xcb00000000000000000000000000000000000000"); + genesis + .config + .extra_fields + .insert("activationAdminAddress".to_string(), serde_json::json!(admin)); + + assert_eq!(BaseChainSpec::from_genesis(genesis).activation_admin_address(), Some(admin)); + } + #[test] fn base_sepolia_genesis() { let base_sepolia_spec = BaseChainSpec::sepolia(); diff --git a/crates/execution/cli/Cargo.toml b/crates/execution/cli/Cargo.toml index 5fcfa7c279..eb2c1d8983 100644 --- a/crates/execution/cli/Cargo.toml +++ b/crates/execution/cli/Cargo.toml @@ -30,6 +30,7 @@ reth-cli-commands.workspace = true reth-node-metrics.workspace = true reth-network-peers.workspace = true reth-rpc-server-types.workspace = true +reth-tasks.workspace = true reth-db = { workspace = true, features = ["mdbx"] } # op-reth @@ -103,10 +104,9 @@ dev = [ serde = [ "alloy-eips/serde", + "base-common-chains/serde", "base-common-consensus/serde", "base-execution-chainspec/serde", "reth-network/serde", "secp256k1/serde", ] - -edge = [ "reth-cli-commands/edge", "reth-node-core/edge" ] diff --git a/crates/execution/cli/src/app.rs b/crates/execution/cli/src/app.rs index 69d2e68900..bf622722a9 100644 --- a/crates/execution/cli/src/app.rs +++ b/crates/execution/cli/src/app.rs @@ -29,8 +29,7 @@ where Ext: clap::Args + fmt::Debug, Rpc: RpcModuleValidator, { - /// Creates a new [`CliApp`] wrapping the given parsed CLI. - pub fn new(cli: Cli) -> Self { + pub(crate) fn new(cli: Cli) -> Self { Self { cli, runner: None, layers: Some(Layers::new()), guard: None } } @@ -96,10 +95,12 @@ where runner.run_command_until_exit(|ctx| command.execute(ctx, launcher)) } Commands::Init(command) => { - runner.run_blocking_until_ctrl_c(command.execute::()) + let runtime = runner.runtime(); + runner.run_blocking_until_ctrl_c(command.execute::(runtime)) } Commands::InitState(command) => { - runner.run_blocking_until_ctrl_c(command.execute::()) + let runtime = runner.runtime(); + runner.run_blocking_until_ctrl_c(command.execute::(runtime)) } Commands::DumpGenesis(command) => runner.run_blocking_until_ctrl_c(command.execute()), Commands::Db(command) => { @@ -116,10 +117,16 @@ where #[cfg(feature = "dev")] Commands::TestVectors(command) => runner.run_until_ctrl_c(command.execute()), Commands::ReExecute(command) => { - runner.run_until_ctrl_c(command.execute::(components)) + let runtime = runner.runtime(); + runner.run_until_ctrl_c(command.execute::(components, runtime)) } Commands::BaseProofs(command) => { - runner.run_blocking_until_ctrl_c(command.execute::()) + let runtime = runner.runtime(); + runner.run_blocking_until_ctrl_c(command.execute::(runtime)) + } + Commands::SnapshotManifest(command) => { + command.execute()?; + Ok(()) } } } @@ -135,7 +142,8 @@ where let otlp_status = runner.block_on(self.cli.traces.init_otlp_tracing(&mut layers))?; let otlp_logs_status = runner.block_on(self.cli.traces.init_otlp_logs(&mut layers))?; - self.guard = self.cli.logs.init_tracing_with_layers(layers)?; + let enable_reload = self.cli.command.debug_namespace_enabled(); + self.guard = self.cli.logs.init_tracing_with_layers(layers, enable_reload)?; info!(target: "reth::cli", log_dir = %self.cli.logs.log_file_directory, "Initialized tracing"); match otlp_status { diff --git a/crates/execution/cli/src/commands/base_proofs/init.rs b/crates/execution/cli/src/commands/base_proofs/init.rs index 9170a11392..6ac3dc219c 100644 --- a/crates/execution/cli/src/commands/base_proofs/init.rs +++ b/crates/execution/cli/src/commands/base_proofs/init.rs @@ -40,12 +40,13 @@ impl> InitCommand { /// Execute the `proofs init` command. pub async fn execute>( self, + runtime: reth_tasks::Runtime, ) -> eyre::Result<()> { info!(target: "reth::cli", version = %version_metadata().short_version, "reth starting"); info!(target: "reth::cli", path = ?self.storage_path, "Initializing Base proofs storage"); // Initialize the environment with read-only access - let Environment { provider_factory, .. } = self.env.init::(AccessRights::RO)?; + let Environment { provider_factory, .. } = self.env.init::(AccessRights::RO, runtime)?; // Create the proofs storage let storage: BaseProofsStorage> = Arc::new( diff --git a/crates/execution/cli/src/commands/base_proofs/mod.rs b/crates/execution/cli/src/commands/base_proofs/mod.rs index 08616f0069..8a2226cb7c 100644 --- a/crates/execution/cli/src/commands/base_proofs/mod.rs +++ b/crates/execution/cli/src/commands/base_proofs/mod.rs @@ -23,11 +23,12 @@ impl> Command { /// Execute `base-proofs` command pub async fn execute>( self, + runtime: reth_tasks::Runtime, ) -> eyre::Result<()> { match self.command { - Subcommands::Init(cmd) => cmd.execute::().await, - Subcommands::Prune(cmd) => cmd.execute::().await, - Subcommands::Unwind(cmd) => cmd.execute::().await, + Subcommands::Init(cmd) => cmd.execute::(runtime.clone()).await, + Subcommands::Prune(cmd) => cmd.execute::(runtime.clone()).await, + Subcommands::Unwind(cmd) => cmd.execute::(runtime).await, } } } diff --git a/crates/execution/cli/src/commands/base_proofs/prune.rs b/crates/execution/cli/src/commands/base_proofs/prune.rs index 2be240924a..185e55a5dd 100644 --- a/crates/execution/cli/src/commands/base_proofs/prune.rs +++ b/crates/execution/cli/src/commands/base_proofs/prune.rs @@ -50,12 +50,13 @@ impl> PruneCommand { /// Execute [`PruneCommand`]. pub async fn execute>( self, + runtime: reth_tasks::Runtime, ) -> eyre::Result<()> { info!(target: "reth::cli", version = %version_metadata().short_version, "reth starting"); info!(target: "reth::cli", path = ?self.storage_path, "Pruning Base proofs storage"); // Initialize the environment with read-only access - let Environment { provider_factory, .. } = self.env.init::(AccessRights::RO)?; + let Environment { provider_factory, .. } = self.env.init::(AccessRights::RO, runtime)?; let storage: BaseProofsStorage> = Arc::new( MdbxProofsStorage::new(&self.storage_path) diff --git a/crates/execution/cli/src/commands/base_proofs/unwind.rs b/crates/execution/cli/src/commands/base_proofs/unwind.rs index 80ab7e0f6a..d4c20ec9b4 100644 --- a/crates/execution/cli/src/commands/base_proofs/unwind.rs +++ b/crates/execution/cli/src/commands/base_proofs/unwind.rs @@ -66,12 +66,13 @@ impl> UnwindCommand { /// Execute [`UnwindCommand`]. pub async fn execute>( self, + runtime: reth_tasks::Runtime, ) -> eyre::Result<()> { info!(target: "reth::cli", version = %version_metadata().short_version, "reth starting"); info!(target: "reth::cli", path = ?self.storage_path, "Unwinding Base proofs storage"); // Initialize the environment with read-only access - let Environment { provider_factory, .. } = self.env.init::(AccessRights::RO)?; + let Environment { provider_factory, .. } = self.env.init::(AccessRights::RO, runtime)?; // Create the proofs storage let storage: BaseProofsStorage> = Arc::new( diff --git a/crates/execution/cli/src/commands/init_state.rs b/crates/execution/cli/src/commands/init_state.rs index fc58d2e784..fcbf4c2035 100644 --- a/crates/execution/cli/src/commands/init_state.rs +++ b/crates/execution/cli/src/commands/init_state.rs @@ -19,8 +19,9 @@ impl> BaseInitStateCommand { /// Execute the `init` command pub async fn execute>( self, + runtime: reth_tasks::Runtime, ) -> eyre::Result<()> { - self.init_state.execute::().await + self.init_state.execute::(runtime).await } } diff --git a/crates/execution/cli/src/commands/migrate_db.rs b/crates/execution/cli/src/commands/migrate_db.rs new file mode 100644 index 0000000000..a0a36c84bd --- /dev/null +++ b/crates/execution/cli/src/commands/migrate_db.rs @@ -0,0 +1,33 @@ +//! Migrate storage from v1 to v2 format. + +use std::sync::Arc; + +use base_alloy_consensus::OpPrimitives; +use base_execution_chainspec::OpChainSpec; +use clap::Parser; +use reth_cli::chainspec::ChainSpecParser; +use reth_cli_commands::common::CliNodeTypes; + +/// Migrate storage from v1 (MDBX-only) to v2 (MDBX + `RocksDB` + static files). +#[derive(Debug, Parser)] +pub struct Command { + #[command(flatten)] + inner: base_migrate_db::Command, +} + +impl> Command { + /// Executes the migration command. + pub async fn execute>( + self, + runtime: reth_tasks::Runtime, + ) -> eyre::Result<()> { + self.inner.execute::(runtime).await + } +} + +impl Command { + /// Returns the chain spec, if configured. + pub const fn chain_spec(&self) -> Option<&Arc> { + self.inner.chain_spec() + } +} diff --git a/crates/execution/cli/src/commands/mod.rs b/crates/execution/cli/src/commands/mod.rs index 74b54bbb6c..a4a29c626b 100644 --- a/crates/execution/cli/src/commands/mod.rs +++ b/crates/execution/cli/src/commands/mod.rs @@ -5,7 +5,9 @@ use std::{fmt, sync::Arc}; use base_execution_chainspec::BaseChainSpec; use clap::Subcommand; use reth_cli_commands::{ - config_cmd, db, dump_genesis, init_cmd, + config_cmd, db, + download::manifest_cmd::SnapshotManifestCommand, + dump_genesis, init_cmd, node::{self, NoArgs}, prune, re_execute, stage, }; @@ -58,6 +60,9 @@ pub enum Commands { /// Manage storage of historical proofs in expanded trie db in fault proof window. #[command(name = "proofs")] BaseProofs(base_proofs::Command), + /// Generate modular chunk archives and a snapshot manifest from a source datadir. + #[command(name = "snapshot-manifest")] + SnapshotManifest(SnapshotManifestCommand), } impl Commands { @@ -77,6 +82,17 @@ impl Commands { Self::TestVectors(_) => None, Self::ReExecute(cmd) => cmd.chain_spec(), Self::BaseProofs(cmd) => cmd.chain_spec(), + Self::SnapshotManifest(_) => None, + } + } + + /// Returns `true` if this is a node command with debug RPC namespace enabled. + pub fn debug_namespace_enabled(&self) -> bool { + match self { + Self::Node(cmd) => { + cmd.rpc.is_namespace_enabled(reth_rpc_server_types::RethRpcModule::Debug) + } + _ => false, } } } diff --git a/crates/execution/cli/src/commands/p2p/bootnode.rs b/crates/execution/cli/src/commands/p2p/bootnode.rs index 20f15cf286..742c013a17 100644 --- a/crates/execution/cli/src/commands/p2p/bootnode.rs +++ b/crates/execution/cli/src/commands/p2p/bootnode.rs @@ -18,7 +18,7 @@ use tokio_stream::StreamExt; use tracing::{info, warn}; /// Start a discovery-only bootnode. -#[derive(Parser, Debug)] +#[derive(Parser, Clone, Debug)] pub struct Command { /// Listen address for the bootnode for discv4 #[arg(long, default_value = "0.0.0.0:30301")] diff --git a/crates/execution/cli/src/lib.rs b/crates/execution/cli/src/lib.rs index 5e3b086ac2..1444c5c3ef 100644 --- a/crates/execution/cli/src/lib.rs +++ b/crates/execution/cli/src/lib.rs @@ -13,6 +13,8 @@ pub mod app; pub mod chainspec; /// Base CLI commands. pub mod commands; +mod node; +pub use node::{ExecutionNodeArgs, ExecutionNodeLaunchConfig}; /// Standard Base execution-node runner wiring. pub mod standard_node; @@ -37,7 +39,7 @@ use reth_node_core::{ // reporting use reth_node_metrics as _; use reth_rpc_server_types::{LenientRpcModuleValidator, RpcModuleValidator}; -pub use standard_node::{StandardBaseRethNode, StandardNodeArgs}; +pub use standard_node::{RpcStandardNodeArgs, StandardBaseRethNode, StandardNodeArgs}; /// The main base-reth cli interface. /// diff --git a/crates/execution/cli/src/node.rs b/crates/execution/cli/src/node.rs new file mode 100644 index 0000000000..3366492c5f --- /dev/null +++ b/crates/execution/cli/src/node.rs @@ -0,0 +1,211 @@ +//! Chainless execution-node arguments and launch helpers. + +use std::{path::PathBuf, sync::Arc}; + +use base_execution_chainspec::BaseChainSpec; +use base_node_runner::LaunchedBaseNode; +use clap::{Args, value_parser}; +use reth_cli_runner::CliContext; +use reth_db::init_db; +use reth_node_builder::NodeBuilder; +use reth_node_core::{ + args::{ + DatabaseArgs, DatadirArgs, DebugArgs, DevArgs, EngineArgs, EraArgs, MetricArgs, + NetworkArgs, PruningArgs, RpcServerArgs, StaticFilesArgs, StorageArgs, TxPoolArgs, + }, + node_config::NodeConfig, + version, +}; +use reth_rpc_server_types::{LenientRpcModuleValidator, RpcModuleValidator}; +use tracing::info; + +use crate::{RpcStandardNodeArgs, StandardNodeArgs}; + +/// Execution node arguments shared by binaries that provide chain selection themselves. +#[derive(Debug, Clone, Args)] +pub struct ExecutionNodeArgs { + /// The path to the configuration file to use. + #[arg(long, value_name = "FILE", verbatim_doc_comment)] + pub config: Option, + + /// Prometheus metrics configuration. + #[command(flatten)] + pub metrics: MetricArgs, + + /// Add a new instance of a node. + /// + /// Configures the ports of the node to avoid conflicts with the defaults. + /// + /// Max number of instances is 200. + #[arg(long, value_name = "INSTANCE", global = true, value_parser = value_parser!(u16).range(1..=200))] + pub instance: Option, + + /// Sets all ports to unused, allowing the OS to choose random unused ports when sockets are + /// bound. + #[arg(long, conflicts_with = "instance", global = true)] + pub with_unused_ports: bool, + + /// All datadir related arguments. + #[command(flatten)] + pub datadir: DatadirArgs, + + /// All networking related arguments. + #[command(flatten)] + pub network: NetworkArgs, + + /// All rpc related arguments. + #[command(flatten)] + pub rpc: RpcServerArgs, + + /// All txpool related arguments with --txpool prefix. + #[command(flatten)] + pub txpool: TxPoolArgs, + + /// All debug related arguments with --debug prefix. + #[command(flatten)] + pub debug: DebugArgs, + + /// All database related arguments. + #[command(flatten)] + pub db: DatabaseArgs, + + /// All dev related arguments with --dev prefix. + #[command(flatten)] + pub dev: DevArgs, + + /// All pruning related arguments. + #[command(flatten)] + pub pruning: PruningArgs, + + /// Engine cli arguments. + #[command(flatten, next_help_heading = "Engine")] + pub engine: EngineArgs, + + /// All ERA related arguments with --era prefix. + #[command(flatten, next_help_heading = "ERA")] + pub era: EraArgs, + + /// All static files related arguments with --static-files prefix. + #[command(flatten, next_help_heading = "Static Files")] + pub static_files: StaticFilesArgs, + + /// All storage related arguments with --storage prefix. + #[command(flatten, next_help_heading = "Storage")] + pub storage: StorageArgs, + + /// Standard Base execution-node extension arguments. + #[command(flatten)] + pub standard: RpcStandardNodeArgs, +} + +impl ExecutionNodeArgs { + /// Converts parsed args into a launchable execution node configuration. + pub fn into_launch_config(self, chain: Arc) -> ExecutionNodeLaunchConfig { + let Self { + config, + metrics, + instance, + with_unused_ports, + datadir, + network, + rpc, + txpool, + debug, + db, + dev, + pruning, + engine, + era, + static_files, + storage, + standard, + } = self; + + let node_config = NodeConfig { + datadir, + config, + chain, + metrics, + instance, + network, + rpc, + txpool, + builder: Default::default(), + debug, + db, + dev, + pruning, + engine, + era, + static_files, + storage, + }; + + ExecutionNodeLaunchConfig { node_config, standard: standard.into(), with_unused_ports } + } +} + +/// A chain-injected execution node configuration ready to launch. +#[derive(Debug, Clone)] +pub struct ExecutionNodeLaunchConfig { + /// Reth node configuration. + pub node_config: NodeConfig, + /// Standard Base execution-node extension arguments. + pub standard: StandardNodeArgs, + /// Whether all ports should be assigned by the OS. + pub with_unused_ports: bool, +} + +impl ExecutionNodeLaunchConfig { + /// Enables authenticated Engine API over IPC. + pub const fn with_auth_ipc(mut self) -> Self { + self.node_config.rpc.auth_ipc = true; + self + } + + /// Returns the configured authenticated Engine API IPC path. + pub const fn auth_ipc_path(&self) -> &str { + self.node_config.rpc.auth_ipc_path.as_str() + } + + /// Launches the execution node and returns its handle. + pub async fn launch(mut self, ctx: CliContext) -> eyre::Result + where + Rpc: RpcModuleValidator, + { + if let Some(http_api) = &self.node_config.rpc.http_api { + Rpc::validate_selection(http_api, "http.api").map_err(|e| eyre::eyre!("{e}"))?; + } + if let Some(ws_api) = &self.node_config.rpc.ws_api { + Rpc::validate_selection(ws_api, "ws.api").map_err(|e| eyre::eyre!("{e}"))?; + } + + info!( + target: "reth::cli", + version = ?version::version_metadata().short_version, + client = %version::version_metadata().name_client, + "Starting client" + ); + + if self.with_unused_ports { + self.node_config = self.node_config.with_unused_ports(); + } + + let data_dir = self.node_config.datadir(); + let db_path = data_dir.db(); + info!(target: "reth::cli", path = ?db_path, "Opening database"); + let database = + init_db(db_path.clone(), self.node_config.db.database_args())?.with_metrics(); + + let builder = NodeBuilder::new(self.node_config) + .with_database(database) + .with_launch_context(ctx.task_executor); + + crate::StandardBaseRethNode::launch(builder, self.standard).await + } + + /// Launches the execution node with the default RPC module validator. + pub async fn launch_default(self, ctx: CliContext) -> eyre::Result { + self.launch::(ctx).await + } +} diff --git a/crates/execution/cli/src/standard_node.rs b/crates/execution/cli/src/standard_node.rs index c170c254cb..857c8295df 100644 --- a/crates/execution/cli/src/standard_node.rs +++ b/crates/execution/cli/src/standard_node.rs @@ -19,39 +19,9 @@ use url::Url; #[derive(Debug, Clone, PartialEq, Eq, clap::Args)] #[command(next_help_heading = "Rollup")] pub struct StandardNodeArgs { - /// Rollup arguments. + /// Shared execution node arguments. #[command(flatten)] - pub rollup_args: RollupArgs, - - /// A URL pointing to a secure websocket subscription that streams out flashblocks. - /// - /// If given, the flashblocks are received to build pending block. All request with "pending" - /// block tag will use the pending state based on flashblocks. - #[arg(long, alias = "websocket-url")] - pub flashblocks_url: Option, - - /// The max pending blocks depth. - #[arg( - long = "max-pending-blocks-depth", - value_name = "MAX_PENDING_BLOCKS_DEPTH", - default_value = "3" - )] - pub max_pending_blocks_depth: u64, - - /// Enable cached execution via the flashblocks-aware engine validator. - #[arg(long = "flashblocks.cached-execution", requires = "flashblocks_url")] - pub flashblocks_cached_execution: bool, - - /// Enable transaction tracing for mempool-to-block timing analysis - #[arg(long = "enable-transaction-tracing", value_name = "ENABLE_TRANSACTION_TRACING")] - pub enable_transaction_tracing: bool, - - /// Enable `info` logs for transaction tracing - #[arg( - long = "enable-transaction-tracing-logs", - value_name = "ENABLE_TRANSACTION_TRACING_LOGS" - )] - pub enable_transaction_tracing_logs: bool, + pub rpc: RpcStandardNodeArgs, /// Enable metering RPC for transaction bundle simulation #[arg(long = "enable-metering", value_name = "ENABLE_METERING")] @@ -139,11 +109,70 @@ pub struct StandardNodeArgs { pub tx_forwarding_max_rps: u32, } +/// CLI arguments for a Base execution node embedded by the unified RPC command. +#[derive(Debug, Clone, PartialEq, Eq, clap::Args)] +#[command(next_help_heading = "Rollup")] +pub struct RpcStandardNodeArgs { + /// Rollup arguments. + #[command(flatten)] + pub rollup_args: RollupArgs, + + /// A URL pointing to a secure websocket subscription that streams out flashblocks. + /// + /// If given, the flashblocks are received to build pending block. All request with "pending" + /// block tag will use the pending state based on flashblocks. + #[arg(long, alias = "websocket-url")] + pub flashblocks_url: Option, + + /// The max pending blocks depth. + #[arg( + long = "max-pending-blocks-depth", + value_name = "MAX_PENDING_BLOCKS_DEPTH", + default_value = "3" + )] + pub max_pending_blocks_depth: u64, + + /// Enable cached execution via the flashblocks-aware engine validator. + #[arg(long = "flashblocks.cached-execution", requires = "flashblocks_url")] + pub flashblocks_cached_execution: bool, + + /// Enable transaction tracing for mempool-to-block timing analysis + #[arg(long = "enable-transaction-tracing", value_name = "ENABLE_TRANSACTION_TRACING")] + pub enable_transaction_tracing: bool, + + /// Enable `info` logs for transaction tracing + #[arg( + long = "enable-transaction-tracing-logs", + value_name = "ENABLE_TRANSACTION_TRACING_LOGS" + )] + pub enable_transaction_tracing_logs: bool, +} + +impl From for StandardNodeArgs { + fn from(args: RpcStandardNodeArgs) -> Self { + Self { + rpc: args, + enable_metering: false, + metering_gas_limit: None, + metering_execution_time_us: None, + metering_state_root_time_us: None, + metering_da_bytes: None, + metering_target_flashblocks_per_block: None, + metering_metered_opcodes: Vec::new(), + enable_tx_forwarding: false, + builder_rpc_urls: Vec::new(), + tx_forwarding_resend_after_ms: DEFAULT_RESEND_AFTER_MS, + tx_forwarding_batch_size: DEFAULT_MAX_BATCH_SIZE, + tx_forwarding_max_rps: DEFAULT_MAX_RPS, + } + } +} + impl From<&StandardNodeArgs> for Option { fn from(args: &StandardNodeArgs) -> Self { - args.flashblocks_url.clone().map(|url| { - let mut config = FlashblocksConfig::new(url, args.max_pending_blocks_depth); - config.cached_execution = args.flashblocks_cached_execution; + args.rpc.flashblocks_url.clone().map(|url| { + let mut config = FlashblocksConfig::new(url, args.rpc.max_pending_blocks_depth); + config.cached_execution = args.rpc.flashblocks_cached_execution; config }) } @@ -169,18 +198,18 @@ pub struct StandardBaseRethNode; impl StandardBaseRethNode { /// Builds a runner with the standard Base execution-node extensions installed. pub fn runner(args: StandardNodeArgs) -> eyre::Result { - let mut runner = BaseNodeRunner::new(args.rollup_args.clone()); + let mut runner = BaseNodeRunner::new(args.rpc.rollup_args.clone()); // Create flashblocks config first so we can share its state with metering. let flashblocks_config: Option = (&args).into(); // Feature extensions (FlashblocksExtension must be last - uses replace_configured). runner.install_ext::(TxPoolRpcConfig { - sequencer_rpc: args.rollup_args.sequencer.clone(), + sequencer_rpc: args.rpc.rollup_args.sequencer.clone(), }); runner.install_ext::(TxpoolConfig { - tracing_enabled: args.enable_transaction_tracing, - tracing_logs_enabled: args.enable_transaction_tracing_logs, + tracing_enabled: args.rpc.enable_transaction_tracing, + tracing_logs_enabled: args.rpc.enable_transaction_tracing_logs, flashblocks_config: flashblocks_config.clone(), }); @@ -214,7 +243,7 @@ impl StandardBaseRethNode { runner.install_ext::(()); runner.install_ext::((&args).into()); runner.install_ext::(flashblocks_config); - runner.install_ext::(args.rollup_args); + runner.install_ext::(args.rpc.rollup_args); Ok(runner) } diff --git a/crates/execution/consensus/src/lib.rs b/crates/execution/consensus/src/lib.rs index 77a4315669..bcb5758f34 100644 --- a/crates/execution/consensus/src/lib.rs +++ b/crates/execution/consensus/src/lib.rs @@ -116,7 +116,9 @@ where // Check empty shanghai-withdrawals if self.chain_spec.is_canyon_active_at_timestamp(block.timestamp()) { canyon::ensure_empty_shanghai_withdrawals(block.body()).map_err(|err| { - ConsensusError::Other(format!("failed to verify block {}: {err}", block.number())) + ConsensusError::Other(Arc::from(Box::::from( + format!("failed to verify block {}: {err}", block.number()), + ))) })? } else { return Ok(()); @@ -136,7 +138,9 @@ where if self.chain_spec.is_isthmus_active_at_timestamp(block.timestamp()) { // storage root of withdrawals pre-deploy is verified post-execution isthmus::ensure_withdrawals_storage_root_is_some(block.header()).map_err(|err| { - ConsensusError::Other(format!("failed to verify block {}: {err}", block.number())) + ConsensusError::Other(Arc::from(Box::::from( + format!("failed to verify block {}: {err}", block.number()), + ))) })? } else { // canyon is active, else would have returned already diff --git a/crates/execution/engine-tree/src/cached_execution.rs b/crates/execution/engine-tree/src/cached_execution.rs index cc4efb2f47..a59432bf74 100644 --- a/crates/execution/engine-tree/src/cached_execution.rs +++ b/crates/execution/engine-tree/src/cached_execution.rs @@ -10,7 +10,7 @@ use base_flashblocks::{FlashblocksAPI, FlashblocksState}; use reth_errors::BlockExecutionError; use reth_evm::{ Evm, RecoveredTx, - block::{BlockExecutor, ExecutableTx, InternalBlockExecutionError, TxResult}, + block::{BlockExecutor, ExecutableTx, GasOutput, InternalBlockExecutionError, TxResult}, }; use reth_primitives_traits::Recovered; use reth_revm::State; @@ -218,7 +218,7 @@ where self.executor.apply_pre_execution_changes() } - fn commit_transaction(&mut self, output: Self::Result) -> Result { + fn commit_transaction(&mut self, output: Self::Result) -> GasOutput { self.executor.commit_transaction(output) } @@ -303,11 +303,10 @@ mod tests { builder.build().expect("test pending blocks should build") } - const fn stub_execution_result() -> ExecutionResult { + fn stub_execution_result() -> ExecutionResult { ExecutionResult::Success { reason: revm::context::result::SuccessReason::Stop, - gas_used: 21_000, - gas_refunded: 0, + gas: revm::context::result::ResultGas::new_with_state_gas(21_000, 0, 0, 0), logs: Vec::new(), output: revm::context::result::Output::Call(Bytes::new()), } @@ -363,6 +362,7 @@ mod tests { inner: Recovered::new_unchecked(envelope, Address::ZERO), block_hash: Some(B256::ZERO), block_number: Some(block_number), + block_timestamp: None, transaction_index: Some(0), effective_gas_price: Some(1_000_000_000), }, diff --git a/crates/execution/engine-tree/src/lib.rs b/crates/execution/engine-tree/src/lib.rs index ed534bab4c..376bd68944 100644 --- a/crates/execution/engine-tree/src/lib.rs +++ b/crates/execution/engine-tree/src/lib.rs @@ -2,6 +2,8 @@ #![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] +use alloy_eip7928 as _; + mod cached_execution; pub use cached_execution::{ CachedExecutionProvider, CachedExecutor, FlashblocksCachedExecutionProvider, diff --git a/crates/execution/engine-tree/src/validator.rs b/crates/execution/engine-tree/src/validator.rs index 8769b98236..1c2403c0a4 100644 --- a/crates/execution/engine-tree/src/validator.rs +++ b/crates/execution/engine-tree/src/validator.rs @@ -5,7 +5,11 @@ use std::{ collections::HashMap, fmt::Debug, panic::{self, AssertUnwindSafe}, - sync::{Arc, mpsc::RecvTimeoutError}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc::RecvTimeoutError, + }, time::Instant, }; @@ -13,7 +17,6 @@ use alloy_consensus::{ Header, transaction::{Either, TxHashRef}, }; -use alloy_eip7928::BlockAccessList; use alloy_eips::eip2718::Decodable2718; use alloy_evm::Evm; use alloy_primitives::B256; @@ -36,17 +39,17 @@ use reth_engine_primitives::{ }; use reth_engine_tree::tree::{ CachedStateProvider, EngineApiMetrics, EngineApiTreeState, EngineValidator, ExecutionEnv, - PayloadHandle, PayloadProcessor, StateProviderBuilder, TreeConfig, + PayloadHandle, PayloadProcessor, SavedCache, StateProviderBuilder, TreeConfig, WaitForCaches, error::{InsertBlockError, InsertBlockErrorKind, InsertPayloadError}, instrumented_state::InstrumentedStateProvider, + payload_processor::multiproof::StateRootHandle, payload_validator::{BlockOrPayload, TreeCtx, ValidationOutcome}, precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap}, receipt_root_task::{IndexedReceipt, ReceiptRootTaskHandle}, - sparse_trie::StateRootComputeOutcome, }; use reth_errors::{BlockExecutionError, ProviderResult}; use reth_evm::{ - ConfigureEvm, EvmEnvFor, ExecutionCtxFor, SpecFor, block::BlockExecutor, + ConfigureEvm, EvmEnvFor, ExecutionCtxFor, OnStateHook, SpecFor, block::BlockExecutor, execute::ExecutableTxFor, }; use reth_node_api::{AddOnsContext, FullNodeComponents, FullNodeTypes, NodeTypes}; @@ -65,14 +68,18 @@ use reth_provider::{ BlockExecutionOutput, BlockNumReader, BlockReader, ChainSpecProvider, ChangeSetReader, DatabaseProviderFactory, DatabaseProviderROFactory, HashedPostStateProvider, ProviderError, PruneCheckpointReader, StageCheckpointReader, StateProvider, StateProviderFactory, StateReader, - StorageChangeSetReader, StorageSettingsCache, providers::OverlayStateProviderFactory, + StorageChangeSetReader, StorageSettingsCache, + providers::{OverlayBuilder, OverlayStateProviderFactory}, }; use reth_revm::{ database::StateProviderDatabase, db::{State, states::bundle_state::BundleRetention}, }; -use reth_trie::{HashedPostState, StateRoot, updates::TrieUpdates}; -use reth_trie_parallel::root::{ParallelStateRoot, ParallelStateRootError}; +use reth_trie::{HashedPostState, StateRoot, trie_cursor::TrieCursorFactory, updates::TrieUpdates}; +use reth_trie_parallel::{ + root::{ParallelStateRoot, ParallelStateRootError}, + state_root_task::StateRootComputeOutcome, +}; use revm_primitives::Address; use tracing::{debug, debug_span, error, info, instrument, trace, warn}; @@ -376,9 +383,11 @@ where match $expr { Ok(val) => val, Err(e) => { - return Err( - InsertBlockError::new($block.into_sealed_block(), e.into()).into() + return Err(InsertBlockError::new( + $block.into_sealed_block(), + InsertBlockErrorKind::from(e), ) + .into()) } } }; @@ -417,13 +426,22 @@ where .in_scope(|| self.evm_env_for(&input)) .map_err(NewPayloadError::other)?; + let decoded_bal = ensure_ok!( + input + .try_decoded_access_list() + .map_err(Box::::from) + ) + .map(Arc::new); + let env = ExecutionEnv { evm_env, hash: input.hash(), parent_hash: input.parent_hash(), parent_state_root: parent_block.state_root(), transaction_count: input.transaction_count(), + gas_used: input.gas_used(), withdrawals: input.withdrawals().map(|w| w.to_vec()), + decoded_bal, }; // Plan the strategy used for state root computation. @@ -438,26 +456,17 @@ where // Get an iterator over the transactions in the payload let txs = self.tx_iterator_for(&input)?; - // Extract the BAL, if valid and available - let block_access_list = ensure_ok!( - input - .block_access_list() - .transpose() - // Eventually gets converted to a `InsertBlockErrorKind::Other` - .map_err(Box::::from) - ) - .map(Arc::new); - // Create lazy overlay from ancestors - this doesn't block, allowing execution to start // before the trie data is ready. The overlay will be computed on first access. let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, ctx.state()); // Create overlay factory for payload processor (StateRootTask path needs it for // multiproofs) - let overlay_factory = - OverlayStateProviderFactory::new(self.provider.clone(), self.changeset_cache.clone()) - .with_block_hash(Some(anchor_hash)) + let overlay_builder = + OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) .with_lazy_overlay(lazy_overlay); + let overlay_factory = + OverlayStateProviderFactory::new(self.provider.clone(), overlay_builder); // Spawn the appropriate processor based on strategy let mut handle = ensure_ok!(self.spawn_payload_processor( @@ -466,7 +475,6 @@ where provider_builder.clone(), overlay_factory.clone(), strategy, - block_access_list, )); // Use cached state provider before executing, used in execution after prewarming threads @@ -535,12 +543,20 @@ where match self.execute_and_trace_block(state_provider, env, &input, tracer, &mut handle) { Ok(output) => output, - Err(err) => return self.handle_execution_error(input, err, &parent_block), + Err(err) => { + return self + .handle_execution_error(input, err, &parent_block) + .map(|executed_block| (executed_block, None)); + } } } None => match self.execute_block(state_provider, env, &input, &mut handle) { Ok(output) => output, - Err(err) => return self.handle_execution_error(input, err, &parent_block), + Err(err) => { + return self + .handle_execution_error(input, err, &parent_block) + .map(|executed_block| (executed_block, None)); + } }, }; @@ -599,7 +615,7 @@ where ); match task_result { - Ok(StateRootComputeOutcome { state_root, trie_updates }) => { + Ok(StateRootComputeOutcome { state_root, trie_updates, .. }) => { let elapsed = root_time.elapsed(); info!(target: "engine::tree::payload_validator", ?state_root, ?elapsed, "State root task finished"); @@ -633,7 +649,7 @@ where ?elapsed, "Regular root task finished" ); - maybe_state_root = Some((result.0, result.1, elapsed)); + maybe_state_root = Some((result.0, Arc::new(result.1), elapsed)); } Err(error) => { debug!(target: "engine::tree::payload_validator", %error, "Parallel state root computation failed"); @@ -668,10 +684,14 @@ where self.metrics.block_validation.state_root_task_fallback_success_total.increment(1); } - (root, updates, root_time.elapsed()) + (root, Arc::new(updates), root_time.elapsed()) }; - self.metrics.block_validation.record_state_root(&trie_output, root_elapsed.as_secs_f64()); + self.metrics + .block_validation + .record_state_root(trie_output.as_ref(), root_elapsed.as_secs_f64()); + self.metrics + .record_state_root_gas_bucket(block.header().gas_used(), root_elapsed.as_secs_f64()); debug!(target: "engine::tree::payload_validator", ?root_elapsed, "Calculated state root"); // ensure state root matches @@ -681,7 +701,7 @@ where &parent_block, &block, &output, - Some((&trie_output, state_root)), + Some((trie_output.as_ref(), state_root)), ctx.state_mut(), ); let block_state_root = block.header().state_root(); @@ -704,13 +724,19 @@ where guard.mark_verified(); } - Ok(self.spawn_deferred_trie_task( - block, - output, - &ctx, - hashed_state, - trie_output, - overlay_factory, + let changeset_provider = + ensure_ok_post_block!(overlay_factory.database_provider_ro(), block); + + Ok(( + self.spawn_deferred_trie_task( + block, + output, + &ctx, + hashed_state, + trie_output, + changeset_provider, + ), + None, )) } @@ -782,7 +808,6 @@ where State::builder() .with_database(StateProviderDatabase::new(state_provider)) .with_bundle_update() - .without_state_clear() .build() }); @@ -810,19 +835,21 @@ where if !self.config.precompile_cache_disabled() { let _span = debug_span!(target: "engine::tree", "setup_precompile_cache").entered(); - executor.evm_mut().precompiles_mut().map_pure_precompiles(|address, precompile| { - let metrics = self - .precompile_cache_metrics - .entry(*address) - .or_insert_with(|| CachedPrecompileMetrics::new_with_address(*address)) - .clone(); - CachedPrecompile::wrap( - precompile, - self.precompile_cache_map.cache_for_address(*address), - spec_id, - Some(metrics), - ) - }); + executor.evm_mut().precompiles_mut().map_cacheable_precompiles( + |address, precompile| { + let metrics = self + .precompile_cache_metrics + .entry(*address) + .or_insert_with(|| CachedPrecompileMetrics::new_with_address(*address)) + .clone(); + CachedPrecompile::wrap( + precompile, + self.precompile_cache_map.cache_for_address(*address), + spec_id, + Some(metrics), + ) + }, + ); } let txs = match &input { @@ -850,10 +877,15 @@ where let (receipt_tx, receipt_rx) = crossbeam_channel::unbounded(); let (result_tx, result_rx) = tokio::sync::oneshot::channel(); let task_handle = ReceiptRootTaskHandle::new(receipt_rx, result_tx); - self.payload_processor.executor().spawn_blocking(move || task_handle.run(receipts_len)); + self.payload_processor + .executor() + .spawn_blocking_named("receipt-root", move || task_handle.run(receipts_len)); let transaction_count = input.transaction_count(); - let executor = executor.with_state_hook(Some(Box::new(handle.state_hook()))); + let executed_tx_index = Arc::clone(handle.executed_tx_index()); + let executor = executor.with_state_hook( + handle.state_hook().map(|hook| Box::new(hook) as Box), + ); let execution_start = Instant::now(); @@ -863,6 +895,7 @@ where transaction_count, handle.iter_transactions(), &receipt_tx, + &executed_tx_index, )?; drop(receipt_tx); @@ -881,6 +914,7 @@ where let execution_duration = execution_start.elapsed(); self.metrics.record_block_execution(&output, execution_duration); + self.metrics.record_block_execution_gas_bucket(output.result.gas_used, execution_duration); debug!(target: "engine::tree::payload_validator", elapsed = ?execution_duration, "Executed block"); Ok((output, senders, result_rx)) @@ -935,7 +969,6 @@ where State::builder() .with_database(StateProviderDatabase::new(state_provider)) .with_bundle_update() - .without_state_clear() .build() }); @@ -969,7 +1002,7 @@ where if !self.config.precompile_cache_disabled() { let _span = debug_span!(target: "engine::tree", "setup_precompile_cache").entered(); - executor.evm_mut().precompiles_mut().map_pure_precompiles(|address, precompile| { + executor.evm_mut().precompiles_mut().map_cacheable_precompiles(|address, precompile| { let metrics = self .precompile_cache_metrics .entry(*address) @@ -992,7 +1025,10 @@ where self.payload_processor.executor().spawn_blocking(move || task_handle.run(receipts_len)); let transaction_count = input.transaction_count(); - let executor = executor.with_state_hook(Some(Box::new(handle.state_hook()))); + let executed_tx_index = Arc::clone(handle.executed_tx_index()); + let executor = executor.with_state_hook( + handle.state_hook().map(|hook| Box::new(hook) as Box), + ); let execution_start = Instant::now(); @@ -1001,6 +1037,7 @@ where transaction_count, handle.iter_transactions(), &receipt_tx, + &executed_tx_index, )?; drop(receipt_tx); @@ -1037,6 +1074,7 @@ where transaction_count: usize, transactions: impl Iterator>, receipt_tx: &crossbeam_channel::Sender>, + executed_tx_index: &AtomicUsize, ) -> Result<(E, Vec
), BlockExecutionError> where E: BlockExecutor, @@ -1082,6 +1120,7 @@ where let tx_start = Instant::now(); executor.execute_transaction(tx)?; self.metrics.record_transaction_execution(tx_start.elapsed()); + executed_tx_index.store(senders.len(), Ordering::Relaxed); let current_len = executor.receipts().len(); if current_len > last_sent_len { @@ -1111,7 +1150,7 @@ where #[instrument(level = "debug", target = "engine::tree::payload_validator", skip_all)] fn compute_state_root_parallel( &self, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &HashedPostState, ) -> Result<(B256, TrieUpdates), ParallelStateRootError> { // The `hashed_state` argument will be taken into account as part of the overlay, but we @@ -1130,7 +1169,7 @@ where /// [`HashedPostState`] containing the changes of this block, to compute the state root and /// trie updates for this block. fn compute_state_root_serial( - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &HashedPostState, ) -> ProviderResult<(B256, TrieUpdates)> { // The `hashed_state` argument will be taken into account as part of the overlay, but we @@ -1169,7 +1208,7 @@ where fn await_state_root_with_timeout( &self, handle: &mut PayloadHandle, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, hashed_state: &HashedPostState, ) -> ProviderResult> { let Some(timeout) = self.config.state_root_task_timeout() else { @@ -1224,7 +1263,10 @@ where )) })?; let (state_root, trie_updates) = result?; - return Ok(Ok(StateRootComputeOutcome { state_root, trie_updates })); + return Ok(Ok(StateRootComputeOutcome { + state_root, + trie_updates: trie_updates.into(), + })); } Err(RecvTimeoutError::Timeout) => {} } @@ -1236,7 +1278,10 @@ where "State root timeout race won" ); let (state_root, trie_updates) = result?; - return Ok(Ok(StateRootComputeOutcome { state_root, trie_updates })); + return Ok(Ok(StateRootComputeOutcome { + state_root, + trie_updates: trie_updates.into(), + })); } } } @@ -1353,9 +1398,8 @@ where env: ExecutionEnv, txs: T, provider_builder: StateProviderBuilder, - overlay_factory: OverlayStateProviderFactory

, + overlay_factory: OverlayStateProviderFactory, strategy: StateRootStrategy, - block_access_list: Option>, ) -> Result< PayloadHandle< impl ExecutableTxFor + use, @@ -1375,7 +1419,6 @@ where provider_builder, overlay_factory, &self.config, - block_access_list, ); // record prewarming initialization duration @@ -1388,12 +1431,8 @@ where } StateRootStrategy::Parallel | StateRootStrategy::Synchronous => { let start = Instant::now(); - let handle = self.payload_processor.spawn_cache_exclusive( - env, - txs, - provider_builder, - block_access_list, - ); + let handle = + self.payload_processor.spawn_cache_exclusive(env, txs, provider_builder); // Record prewarming initialization duration self.metrics @@ -1479,7 +1518,7 @@ where fn get_parent_lazy_overlay( parent_hash: B256, state: &EngineApiTreeState, - ) -> (Option, B256) { + ) -> (Option>, B256) { // Get blocks leading to the parent to determine the anchor let (anchor_hash, blocks) = state.tree_state().blocks_by_hash(parent_hash).unwrap_or_else(|| (parent_hash, vec![])); @@ -1508,10 +1547,7 @@ where "Creating lazy overlay for in-memory blocks" ); - // Extract deferred trie data handles (non-blocking) - let handles: Vec = blocks.iter().map(|b| b.trie_data_handle()).collect(); - - (Some(LazyOverlay::new(anchor_hash, handles)), anchor_hash) + (Some(LazyOverlay::::new(blocks)), anchor_hash) } /// Spawns a background task to compute and sort trie data for the executed block. @@ -1536,8 +1572,8 @@ where execution_outcome: Arc>, ctx: &TreeCtx<'_, BasePrimitives>, hashed_state: HashedPostState, - trie_output: TrieUpdates, - overlay_factory: OverlayStateProviderFactory

, + trie_output: Arc, + changeset_provider: impl TrieCursorFactory + Send + 'static, ) -> ExecutedBlock { // Capture parent hash and ancestor overlays for deferred trie input construction. let (anchor_hash, overlay_blocks) = ctx @@ -1552,19 +1588,15 @@ where overlay_blocks.iter().rev().map(|b| b.trie_data_handle()).collect(); // Create deferred handle with fallback inputs in case the background task hasn't completed. - let deferred_trie_data = DeferredTrieData::pending( - Arc::new(hashed_state), - Arc::new(trie_output), - anchor_hash, - ancestors, - ); + let deferred_trie_data = + DeferredTrieData::pending(Arc::new(hashed_state), trie_output, anchor_hash, ancestors); let deferred_handle_task = deferred_trie_data.clone(); let block_validation_metrics = self.metrics.block_validation.clone(); // Capture block info and cache handle for changeset computation let block_hash = block.hash(); let block_number = block.number(); - let changeset_cache = self.changeset_cache.clone(); + let pending_changeset_guard = self.changeset_cache.register_pending(block_hash); // Spawn background task to compute trie data. Calling `wait_cloned` will compute from // the stored inputs and cache the result, so subsequent calls return immediately. @@ -1599,20 +1631,13 @@ where .record(anchored.trie_input.state.total_len() as f64); } - // Compute and cache changesets using the computed trie_updates + // Compute and cache changesets using the computed trie_updates. let changeset_start = Instant::now(); - // Get a provider from the overlay factory for trie cursor access - let changeset_result = - overlay_factory.database_provider_ro().and_then(|provider| { - reth_trie::changesets::compute_trie_changesets( - &provider, - &computed.trie_updates, - ) - .map_err(ProviderError::Database) - }); - - match changeset_result { + match reth_trie::changesets::compute_trie_changesets( + &changeset_provider, + &computed.trie_updates, + ) { Ok(changesets) => { debug!( target: "engine::tree::changeset", @@ -1621,7 +1646,7 @@ where "Computed and caching changesets" ); - changeset_cache.insert(block_hash, block_number, Arc::new(changesets)); + pending_changeset_guard.resolve(block_number, Arc::new(changesets)); } Err(e) => { warn!( @@ -1643,7 +1668,9 @@ where }; // Spawn task that computes trie data asynchronously. - self.payload_processor.executor().spawn_blocking(compute_trie_input_task); + self.payload_processor + .executor() + .spawn_blocking_named("trie-input", compute_trie_input_task); ExecutedBlock::with_deferred_trie_data( Arc::new(block), @@ -1740,6 +1767,40 @@ where &block.execution_output.state, ); } + + fn cache_for(&self, block_hash: B256) -> Option { + Some(self.payload_processor.cache_for(block_hash)) + } + + fn sparse_trie_handle_for( + &self, + parent_hash: B256, + parent_state_root: B256, + state: &EngineApiTreeState, + ) -> Option { + let (lazy_overlay, anchor_hash) = Self::get_parent_lazy_overlay(parent_hash, state); + let overlay_builder = + OverlayBuilder::::new(anchor_hash, self.changeset_cache.clone()) + .with_lazy_overlay(lazy_overlay); + let overlay_factory = + OverlayStateProviderFactory::new(self.provider.clone(), overlay_builder); + + Some(self.payload_processor.spawn_state_root( + overlay_factory, + parent_state_root, + false, + &self.config, + )) + } +} + +impl WaitForCaches for BaseEngineValidator +where + Evm: ConfigureEvm, +{ + fn wait_for_caches(&self) -> reth_engine_tree::tree::CacheWaitDurations { + self.payload_processor.wait_for_caches() + } } /// Basic implementation of [`EngineValidatorBuilder`]. diff --git a/crates/execution/evm/src/build.rs b/crates/execution/evm/src/build.rs index 71ac6992a1..b0e93033c6 100644 --- a/crates/execution/evm/src/build.rs +++ b/crates/execution/evm/src/build.rs @@ -113,6 +113,8 @@ impl BaseBlockAssembler { blob_gas_used, excess_blob_gas, requests_hash, + block_access_list_hash: None, + slot_number: None, }; Ok(Block::new( diff --git a/crates/execution/evm/src/config.rs b/crates/execution/evm/src/config.rs index e9f854d16f..abdb56bc18 100644 --- a/crates/execution/evm/src/config.rs +++ b/crates/execution/evm/src/config.rs @@ -21,7 +21,7 @@ use base_execution_chainspec::BaseChainSpec; use reth_chainspec::EthChainSpec; #[cfg(feature = "std")] use reth_evm::{ConfigureEngineEvm, EvmEnvFor, ExecutableTxIterator, ExecutionCtxFor}; -use reth_evm::{ConfigureEvm, EvmEnv, TransactionEnv, precompiles::PrecompilesMap}; +use reth_evm::{ConfigureEvm, EvmEnv, TransactionEnvMut, precompiles::PrecompilesMap}; use reth_primitives_traits::{NodePrimitives, SealedBlock, SealedHeader, SignedTransaction}; #[cfg(feature = "std")] use reth_primitives_traits::{TxTy, WithEncoded}; @@ -85,6 +85,9 @@ pub struct BaseEvmConfig< pub _pd: PhantomData, } +/// Helper type with backwards compatible methods to obtain executor providers. +pub type BaseExecutorProvider = BaseEvmConfig; + impl Clone for BaseEvmConfig { @@ -107,12 +110,13 @@ impl BaseEvmConfig { impl BaseEvmConfig { /// Creates a new [`BaseEvmConfig`] with the given chain spec. pub fn new(chain_spec: Arc, receipt_builder: R) -> Self { + let activation_admin_address = chain_spec.as_ref().activation_admin_address(); Self { block_assembler: BaseBlockAssembler::new(Arc::clone(&chain_spec)), executor_factory: BaseBlockExecutorFactory::new( receipt_builder, chain_spec, - BaseEvmFactory::default(), + BaseEvmFactory::new(activation_admin_address), ), _pd: PhantomData, } @@ -141,11 +145,11 @@ where Block = alloy_consensus::Block, >, BaseTransaction: FromRecoveredTx + FromTxWithEncoded, - R: BaseReceiptBuilder, + R: BaseReceiptBuilder + Clone, EvmF: EvmFactory< Tx: FromRecoveredTx + FromTxWithEncoded - + TransactionEnv + + TransactionEnvMut + BaseTxEnv, Precompiles = PrecompilesMap, Spec = BaseSpecId, @@ -218,7 +222,7 @@ where Block = alloy_consensus::Block, >, BaseTransaction: FromRecoveredTx + FromTxWithEncoded, - R: BaseReceiptBuilder, + R: BaseReceiptBuilder + Clone, Self: Send + Sync + Unpin + Clone + 'static, { fn evm_env_for_payload(&self, payload: &ExecutionData) -> Result, Self::Error> { @@ -323,7 +327,7 @@ mod tests { // Use the `BaseEvmConfig` to create the `cfg_env` and `block_env` based on the ChainSpec, // Header, and total difficulty let EvmEnv { cfg_env, .. } = - BaseEvmConfig::base(Arc::new(BaseChainSpec { inner: chain_spec.clone() })) + BaseEvmConfig::base(Arc::new(BaseChainSpec::from(chain_spec.clone()))) .evm_env(&header) .unwrap(); diff --git a/crates/execution/evm/src/env.rs b/crates/execution/evm/src/env.rs index 76225f6ef8..918aed0c2d 100644 --- a/crates/execution/evm/src/env.rs +++ b/crates/execution/evm/src/env.rs @@ -57,6 +57,7 @@ impl BaseEvmEnvBuilder { gas_limit: header.gas_limit, basefee: header.base_fee_per_gas.unwrap_or_default(), blob_excess_gas_and_price, + slot_num: 0, }; EvmEnv { cfg_env, block_env } @@ -83,6 +84,7 @@ impl BaseEvmEnvBuilder { gas_limit: attributes.gas_limit, basefee: base_fee_per_gas, blob_excess_gas_and_price, + slot_num: 0, }; EvmEnv { cfg_env, block_env } @@ -114,6 +116,7 @@ impl BaseEvmEnvBuilder { gas_limit: payload.payload.as_v1().gas_limit, basefee: payload.payload.as_v1().base_fee_per_gas.to(), blob_excess_gas_and_price, + slot_num: 0, }; EvmEnv { cfg_env, block_env } diff --git a/crates/execution/evm/src/execute.rs b/crates/execution/evm/src/execute.rs deleted file mode 100644 index 7a42f26987..0000000000 --- a/crates/execution/evm/src/execute.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Base block execution strategy. - -/// Helper type with backwards compatible methods to obtain executor providers. -pub type BaseExecutorProvider = crate::BaseEvmConfig; - -#[cfg(test)] -mod tests { - use alloc::sync::Arc; - use std::{collections::HashMap, str::FromStr}; - - use alloy_consensus::{Block, BlockBody, Header, SignableTransaction, TxEip1559}; - use alloy_primitives::{Address, Signature, StorageKey, StorageValue, U256, b256}; - use base_common_consensus::{BaseReceipt, BaseTransactionSigned, Predeploys, TxDeposit}; - use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; - use reth_chainspec::MIN_TRANSACTION_GAS; - use reth_evm::execute::{BasicBlockExecutor, Executor}; - use reth_primitives_traits::{Account, RecoveredBlock}; - use reth_revm::{database::StateProviderDatabase, test_utils::StateProviderTest}; - - use crate::{BaseEvmConfig, BaseRethReceiptBuilder}; - - fn create_base_state_provider() -> StateProviderTest { - let mut db = StateProviderTest::default(); - - let l1_block_contract_account = - Account { balance: U256::ZERO, bytecode_hash: None, nonce: 1 }; - - let mut l1_block_storage = HashMap::default(); - // base fee - l1_block_storage.insert(StorageKey::with_last_byte(1), StorageValue::from(1000000000)); - // l1 fee overhead - l1_block_storage.insert(StorageKey::with_last_byte(5), StorageValue::from(188)); - // l1 fee scalar - l1_block_storage.insert(StorageKey::with_last_byte(6), StorageValue::from(684000)); - // l1 free scalars post ecotone - l1_block_storage.insert( - StorageKey::with_last_byte(3), - StorageValue::from_str( - "0x0000000000000000000000000000000000001db0000d27300000000000000005", - ) - .unwrap(), - ); - - db.insert_account( - Predeploys::L1_BLOCK_INFO, - l1_block_contract_account, - None, - l1_block_storage, - ); - - db - } - - fn evm_config(chain_spec: Arc) -> BaseEvmConfig { - BaseEvmConfig::new(chain_spec, BaseRethReceiptBuilder::default()) - } - - #[test] - fn base_deposit_fields_pre_canyon() { - let header = Header { - timestamp: 1, - number: 1, - gas_limit: 1_000_000, - gas_used: 42_000, - receipts_root: b256!( - "0x83465d1e7d01578c0d609be33570f91242f013e9e295b0879905346abbd63731" - ), - ..Default::default() - }; - - let mut db = create_base_state_provider(); - - let addr = Address::ZERO; - let account = Account { balance: U256::MAX, ..Account::default() }; - db.insert_account(addr, account, None, HashMap::default()); - - let chain_spec = - Arc::new(BaseChainSpecBuilder::base_mainnet().regolith_activated().build()); - - let tx: BaseTransactionSigned = TxEip1559 { - chain_id: chain_spec.chain.id(), - nonce: 0, - gas_limit: MIN_TRANSACTION_GAS, - to: addr.into(), - ..Default::default() - } - .into_signed(Signature::test_signature()) - .into(); - - let tx_deposit: BaseTransactionSigned = TxDeposit { - from: addr, - to: addr.into(), - gas_limit: MIN_TRANSACTION_GAS, - ..Default::default() - } - .into(); - - let provider = evm_config(chain_spec); - let mut executor = BasicBlockExecutor::new(provider, StateProviderDatabase::new(&db)); - - // make sure the L1 block contract state is preloaded. - executor.with_state_mut(|state| { - state.load_cache_account(Predeploys::L1_BLOCK_INFO).unwrap(); - }); - - // Attempt to execute a block with one deposit and one non-deposit transaction - let output = executor - .execute(&RecoveredBlock::new_unhashed( - Block { - header, - body: BlockBody { transactions: vec![tx, tx_deposit], ..Default::default() }, - }, - vec![addr, addr], - )) - .unwrap(); - - let receipts = &output.receipts; - let tx_receipt = &receipts[0]; - let deposit_receipt = &receipts[1]; - - assert!(!matches!(tx_receipt, BaseReceipt::Deposit(_))); - // deposit_nonce is present only in deposit transactions - let BaseReceipt::Deposit(deposit_receipt) = deposit_receipt else { - panic!("expected deposit") - }; - assert!(deposit_receipt.deposit_nonce.is_some()); - // deposit_receipt_version is not present in pre canyon transactions - assert!(deposit_receipt.deposit_receipt_version.is_none()); - } - - #[test] - fn base_deposit_fields_post_canyon() { - // ensure_create2_deployer will fail if timestamp is set to less than 2 - let header = Header { - timestamp: 2, - number: 1, - gas_limit: 1_000_000, - gas_used: 42_000, - receipts_root: b256!( - "0xfffc85c4004fd03c7bfbe5491fae98a7473126c099ac11e8286fd0013f15f908" - ), - ..Default::default() - }; - - let mut db = create_base_state_provider(); - let addr = Address::ZERO; - let account = Account { balance: U256::MAX, ..Account::default() }; - - db.insert_account(addr, account, None, HashMap::default()); - - let chain_spec = Arc::new(BaseChainSpecBuilder::base_mainnet().canyon_activated().build()); - - let tx: BaseTransactionSigned = TxEip1559 { - chain_id: chain_spec.chain.id(), - nonce: 0, - gas_limit: MIN_TRANSACTION_GAS, - to: addr.into(), - ..Default::default() - } - .into_signed(Signature::test_signature()) - .into(); - - let tx_deposit: BaseTransactionSigned = TxDeposit { - from: addr, - to: addr.into(), - gas_limit: MIN_TRANSACTION_GAS, - ..Default::default() - } - .into(); - - let provider = evm_config(chain_spec); - let mut executor = BasicBlockExecutor::new(provider, StateProviderDatabase::new(&db)); - - // make sure the L1 block contract state is preloaded. - executor.with_state_mut(|state| { - state.load_cache_account(Predeploys::L1_BLOCK_INFO).unwrap(); - }); - - // attempt to execute an empty block with parent beacon block root, this should not fail - let output = executor - .execute(&RecoveredBlock::new_unhashed( - Block { - header, - body: BlockBody { transactions: vec![tx, tx_deposit], ..Default::default() }, - }, - vec![addr, addr], - )) - .expect("Executing a block while canyon is active should not fail"); - - let receipts = &output.receipts; - let tx_receipt = &receipts[0]; - let deposit_receipt = &receipts[1]; - - // deposit_receipt_version is set to 1 for post canyon deposit transactions - assert!(!matches!(tx_receipt, BaseReceipt::Deposit(_))); - let BaseReceipt::Deposit(deposit_receipt) = deposit_receipt else { - panic!("expected deposit") - }; - assert_eq!(deposit_receipt.deposit_receipt_version, Some(1)); - - // deposit_nonce is present only in deposit transactions - assert!(deposit_receipt.deposit_nonce.is_some()); - } -} diff --git a/crates/execution/evm/src/lib.rs b/crates/execution/evm/src/lib.rs index c45a996c15..4197712035 100644 --- a/crates/execution/evm/src/lib.rs +++ b/crates/execution/evm/src/lib.rs @@ -14,7 +14,7 @@ mod build; pub use build::BaseBlockAssembler; mod config; -pub use config::{BaseEvmConfig, BaseNextBlockEnvAttributes}; +pub use config::{BaseEvmConfig, BaseExecutorProvider, BaseNextBlockEnvAttributes}; mod env; pub use env::BaseEvmEnvBuilder; @@ -22,9 +22,6 @@ pub use env::BaseEvmEnvBuilder; mod error; pub use error::{BaseBlockExecutionError, L1BlockInfoError}; -mod execute; -pub use execute::*; - mod l1; pub use l1::*; diff --git a/crates/execution/evm/src/receipts.rs b/crates/execution/evm/src/receipts.rs index cdc9fc4c93..7dd8619fa9 100644 --- a/crates/execution/evm/src/receipts.rs +++ b/crates/execution/evm/src/receipts.rs @@ -17,9 +17,9 @@ impl BaseReceiptBuilder for BaseRethReceiptBuilder { fn build_receipt<'a, E: Evm>( &self, ctx: ReceiptBuilderCtx<'a, OpTxType, E>, - ) -> Result> { + ) -> Result>> { match ctx.tx_type { - OpTxType::Deposit => Err(ctx), + OpTxType::Deposit => Err(Box::new(ctx)), ty => { let receipt = Receipt { // Success flag was added in `EIP-658: Embedding transaction status code in @@ -35,6 +35,7 @@ impl BaseReceiptBuilder for BaseRethReceiptBuilder { OpTxType::Eip2930 => BaseReceipt::Eip2930(receipt), OpTxType::Eip7702 => BaseReceipt::Eip7702(receipt), OpTxType::Deposit => unreachable!(), + OpTxType::Eip8130 => BaseReceipt::Eip8130(receipt), }) } } diff --git a/crates/execution/evm/tests/block_execution.rs b/crates/execution/evm/tests/block_execution.rs new file mode 100644 index 0000000000..63eb1806ef --- /dev/null +++ b/crates/execution/evm/tests/block_execution.rs @@ -0,0 +1,171 @@ +//! Integration tests for Base block execution behavior. + +use std::{collections::HashMap, str::FromStr, sync::Arc}; + +use alloy_consensus::{Block, BlockBody, Header, SignableTransaction, TxEip1559}; +use alloy_primitives::{Address, Signature, StorageKey, StorageValue, U256, b256}; +use base_common_consensus::{BaseReceipt, BaseTransactionSigned, Predeploys, TxDeposit}; +use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; +use base_execution_evm::{BaseEvmConfig, BaseRethReceiptBuilder}; +use reth_chainspec::MIN_TRANSACTION_GAS; +use reth_evm::execute::{BasicBlockExecutor, Executor}; +use reth_primitives_traits::{Account, RecoveredBlock}; +use reth_revm::{database::StateProviderDatabase, test_utils::StateProviderTest}; + +fn create_base_state_provider() -> StateProviderTest { + let mut db = StateProviderTest::default(); + + let l1_block_contract_account = Account { balance: U256::ZERO, bytecode_hash: None, nonce: 1 }; + + let mut l1_block_storage = HashMap::default(); + // base fee + l1_block_storage.insert(StorageKey::with_last_byte(1), StorageValue::from(1000000000)); + // l1 fee overhead + l1_block_storage.insert(StorageKey::with_last_byte(5), StorageValue::from(188)); + // l1 fee scalar + l1_block_storage.insert(StorageKey::with_last_byte(6), StorageValue::from(684000)); + // l1 free scalars post ecotone + l1_block_storage.insert( + StorageKey::with_last_byte(3), + StorageValue::from_str( + "0x0000000000000000000000000000000000001db0000d27300000000000000005", + ) + .unwrap(), + ); + + db.insert_account(Predeploys::L1_BLOCK_INFO, l1_block_contract_account, None, l1_block_storage); + + db +} + +fn evm_config(chain_spec: Arc) -> BaseEvmConfig { + BaseEvmConfig::new(chain_spec, BaseRethReceiptBuilder::default()) +} + +#[test] +fn base_deposit_fields_pre_canyon() { + let header = Header { + timestamp: 1, + number: 1, + gas_limit: 1_000_000, + gas_used: 42_000, + receipts_root: b256!("0x83465d1e7d01578c0d609be33570f91242f013e9e295b0879905346abbd63731"), + ..Default::default() + }; + + let mut db = create_base_state_provider(); + + let addr = Address::ZERO; + let account = Account { balance: U256::MAX, ..Account::default() }; + db.insert_account(addr, account, None, HashMap::default()); + + let chain_spec = Arc::new(BaseChainSpecBuilder::base_mainnet().regolith_activated().build()); + + let tx: BaseTransactionSigned = TxEip1559 { + chain_id: chain_spec.chain.id(), + nonce: 0, + gas_limit: MIN_TRANSACTION_GAS, + to: addr.into(), + ..Default::default() + } + .into_signed(Signature::test_signature()) + .into(); + + let tx_deposit: BaseTransactionSigned = TxDeposit { + from: addr, + to: addr.into(), + gas_limit: MIN_TRANSACTION_GAS, + ..Default::default() + } + .into(); + + let provider = evm_config(chain_spec); + let mut executor = BasicBlockExecutor::new(provider, StateProviderDatabase::new(&db)); + + executor.with_state_mut(|state| { + state.load_cache_account(Predeploys::L1_BLOCK_INFO).unwrap(); + }); + + let output = executor + .execute(&RecoveredBlock::new_unhashed( + Block { + header, + body: BlockBody { transactions: vec![tx, tx_deposit], ..Default::default() }, + }, + vec![addr, addr], + )) + .unwrap(); + + let receipts = &output.receipts; + let tx_receipt = &receipts[0]; + let deposit_receipt = &receipts[1]; + + assert!(!matches!(tx_receipt, BaseReceipt::Deposit(_))); + let BaseReceipt::Deposit(deposit_receipt) = deposit_receipt else { panic!("expected deposit") }; + assert!(deposit_receipt.deposit_nonce.is_some()); + assert!(deposit_receipt.deposit_receipt_version.is_none()); +} + +#[test] +fn base_deposit_fields_post_canyon() { + let header = Header { + timestamp: 2, + number: 1, + gas_limit: 1_000_000, + gas_used: 42_000, + receipts_root: b256!("0xfffc85c4004fd03c7bfbe5491fae98a7473126c099ac11e8286fd0013f15f908"), + ..Default::default() + }; + + let mut db = create_base_state_provider(); + let addr = Address::ZERO; + let account = Account { balance: U256::MAX, ..Account::default() }; + + db.insert_account(addr, account, None, HashMap::default()); + + let chain_spec = Arc::new(BaseChainSpecBuilder::base_mainnet().canyon_activated().build()); + + let tx: BaseTransactionSigned = TxEip1559 { + chain_id: chain_spec.chain.id(), + nonce: 0, + gas_limit: MIN_TRANSACTION_GAS, + to: addr.into(), + ..Default::default() + } + .into_signed(Signature::test_signature()) + .into(); + + let tx_deposit: BaseTransactionSigned = TxDeposit { + from: addr, + to: addr.into(), + gas_limit: MIN_TRANSACTION_GAS, + ..Default::default() + } + .into(); + + let provider = evm_config(chain_spec); + let mut executor = BasicBlockExecutor::new(provider, StateProviderDatabase::new(&db)); + + executor.with_state_mut(|state| { + state.load_cache_account(Predeploys::L1_BLOCK_INFO).unwrap(); + }); + + let output = executor + .execute(&RecoveredBlock::new_unhashed( + Block { + header, + body: BlockBody { transactions: vec![tx, tx_deposit], ..Default::default() }, + }, + vec![addr, addr], + )) + .expect("Executing a block while canyon is active should not fail"); + + let receipts = &output.receipts; + let tx_receipt = &receipts[0]; + let deposit_receipt = &receipts[1]; + + assert!(!matches!(tx_receipt, BaseReceipt::Deposit(_))); + let BaseReceipt::Deposit(deposit_receipt) = deposit_receipt else { panic!("expected deposit") }; + assert_eq!(deposit_receipt.deposit_receipt_version, Some(1)); + assert!(deposit_receipt.deposit_nonce.is_some()); +} diff --git a/crates/execution/firehose/src/evm_config.rs b/crates/execution/firehose/src/evm_config.rs index 7e8541b9af..13c41d9069 100644 --- a/crates/execution/firehose/src/evm_config.rs +++ b/crates/execution/firehose/src/evm_config.rs @@ -19,7 +19,7 @@ use base_common_consensus::BasePrimitives; use base_common_evm::{BaseEvmFactory, BaseTransaction}; use reth_errors::BlockExecutionError; use reth_evm::{ - ConfigureEngineEvm, ConfigureEvm, EvmEnvFor, ExecutionCtxFor, TransactionEnv, + ConfigureEngineEvm, ConfigureEvm, EvmEnvFor, ExecutionCtxFor, TransactionEnvMut, execute::Executor, }; use reth_firehose::{ @@ -53,7 +53,7 @@ where >, > + Unpin + 'static, - BaseTransaction: TransactionEnv, + BaseTransaction: TransactionEnvMut, BlockTy: BlockTrait, as BlockTrait>::Header: BlockHeader + Sealable, as BlockTrait>::Body: BlockBody, @@ -121,7 +121,7 @@ where >, > + Unpin + 'static, - BaseTransaction: TransactionEnv, + BaseTransaction: TransactionEnvMut, BlockTy: BlockTrait, as BlockTrait>::Header: BlockHeader + Sealable, as BlockTrait>::Body: BlockBody, diff --git a/crates/execution/flashblocks-node/Cargo.toml b/crates/execution/flashblocks-node/Cargo.toml index 3e6b2dbfcc..f766a64b66 100644 --- a/crates/execution/flashblocks-node/Cargo.toml +++ b/crates/execution/flashblocks-node/Cargo.toml @@ -63,7 +63,6 @@ reth-evm.workspace = true reth-revm.workspace = true reth-tracing.workspace = true reth-db-common.workspace = true -reth-primitives.workspace = true reth-rpc-eth-api.workspace = true reth-testing-utils.workspace = true base-node-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/execution/flashblocks-node/src/extension.rs b/crates/execution/flashblocks-node/src/extension.rs index 1489e58280..5c1ceab8e3 100644 --- a/crates/execution/flashblocks-node/src/extension.rs +++ b/crates/execution/flashblocks-node/src/extension.rs @@ -91,7 +91,11 @@ impl BaseNodeExtension for FlashblocksExtension { // Register the eth_subscribe subscription endpoint for flashblocks // Uses replace_configured since eth_subscribe already exists from reth's standard module // Pass eth_api to enable proxying standard subscription types to reth's implementation - let eth_pubsub = EthPubSub::new(ctx.registry.eth_api().clone(), state_for_rpc); + let eth_pubsub = EthPubSub::new( + ctx.registry.eth_api().clone(), + ctx.node().task_executor.clone(), + state_for_rpc, + ); ctx.modules.replace_configured(eth_pubsub.into_rpc())?; Ok(()) diff --git a/crates/execution/flashblocks-node/src/test_harness.rs b/crates/execution/flashblocks-node/src/test_harness.rs index 84bf47c89d..087d806a22 100644 --- a/crates/execution/flashblocks-node/src/test_harness.rs +++ b/crates/execution/flashblocks-node/src/test_harness.rs @@ -189,7 +189,11 @@ impl BaseNodeExtension for FlashblocksTestExtension { // Register eth_subscribe subscription endpoint for flashblocks // Uses replace_configured since eth_subscribe already exists from reth's standard module // Pass eth_api to enable proxying standard subscription types to reth's implementation - let eth_pubsub = EthPubSub::new(ctx.registry.eth_api().clone(), Arc::clone(&fb)); + let eth_pubsub = EthPubSub::new( + ctx.registry.eth_api().clone(), + ctx.node().task_executor.clone(), + Arc::clone(&fb), + ); ctx.modules.replace_configured(eth_pubsub.into_rpc())?; let fb_for_task = fb; diff --git a/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs b/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs index 0b4a201caa..017d946664 100644 --- a/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs +++ b/crates/execution/flashblocks-node/tests/flashblocks_rpc.rs @@ -551,10 +551,12 @@ async fn test_eth_call() -> Result<()> { // We included a big spending transaction in the payloads // and now don't have enough funds for this request, so this eth_call will fail let res = - provider.call(big_spend.clone().nonce(3)).block(BlockNumberOrTag::Pending.into()).await; + provider.call(big_spend.clone().nonce(2)).block(BlockNumberOrTag::Pending.into()).await; assert!(res.is_err()); + let message = res.unwrap_err().as_error_resp().unwrap().message.clone(); assert!( - res.unwrap_err().as_error_resp().unwrap().message.contains("insufficient funds for gas") + message.contains("insufficient funds") || message.contains("OutOfFunds"), + "unexpected eth_call error: {message}" ); // read count1 from counter contract @@ -597,13 +599,15 @@ async fn test_eth_estimate_gas() -> Result<()> { // We included a heavy spending transaction and now don't have enough funds for this request, so // this eth_estimate_gas will fail let res = provider - .estimate_gas(send_estimate_gas.nonce(4)) + .estimate_gas(send_estimate_gas.nonce(2)) .block(BlockNumberOrTag::Pending.into()) .await; assert!(res.is_err()); + let message = res.unwrap_err().as_error_resp().unwrap().message.clone(); assert!( - res.unwrap_err().as_error_resp().unwrap().message.contains("insufficient funds for gas") + message.contains("insufficient funds") || message.contains("OutOfFunds"), + "unexpected estimate_gas error: {message}" ); Ok(()) diff --git a/crates/execution/flashblocks/Cargo.toml b/crates/execution/flashblocks/Cargo.toml index 994e8d22f3..4fa9389c96 100644 --- a/crates/execution/flashblocks/Cargo.toml +++ b/crates/execution/flashblocks/Cargo.toml @@ -23,7 +23,8 @@ reth-evm.workspace = true reth-revm.workspace = true reth-provider.workspace = true reth-chainspec.workspace = true -reth-primitives.workspace = true +reth-tasks.workspace = true +reth-primitives-traits.workspace = true reth-rpc-convert.workspace = true reth-rpc-eth-api.workspace = true base-execution-evm.workspace = true diff --git a/crates/execution/flashblocks/src/pending_blocks.rs b/crates/execution/flashblocks/src/pending_blocks.rs index 694820e3ae..da8fa892dc 100644 --- a/crates/execution/flashblocks/src/pending_blocks.rs +++ b/crates/execution/flashblocks/src/pending_blocks.rs @@ -11,7 +11,7 @@ use alloy_rpc_types::{BlockTransactions, Withdrawal, state::StateOverride}; use alloy_rpc_types_engine::PayloadId; use alloy_rpc_types_eth::{Filter, Header as RPCHeader, Log}; use arc_swap::Guard; -use base_common_consensus::OpTxType; +use base_common_consensus::{BaseTxReceipt, OpTxType}; use base_common_evm::{BaseHaltReason, BaseTxResult}; use base_common_flashblocks::Flashblock; use base_common_network::Base; @@ -21,8 +21,9 @@ use reth_revm::db::BundleState; use reth_rpc_convert::RpcTransaction; use reth_rpc_eth_api::{RpcBlock, RpcReceipt}; use revm::{ - context::result::ExecResultAndState, context_interface::result::ExecutionResult, - state::EvmState, + context::result::ExecResultAndState, + context_interface::result::ExecutionResult, + state::{AccountInfo, EvmState}, }; use crate::{ @@ -433,8 +434,19 @@ impl PendingBlocks { tx_type: tx.inner.inner.tx_type(), }; - let base_tx_result = - BaseTxResult { inner: eth_tx_result, is_deposit: tx.inner.inner.is_deposit(), sender }; + // For deposit transactions, reconstruct the depositor's AccountInfo so that + // CachedExecutor's commit_transaction can set `deposit_nonce` correctly on the + // receipt it builds. Only the `nonce` field is consumed downstream. + let is_deposit = tx.inner.inner.is_deposit(); + let depositor = is_deposit + .then(|| { + self.get_receipt(*tx_hash) + .and_then(|r| r.inner.inner.receipt.deposit_nonce()) + .map(|nonce| AccountInfo { nonce, ..Default::default() }) + }) + .flatten(); + + let base_tx_result = BaseTxResult { inner: eth_tx_result, is_deposit, sender, depositor }; Some(base_tx_result) } @@ -709,6 +721,7 @@ mod tests { ), block_hash: None, block_number: Some(1), + block_timestamp: None, transaction_index: Some(0), effective_gas_price: Some(1_000_000_000), }, @@ -739,6 +752,7 @@ mod tests { inner: recovered, block_hash: Some(B256::ZERO), block_number: Some(1), + block_timestamp: None, transaction_index: Some(0), effective_gas_price: Some(1_000_000_000), }, @@ -766,6 +780,7 @@ mod tests { ), block_hash: None, block_number: Some(1), + block_timestamp: None, transaction_index: Some(0), effective_gas_price: Some(0), }, @@ -861,8 +876,10 @@ mod tests { fn test_execution_result() -> ExecutionResult { ExecutionResult::Success { reason: revm::context::result::SuccessReason::Stop, - gas_used: 21000, - gas_refunded: 0, + gas: revm::context::result::ResultGas::default() + .with_total_gas_spent(21_000) + .with_refunded(0) + .with_floor_gas(0), logs: vec![], output: revm::context::result::Output::Call(Bytes::new()), } @@ -909,12 +926,33 @@ mod tests { assert_eq!(result.inner.tx_type, OpTxType::Legacy); assert!(!result.is_deposit); assert_eq!(result.sender, test_sender()); - assert_eq!(result.inner.result.result.gas_used(), 21000); + assert_eq!(result.inner.result.result.tx_gas_used(), 21000); } #[test] fn get_tx_result_reconstructs_all_fields_for_deposit_tx() { - let (tx_hash, pending_blocks) = build_pending_blocks(test_deposit_transaction(), Some(0)); + let tx = test_deposit_transaction(); + let tx_hash = tx.tx_hash(); + let mut builder = PendingBlocksBuilder::default(); + builder.with_flashblocks([test_flashblock()]); + builder.with_header(Sealed::new_unchecked(Header::default(), B256::ZERO)); + builder.with_transaction(tx); + builder.with_transaction_sender(tx_hash, test_sender()); + builder.with_transaction_state(tx_hash, Default::default()); + builder.with_transaction_result(tx_hash, test_execution_result()); + let mut receipt = test_receipt(tx_hash, Some(0)); + receipt.inner.inner.receipt = + base_common_consensus::BaseReceipt::Deposit(base_common_consensus::DepositReceipt { + inner: alloy_consensus::Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![], + }, + deposit_nonce: Some(42), + deposit_receipt_version: Some(1), + }); + builder.with_receipt(tx_hash, receipt); + let pending_blocks = builder.build().expect("should build pending blocks"); let result = pending_blocks.get_tx_result(&tx_hash).expect("should return tx result"); @@ -922,7 +960,8 @@ mod tests { assert_eq!(result.inner.tx_type, OpTxType::Deposit); assert!(result.is_deposit); assert_eq!(result.sender, test_sender()); - assert_eq!(result.inner.result.result.gas_used(), 21000); + assert_eq!(result.inner.result.result.tx_gas_used(), 21000); + assert_eq!(result.depositor.expect("deposit tx should have depositor").nonce, 42); } #[test] diff --git a/crates/execution/flashblocks/src/processor.rs b/crates/execution/flashblocks/src/processor.rs index 3ebcb08e00..fb1f770a4c 100644 --- a/crates/execution/flashblocks/src/processor.rs +++ b/crates/execution/flashblocks/src/processor.rs @@ -18,7 +18,7 @@ use base_execution_evm::{BaseEvmConfig, BaseNextBlockEnvAttributes}; use rayon::prelude::*; use reth_chainspec::{ChainSpecProvider, EthChainSpec}; use reth_evm::ConfigureEvm; -use reth_primitives::RecoveredBlock; +use reth_primitives_traits::RecoveredBlock; use reth_provider::{BlockReaderIdExt, StateProviderFactory}; use reth_revm::{State, database::StateProviderDatabase}; use revm_database::states::bundle_state::BundleRetention; @@ -35,6 +35,7 @@ use crate::{ }; /// Messages consumed by the state processor. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone)] pub enum StateUpdate { /// New canonical block to reconcile against pending state. diff --git a/crates/execution/flashblocks/src/receipt_builder.rs b/crates/execution/flashblocks/src/receipt_builder.rs index 70606d0126..4e3becab5c 100644 --- a/crates/execution/flashblocks/src/receipt_builder.rs +++ b/crates/execution/flashblocks/src/receipt_builder.rs @@ -118,6 +118,7 @@ impl UnifiedReceiptBuilder { OpTxType::Eip1559 => BaseReceipt::Eip1559(receipt), OpTxType::Eip7702 => BaseReceipt::Eip7702(receipt), OpTxType::Deposit => unreachable!(), + OpTxType::Eip8130 => BaseReceipt::Eip8130(receipt), }) } } @@ -175,8 +176,10 @@ mod tests { fn create_success_result() -> ExecutionResult { ExecutionResult::Success { reason: revm::context::result::SuccessReason::Stop, - gas_used: 21000, - gas_refunded: 0, + gas: revm::context::result::ResultGas::default() + .with_total_gas_spent(21_000) + .with_refunded(0) + .with_floor_gas(0), logs: vec![Log { address: Address::ZERO, data: LogData::new_unchecked(vec![], alloy_primitives::Bytes::new()), @@ -207,8 +210,14 @@ mod tests { #[test] fn test_receipt_from_revert_result() { - let result: ExecutionResult = - ExecutionResult::Revert { gas_used: 10000, output: alloy_primitives::Bytes::new() }; + let result: ExecutionResult = ExecutionResult::Revert { + gas: revm::context::result::ResultGas::default() + .with_total_gas_spent(10_000) + .with_refunded(0) + .with_floor_gas(0), + logs: vec![], + output: alloy_primitives::Bytes::new(), + }; let receipt = Receipt { status: Eip658Value::Eip658(result.is_success()), cumulative_gas_used: 10000, @@ -326,8 +335,14 @@ mod tests { let builder = UnifiedReceiptBuilder::new(chain_spec); let tx = create_legacy_tx(); - let result: ExecutionResult = - ExecutionResult::Revert { gas_used: 10000, output: alloy_primitives::Bytes::new() }; + let result: ExecutionResult = ExecutionResult::Revert { + gas: revm::context::result::ResultGas::default() + .with_total_gas_spent(10_000) + .with_refunded(0) + .with_floor_gas(0), + logs: vec![], + output: alloy_primitives::Bytes::new(), + }; let receipt = builder.build(&mut evm, &tx, &result, 10000, 0).expect("build should succeed"); diff --git a/crates/execution/flashblocks/src/rpc/pubsub.rs b/crates/execution/flashblocks/src/rpc/pubsub.rs index b9e1af361d..94b696ff8d 100644 --- a/crates/execution/flashblocks/src/rpc/pubsub.rs +++ b/crates/execution/flashblocks/src/rpc/pubsub.rs @@ -21,6 +21,7 @@ use reth_rpc_eth_api::{ EthApiTypes, RpcBlock, RpcNodeCore, RpcTransaction, pubsub::EthPubSubApiServer as RethEthPubSubApiServer, }; +use reth_tasks::Runtime; use serde::Serialize; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use tracing::error; @@ -67,8 +68,12 @@ pub struct EthPubSub { impl EthPubSub { /// Creates a new instance with the given eth API and flashblocks state. - pub fn new(eth_api: Eth, flashblocks_state: Arc) -> Self { - Self { inner: RethEthPubSub::new(eth_api), flashblocks_state } + pub fn new( + eth_api: Eth, + subscription_task_spawner: Runtime, + flashblocks_state: Arc, + ) -> Self { + Self { inner: RethEthPubSub::new(eth_api, subscription_task_spawner), flashblocks_state } } /// Returns a stream that yields all new flashblocks as RPC blocks diff --git a/crates/execution/flashblocks/src/rpc/types.rs b/crates/execution/flashblocks/src/rpc/types.rs index 1697d870c3..e96e8c0f68 100644 --- a/crates/execution/flashblocks/src/rpc/types.rs +++ b/crates/execution/flashblocks/src/rpc/types.rs @@ -140,6 +140,7 @@ mod tests { inner: recovered, block_hash: Some(B256::ZERO), block_number: Some(42), + block_timestamp: None, transaction_index: Some(3), effective_gas_price: Some(1_000_000_000), }, diff --git a/crates/execution/flashblocks/src/state.rs b/crates/execution/flashblocks/src/state.rs index 90abf14e84..52ed4616b4 100644 --- a/crates/execution/flashblocks/src/state.rs +++ b/crates/execution/flashblocks/src/state.rs @@ -8,7 +8,7 @@ use base_common_chains::Upgrades; use base_common_consensus::BaseBlock; use base_common_flashblocks::Flashblock; use reth_chainspec::{ChainSpecProvider, EthChainSpec}; -use reth_primitives::RecoveredBlock; +use reth_primitives_traits::RecoveredBlock; use reth_provider::{BlockReaderIdExt, StateProviderFactory}; use tokio::sync::{ Mutex, diff --git a/crates/execution/flashblocks/src/state_builder.rs b/crates/execution/flashblocks/src/state_builder.rs index 2183bd08be..23369dbc16 100644 --- a/crates/execution/flashblocks/src/state_builder.rs +++ b/crates/execution/flashblocks/src/state_builder.rs @@ -165,9 +165,6 @@ where ChainSpec: Clone, { let spec = self.receipt_builder.chain_spec(); - let state_clear_flag = spec.is_spurious_dragon_active_at_block(self.pending_block.number); - self.evm.db_mut().set_state_clear_flag(state_clear_flag); - let mut system_caller = SystemCaller::new(spec.clone()); system_caller .apply_blockhashes_contract_call(parent_hash, &mut self.evm) @@ -208,6 +205,7 @@ where inner: transaction, block_hash: None, block_number: Some(self.pending_block.number), + block_timestamp: Some(self.pending_block.timestamp), transaction_index: Some(idx as u64), effective_gas_price: Some(effective_gas_price), }, @@ -221,6 +219,13 @@ where .ok_or(ExecutionError::GasOverflow)?; self.next_log_index += receipt.inner.logs().len(); + for address in state.keys() { + self.evm.db_mut().basic(*address).map_err(|err| { + StateProcessorError::Execution(ExecutionError::EvmEnv(err.to_string())) + })?; + } + self.evm.db_mut().commit(state.clone()); + Ok(ExecutedPendingTransaction { rpc_transaction, receipt, @@ -282,7 +287,7 @@ where match transact_result { Ok(ResultAndState { state, result }) => { - let gas_used = result.gas_used(); + let gas_used = result.tx_gas_used(); for (addr, acc) in &state { let existing_override = self.state_overrides.entry(*addr).or_default(); existing_override.balance = Some(acc.info.balance); @@ -360,6 +365,7 @@ where inner: transaction, block_hash: None, block_number: Some(self.pending_block.number), + block_timestamp: Some(self.pending_block.timestamp), transaction_index: Some(idx as u64), effective_gas_price: Some(effective_gas_price), }, @@ -810,4 +816,155 @@ mod tests { "blob_gas_used should be 0 for deposit tx even when Jovian is active" ); } + + /// Regression test: `execute_with_cached_data` must commit the cached `EvmState` to the EVM + /// database so that subsequent transactions see the correct post-tx state. + /// + /// Without the commit, a fresh tx executed after a cached one runs against stale state + /// (e.g. missing nonce increment, stale storage), producing logs that differ from what + /// the final block-building executor produces. This causes a receipt root mismatch + /// during block validation because the sequencer re-executes everything from scratch. + #[test] + fn cached_execute_commits_state_so_subsequent_fresh_txs_see_updated_nonce() { + // Phase 1: execute tx A freshly to obtain the real EvmState and receipt + // that would be stored in PendingBlocks after the first flashblock round. + let chain_spec = Arc::new(BaseChainSpecBuilder::base_mainnet().build()); + let evm_config = BaseEvmConfig::base(Arc::clone(&chain_spec)); + let sender = Address::ZERO; + + let header = Header { + number: 1, + timestamp: 100, + gas_limit: 30_000_000, + base_fee_per_gas: Some(1_000_000_000), + ..Default::default() + }; + + let mut inner_db = InMemoryDB::default(); + inner_db.insert_account_info( + sender, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + ..Default::default() + }, + ); + let db = State::builder().with_database(inner_db).build(); + + let evm_env = evm_config.evm_env(&header).expect("failed to create evm env"); + let evm = evm_config.evm_with_env(db, evm_env); + let pending_block = Block { header: header.clone(), body: Default::default() }; + let mut first_builder = PendingStateBuilder::new( + (*chain_spec).clone(), + evm, + pending_block, + None, + L1BlockInfo::default(), + StateOverride::default(), + ); + + let tx_a = create_legacy_tx(); + let tx_a_hash = tx_a.tx_hash(); + let first_result = + first_builder.execute_transaction(0, tx_a).expect("first execution failed"); + + // Sanity-check: fresh execution increments the sender nonce from 0 to 1. + let (first_db, _) = first_builder.into_db_and_state_overrides(); + let sender_nonce_after_tx_a = first_db + .cache + .accounts + .get(&sender) + .and_then(|a| a.account_info()) + .map(|info| info.nonce) + .expect("sender should be in cache after tx A"); + + assert_eq!(sender_nonce_after_tx_a, 1, "tx A should increment nonce to 1"); + + // Phase 2: store the result of tx A in PendingBlocks, simulating what the + // processor does after the first flashblock is built. + let mut pending_blocks_builder = crate::PendingBlocksBuilder::new(); + pending_blocks_builder + .with_header(alloy_consensus::Sealed::new_unchecked(header.clone(), B256::ZERO)); + pending_blocks_builder.with_flashblocks([Flashblock { + payload_id: PayloadId::default(), + index: 0, + base: Some(ExecutionPayloadBaseV1 { + parent_beacon_block_root: B256::ZERO, + parent_hash: B256::ZERO, + fee_recipient: Address::ZERO, + prev_randao: B256::ZERO, + block_number: header.number, + gas_limit: header.gas_limit, + timestamp: header.timestamp, + extra_data: Default::default(), + base_fee_per_gas: U256::from(header.base_fee_per_gas.unwrap_or_default()), + }), + diff: ExecutionPayloadFlashblockDeltaV1 { + state_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Default::default(), + gas_used: first_result.receipt.inner.gas_used, + block_hash: B256::ZERO, + transactions: vec![], + withdrawals: vec![], + withdrawals_root: B256::ZERO, + blob_gas_used: None, + }, + metadata: Metadata { block_number: header.number }, + }]); + pending_blocks_builder.with_transaction_sender(tx_a_hash, sender); + pending_blocks_builder.with_receipt(tx_a_hash, first_result.receipt.clone()); + pending_blocks_builder.with_transaction_state(tx_a_hash, first_result.state.clone()); + pending_blocks_builder.with_transaction_result(tx_a_hash, first_result.result); + + let prev_pending_blocks = + Arc::new(pending_blocks_builder.build().expect("should build pending blocks")); + + // Phase 3: build a second flashblock whose EVM starts from scratch (nonce 0). + // tx A is now in prev_pending_blocks so execute_transaction will take the cached + // path (execute_with_cached_data). After that call the EVM database must reflect + // the committed state of tx A (nonce 1) so any subsequent fresh tx executes + // against the correct state. + let mut inner_db2 = InMemoryDB::default(); + inner_db2.insert_account_info( + sender, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u128), + nonce: 0, + ..Default::default() + }, + ); + let db2 = State::builder().with_database(inner_db2).build(); + let second_evm_env = evm_config.evm_env(&header).expect("failed to create evm env"); + let second_evm = evm_config.evm_with_env(db2, second_evm_env); + let second_pending_block = Block { header, body: Default::default() }; + let mut second_builder = PendingStateBuilder::new( + (*chain_spec).clone(), + second_evm, + second_pending_block, + Some(prev_pending_blocks), + L1BlockInfo::default(), + StateOverride::default(), + ); + + second_builder + .execute_transaction(0, create_legacy_tx()) + .expect("cached tx A execution failed"); + + // The EVM database must now show nonce 1 for the sender, proving that + // execute_with_cached_data committed the state before returning. + let (second_db_after, _) = second_builder.into_db_and_state_overrides(); + let sender_nonce_after_cached_tx_a = second_db_after + .cache + .accounts + .get(&sender) + .and_then(|a| a.account_info()) + .map(|info| info.nonce) + .expect("sender should be in cache after cached tx A"); + + assert_eq!( + sender_nonce_after_cached_tx_a, 1, + "cached tx A must commit state so the sender nonce is 1 (not 0)" + ); + } } diff --git a/crates/execution/metering/Cargo.toml b/crates/execution/metering/Cargo.toml index 0c7cdd1462..a2925f1998 100644 --- a/crates/execution/metering/Cargo.toml +++ b/crates/execution/metering/Cargo.toml @@ -77,7 +77,8 @@ base-common-consensus = { workspace = true, features = ["reth"] } reth-transaction-pool = { workspace = true, features = ["test-utils"] } # revm -revm-context-interface.workspace = true +revm.workspace = true +revm-bytecode.workspace = true # alloy alloy-network.workspace = true diff --git a/crates/execution/metering/src/block.rs b/crates/execution/metering/src/block.rs index c191a8fb94..9a9d50a46f 100644 --- a/crates/execution/metering/src/block.rs +++ b/crates/execution/metering/src/block.rs @@ -99,7 +99,8 @@ where let gas_used = builder .execute_transaction(recovered_tx) - .map_err(|e| eyre!("Transaction {} execution failed: {}", tx_hash, e))?; + .map_err(|e| eyre!("Transaction {} execution failed: {}", tx_hash, e))? + .tx_gas_used(); let execution_time = tx_start.elapsed().as_micros(); diff --git a/crates/execution/metering/src/collector.rs b/crates/execution/metering/src/collector.rs index 70cfcfcd7a..3433214747 100644 --- a/crates/execution/metering/src/collector.rs +++ b/crates/execution/metering/src/collector.rs @@ -345,6 +345,7 @@ mod tests { ), block_hash: None, block_number: Some(100), + block_timestamp: None, transaction_index: Some(entry.index), effective_gas_price: Some(entry.effective_gas_price), }, @@ -414,8 +415,10 @@ mod tests { entry.tx_hash, ExecutionResult::Success { reason: revm::context::result::SuccessReason::Stop, - gas_used: 21000, - gas_refunded: 0, + gas: revm::context::result::ResultGas::default() + .with_total_gas_spent(21_000) + .with_refunded(0) + .with_floor_gas(0), logs: vec![], output: revm::context::result::Output::Call(Bytes::new()), }, diff --git a/crates/execution/metering/src/inspector.rs b/crates/execution/metering/src/inspector.rs index 2971668d4a..51565cd226 100644 --- a/crates/execution/metering/src/inspector.rs +++ b/crates/execution/metering/src/inspector.rs @@ -91,7 +91,7 @@ where self.inner.call_end(context, inputs, outcome); let target = inputs.bytecode_address; if self.metered_precompiles.contains(&target) { - let gas_used = outcome.result.gas.spent(); + let gas_used = outcome.result.gas.total_gas_spent(); let entry = self.precompile_gas.entry(target).or_default(); entry.count += 1; entry.gas_used += gas_used; diff --git a/crates/execution/metering/src/meter.rs b/crates/execution/metering/src/meter.rs index ffb1c51a3d..9e59192fe1 100644 --- a/crates/execution/metering/src/meter.rs +++ b/crates/execution/metering/src/meter.rs @@ -57,7 +57,6 @@ fn cache_state_from_bundle_state(bundle_state: &BundleState) -> CacheState { .iter() .map(|(&hash, code)| (hash, code.clone())) .collect(), - ..Default::default() } } @@ -442,7 +441,8 @@ where let gas_used = builder .execute_transaction(tx.clone()) - .map_err(|e| eyre!("Transaction {tx_hash} execution failed: {e}"))?; + .map_err(|e| eyre!("Transaction {tx_hash} execution failed: {e}"))? + .tx_gas_used(); let gas_fees = U256::from(gas_used) * U256::from(gas_price); total_gas_used = total_gas_used.saturating_add(gas_used); diff --git a/crates/execution/node/src/engine.rs b/crates/execution/node/src/engine.rs index e8946a00dc..8f422b1db0 100644 --- a/crates/execution/node/src/engine.rs +++ b/crates/execution/node/src/engine.rs @@ -7,10 +7,12 @@ use base_common_chains::Upgrades; use base_common_consensus::{BaseBlock, Predeploys}; use base_common_rpc_types_engine::{ BaseExecutionPayloadEnvelopeV3, BaseExecutionPayloadEnvelopeV4, BaseExecutionPayloadEnvelopeV5, - BasePayloadAttributes, ExecutionData, + ExecutionData, }; use base_execution_consensus::isthmus; -use base_execution_payload_builder::{BaseExecutionPayloadValidator, BasePayloadTypes}; +use base_execution_payload_builder::{ + BaseExecutionPayloadValidator, BasePayloadBuilderAttributes, BasePayloadTypes, +}; use reth_consensus::ConsensusError; use reth_node_api::{ BuiltPayload, EngineApiValidator, EngineTypes, NodePrimitives, PayloadValidator, @@ -36,7 +38,6 @@ impl> PayloadTypes for BaseEngine type ExecutionData = T::ExecutionData; type BuiltPayload = T::BuiltPayload; type PayloadAttributes = T::PayloadAttributes; - type PayloadBuilderAttributes = T::PayloadBuilderAttributes; fn block_to_payload( block: SealedBlock< @@ -141,7 +142,9 @@ where .unwrap_or_default(); isthmus::verify_withdrawals_root_prehashed(predeploy_storage_updates, parent_state, header) .map_err(|err| { - ConsensusError::Other(format!("failed to verify block post-execution: {err}")) + ConsensusError::Other(Arc::from(Box::::from( + format!("failed to verify block post-execution: {err}"), + ))) }) } } @@ -205,9 +208,11 @@ where let parent_state = self.provider.state_by_block_hash(block.parent_hash()).map_err(|err| { - ConsensusError::Other(format!( - "failed to load parent state for Isthmus withdrawals root validation: {err}" - )) + ConsensusError::Other(Arc::from(Box::::from( + format!( + "failed to load parent state for Isthmus withdrawals root validation: {err}" + ), + ))) })?; self.validate_block_post_execution_with_state(state_updates, parent_state, block.header()) @@ -224,7 +229,7 @@ where impl EngineApiValidator for BaseEngineValidator where Types: PayloadTypes< - PayloadAttributes = BasePayloadAttributes, + PayloadAttributes = BasePayloadBuilderAttributes, ExecutionData = ExecutionData, BuiltPayload: BuiltPayload>, >, @@ -265,7 +270,7 @@ where validate_version_specific_fields( self.chain_spec(), version, - PayloadOrAttributes::::PayloadAttributes( + PayloadOrAttributes::::PayloadAttributes( attributes, ), )?; @@ -345,7 +350,8 @@ pub fn validate_withdrawals_presence( EngineApiMessageVersion::V2 | EngineApiMessageVersion::V3 | EngineApiMessageVersion::V4 - | EngineApiMessageVersion::V5 => { + | EngineApiMessageVersion::V5 + | EngineApiMessageVersion::V6 => { if is_shanghai && !has_withdrawals { return Err(message_validation_kind .to_error(VersionSpecificValidationError::NoWithdrawalsPostShanghai)); @@ -366,6 +372,7 @@ mod tests { use alloy_rpc_types_engine::PayloadAttributes; use base_common_chains::ChainConfig; use base_common_consensus::BaseTxEnvelope; + use base_common_rpc_types_engine::BasePayloadAttributes; use base_execution_chainspec::BaseChainSpec; use reth_provider::noop::NoopProvider; use reth_trie_common::KeccakKeyHasher; @@ -392,25 +399,31 @@ mod tests { }}; } - const fn get_attributes( + fn get_attributes( eip_1559_params: Option, min_base_fee: Option, timestamp: u64, - ) -> BasePayloadAttributes { - BasePayloadAttributes { - gas_limit: Some(1000), - eip_1559_params, - min_base_fee, - transactions: None, - no_tx_pool: None, - payload_attributes: PayloadAttributes { - timestamp, - prev_randao: B256::ZERO, - suggested_fee_recipient: Address::ZERO, - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(B256::ZERO), + ) -> BasePayloadBuilderAttributes { + BasePayloadBuilderAttributes::try_new( + B256::ZERO, + BasePayloadAttributes { + gas_limit: Some(1000), + eip_1559_params, + min_base_fee, + transactions: None, + no_tx_pool: None, + payload_attributes: PayloadAttributes { + timestamp, + prev_randao: B256::ZERO, + suggested_fee_recipient: Address::ZERO, + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, + }, }, - } + 3, + ) + .expect("valid test payload attributes") } #[test] diff --git a/crates/execution/node/src/node.rs b/crates/execution/node/src/node.rs index 0bff4bfd22..44f2ace800 100644 --- a/crates/execution/node/src/node.rs +++ b/crates/execution/node/src/node.rs @@ -10,21 +10,20 @@ use alloy_consensus::BlockHeader; use alloy_primitives::{Address, B64, B256, Bytes, bytes::BytesMut}; use alloy_rlp::Encodable; use base_common_chains::Upgrades; -use base_common_consensus::BasePrimitives; +use base_common_consensus::{BasePrimitives, BaseTxEnvelope}; use base_common_rpc_types_engine::{BasePayloadAttributes, ExecutionData}; use base_execution_chainspec::BaseChainSpec; use base_execution_consensus::BaseBeaconConsensus; use base_execution_evm::{BaseEvmConfig, BaseRethReceiptBuilder}; use base_execution_payload_builder::{ - Attributes, BaseBuiltPayload, PayloadPrimitives, + Attributes, BaseBuiltPayload, BasePayloadBuilderAttributes, PayloadPrimitives, builder::BasePayloadTransactions, config::{BaseBuilderConfig, BaseDAConfig, GasLimitConfig}, }; use base_execution_rpc::{ - MinerApiExtServer, config::{BaseEthConfigApiServer, BaseEthConfigHandler}, eth::BaseEthApiBuilder, - miner::BaseMinerExtApi, + miner::{BaseMinerExtApi, MinerApiExtServer}, witness::BaseDebugWitnessApi, }; use base_execution_txpool::{ @@ -113,10 +112,6 @@ impl BaseFullNodeTypes for N where } /// Local payload attributes builder for Base. -/// -/// This mirrors the upstream `LocalPayloadAttributesBuilder` for -/// `op_alloy_rpc_types_engine::BasePayloadAttributes`, but targets -/// `base_common_rpc_types_engine::BasePayloadAttributes`. #[derive(Debug)] pub struct BaseLocalPayloadAttributesBuilder { chain_spec: Arc, @@ -129,8 +124,13 @@ impl BaseLocalPayloadAttributesBuilder { } } -impl PayloadAttributesBuilder for BaseLocalPayloadAttributesBuilder { - fn build(&self, parent: &SealedHeader) -> BasePayloadAttributes { +impl PayloadAttributesBuilder> + for BaseLocalPayloadAttributesBuilder +{ + fn build( + &self, + parent: &SealedHeader, + ) -> BasePayloadBuilderAttributes { /// Dummy system transaction for dev mode. const TX_SET_L1_BLOCK_BASE_MAINNET_BLOCK_1: [u8; 349] = alloy_primitives::hex!( "7ef90159a024fa2288af14732611c4b9a8f99b2c929eaf2af8fb45981a752a01417994df3b94deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b90104015d8eb900000000000000000000000000000000000000000000000000000000010ac02800000000000000000000000000000000000000000000000000000000648a5ce300000000000000000000000000000000000000000000000000000003ded24b5e5c13d307623a926cd31415036c8b7fa14572f9dac64528e857a470511fc3077100000000000000000000000000000000000000000000000000000000000000010000000000000000000000005050f69a9786f081509234f1a7f4684b5e5b76c900000000000000000000000000000000000000000000000000000000000000bc00000000000000000000000000000000000000000000000000000000000a6fe0" @@ -158,7 +158,7 @@ impl PayloadAttributesBuilder for BaseLocalPayloadAttribu eip1559_bytes[4..8].copy_from_slice(&elasticity.to_be_bytes()); let eip_1559_params = Some(B64::from(eip1559_bytes)); - BasePayloadAttributes { + let attributes = BasePayloadAttributes { payload_attributes: alloy_rpc_types_engine::PayloadAttributes { timestamp, prev_randao: B256::random(), @@ -171,13 +171,17 @@ impl PayloadAttributesBuilder for BaseLocalPayloadAttribu .chain_spec .is_ecotone_active_at_timestamp(timestamp) .then(B256::random), + slot_number: None, }, transactions: Some(vec![TX_SET_L1_BLOCK_BASE_MAINNET_BLOCK_1.into()]), no_tx_pool: None, gas_limit, eip_1559_params, min_base_fee: Some(0), - } + }; + + BasePayloadBuilderAttributes::try_new(parent.hash(), attributes, 3) + .expect("static dev payload attributes must decode") } } @@ -352,7 +356,7 @@ impl DebugNode for BaseNode where N: FullNodeComponents, { - type RpcBlock = alloy_rpc_types_eth::Block; + type RpcBlock = alloy_rpc_types_eth::Block; fn rpc_to_primitive_block(rpc_block: Self::RpcBlock) -> reth_node_api::BlockTy { rpc_block.into_consensus() @@ -559,8 +563,7 @@ impl NodeAddOns for BaseAddOns where N: FullNodeComponents< - Types: BaseNodeTypes - + NodeTypes>, + Types: BaseNodeTypes + NodeTypes>, Evm: ConfigureEvm< NextBlockEnvCtx: BuildNextEnv, BaseChainSpec>, >, @@ -571,7 +574,10 @@ where EB: EngineApiBuilder, EVB: EngineValidatorBuilder, RpcMiddleware: RethRpcMiddleware, - Attrs: Attributes, RpcPayloadAttributes: DeserializeOwned>, + Attrs: Attributes< + Transaction = TxTy, + RpcPayloadAttributes: DeserializeOwned + Send + Sync + 'static, + >, ::Primitives: PayloadPrimitives<_Header: HeaderMut>, { type Handle = RpcHandle; @@ -592,7 +598,7 @@ where // Install additional rollup-specific RPC methods. let debug_ext = BaseDebugWitnessApi::<_, _, _, Attrs>::new( ctx.node.provider().clone(), - Box::new(ctx.node.task_executor().clone()), + ctx.node.task_executor().clone(), builder, ); let miner_ext = BaseMinerExtApi::new(da_config, gas_limit_config); @@ -635,8 +641,7 @@ impl RethRpcAddOns for BaseAddOns where N: FullNodeComponents< - Types: BaseNodeTypes - + NodeTypes>, + Types: BaseNodeTypes + NodeTypes>, Evm: ConfigureEvm< NextBlockEnvCtx: BuildNextEnv, BaseChainSpec>, >, @@ -647,7 +652,10 @@ where EB: EngineApiBuilder, EVB: EngineValidatorBuilder, RpcMiddleware: RethRpcMiddleware, - Attrs: Attributes, RpcPayloadAttributes: DeserializeOwned>, + Attrs: Attributes< + Transaction = TxTy, + RpcPayloadAttributes: DeserializeOwned + Send + Sync + 'static, + >, ::Primitives: PayloadPrimitives<_Header: HeaderMut>, { type EthApi = EthB::EthApi; @@ -809,6 +817,7 @@ impl BaseAddOnsBuilder { EB::default(), EVB::default(), rpc_middleware, + Identity::new(), ) .with_tokio_runtime(tokio_runtime), da_config.unwrap_or_default(), @@ -1031,7 +1040,7 @@ where Primitives: PayloadPrimitives, Payload: PayloadTypes< BuiltPayload = BaseBuiltPayload>, - PayloadBuilderAttributes = Attrs, + PayloadAttributes = Attrs, >, >, >, @@ -1046,7 +1055,7 @@ where Pool: TransactionPool>> + Unpin + 'static, Txs: BasePayloadTransactions, - Attrs: Attributes>, + Attrs: Attributes> + Unpin, { type PayloadBuilder = base_execution_payload_builder::BasePayloadBuilder; @@ -1410,7 +1419,9 @@ mod tests { let network_config = discovery_config .apply_to_network_builder( - NetworkConfigBuilder::::with_rng_secret_key(), + NetworkConfigBuilder::::with_rng_secret_key( + reth_tasks::Runtime::test(), + ), &args, Vec::::new(), None, @@ -1433,7 +1444,9 @@ mod tests { let network_config = discovery_config .apply_to_network_builder( - NetworkConfigBuilder::::with_rng_secret_key(), + NetworkConfigBuilder::::with_rng_secret_key( + reth_tasks::Runtime::test(), + ), &args, Vec::::new(), None, diff --git a/crates/execution/node/src/proof_history.rs b/crates/execution/node/src/proof_history.rs index a0858db4e4..9685c54639 100644 --- a/crates/execution/node/src/proof_history.rs +++ b/crates/execution/node/src/proof_history.rs @@ -2,8 +2,10 @@ use std::{sync::Arc, time::Duration}; +use base_common_consensus::BaseTxEnvelope; use base_execution_chainspec::BaseChainSpec; use base_execution_exex::BaseProofsExEx; +use base_execution_payload_builder::BasePayloadBuilderAttributes; use base_execution_rpc::{ debug::{DebugApiExt, DebugApiOverrideServer}, eth::proofs::{EthApiExt, EthApiOverrideServer}, @@ -73,11 +75,17 @@ pub async fn launch_node_with_proof_history( }) .extend_rpc_modules(move |ctx| { let api_ext = EthApiExt::new(ctx.registry.eth_api().clone(), storage.clone()); - let debug_ext = DebugApiExt::new( + let debug_ext: DebugApiExt< + _, + _, + _, + _, + BasePayloadBuilderAttributes, + > = DebugApiExt::new( ctx.node().provider().clone(), ctx.registry.eth_api().clone(), storage, - Box::new(ctx.node().task_executor().clone()), + ctx.node().task_executor().clone(), ctx.node().evm_config().clone(), ); ctx.modules.replace_configured(api_ext.into_rpc())?; diff --git a/crates/execution/node/src/rpc.rs b/crates/execution/node/src/rpc.rs index 26b982956c..40ee5f2a3a 100644 --- a/crates/execution/node/src/rpc.rs +++ b/crates/execution/node/src/rpc.rs @@ -143,7 +143,7 @@ where ctx.beacon_engine_handle.clone(), PayloadStore::new(ctx.node.payload_builder_handle().clone()), ctx.node.pool().clone(), - Box::new(ctx.node.task_executor().clone()), + ctx.node.task_executor().clone(), client, EngineCapabilities::new(ENGINE_CAPABILITIES.iter().copied()), engine_validator, diff --git a/crates/execution/node/src/utils.rs b/crates/execution/node/src/utils.rs index 167a4f5676..6c3a58f3b6 100644 --- a/crates/execution/node/src/utils.rs +++ b/crates/execution/node/src/utils.rs @@ -4,12 +4,13 @@ use alloy_genesis::Genesis; use alloy_primitives::{Address, B256}; use alloy_rpc_types_engine::PayloadAttributes; use base_execution_chainspec::BaseChainSpecBuilder; -use base_execution_payload_builder::{BaseBuiltPayload, BasePayloadBuilderAttributes}; +use base_execution_payload_builder::{ + BaseBuiltPayload, BasePayloadBuilderAttributes, payload::EthPayloadBuilderAttributes, +}; use reth_e2e_test_utils::{ NodeHelperType, TmpDB, transaction::TransactionTestContext, wallet::Wallet, }; use reth_node_api::NodeTypesWithDBAdapter; -use reth_payload_builder::EthPayloadBuilderAttributes; use reth_provider::providers::BlockchainProvider; use tokio::sync::Mutex; @@ -63,10 +64,21 @@ pub fn payload_attributes(timestamp: u64) -> BasePayloadBuilderAttributes suggested_fee_recipient: Address::ZERO, withdrawals: Some(vec![]), parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, }; BasePayloadBuilderAttributes { - payload_attributes: EthPayloadBuilderAttributes::new(B256::ZERO, attributes), + payload_attributes: EthPayloadBuilderAttributes { + id: Default::default(), + parent: B256::ZERO, + timestamp: attributes.timestamp, + suggested_fee_recipient: attributes.suggested_fee_recipient, + prev_randao: attributes.prev_randao, + has_withdrawals: attributes.withdrawals.is_some(), + withdrawals: attributes.withdrawals.unwrap_or_default().into(), + parent_beacon_block_root: attributes.parent_beacon_block_root, + slot_number: None, + }, transactions: vec![], no_tx_pool: false, gas_limit: Some(30_000_000), diff --git a/crates/execution/node/tests/e2e-testsuite/testsuite.rs b/crates/execution/node/tests/e2e-testsuite/testsuite.rs index d45880fb6e..a7972f1bef 100644 --- a/crates/execution/node/tests/e2e-testsuite/testsuite.rs +++ b/crates/execution/node/tests/e2e-testsuite/testsuite.rs @@ -1,10 +1,10 @@ -//! Reth e2e testsuite integration tests for the execution node. - use std::sync::Arc; use alloy_primitives::{Address, B64, B256}; +use base_common_consensus::BaseTxEnvelope; use base_common_rpc_types_engine::BasePayloadAttributes; use base_execution_chainspec::{BaseChainSpec, BaseChainSpecBuilder}; +use base_execution_payload_builder::BasePayloadBuilderAttributes; use base_node_core::{BaseEngineTypes, BaseNode}; use eyre::Result; use reth_e2e_test_utils::testsuite::{ @@ -23,7 +23,7 @@ async fn test_testsuite_op_assert_mine_block() -> Result<()> { .chain(BaseChainSpec::mainnet().chain) .genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap()) .build() - .into(), + .inner, )) .with_network(NetworkSetup::single_node()); @@ -33,23 +33,29 @@ async fn test_testsuite_op_assert_mine_block() -> Result<()> { vec![], Some(B256::ZERO), // TODO: refactor once we have actions to generate payload attributes. - BasePayloadAttributes { - payload_attributes: alloy_rpc_types_engine::PayloadAttributes { - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - prev_randao: B256::random(), - suggested_fee_recipient: Address::random(), - withdrawals: None, - parent_beacon_block_root: None, + BasePayloadBuilderAttributes::::try_new( + B256::ZERO, + BasePayloadAttributes { + payload_attributes: alloy_rpc_types_engine::PayloadAttributes { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + prev_randao: B256::random(), + suggested_fee_recipient: Address::random(), + withdrawals: None, + parent_beacon_block_root: None, + slot_number: None, + }, + transactions: None, + no_tx_pool: None, + eip_1559_params: None, + min_base_fee: None, + gas_limit: Some(30_000_000), }, - transactions: None, - no_tx_pool: None, - eip_1559_params: None, - min_base_fee: None, - gas_limit: Some(30_000_000), - }, + 3, + ) + .expect("valid test payload attributes"), )); test.run::().await?; @@ -68,7 +74,7 @@ async fn test_testsuite_op_assert_mine_block_isthmus_activated() -> Result<()> { .genesis(serde_json::from_str(include_str!("../assets/genesis.json")).unwrap()) .isthmus_activated() .build() - .into(), + .inner, )) .with_network(NetworkSetup::single_node()); @@ -78,23 +84,29 @@ async fn test_testsuite_op_assert_mine_block_isthmus_activated() -> Result<()> { vec![], Some(B256::ZERO), // TODO: refactor once we have actions to generate payload attributes. - BasePayloadAttributes { - payload_attributes: alloy_rpc_types_engine::PayloadAttributes { - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - prev_randao: B256::random(), - suggested_fee_recipient: Address::random(), - withdrawals: Some(vec![]), - parent_beacon_block_root: Some(B256::ZERO), + BasePayloadBuilderAttributes::::try_new( + B256::ZERO, + BasePayloadAttributes { + payload_attributes: alloy_rpc_types_engine::PayloadAttributes { + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + prev_randao: B256::random(), + suggested_fee_recipient: Address::random(), + withdrawals: Some(vec![]), + parent_beacon_block_root: Some(B256::ZERO), + slot_number: None, + }, + transactions: None, + no_tx_pool: None, + eip_1559_params: Some(B64::ZERO), + min_base_fee: None, + gas_limit: Some(30_000_000), }, - transactions: None, - no_tx_pool: None, - eip_1559_params: Some(B64::ZERO), - min_base_fee: None, - gas_limit: Some(30_000_000), - }, + 3, + ) + .expect("valid test payload attributes"), )); test.run::().await?; diff --git a/crates/execution/node/tests/it/custom_genesis.rs b/crates/execution/node/tests/it/custom_genesis.rs index df488e95df..5e98fa23e7 100644 --- a/crates/execution/node/tests/it/custom_genesis.rs +++ b/crates/execution/node/tests/it/custom_genesis.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use alloy_consensus::BlockHeader; use alloy_genesis::Genesis; use alloy_primitives::B256; +use alloy_rpc_types_engine::ForkchoiceState; +use alloy_rpc_types_eth::BlockNumberOrTag; use base_execution_chainspec::BaseChainSpecBuilder; use base_node_core::{BaseNode, utils::payload_attributes}; use reth_chainspec::EthChainSpec; @@ -14,7 +16,9 @@ use reth_e2e_test_utils::{ }; use reth_node_builder::{EngineNodeLauncher, Node, NodeBuilder, NodeConfig}; use reth_node_core::args::DatadirArgs; -use reth_provider::{HeaderProvider, StageCheckpointReader, providers::BlockchainProvider}; +use reth_provider::{ + BlockReaderIdExt, HeaderProvider, StageCheckpointReader, providers::BlockchainProvider, +}; use reth_stages_types::StageId; use tokio::sync::Mutex; @@ -67,6 +71,20 @@ async fn test_base_node_custom_genesis_number() { let mut node = NodeTestContext::new(node_handle.node, payload_attributes).await.unwrap(); + let genesis_hash = node + .inner + .provider + .sealed_header_by_number_or_tag(BlockNumberOrTag::Number(genesis_number)) + .unwrap() + .expect("genesis header should exist") + .hash(); + node.inner + .add_ons_handle + .beacon_engine_handle + .fork_choice_updated(ForkchoiceState::same_hash(genesis_hash), None) + .await + .expect("able to seed forkchoice for custom genesis"); + // Verify stage checkpoints are initialized to genesis block number (1000) for stage in StageId::ALL { let checkpoint = node.inner.provider.get_stage_checkpoint(stage).unwrap(); diff --git a/crates/execution/payload/Cargo.toml b/crates/execution/payload/Cargo.toml index e36909a172..5492a0fc25 100644 --- a/crates/execution/payload/Cargo.toml +++ b/crates/execution/payload/Cargo.toml @@ -26,6 +26,7 @@ reth-payload-primitives.workspace = true reth-basic-payload-builder.workspace = true reth-payload-builder-primitives.workspace = true reth-revm = { workspace = true, features = ["witness"] } +reth-trie-common.workspace = true # op-reth base-common-evm.workspace = true diff --git a/crates/execution/payload/src/builder.rs b/crates/execution/payload/src/builder.rs index 935f4f7a6f..c8e34b9460 100644 --- a/crates/execution/payload/src/builder.rs +++ b/crates/execution/payload/src/builder.rs @@ -17,14 +17,13 @@ use reth_basic_payload_builder::{ use reth_chainspec::{ChainSpecProvider, EthChainSpec}; use reth_evm::{ ConfigureEvm, Database, - block::BlockExecutorFor, execute::{ BlockBuilder, BlockBuilderOutcome, BlockExecutionError, BlockExecutor, BlockValidationError, }, }; use reth_execution_types::BlockExecutionOutput; use reth_payload_builder_primitives::PayloadBuilderError; -use reth_payload_primitives::{BuildNextEnv, BuiltPayloadExecutedBlock, PayloadBuilderAttributes}; +use reth_payload_primitives::{BuildNextEnv, BuiltPayloadExecutedBlock}; use reth_payload_util::{BestPayloadTransactions, NoopPayloadTransactions, PayloadTransactions}; use reth_primitives_traits::{ HeaderTy, NodePrimitives, SealedHeader, SealedHeaderFor, SignedTransaction, TxTy, @@ -35,6 +34,7 @@ use reth_revm::{ }; use reth_storage_api::{StateProvider, StateProviderFactory, errors::ProviderError}; use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction, TransactionPool}; +use reth_trie_common::ExecutionWitnessMode; use revm::context::{Block, BlockEnv}; use tracing::{debug, trace, warn}; @@ -181,7 +181,7 @@ where Transaction: PoolTransaction + BasePooledTx, >, { - let BuildArguments { mut cached_reads, config, cancel, best_payload } = args; + let BuildArguments { mut cached_reads, config, cancel, best_payload, .. } = args; let ctx = BasePayloadBuilderCtx { evm_config: self.evm_config.clone(), @@ -213,12 +213,13 @@ where attributes: Attrs::RpcPayloadAttributes, ) -> Result where - Attrs: PayloadBuilderAttributes, + Attrs: Attributes, { let attributes = Attrs::try_new(parent.hash(), attributes, 3).map_err(PayloadBuilderError::other)?; - let config = PayloadConfig { parent_header: Arc::new(parent), attributes }; + let payload_id = attributes.payload_id(&parent.hash()); + let config = PayloadConfig::new(Arc::new(parent), attributes, payload_id); let ctx = BasePayloadBuilderCtx { evm_config: self.evm_config.clone(), builder_config: self.config.clone(), @@ -278,6 +279,8 @@ where let args = BuildArguments { config, cached_reads: Default::default(), + execution_cache: None, + trie_handle: None, cancel: Default::default(), best_payload: None, }; @@ -372,10 +375,10 @@ impl Builder<'_, Txs> { } let BlockBuilderOutcome { execution_result, hashed_state, trie_updates, block } = - builder.finish(state_provider)?; + builder.finish(state_provider, None)?; let sealed_block = Arc::new(block.sealed_block().clone()); - debug!(target: "payload_builder", id=%ctx.attributes().payload_id(), sealed_block_header = ?sealed_block.header(), "sealed built block"); + debug!(target: "payload_builder", id=%ctx.payload_id(), sealed_block_header = ?sealed_block.header(), "sealed built block"); let execution_outcome = BlockExecutionOutput { state: db.take_bundle(), result: execution_result }; @@ -436,9 +439,10 @@ impl Builder<'_, Txs> { _ = db.load_cache_account(Predeploys::L2_TO_L1_MESSAGE_PASSER)?; } + let mode = ExecutionWitnessMode::default(); let ExecutionWitnessRecord { hashed_state, codes, keys, lowest_block_number: _ } = - ExecutionWitnessRecord::from_executed_state(&db); - let state = state_provider.witness(Default::default(), hashed_state)?; + ExecutionWitnessRecord::from_executed_state(&db, mode); + let state = state_provider.witness(Default::default(), hashed_state, mode)?; Ok(ExecutionWitness { state: state.into_iter().collect(), codes, @@ -586,8 +590,8 @@ where } /// Returns the unique id for this payload job. - pub fn payload_id(&self) -> PayloadId { - self.attributes().payload_id() + pub const fn payload_id(&self) -> PayloadId { + self.config.payload_id() } /// Returns true if the fees are higher than the previous payload. @@ -599,13 +603,7 @@ where pub fn block_builder<'a, DB: Database>( &'a self, db: &'a mut State, - ) -> Result< - impl BlockBuilder< - Primitives = Evm::Primitives, - Executor: BlockExecutorFor<'a, Evm::BlockExecutorFactory, DB>, - > + 'a, - PayloadBuilderError, - > { + ) -> Result + 'a, PayloadBuilderError> { self.evm_config .builder_for_next_block( db, @@ -657,8 +655,8 @@ where PayloadBuilderError::other(BasePayloadBuilderError::TransactionEcRecoverFailed) })?; - let gas_used = match builder.execute_transaction(sequencer_tx.clone()) { - Ok(gas_used) => gas_used, + let gas_output = match builder.execute_transaction(sequencer_tx.clone()) { + Ok(gas_output) => gas_output, Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { error, .. @@ -667,17 +665,11 @@ where continue; } Err(err) => { - // Either a fatal execution error, or an `InvalidTx` from an - // attribute-derived (`no_tx_pool=true`) transaction list. The latter must - // be fatal so the EL rejects the payload exactly like the proof executor - // does, allowing Holocene's deposit-only fallback to apply consistently - // across both consumers of the same L1 input. return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); } }; - // add gas used by the transaction to cumulative gas used, before creating the receipt - info.cumulative_gas_used += gas_used; + info.cumulative_gas_used += gas_output.tx_gas_used(); } Ok(info) @@ -749,39 +741,32 @@ where return Ok(Some(())); } - let gas_used = match builder.execute_transaction(tx.clone()) { - Ok(gas_used) => gas_used, + let gas_output = match builder.execute_transaction(tx.clone()) { + Ok(gas_output) => gas_output, Err(BlockExecutionError::Validation(BlockValidationError::InvalidTx { error, .. })) => { if error.is_nonce_too_low() { - // if the nonce is too low, we can skip this transaction trace!(target: "payload_builder", %error, ?tx, "skipping nonce too low transaction"); } else { - // if the transaction is invalid, we can skip it and all of its - // descendants trace!(target: "payload_builder", %error, ?tx, "skipping invalid transaction and its descendants"); best_txs.mark_invalid(tx.signer(), tx.nonce()); } continue; } Err(err) => { - // this is an error that we should treat as fatal for this attempt return Err(PayloadBuilderError::EvmExecutionError(Box::new(err))); } }; - // add gas used by the transaction to cumulative gas used, before creating the - // receipt - info.cumulative_gas_used += gas_used; + info.cumulative_gas_used += gas_output.tx_gas_used(); info.cumulative_da_bytes_used += tx_da_size; - // update and add to total fees let miner_fee = tx .effective_tip_per_gas(base_fee) .expect("fee is always valid; execution succeeded"); - info.total_fees += U256::from(miner_fee) * U256::from(gas_used); + info.total_fees += U256::from(miner_fee) * U256::from(gas_output.tx_gas_used()); } Ok(None) diff --git a/crates/execution/payload/src/payload.rs b/crates/execution/payload/src/payload.rs index a28a96daa4..9866c6c358 100644 --- a/crates/execution/payload/src/payload.rs +++ b/crates/execution/payload/src/payload.rs @@ -10,26 +10,49 @@ use alloy_primitives::{Address, B64, B256, Bytes, U256, keccak256}; use alloy_rlp::Encodable; use alloy_rpc_types_engine::{ BlobsBundleV1, BlobsBundleV2, ExecutionPayloadEnvelopeV2, ExecutionPayloadFieldV2, - ExecutionPayloadV1, ExecutionPayloadV3, PayloadId, + ExecutionPayloadV1, ExecutionPayloadV3, PayloadAttributes as EthPayloadAttributes, PayloadId, }; use base_common_chains::Upgrades; use base_common_consensus::{ BasePrimitives, EIP1559ParamError, HoloceneExtraData, JovianExtraData, }; +/// Re-export for use in downstream arguments. +pub use base_common_rpc_types_engine::BasePayloadAttributes; use base_common_rpc_types_engine::{ BaseExecutionPayloadEnvelopeV3, BaseExecutionPayloadEnvelopeV4, BaseExecutionPayloadEnvelopeV5, - BaseExecutionPayloadV4, BasePayloadAttributes, + BaseExecutionPayloadV4, }; use base_execution_evm::BaseNextBlockEnvAttributes; use reth_chainspec::EthChainSpec; -use reth_payload_builder::{EthPayloadBuilderAttributes, PayloadBuilderError}; -use reth_payload_primitives::{ - BuildNextEnv, BuiltPayload, BuiltPayloadExecutedBlock, PayloadBuilderAttributes, -}; +use reth_payload_builder::PayloadBuilderError; +use reth_payload_primitives::{BuildNextEnv, BuiltPayload, BuiltPayloadExecutedBlock}; use reth_primitives_traits::{ NodePrimitives, SealedBlock, SealedHeader, SignedTransaction, WithEncoded, }; +/// Minimal Ethereum payload builder attributes retained for Base payload construction. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EthPayloadBuilderAttributes { + /// Payload job ID. + pub id: PayloadId, + /// Parent block hash. + pub parent: B256, + /// Timestamp for the payload. + pub timestamp: u64, + /// Suggested fee recipient. + pub suggested_fee_recipient: Address, + /// Prev-randao value for the payload. + pub prev_randao: B256, + /// Whether withdrawals were provided in the original payload attributes. + pub has_withdrawals: bool, + /// Withdrawals included in the payload. + pub withdrawals: Withdrawals, + /// Parent beacon block root. + pub parent_beacon_block_root: Option, + /// Slot number for the payload. + pub slot_number: Option, +} + /// Base Payload Builder Attributes #[derive(Debug, Clone, PartialEq, Eq)] pub struct BasePayloadBuilderAttributes { @@ -62,6 +85,29 @@ impl Default for BasePayloadBuilderAttributes { } impl BasePayloadBuilderAttributes { + /// Converts these builder attributes back into the RPC payload attribute representation. + pub fn as_rpc_payload_attributes(&self) -> BasePayloadAttributes { + BasePayloadAttributes { + payload_attributes: EthPayloadAttributes { + timestamp: self.payload_attributes.timestamp, + prev_randao: self.payload_attributes.prev_randao, + suggested_fee_recipient: self.payload_attributes.suggested_fee_recipient, + withdrawals: self + .payload_attributes + .has_withdrawals + .then(|| self.payload_attributes.withdrawals.to_vec()), + parent_beacon_block_root: self.payload_attributes.parent_beacon_block_root, + slot_number: self.payload_attributes.slot_number, + }, + transactions: (!self.transactions.is_empty()) + .then(|| self.transactions.iter().map(|tx| tx.encoded_bytes().clone()).collect()), + no_tx_pool: Some(self.no_tx_pool), + gas_limit: self.gas_limit, + eip_1559_params: self.eip_1559_params, + min_base_fee: self.min_base_fee, + } + } + /// Extracts the extra data parameters post-Holocene hardfork. /// In Holocene, those parameters are the EIP-1559 base fee parameters. pub fn get_holocene_extra_data( @@ -84,22 +130,22 @@ impl BasePayloadBuilderAttributes { .map(|params| JovianExtraData::encode(params, default_base_fee_params, min_base_fee)) .ok_or(EIP1559ParamError::NoEIP1559Params)? } -} - -impl PayloadBuilderAttributes - for BasePayloadBuilderAttributes -{ - type RpcPayloadAttributes = BasePayloadAttributes; - type Error = alloy_rlp::Error; - /// Creates a new payload builder for the given parent block and the attributes. + /// Extracts the Holocene EIP-1559 parameters from the encoded form. /// - /// Derives the unique [`PayloadId`] for the given parent and attributes - fn try_new( + /// Returns (`elasticity`, `denominator`). + pub fn decode_eip_1559_params(&self) -> Option<(u32, u32)> { + self.eip_1559_params.map(HoloceneExtraData::decode_params) + } +} + +impl BasePayloadBuilderAttributes { + /// Creates payload builder attributes for the given parent block and RPC payload attributes. + pub fn try_new( parent: B256, attributes: BasePayloadAttributes, version: u8, - ) -> Result { + ) -> Result { let id = payload_id(&parent, &attributes, version); let transactions = attributes @@ -117,8 +163,10 @@ impl PayloadBuilderAtt timestamp: attributes.payload_attributes.timestamp, suggested_fee_recipient: attributes.payload_attributes.suggested_fee_recipient, prev_randao: attributes.payload_attributes.prev_randao, + has_withdrawals: attributes.payload_attributes.withdrawals.is_some(), withdrawals: attributes.payload_attributes.withdrawals.unwrap_or_default().into(), parent_beacon_block_root: attributes.payload_attributes.parent_beacon_block_root, + slot_number: attributes.payload_attributes.slot_number, }; Ok(Self { @@ -130,41 +178,81 @@ impl PayloadBuilderAtt min_base_fee: attributes.min_base_fee, }) } +} - fn payload_id(&self) -> PayloadId { - self.payload_attributes.id +impl From + for BasePayloadBuilderAttributes +{ + fn from(value: EthPayloadBuilderAttributes) -> Self { + Self { payload_attributes: value, ..Default::default() } } +} - fn parent(&self) -> B256 { - self.payload_attributes.parent +impl From + for BasePayloadBuilderAttributes +{ + fn from(value: EthPayloadAttributes) -> Self { + Self { + payload_attributes: EthPayloadBuilderAttributes { + id: Default::default(), + parent: B256::ZERO, + timestamp: value.timestamp, + suggested_fee_recipient: value.suggested_fee_recipient, + prev_randao: value.prev_randao, + has_withdrawals: value.withdrawals.is_some(), + withdrawals: value.withdrawals.unwrap_or_default().into(), + parent_beacon_block_root: value.parent_beacon_block_root, + slot_number: value.slot_number, + }, + ..Default::default() + } } +} - fn timestamp(&self) -> u64 { - self.payload_attributes.timestamp +impl serde::Serialize for BasePayloadBuilderAttributes { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.as_rpc_payload_attributes().serialize(serializer) } +} - fn parent_beacon_block_root(&self) -> Option { - self.payload_attributes.parent_beacon_block_root +impl<'de, T> serde::Deserialize<'de> for BasePayloadBuilderAttributes +where + T: Decodable2718 + Send + Sync + Debug + Unpin + 'static, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let attrs = BasePayloadAttributes::deserialize(deserializer)?; + Self::try_new(B256::ZERO, attrs, 3).map_err(serde::de::Error::custom) } +} - fn suggested_fee_recipient(&self) -> Address { - self.payload_attributes.suggested_fee_recipient +impl reth_payload_primitives::PayloadAttributes for BasePayloadBuilderAttributes +where + T: Clone + Decodable2718 + Send + Sync + Debug + Unpin + 'static, +{ + fn payload_id(&self, parent_hash: &B256) -> PayloadId { + payload_id(parent_hash, &self.as_rpc_payload_attributes(), 3) } - fn prev_randao(&self) -> B256 { - self.payload_attributes.prev_randao + fn timestamp(&self) -> u64 { + self.payload_attributes.timestamp } - fn withdrawals(&self) -> &Withdrawals { - &self.payload_attributes.withdrawals + fn withdrawals(&self) -> Option<&Vec> { + self.payload_attributes.has_withdrawals.then_some(&self.payload_attributes.withdrawals) } -} -impl From - for BasePayloadBuilderAttributes -{ - fn from(value: EthPayloadBuilderAttributes) -> Self { - Self { payload_attributes: value, ..Default::default() } + fn parent_beacon_block_root(&self) -> Option { + self.payload_attributes.parent_beacon_block_root + } + + fn slot_number(&self) -> Option { + self.payload_attributes.slot_number } } @@ -172,13 +260,13 @@ impl From #[derive(Debug, Clone)] pub struct BaseBuiltPayload { /// Identifier of the payload - pub id: PayloadId, + pub(crate) id: PayloadId, /// Sealed block - pub block: Arc>, + pub(crate) block: Arc>, /// Block execution data for the payload, if any. - pub executed_block: Option>, + pub(crate) executed_block: Option>, /// The fees of the block - pub fees: U256, + pub(crate) fees: U256, } // === impl BuiltPayload === @@ -449,28 +537,33 @@ where parent: &SealedHeader, chain_spec: &ChainSpec, ) -> Result { - let extra_data = if chain_spec.is_jovian_active_at_timestamp(attributes.timestamp()) { - attributes - .get_jovian_extra_data( - chain_spec.base_fee_params_at_timestamp(attributes.timestamp()), - ) - .map_err(PayloadBuilderError::other)? - } else if chain_spec.is_holocene_active_at_timestamp(attributes.timestamp()) { - attributes - .get_holocene_extra_data( - chain_spec.base_fee_params_at_timestamp(attributes.timestamp()), - ) - .map_err(PayloadBuilderError::other)? - } else { - Default::default() - }; + let extra_data = + if chain_spec.is_jovian_active_at_timestamp(attributes.payload_attributes.timestamp) { + attributes + .get_jovian_extra_data( + chain_spec + .base_fee_params_at_timestamp(attributes.payload_attributes.timestamp), + ) + .map_err(PayloadBuilderError::other)? + } else if chain_spec + .is_holocene_active_at_timestamp(attributes.payload_attributes.timestamp) + { + attributes + .get_holocene_extra_data( + chain_spec + .base_fee_params_at_timestamp(attributes.payload_attributes.timestamp), + ) + .map_err(PayloadBuilderError::other)? + } else { + Default::default() + }; Ok(Self { - timestamp: attributes.timestamp(), - suggested_fee_recipient: attributes.suggested_fee_recipient(), - prev_randao: attributes.prev_randao(), + timestamp: attributes.payload_attributes.timestamp, + suggested_fee_recipient: attributes.payload_attributes.suggested_fee_recipient, + prev_randao: attributes.payload_attributes.prev_randao, gas_limit: attributes.gas_limit.unwrap_or_else(|| parent.gas_limit()), - parent_beacon_block_root: attributes.parent_beacon_block_root(), + parent_beacon_block_root: attributes.payload_attributes.parent_beacon_block_root, extra_data, }) } @@ -483,11 +576,9 @@ mod tests { use alloy_primitives::{FixedBytes, address, b256, bytes}; use alloy_rpc_types_engine::PayloadAttributes; use base_common_consensus::BaseTransactionSigned; - use base_common_rpc_types_engine::BasePayloadAttributes; use reth_payload_primitives::EngineApiMessageVersion; use super::*; - #[test] fn test_payload_id_parity_op_geth() { // INFO rollup_boost::server:received fork_choice_updated_v3 from builder and l2_client @@ -501,6 +592,7 @@ mod tests { suggested_fee_recipient: address!("0x4200000000000000000000000000000000000011"), withdrawals: Some([].into()), parent_beacon_block_root: b256!("0x8fe0193b9bf83cb7e5a08538e494fecc23046aab9a497af3704f4afdae3250ff").into(), + slot_number: None, }, transactions: Some([bytes!("7ef8f8a0dc19cfa777d90980e4875d0a548a881baaa3f83f14d1bc0d3038bc329350e54194deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8a4440a5e20000f424000000000000000000000000300000000670d6d890000000000000125000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000014bf9181db6e381d4384bbf69c48b0ee0eed23c6ca26143c6d2544f9d39997a590000000000000000000000007f83d659683caf2767fd3c720981d51f5bc365bc")].into()), no_tx_pool: None, @@ -532,6 +624,7 @@ mod tests { suggested_fee_recipient: address!("0x4200000000000000000000000000000000000011"), withdrawals: Some([].into()), parent_beacon_block_root: b256!("0x8fe0193b9bf83cb7e5a08538e494fecc23046aab9a497af3704f4afdae3250ff").into(), + slot_number: None, }, transactions: Some([bytes!("7ef8f8a0dc19cfa777d90980e4875d0a548a881baaa3f83f14d1bc0d3038bc329350e54194deaddeaddeaddeaddeaddeaddeaddeaddead00019442000000000000000000000000000000000000158080830f424080b8a4440a5e20000f424000000000000000000000000300000000670d6d890000000000000125000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000014bf9181db6e381d4384bbf69c48b0ee0eed23c6ca26143c6d2544f9d39997a590000000000000000000000007f83d659683caf2767fd3c720981d51f5bc365bc")].into()), no_tx_pool: None, diff --git a/crates/execution/payload/src/traits.rs b/crates/execution/payload/src/traits.rs index 175e358e68..5ac28c62e8 100644 --- a/crates/execution/payload/src/traits.rs +++ b/crates/execution/payload/src/traits.rs @@ -1,6 +1,9 @@ use alloy_consensus::BlockBody; +use alloy_primitives::B256; +use alloy_rpc_types_engine::PayloadId; use base_common_consensus::{BaseTransaction, DepositReceiptExt}; -use reth_payload_primitives::PayloadBuilderAttributes; +use base_common_rpc_types_engine::BasePayloadAttributes; +use reth_payload_primitives::PayloadAttributes; use reth_primitives_traits::{FullBlockHeader, NodePrimitives, SignedTransaction, WithEncoded}; use crate::BasePayloadBuilderAttributes; @@ -36,9 +39,23 @@ where } /// Attributes for the payload builder. -pub trait Attributes: PayloadBuilderAttributes { +pub trait Attributes: PayloadAttributes { /// Primitive transaction type. type Transaction: SignedTransaction; + /// RPC payload attributes type accepted by the builder. + type RpcPayloadAttributes; + + /// Creates builder attributes for the given parent and RPC payload attributes. + fn try_new( + parent: B256, + attributes: Self::RpcPayloadAttributes, + version: u8, + ) -> Result + where + Self: Sized; + + /// Returns the precomputed payload job ID. + fn payload_job_id(&self) -> PayloadId; /// Whether to use the transaction pool for the payload. fn no_tx_pool(&self) -> bool; @@ -49,6 +66,19 @@ pub trait Attributes: PayloadBuilderAttributes { impl Attributes for BasePayloadBuilderAttributes { type Transaction = T; + type RpcPayloadAttributes = BasePayloadAttributes; + + fn try_new( + parent: B256, + attributes: Self::RpcPayloadAttributes, + version: u8, + ) -> Result { + Self::try_new(parent, attributes, version) + } + + fn payload_job_id(&self) -> PayloadId { + self.payload_attributes.id + } fn no_tx_pool(&self) -> bool { self.no_tx_pool diff --git a/crates/execution/payload/src/types.rs b/crates/execution/payload/src/types.rs index c00b76ab92..e70f990fa4 100644 --- a/crates/execution/payload/src/types.rs +++ b/crates/execution/payload/src/types.rs @@ -1,5 +1,5 @@ use base_common_consensus::BasePrimitives; -use base_common_rpc_types_engine::{BasePayloadAttributes, ExecutionData}; +use base_common_rpc_types_engine::ExecutionData; use reth_payload_primitives::{BuiltPayload, PayloadTypes}; use reth_primitives_traits::{Block, NodePrimitives, SealedBlock}; @@ -16,8 +16,7 @@ where { type ExecutionData = ExecutionData; type BuiltPayload = BaseBuiltPayload; - type PayloadAttributes = BasePayloadAttributes; - type PayloadBuilderAttributes = BasePayloadBuilderAttributes; + type PayloadAttributes = BasePayloadBuilderAttributes; fn block_to_payload( block: SealedBlock< diff --git a/crates/execution/proofs/Cargo.toml b/crates/execution/proofs/Cargo.toml index 931f069733..79e7b402ba 100644 --- a/crates/execution/proofs/Cargo.toml +++ b/crates/execution/proofs/Cargo.toml @@ -22,6 +22,8 @@ reth-node-api.workspace = true base-node-core.workspace = true base-execution-rpc.workspace = true base-execution-trie.workspace = true +base-common-consensus.workspace = true +base-execution-payload-builder.workspace = true base-execution-exex = { workspace = true, features = ["metrics"] } # tokio diff --git a/crates/execution/proofs/src/proofs.rs b/crates/execution/proofs/src/proofs.rs index 8f60f58b01..3b75e53a11 100644 --- a/crates/execution/proofs/src/proofs.rs +++ b/crates/execution/proofs/src/proofs.rs @@ -1,6 +1,8 @@ use std::{sync::Arc, time::Duration}; +use base_common_consensus::BaseTxEnvelope; use base_execution_exex::BaseProofsExEx; +use base_execution_payload_builder::BasePayloadBuilderAttributes; use base_execution_rpc::{ debug::{DebugApiExt, DebugApiOverrideServer}, eth::proofs::{EthApiExt, EthApiOverrideServer}, @@ -82,11 +84,17 @@ impl BaseNodeExtension for ProofsHistoryExtension { }) .add_rpc_module(move |ctx| { let api_ext = EthApiExt::new(ctx.registry.eth_api().clone(), storage.clone()); - let debug_ext = DebugApiExt::new( + let debug_ext: DebugApiExt< + _, + _, + _, + _, + BasePayloadBuilderAttributes, + > = DebugApiExt::new( ctx.node().provider().clone(), ctx.registry.eth_api().clone(), storage, - Box::new(ctx.node().task_executor().clone()), + ctx.node().task_executor().clone(), ctx.node().evm_config().clone(), ); ctx.modules.replace_configured(api_ext.into_rpc())?; diff --git a/crates/execution/rpc/Cargo.toml b/crates/execution/rpc/Cargo.toml index f0c55bc2eb..b1636559eb 100644 --- a/crates/execution/rpc/Cargo.toml +++ b/crates/execution/rpc/Cargo.toml @@ -21,6 +21,7 @@ reth-provider.workspace = true reth-node-api.workspace = true reth-chainspec.workspace = true reth-storage-api.workspace = true +reth-trie-common.workspace = true reth-chain-state.workspace = true reth-rpc-eth-api.workspace = true reth-payload-util.workspace = true diff --git a/crates/execution/rpc/src/config.rs b/crates/execution/rpc/src/config.rs index a16f38f4de..c683277dcf 100644 --- a/crates/execution/rpc/src/config.rs +++ b/crates/execution/rpc/src/config.rs @@ -41,8 +41,8 @@ fn sanitize_system_contracts_for_fork(chain_spec: &impl Upgrades, fork_config: & // Base does not support L1-style deposit, consolidation, or withdrawal request contracts. SystemContract::ConsolidationRequestPredeploy | SystemContract::DepositContract - | SystemContract::WithdrawalRequestPredeploy - | SystemContract::Other(_) => false, + | SystemContract::WithdrawalRequestPredeploy => false, + SystemContract::Other(_) => true, }); } diff --git a/crates/execution/rpc/src/debug.rs b/crates/execution/rpc/src/debug.rs index 9a400a720d..5db0054192 100644 --- a/crates/execution/rpc/src/debug.rs +++ b/crates/execution/rpc/src/debug.rs @@ -31,9 +31,10 @@ use reth_revm::{State, database::StateProviderDatabase, witness::ExecutionWitnes use reth_rpc_api::eth::helpers::FullEthApi; use reth_rpc_eth_types::EthApiError; use reth_rpc_server_types::{ToRpcResult, result::internal_rpc_err}; -use reth_tasks::TaskSpawner; +use reth_tasks::Runtime; +use reth_trie_common::ExecutionWitnessMode; use serde::{Deserialize, Serialize}; -use tokio::sync::oneshot; +use tokio::sync::{Semaphore, oneshot}; use crate::{ metrics::{DebugApiExtMetrics, DebugApis}, @@ -88,7 +89,7 @@ where provider: Provider, eth_api: Eth, preimage_store: BaseProofsStorage, - task_spawner: Box, + task_spawner: Runtime, evm_config: EvmConfig, ) -> Self { Self { @@ -111,7 +112,8 @@ pub struct DebugApiExtInner, state_provider_factory: BaseStateProviderFactory, evm_config: EvmConfig, - task_spawner: Box, + task_spawner: Runtime, + semaphore: Semaphore, _attrs: PhantomData, } @@ -126,7 +128,7 @@ where provider: Provider, eth_api: Eth, storage: BaseProofsStorage

, - task_spawner: Box, + task_spawner: Runtime, evm_config: EvmConfig, ) -> Self { Self { @@ -136,6 +138,7 @@ where eth_api, evm_config, task_spawner, + semaphore: Semaphore::new(3), _attrs: PhantomData, } } @@ -169,6 +172,7 @@ where ErrorObject<'static>: From, P: BaseProofsStore + Clone + 'static, Attrs: Attributes>, + Attrs::RpcPayloadAttributes: Send + Sync + 'static, N: PayloadPrimitives, EvmConfig: ConfigureEvm< Primitives = N, @@ -191,18 +195,21 @@ where attributes: Attrs::RpcPayloadAttributes, ) -> RpcResult { DebugApiExtMetrics::record_operation_async(DebugApis::DebugExecutePayload, async { + let _permit = self.inner.semaphore.acquire().await; + let parent_header = self.parent_header(parent_block_hash).to_rpc_result()?; let (tx, rx) = oneshot::channel(); let this = Arc::clone(&self.inner); - self.inner.task_spawner.spawn_blocking_task(Box::pin(async move { + self.inner.task_spawner.spawn_blocking_task(async move { let result = async { let parent_hash = parent_header.hash(); let attributes = Attrs::try_new(parent_hash, attributes, 3) .map_err(PayloadBuilderError::other)?; + let payload_id = attributes.payload_job_id(); let config = - PayloadConfig { parent_header: Arc::new(parent_header), attributes }; + PayloadConfig::new(Arc::new(parent_header), attributes, payload_id); let ctx = BasePayloadBuilderCtx { evm_config: this.evm_config.clone(), chain_spec: this.provider.chain_spec(), @@ -231,7 +238,7 @@ where }; let _ = tx.send(result.await); - })); + }); rx.await .map_err(|err| internal_rpc_err(err.to_string()))? @@ -242,6 +249,8 @@ where async fn execution_witness(&self, block_id: BlockNumberOrTag) -> RpcResult { DebugApiExtMetrics::record_operation_async(DebugApis::DebugExecutionWitness, async { + let _permit = self.inner.semaphore.acquire().await; + let block = self .inner .eth_api @@ -262,9 +271,10 @@ where let mut witness_record = ExecutionWitnessRecord::default(); + let mode = ExecutionWitnessMode::default(); let _ = block_executor .execute_with_state_closure(&block, |statedb: &State<_>| { - witness_record.record_executed_state(statedb); + witness_record.record_executed_state(statedb, mode); }) .map_err(EthApiError::from)?; @@ -272,7 +282,7 @@ where witness_record; let state = state_provider - .witness(Default::default(), hashed_state) + .witness(Default::default(), hashed_state, mode) .map_err(EthApiError::from)?; let mut exec_witness = ExecutionWitness { state, codes, keys, ..Default::default() }; diff --git a/crates/execution/rpc/src/error.rs b/crates/execution/rpc/src/error.rs index f508c24322..90de018018 100644 --- a/crates/execution/rpc/src/error.rs +++ b/crates/execution/rpc/src/error.rs @@ -75,6 +75,16 @@ pub enum BaseInvalidTransactionError { /// The encoded transaction was missing during evm execution. #[error("missing enveloped transaction bytes")] MissingEnvelopedTx, + /// An EIP-8130 (account-abstraction) transaction was submitted via + /// `eth_sendRawTransaction` and rejected at the RPC ingress boundary. + /// + /// The transaction type byte (`0x7D`) is recognised by the consensus layer for + /// decoding/serialization purposes, but no validation, mempool admission, or + /// execution path exists yet. The rejection is unconditional (not gated on any + /// fork activation) and is mirrored by the txpool validator so EIP-8130 + /// transactions are also dropped if they arrive over devp2p. + #[error("{}", base_common_consensus::EIP8130_REJECTION_MSG)] + Eip8130NotAccepted, } impl From for jsonrpsee_types::error::ErrorObject<'static> { @@ -82,7 +92,8 @@ impl From for jsonrpsee_types::error::ErrorObject<' match err { BaseInvalidTransactionError::DepositSystemTxPostRegolith | BaseInvalidTransactionError::HaltedDepositPostRegolith - | BaseInvalidTransactionError::MissingEnvelopedTx => { + | BaseInvalidTransactionError::MissingEnvelopedTx + | BaseInvalidTransactionError::Eip8130NotAccepted => { rpc_err(EthRpcErrorCode::TransactionRejected.code(), err.to_string(), None) } } @@ -142,6 +153,7 @@ where EVMError::Database(err) => Self::Eth(err.into()), EVMError::Header(err) => Self::Eth(err.into()), EVMError::Custom(err) => Self::Eth(EthApiError::EvmCustom(err)), + EVMError::CustomAny(err) => Self::Eth(EthApiError::EvmCustom(err.to_string())), } } } diff --git a/crates/execution/rpc/src/eth/mod.rs b/crates/execution/rpc/src/eth/mod.rs index 0fba5cbb44..899f6ce2de 100644 --- a/crates/execution/rpc/src/eth/mod.rs +++ b/crates/execution/rpc/src/eth/mod.rs @@ -27,14 +27,14 @@ use reth_rpc_eth_api::{ EthApiTypes, FromEvmError, FullEthApiServer, RpcConvert, RpcConverter, RpcNodeCore, RpcNodeCoreExt, RpcTypes, helpers::{ - EthApiSpec, EthFees, EthState, LoadFee, LoadPendingBlock, LoadState, SpawnBlocking, Trace, - pending_block::BuildPendingEnv, + EthApiSpec, EthFees, EthState, GetBlockAccessList, LoadFee, LoadPendingBlock, LoadState, + SpawnBlocking, Trace, pending_block::BuildPendingEnv, }, }; use reth_rpc_eth_types::{EthStateCache, FeeHistoryCache, GasPriceOracle}; use reth_storage_api::ProviderHeader; use reth_tasks::{ - TaskSpawner, + Runtime, pool::{BlockingTaskGuard, BlockingTaskPool}, }; @@ -168,7 +168,7 @@ where Rpc: RpcConvert, { #[inline] - fn io_task_spawner(&self) -> impl TaskSpawner { + fn io_task_spawner(&self) -> &Runtime { self.inner.eth_api.task_spawner() } @@ -250,6 +250,14 @@ where { } +impl GetBlockAccessList for BaseEthApi +where + N: RpcNodeCore, + BaseEthApiError: FromEvmError, + Rpc: RpcConvert, +{ +} + impl fmt::Debug for BaseEthApi { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("BaseEthApi").finish_non_exhaustive() diff --git a/crates/execution/rpc/src/eth/receipt.rs b/crates/execution/rpc/src/eth/receipt.rs index 4212d090e7..8194702de0 100644 --- a/crates/execution/rpc/src/eth/receipt.rs +++ b/crates/execution/rpc/src/eth/receipt.rs @@ -303,6 +303,7 @@ impl BaseReceiptBuilder { BaseReceipt::Eip1559(receipt) => BaseReceipt::Eip1559(map_logs(receipt)), BaseReceipt::Eip7702(receipt) => BaseReceipt::Eip7702(map_logs(receipt)), BaseReceipt::Deposit(receipt) => BaseReceipt::Deposit(receipt.map_inner(map_logs)), + BaseReceipt::Eip8130(receipt) => BaseReceipt::Eip8130(map_logs(receipt)), }; mapped_receipt.into_with_bloom() }); diff --git a/crates/execution/rpc/src/eth/transaction.rs b/crates/execution/rpc/src/eth/transaction.rs index 6dc96b8d9e..fb282ee6c7 100644 --- a/crates/execution/rpc/src/eth/transaction.rs +++ b/crates/execution/rpc/src/eth/transaction.rs @@ -6,9 +6,12 @@ use std::{ time::Duration, }; +use alloy_consensus::Typed2718; use alloy_primitives::{B256, Bytes}; use alloy_rpc_types_eth::TransactionInfo; -use base_common_consensus::{BaseTransaction, BaseTransactionInfo, DepositInfo, DepositReceiptExt}; +use base_common_consensus::{ + BaseTransaction, BaseTransactionInfo, DepositInfo, DepositReceiptExt, EIP8130_TX_TYPE_ID, +}; use futures::StreamExt; use reth_chain_state::CanonStateSubscriptions; use reth_primitives_traits::{Recovered, SignedTransaction, SignerRecoverable, WithEncoded}; @@ -24,7 +27,7 @@ use reth_transaction_pool::{ }; use tracing::{debug, warn}; -use crate::{BaseEthApi, BaseEthApiError, SequencerClient}; +use crate::{BaseEthApi, BaseEthApiError, BaseInvalidTransactionError, SequencerClient}; impl EthTransactions for BaseEthApi where @@ -47,6 +50,10 @@ where ) -> Result { let (tx, recovered) = tx.split(); + if recovered.ty() == EIP8130_TX_TYPE_ID { + return Err(BaseInvalidTransactionError::Eip8130NotAccepted.into()); + } + // broadcast raw transaction to subscribers if there is any. self.eth_api().broadcast_raw_transaction(tx.clone()); @@ -131,10 +138,12 @@ where { let this = self.clone(); async move { - let Some((tx, meta, receipt)) = this.load_transaction_and_receipt(hash).await? else { + let Some((tx, meta, receipt, all_receipts)) = + this.load_transaction_and_receipt(hash).await? + else { return Ok(None); }; - self.build_transaction_receipt(tx, meta, receipt).await.map(Some) + this.build_transaction_receipt(tx, meta, receipt, all_receipts).await.map(Some) } } } @@ -167,6 +176,7 @@ where index: meta.index, block_hash: meta.block_hash, block_number: meta.block_number, + block_timestamp: meta.timestamp, base_fee: meta.base_fee, })); } diff --git a/crates/execution/rpc/src/witness.rs b/crates/execution/rpc/src/witness.rs index 567b8b4d1a..8511ec3a30 100644 --- a/crates/execution/rpc/src/witness.rs +++ b/crates/execution/rpc/src/witness.rs @@ -18,9 +18,9 @@ use reth_storage_api::{ BlockReaderIdExt, NodePrimitivesProvider, StateProviderFactory, errors::{ProviderError, ProviderResult}, }; -use reth_tasks::TaskSpawner; +use reth_tasks::Runtime; use reth_transaction_pool::TransactionPool; -use tokio::sync::oneshot; +use tokio::sync::{Semaphore, oneshot}; /// An extension to the `debug_` namespace of the RPC API. pub struct BaseDebugWitnessApi { @@ -31,10 +31,11 @@ impl BaseDebugWitnessApi, + task_spawner: Runtime, builder: BasePayloadBuilder, ) -> Self { - let inner = BaseDebugWitnessApiInner { provider, builder, task_spawner }; + let semaphore = Arc::new(Semaphore::new(3)); + let inner = BaseDebugWitnessApiInner { provider, builder, task_spawner, semaphore }; Self { inner: Arc::new(inner) } } } @@ -77,20 +78,23 @@ where NextBlockEnvCtx: BuildNextEnv, > + 'static, Attrs: Attributes>, + Attrs::RpcPayloadAttributes: Send + Sync + 'static, { async fn execute_payload( &self, parent_block_hash: B256, attributes: Attrs::RpcPayloadAttributes, ) -> RpcResult { + let _permit = self.inner.semaphore.acquire().await; + let parent_header = self.parent_header(parent_block_hash).to_rpc_result()?; let (tx, rx) = oneshot::channel(); let this = self.clone(); - self.inner.task_spawner.spawn_blocking_task(Box::pin(async move { + self.inner.task_spawner.spawn_blocking_task(async move { let res = this.inner.builder.payload_witness(parent_header, attributes); let _ = tx.send(res); - })); + }); rx.await .map_err(|err| internal_rpc_err(err.to_string()))? @@ -116,5 +120,6 @@ impl Debug struct BaseDebugWitnessApiInner { provider: Provider, builder: BasePayloadBuilder, - task_spawner: Box, + task_spawner: Runtime, + semaphore: Arc, } diff --git a/crates/execution/runner/Cargo.toml b/crates/execution/runner/Cargo.toml index 9858062258..abee6bc4b4 100644 --- a/crates/execution/runner/Cargo.toml +++ b/crates/execution/runner/Cargo.toml @@ -78,6 +78,10 @@ tracing-subscriber = { workspace = true, optional = true } [dev-dependencies] base-node-runner = { path = ".", features = ["test-utils"] } criterion = { workspace = true, features = ["async_tokio", "html_reports"] } +reth-firehose.workspace = true +reth-chainspec.workspace = true +base-test-utils.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [[bench]] name = "fcu_unsafe" diff --git a/crates/execution/runner/src/add_ons.rs b/crates/execution/runner/src/add_ons.rs index e6247d02ba..64e0f30258 100644 --- a/crates/execution/runner/src/add_ons.rs +++ b/crates/execution/runner/src/add_ons.rs @@ -5,10 +5,9 @@ use base_execution_payload_builder::{ config::{BaseDAConfig, GasLimitConfig}, }; use base_execution_rpc::{ - MinerApiExtServer, config::{BaseEthConfigApiServer, BaseEthConfigHandler}, eth::BaseEthApiBuilder, - miner::BaseMinerExtApi, + miner::{BaseMinerExtApi, MinerApiExtServer}, witness::BaseDebugWitnessApi, }; use base_execution_txpool::BasePooledTx; @@ -235,8 +234,7 @@ impl NodeAddOns for BaseAddOns where N: FullNodeComponents< - Types: BaseNodeTypes - + NodeTypes>, + Types: BaseNodeTypes + NodeTypes>, Evm: ConfigureEvm< NextBlockEnvCtx: BuildNextEnv< Attrs, @@ -251,7 +249,10 @@ where EB: EngineApiBuilder, EVB: EngineValidatorBuilder, RpcMiddleware: RethRpcMiddleware, - Attrs: Attributes, RpcPayloadAttributes: DeserializeOwned>, + Attrs: Attributes< + Transaction = TxTy, + RpcPayloadAttributes: DeserializeOwned + Send + Sync + 'static, + >, ::Primitives: PayloadPrimitives<_Header: HeaderMut>, { type Handle = RpcHandle; @@ -283,7 +284,7 @@ where // Install additional rollup-specific RPC methods. let debug_ext = BaseDebugWitnessApi::<_, _, _, Attrs>::new( ctx.node.provider().clone(), - Box::new(ctx.node.task_executor().clone()), + ctx.node.task_executor().clone(), builder, ); let miner_ext = BaseMinerExtApi::new(da_config, gas_limit_config); @@ -326,8 +327,7 @@ impl RethRpcAddOns for BaseAddOns where N: FullNodeComponents< - Types: BaseNodeTypes - + NodeTypes>, + Types: BaseNodeTypes + NodeTypes>, Evm: ConfigureEvm< NextBlockEnvCtx: BuildNextEnv< Attrs, @@ -342,7 +342,10 @@ where EB: EngineApiBuilder, EVB: EngineValidatorBuilder, RpcMiddleware: RethRpcMiddleware, - Attrs: Attributes, RpcPayloadAttributes: DeserializeOwned>, + Attrs: Attributes< + Transaction = TxTy, + RpcPayloadAttributes: DeserializeOwned + Send + Sync + 'static, + >, ::Primitives: PayloadPrimitives<_Header: HeaderMut>, { type EthApi = EthB::EthApi; @@ -504,6 +507,7 @@ impl BaseAddOnsBuilder { EB::default(), EVB::default(), rpc_middleware, + Identity::new(), ) .with_tokio_runtime(tokio_runtime), da_config.unwrap_or_default(), diff --git a/crates/execution/runner/src/runner.rs b/crates/execution/runner/src/runner.rs index 2657596487..c89bfa8a22 100644 --- a/crates/execution/runner/src/runner.rs +++ b/crates/execution/runner/src/runner.rs @@ -2,7 +2,7 @@ use std::fmt; -use base_execution_payload_builder::config::BaseDAConfig; +use base_execution_payload_builder::config::{BaseDAConfig, GasLimitConfig}; use base_node_core::args::RollupArgs; use eyre::Result; use reth_node_builder::{Node, NodeHandle, NodeHandleFor}; @@ -32,8 +32,10 @@ pub struct BaseNodeRunner>, /// Payload service builder. service_builder: SB, - /// Shared DA configuration for the node and metering extension. + /// Shared DA configuration for the node and payload builder. da_config: Option, + /// Shared gas-limit configuration for the node and payload builder. + gas_limit_config: Option, /// Binary-owned callbacks to run after the node has started. started_callbacks: Vec, } @@ -46,6 +48,7 @@ impl BaseNodeRunner { extensions: Vec::new(), service_builder: DefaultPayloadServiceBuilder, da_config: None, + gas_limit_config: None, started_callbacks: Vec::new(), } } @@ -57,6 +60,7 @@ impl fmt::Debug for BaseNodeRunner { .field("rollup_args", &self.rollup_args) .field("extensions", &self.extensions.len()) .field("da_config", &self.da_config) + .field("gas_limit_config", &self.gas_limit_config) .field("started_callbacks", &self.started_callbacks.len()) .finish() } @@ -69,6 +73,12 @@ impl BaseNodeRunner { self } + /// Sets the shared gas-limit configuration. + pub fn with_gas_limit_config(mut self, gas_limit_config: GasLimitConfig) -> Self { + self.gas_limit_config = Some(gas_limit_config); + self + } + /// Swap the payload service builder. pub fn with_service_builder(self, sb: SB2) -> BaseNodeRunner { BaseNodeRunner { @@ -76,6 +86,7 @@ impl BaseNodeRunner { extensions: self.extensions, service_builder: sb, da_config: self.da_config, + gas_limit_config: self.gas_limit_config, started_callbacks: self.started_callbacks, } } @@ -105,12 +116,20 @@ impl BaseNodeRunner { /// Applies all Base-specific wiring to the supplied builder and returns a launched node /// handle without waiting for shutdown. pub async fn launch(self, builder: BaseNodeBuilder) -> Result { - let Self { rollup_args, extensions, service_builder, da_config, started_callbacks } = self; + let Self { + rollup_args, + extensions, + service_builder, + da_config, + gas_limit_config, + started_callbacks, + } = self; let handle = Self::launch_node( rollup_args, extensions, service_builder, da_config, + gas_limit_config, started_callbacks, builder, ) @@ -123,6 +142,7 @@ impl BaseNodeRunner { extensions: Vec>, service_builder: SB, da_config: Option, + gas_limit_config: Option, started_callbacks: Vec, builder: BaseNodeBuilder, ) -> Result> { @@ -132,6 +152,9 @@ impl BaseNodeRunner { if let Some(da_config) = da_config { base_node = base_node.with_da_config(da_config); } + if let Some(gas_limit_config) = gas_limit_config { + base_node = base_node.with_gas_limit_config(gas_limit_config); + } let components = service_builder.build_components(&base_node); let builder = builder @@ -148,3 +171,44 @@ impl BaseNodeRunner { hooks.apply_to(builder).launch().await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug)] + struct TestPayloadServiceBuilder; + + impl crate::service::PayloadServiceBuilder for TestPayloadServiceBuilder { + type ComponentsBuilder = crate::types::BaseComponentsBuilder; + + fn build_components(self, base_node: &BaseNode) -> Self::ComponentsBuilder { + base_node.components() + } + } + + #[test] + fn service_builder_swap_preserves_shared_runtime_configs() { + let da_config = BaseDAConfig::new(100, 200); + let gas_limit_config = GasLimitConfig::new(30_000_000); + + let runner = BaseNodeRunner::new(RollupArgs::default()) + .with_da_config(da_config.clone()) + .with_gas_limit_config(gas_limit_config.clone()) + .with_service_builder(TestPayloadServiceBuilder); + + let configured_da = runner.da_config.expect("DA config should be preserved"); + let configured_gas = runner.gas_limit_config.expect("gas-limit config should be preserved"); + + assert_eq!(configured_da.max_da_tx_size(), Some(100)); + assert_eq!(configured_da.max_da_block_size(), Some(200)); + assert_eq!(configured_gas.gas_limit(), Some(30_000_000)); + + da_config.set_max_da_size(300, 400); + gas_limit_config.set_gas_limit(40_000_000); + + assert_eq!(configured_da.max_da_tx_size(), Some(300)); + assert_eq!(configured_da.max_da_block_size(), Some(400)); + assert_eq!(configured_gas.gas_limit(), Some(40_000_000)); + } +} diff --git a/crates/execution/runner/src/test_utils/harness.rs b/crates/execution/runner/src/test_utils/harness.rs index 719d450cae..898a4ad1bb 100644 --- a/crates/execution/runner/src/test_utils/harness.rs +++ b/crates/execution/runner/src/test_utils/harness.rs @@ -8,11 +8,12 @@ use alloy_provider::{Provider, RootProvider}; use alloy_rpc_client::RpcClient; use alloy_rpc_types::BlockNumberOrTag; use alloy_rpc_types_engine::PayloadAttributes; -use base_common_consensus::BaseBlock; +use base_common_consensus::{BaseBlock, BaseTxEnvelope}; use base_common_network::Base; use base_common_rpc_types::GenesisInfo; use base_common_rpc_types_engine::BasePayloadAttributes; use base_execution_chainspec::BaseChainSpec; +use base_execution_payload_builder::BasePayloadBuilderAttributes; use base_test_utils::build_test_genesis; use eyre::{Result, eyre}; use reth_primitives_traits::{Block as BlockT, RecoveredBlock}; @@ -180,19 +181,24 @@ impl TestHarness { let eip_1559_params = ((base_fee_params.max_change_denominator as u64) << 32) | (base_fee_params.elasticity_multiplier as u64); - let payload_attributes = BasePayloadAttributes { - payload_attributes: PayloadAttributes { - timestamp: next_timestamp, - parent_beacon_block_root: Some(parent_beacon_block_root), - withdrawals: Some(vec![]), - ..Default::default() + let payload_attributes = BasePayloadBuilderAttributes::::try_new( + parent_hash, + BasePayloadAttributes { + payload_attributes: PayloadAttributes { + timestamp: next_timestamp, + parent_beacon_block_root: Some(parent_beacon_block_root), + withdrawals: Some(vec![]), + slot_number: None, + ..Default::default() + }, + transactions: Some(transactions), + gas_limit: Some(GAS_LIMIT), + no_tx_pool: Some(true), + min_base_fee: Some(min_base_fee), + eip_1559_params: Some(B64::from(eip_1559_params)), }, - transactions: Some(transactions), - gas_limit: Some(GAS_LIMIT), - no_tx_pool: Some(true), - min_base_fee: Some(min_base_fee), - eip_1559_params: Some(B64::from(eip_1559_params)), - }; + 3, + )?; let forkchoice_result = self .engine diff --git a/crates/execution/runner/tests/firehose_live_tracing.rs b/crates/execution/runner/tests/firehose_live_tracing.rs new file mode 100644 index 0000000000..748ba245fc --- /dev/null +++ b/crates/execution/runner/tests/firehose_live_tracing.rs @@ -0,0 +1,209 @@ +//! Regression test for Firehose tracing on the engine-tree live-block path. +//! +//! Base's live-path Firehose hooks live in `base_engine_tree`'s payload validator +//! (`validate_block_with_state` → `execute_and_trace_block`) and are wired into the node through +//! [`base_node_runner::BaseNode`]'s `BaseEngineValidatorBuilder` add-on. Blocks that a node has to +//! *execute itself* when they arrive via `engine_newPayload` are routed into the Firehose tracer +//! there. Those hooks have been dropped during upstream merges before, silently disabling +//! live-block tracing while the historical/stage path kept working — a regression that compiled and +//! passed every existing test. +//! +//! ## Why two nodes +//! +//! A node that *builds* a block (the sequencer flow) inserts it into its tree as already-executed +//! (`InsertExecutedBlock`), so a subsequent `engine_newPayload` for that same block short-circuits +//! and never re-runs `validate_block_with_state` — the traced path is skipped. The live path is +//! only exercised by a node that did **not** build the block: a follower receiving payloads from a +//! sequencer. So this test runs two nodes — a `sequencer` that builds payloads and a `follower` +//! that executes them via `engine_newPayload` — and asserts the follower emits `FIRE BLOCK` lines. +//! If the dispatch into `execute_and_trace_block` is missing, no `FIRE BLOCK` lines are produced and +//! the test fails. +//! +//! It lives in its own integration-test binary because it installs a process-wide tracer; +//! cargo/nextest run each integration binary in its own process, keeping the global tracer isolated +//! from the rest of the suite. The tracer is global, but only the follower's +//! `validate_block_with_state` execution feeds it — building on the sequencer does not trace. + +use std::{sync::Arc, time::Duration}; + +use alloy_eips::eip7685::Requests; +use alloy_primitives::{B64, B256, Bytes}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockNumberOrTag; +use alloy_rpc_types_engine::PayloadAttributes; +use base_common_consensus::BaseTxEnvelope; +use base_common_rpc_types_engine::BasePayloadAttributes; +use base_execution_chainspec::BaseChainSpec; +use base_execution_payload_builder::BasePayloadBuilderAttributes; +use base_node_runner::test_utils::{ + BLOCK_BUILD_DELAY_MS, BLOCK_TIME_SECONDS, GAS_LIMIT, L1_BLOCK_INFO_DEPOSIT_TX, LocalNode, + NODE_STARTUP_DELAY_MS, +}; +use base_test_utils::{DEVNET_CHAIN_ID, build_test_genesis}; +use eyre::{Result, eyre}; +use reth_chainspec::EthChainSpec; +use reth_provider::ChainSpecProvider; +use tokio::time::sleep; + +/// Number of blocks to advance. Block 1 is the Firehose genesis marker (emitted via +/// `on_genesis_block`); blocks 2.. exercise the live `execute_and_trace_block` path on the follower. +const PRODUCED_BLOCKS: u64 = 3; + +/// Base EIP-1559 base-fee params (`eip1559Denominator` / `eip1559Elasticity`) matching the +/// `optimism` section of [`build_test_genesis`]. +const EIP1559_DENOMINATOR: u32 = 50; +const EIP1559_ELASTICITY: u32 = 6; +/// Jovian minimum base fee, matching the genesis `base_fee_per_gas` from [`build_test_genesis`]. +const MIN_BASE_FEE: u64 = 1_000_000_000; + +#[tokio::test(flavor = "multi_thread")] +async fn live_payload_validation_emits_firehose_blocks() -> Result<()> { + // Install a buffer-backed global Firehose tracer BEFORE any block is validated, so the live + // path's `is_tracer_initialized()` gate activates and routes execution through + // `execute_and_trace_block`. The chain id matches the test genesis; the fork timestamps only + // affect how block contents are mapped, not whether a block is emitted. + let buffer = reth_firehose::init_tracer_with_buffer( + DEVNET_CHAIN_ID, + Some(0), // shanghai / canyon + Some(0), // cancun / ecotone + None, // prague + ); + + // `build_test_genesis` enables Jovian at genesis but leaves `extraData` as a single zero byte. + // From Holocene on, the EIP-1559 base-fee parameters live in the block's `extraData`, and from + // Jovian on the encoding is the version-1 form `0x01 || denominator(u32 BE) || elasticity(u32 + // BE) || min_base_fee(u64 BE)`. A fresh follower derives a block's base fee from its parent's + // `extraData`, so the genesis must carry a well-formed version-1 header — otherwise the builder + // cannot read the min base fee (base fee collapses to 0) and the follower fails + // `validate_header_base_fee` ("base fee missing"). + let mut genesis = build_test_genesis(); + let mut extra_data = vec![1u8]; + extra_data.extend_from_slice(&EIP1559_DENOMINATOR.to_be_bytes()); + extra_data.extend_from_slice(&EIP1559_ELASTICITY.to_be_bytes()); + extra_data.extend_from_slice(&MIN_BASE_FEE.to_be_bytes()); + genesis.extra_data = Bytes::from(extra_data); + let chain_spec = Arc::new(BaseChainSpec::from_genesis(genesis)); + + // Two nodes on the same genesis: the sequencer builds payloads; the follower executes them via + // `engine_newPayload` (the path under test). Building does not trace; only the follower's + // `validate_block_with_state` execution feeds the global tracer. + let sequencer = LocalNode::new(vec![], chain_spec.clone()).await?; + let follower = LocalNode::new(vec![], chain_spec.clone()).await?; + sleep(Duration::from_millis(NODE_STARTUP_DELAY_MS)).await; + + let seq_engine = sequencer.engine_api()?; + let fol_engine = follower.engine_api()?; + let spec = sequencer.blockchain_provider().chain_spec(); + + // Bootstrap both nodes by pointing their forkchoice at the genesis head. + let genesis_hash = sequencer + .provider()? + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .ok_or_else(|| eyre!("no genesis block"))? + .header + .hash; + seq_engine.update_forkchoice(genesis_hash, genesis_hash, None).await?; + fol_engine.update_forkchoice(genesis_hash, genesis_hash, None).await?; + + for _ in 0..PRODUCED_BLOCKS { + // Use the sequencer head as the parent for the next block. + let parent = sequencer + .provider()? + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .ok_or_else(|| eyre!("no head block on sequencer"))?; + + let parent_hash = parent.header.hash; + let parent_beacon_block_root = parent.header.parent_beacon_block_root.unwrap_or(B256::ZERO); + let next_timestamp = parent.header.timestamp + BLOCK_TIME_SECONDS; + let min_base_fee = parent.header.base_fee_per_gas.unwrap_or_default(); + let base_fee_params = spec.base_fee_params_at_timestamp(next_timestamp); + let eip_1559_params = ((base_fee_params.max_change_denominator as u64) << 32) + | (base_fee_params.elasticity_multiplier as u64); + + let attributes = BasePayloadBuilderAttributes::::try_new( + parent_hash, + BasePayloadAttributes { + payload_attributes: PayloadAttributes { + timestamp: next_timestamp, + parent_beacon_block_root: Some(parent_beacon_block_root), + withdrawals: Some(vec![]), + slot_number: None, + ..Default::default() + }, + transactions: Some(vec![L1_BLOCK_INFO_DEPOSIT_TX]), + gas_limit: Some(GAS_LIMIT), + no_tx_pool: Some(true), + min_base_fee: Some(min_base_fee), + eip_1559_params: Some(B64::from(eip_1559_params)), + }, + 3, + )?; + + // Sequencer builds the payload (this does NOT trace — it builds, it does not validate). + let payload_id = seq_engine + .update_forkchoice(parent_hash, parent_hash, Some(attributes)) + .await? + .payload_id + .ok_or_else(|| eyre!("sequencer forkchoice update returned no payload id"))?; + + sleep(Duration::from_millis(BLOCK_BUILD_DELAY_MS)).await; + + let envelope = seq_engine.get_payload_v4(payload_id).await?; + let execution_payload = envelope.execution_payload; + let execution_requests: Vec = envelope.execution_requests; + let execution_requests = if execution_requests.is_empty() { + Requests::default() + } else { + Requests::new(execution_requests) + }; + + // Follower validates the externally-produced payload via `engine_newPayload`. Since the + // follower did not build it, this drives `validate_block_with_state` → + // `execute_and_trace_block` — the traced path under test. + let status = fol_engine + .new_payload(execution_payload, vec![], parent_beacon_block_root, execution_requests) + .await?; + assert!(!status.status.is_invalid(), "follower rejected payload: {status:?}"); + + let new_block_hash = status + .latest_valid_hash + .ok_or_else(|| eyre!("follower payload status missing latest_valid_hash"))?; + + // Advance both heads to the new block so the next iteration builds/validates on top of it. + seq_engine.update_forkchoice(parent_hash, new_block_hash, None).await?; + fol_engine.update_forkchoice(parent_hash, new_block_hash, None).await?; + } + + // Collect the block numbers from every captured `FIRE BLOCK ...` line. + let raw = buffer.get_bytes(); + let text = String::from_utf8(raw).expect("captured tracer output is UTF-8"); + let traced: Vec = text + .lines() + .filter_map(|line| { + let mut parts = line.split(' '); + if parts.next()? != "FIRE" || parts.next()? != "BLOCK" { + return None; + } + parts.next()?.parse::().ok() + }) + .collect(); + + assert!( + !traced.is_empty(), + "no FIRE BLOCK lines were emitted — the follower's live payload-validation path is not \ + traced.\nCaptured tracer output:\n{text}" + ); + + // Blocks 2..=PRODUCED_BLOCKS go through the live `execute_and_trace_block` path (block 1 is the + // genesis marker, emitted separately via `on_genesis_block`). Require each to have been traced. + for number in 2..=PRODUCED_BLOCKS { + assert!( + traced.contains(&number), + "expected a FIRE BLOCK line for live block #{number}, got traced blocks {traced:?}" + ); + } + + Ok(()) +} diff --git a/crates/execution/trie/Cargo.toml b/crates/execution/trie/Cargo.toml index ab9a54b178..9a6414c084 100644 --- a/crates/execution/trie/Cargo.toml +++ b/crates/execution/trie/Cargo.toml @@ -20,6 +20,7 @@ reth-provider.workspace = true reth-execution-errors.workspace = true reth-primitives-traits.workspace = true reth-db = { workspace = true, features = ["mdbx"] } +reth-codecs.workspace = true reth-trie = { workspace = true, features = ["serde"] } reth-trie-common = { workspace = true, features = ["serde"] } @@ -85,8 +86,6 @@ serde-bincode-compat = [ "alloy-eips/serde-bincode-compat", "dep:reth-ethereum-primitives", "reth-ethereum-primitives?/serde", - "reth-ethereum-primitives?/serde-bincode-compat", - "reth-primitives-traits/serde-bincode-compat", "reth-trie-common/serde-bincode-compat", "reth-trie/serde-bincode-compat", ] diff --git a/crates/execution/trie/src/batch_provider.rs b/crates/execution/trie/src/batch_provider.rs index e67b8edeff..7826e776d5 100644 --- a/crates/execution/trie/src/batch_provider.rs +++ b/crates/execution/trie/src/batch_provider.rs @@ -26,8 +26,8 @@ use reth_trie::{ witness::TrieWitness, }; use reth_trie_common::{ - AccountProof, HashedPostState, HashedPostStateSorted, HashedStorage, KeccakKeyHasher, - MultiProof, MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, + AccountProof, ExecutionWitnessMode, HashedPostState, HashedPostStateSorted, HashedStorage, + KeccakKeyHasher, MultiProof, MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, updates::TrieUpdates, }; @@ -247,7 +247,12 @@ impl StateProofProvider for BaseProofsBatchStateProvi .map_err(ProviderError::from) } - fn witness(&self, input: TrieInput, target: HashedPostState) -> ProviderResult> { + fn witness( + &self, + input: TrieInput, + target: HashedPostState, + _mode: ExecutionWitnessMode, + ) -> ProviderResult> { let nodes_sorted = input.nodes.into_sorted(); let state_sorted = input.state.into_sorted(); let (trie_factory, hashed_factory) = self.factories(); @@ -287,14 +292,6 @@ impl AccountReader for BaseProofsBatchStateProviderRe impl StateProvider for BaseProofsBatchStateProviderRef<'_, S> { fn storage(&self, address: Address, storage_key: B256) -> ProviderResult> { let hashed_key = keccak256(storage_key); - self.storage_by_hashed_key(address, hashed_key) - } - - fn storage_by_hashed_key( - &self, - address: Address, - hashed_key: B256, - ) -> ProviderResult> { Ok(self .session .storage_hashed_cursor(keccak256(address.0), self.block_number) diff --git a/crates/execution/trie/src/db/models/block.rs b/crates/execution/trie/src/db/models/block.rs index e5ad7949ea..3a2e589235 100644 --- a/crates/execution/trie/src/db/models/block.rs +++ b/crates/execution/trie/src/db/models/block.rs @@ -2,6 +2,7 @@ use alloy_eips::BlockNumHash; use alloy_primitives::B256; use bytes::BufMut; use derive_more::{From, Into}; +use reth_codecs::DecompressError; use reth_db::{ DatabaseError, table::{Compress, Decompress}, @@ -25,12 +26,14 @@ impl Compress for BlockNumberHash { } impl Decompress for BlockNumberHash { - fn decompress(value: &[u8]) -> Result { + fn decompress(value: &[u8]) -> Result { if value.len() != 40 { - return Err(DatabaseError::Decode); + return Err(DecompressError::new(DatabaseError::Decode)); } - let number = u64::from_be_bytes(value[..8].try_into().map_err(|_| DatabaseError::Decode)?); + let number = u64::from_be_bytes( + value[..8].try_into().map_err(|_| DecompressError::new(DatabaseError::Decode))?, + ); let hash = B256::from_slice(&value[8..40]); Ok(Self(BlockNumHash { number, hash })) diff --git a/crates/execution/trie/src/db/models/change_set.rs b/crates/execution/trie/src/db/models/change_set.rs index bb2ddff6e4..d9f7d80648 100644 --- a/crates/execution/trie/src/db/models/change_set.rs +++ b/crates/execution/trie/src/db/models/change_set.rs @@ -1,4 +1,5 @@ use alloy_primitives::B256; +use reth_codecs::DecompressError; use reth_db::{ DatabaseError, table::{self, Decode, Encode}, @@ -48,8 +49,8 @@ impl table::Compress for ChangeSet { } impl table::Decompress for ChangeSet { - fn decompress(value: &[u8]) -> Result { - Self::decode(value) + fn decompress(value: &[u8]) -> Result { + Self::decode(value).map_err(DecompressError::new) } } diff --git a/crates/execution/trie/src/db/models/storage.rs b/crates/execution/trie/src/db/models/storage.rs index 41a0c086e9..913df77436 100644 --- a/crates/execution/trie/src/db/models/storage.rs +++ b/crates/execution/trie/src/db/models/storage.rs @@ -1,5 +1,6 @@ use alloy_primitives::{B256, U256}; use derive_more::{Constructor, From, Into}; +use reth_codecs::DecompressError; use reth_db::{ DatabaseError, table::{Compress, Decode, Decompress, Encode}, @@ -114,11 +115,12 @@ impl Compress for StorageValue { } impl Decompress for StorageValue { - fn decompress(value: &[u8]) -> Result { + fn decompress(value: &[u8]) -> Result { if value.len() != 32 { - return Err(DatabaseError::Decode); + return Err(DecompressError::new(DatabaseError::Decode)); } - let bytes: [u8; 32] = value.try_into().map_err(|_| DatabaseError::Decode)?; + let bytes: [u8; 32] = + value.try_into().map_err(|_| DecompressError::new(DatabaseError::Decode))?; Ok(Self(U256::from_be_bytes(bytes))) } } diff --git a/crates/execution/trie/src/db/models/version.rs b/crates/execution/trie/src/db/models/version.rs index 45ce7a2eb6..c11ea943f1 100644 --- a/crates/execution/trie/src/db/models/version.rs +++ b/crates/execution/trie/src/db/models/version.rs @@ -1,4 +1,5 @@ use bytes::{Buf, BufMut}; +use reth_codecs::DecompressError; use reth_db::{ DatabaseError, table::{Compress, Decompress}, @@ -45,7 +46,7 @@ impl Compress for MaybeDeleted { } impl Decompress for MaybeDeleted { - fn decompress(value: &[u8]) -> Result { + fn decompress(value: &[u8]) -> Result { if value.is_empty() { // Empty = deleted Ok(Self(None)) @@ -96,9 +97,9 @@ impl Compress for VersionedValue { } impl Decompress for VersionedValue { - fn decompress(value: &[u8]) -> Result { + fn decompress(value: &[u8]) -> Result { if value.len() < 8 { - return Err(DatabaseError::Decode); + return Err(DecompressError::new(DatabaseError::Decode)); } let mut buf: &[u8] = value; diff --git a/crates/execution/trie/src/proof.rs b/crates/execution/trie/src/proof.rs index 2d9925d7e6..a8294b7865 100644 --- a/crates/execution/trie/src/proof.rs +++ b/crates/execution/trie/src/proof.rs @@ -15,8 +15,9 @@ use reth_trie::{ witness::TrieWitness, }; use reth_trie_common::{ - AccountProof, HashedPostState, HashedPostStateSorted, HashedStorage, MultiProof, - MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, updates::TrieUpdates, + AccountProof, ExecutionWitnessMode, HashedPostState, HashedPostStateSorted, HashedStorage, + MultiProof, MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, + updates::TrieUpdates, }; use crate::{ @@ -348,6 +349,7 @@ pub trait DatabaseTrieWitness<'tx, S: BaseProofsStore + 'tx + Clone> { block_number: u64, input: TrieInput, target: HashedPostState, + mode: ExecutionWitnessMode, ) -> Result, TrieWitnessError>; } @@ -364,6 +366,7 @@ where block_number: u64, input: TrieInput, target: HashedPostState, + mode: ExecutionWitnessMode, ) -> Result, TrieWitnessError> { let nodes_sorted = input.nodes.into_sorted(); let state_sorted = input.state.into_sorted(); @@ -380,6 +383,7 @@ where )) .with_prefix_sets_mut(input.prefix_sets) .always_include_root_node() + .with_execution_witness_mode(mode) .compute(target) } } diff --git a/crates/execution/trie/src/provider.rs b/crates/execution/trie/src/provider.rs index f23d662c90..c43f7ac4a8 100644 --- a/crates/execution/trie/src/provider.rs +++ b/crates/execution/trie/src/provider.rs @@ -20,8 +20,9 @@ use reth_trie::{ witness::TrieWitness, }; use reth_trie_common::{ - AccountProof, HashedPostState, HashedStorage, KeccakKeyHasher, MultiProof, MultiProofTargets, - StorageMultiProof, StorageProof, TrieInput, updates::TrieUpdates, + AccountProof, ExecutionWitnessMode, HashedPostState, HashedStorage, KeccakKeyHasher, + MultiProof, MultiProofTargets, StorageMultiProof, StorageProof, TrieInput, + updates::TrieUpdates, }; use crate::{ @@ -57,6 +58,22 @@ where } } +impl<'a, Storage: BaseProofsStore + Clone> BaseProofsStateProviderRef<'a, Storage> { + fn storage_by_hashed_key( + &self, + address: Address, + hashed_key: B256, + ) -> ProviderResult> { + Ok(self + .storage + .storage_hashed_cursor(keccak256(address.0), self.block_number) + .map_err(Into::::into)? + .seek(hashed_key) + .map_err(Into::::into)? + .and_then(|(key, storage)| (key == hashed_key).then_some(storage))) + } +} + impl From for ProviderError { fn from(error: BaseProofsStorageError) -> Self { Self::other(error) @@ -166,8 +183,13 @@ impl<'a, Storage: BaseProofsStore + Clone> StateProofProvider .map_err(ProviderError::from) } - fn witness(&self, input: TrieInput, target: HashedPostState) -> ProviderResult> { - TrieWitness::overlay_witness(self.storage, self.block_number, input, target) + fn witness( + &self, + input: TrieInput, + target: HashedPostState, + mode: ExecutionWitnessMode, + ) -> ProviderResult> { + TrieWitness::overlay_witness(self.storage, self.block_number, input, target, mode) .map_err(ProviderError::from) .map(|hm| hm.into_values().collect()) } @@ -202,20 +224,6 @@ where let hashed_key = keccak256(storage_key); self.storage_by_hashed_key(address, hashed_key) } - - fn storage_by_hashed_key( - &self, - address: Address, - hashed_key: B256, - ) -> ProviderResult> { - Ok(self - .storage - .storage_hashed_cursor(keccak256(address.0), self.block_number) - .map_err(Into::::into)? - .seek(hashed_key) - .map_err(Into::::into)? - .and_then(|(key, storage)| (key == hashed_key).then_some(storage))) - } } impl<'a, Storage: BaseProofsStore> BytecodeReader for BaseProofsStateProviderRef<'a, Storage> { diff --git a/crates/execution/trie/tests/tx_sharing.rs b/crates/execution/trie/tests/tx_sharing.rs index e181517e05..e9d4ee81f2 100644 --- a/crates/execution/trie/tests/tx_sharing.rs +++ b/crates/execution/trie/tests/tx_sharing.rs @@ -29,7 +29,9 @@ use reth_primitives_traits::Account; use reth_provider::{ StateProofProvider, StateRootProvider, StorageRootProvider, noop::NoopProvider, }; -use reth_trie_common::{HashedPostState, HashedStorage, MultiProofTargets, TrieInput}; +use reth_trie_common::{ + ExecutionWitnessMode, HashedPostState, HashedStorage, MultiProofTargets, TrieInput, +}; use tempfile::TempDir; /// Number of accounts we seed and target per test request. @@ -177,7 +179,11 @@ fn witness_acquires_one_tx_per_call() { assert_tx_acquisitions(&storage, 1, "witness", || { provider - .witness(TrieInput::from_state(full_post_state()), full_post_state()) + .witness( + TrieInput::from_state(full_post_state()), + full_post_state(), + ExecutionWitnessMode::default(), + ) .expect("witness"); }); } diff --git a/crates/execution/txpool-tracing/src/events.rs b/crates/execution/txpool-tracing/src/events.rs index bf9adc0f19..9245f5b620 100644 --- a/crates/execution/txpool-tracing/src/events.rs +++ b/crates/execution/txpool-tracing/src/events.rs @@ -2,6 +2,7 @@ use std::time::Instant; +use alloy_primitives::Address; use chrono::{DateTime, Local}; use derive_more::Display; @@ -43,6 +44,45 @@ pub enum Pool { Queued, } +/// Key for tracking a unique nonce slot: `(sender, nonce)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct NonceSlot { + /// Transaction sender address. + pub sender: Address, + /// Transaction nonce. + pub nonce: u64, +} + +impl NonceSlot { + /// Creates a new nonce slot key. + pub const fn new(sender: Address, nonce: u64) -> Self { + Self { sender, nonce } + } +} + +/// Tracks the end-to-end lifecycle of a `(sender, nonce)` pair across +/// replacements until final inclusion. +#[derive(Debug, Clone)] +pub struct NonceSummary { + /// When the first transaction for this nonce slot entered the mempool. + pub first_seen: Instant, + /// Number of replacement transactions observed for this nonce slot. + pub replacement_count: u32, +} + +impl NonceSummary { + /// Creates a new nonce summary starting from now. + pub fn new() -> Self { + Self::default() + } +} + +impl Default for NonceSummary { + fn default() -> Self { + Self { first_seen: Instant::now(), replacement_count: 0 } + } +} + /// History of events for a transaction. #[derive(Debug, Clone)] pub struct EventLog { diff --git a/crates/execution/txpool-tracing/src/lib.rs b/crates/execution/txpool-tracing/src/lib.rs index 90b183873b..eecc70663e 100644 --- a/crates/execution/txpool-tracing/src/lib.rs +++ b/crates/execution/txpool-tracing/src/lib.rs @@ -8,7 +8,7 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] mod events; -pub use events::{EventLog, Pool, TxEvent}; +pub use events::{EventLog, NonceSlot, NonceSummary, Pool, TxEvent}; mod subscription; pub use subscription::tracex_subscription; diff --git a/crates/execution/txpool-tracing/src/metrics.rs b/crates/execution/txpool-tracing/src/metrics.rs index ab5ccac186..11c148057c 100644 --- a/crates/execution/txpool-tracing/src/metrics.rs +++ b/crates/execution/txpool-tracing/src/metrics.rs @@ -6,4 +6,18 @@ base_metrics::define_metrics! { inclusion_duration: histogram, #[describe("Time taken for a transaction to be included in a flashblock from when it's marked as pending")] fb_inclusion_duration: histogram, + #[describe("Number of transactions included in a flashblock within the healthy threshold")] + fb_healthy_inclusions: counter, + #[describe("Number of transactions that exceeded the healthy flashblock inclusion threshold")] + fb_slow_inclusions: counter, + #[describe("Number of transactions included in a block within the healthy threshold")] + healthy_inclusions: counter, + #[describe("Number of transactions that exceeded the healthy block inclusion threshold")] + slow_inclusions: counter, + #[describe("End-to-end time from first submission to block inclusion for a (sender, nonce) pair")] + e2e_inclusion_duration: histogram, + #[describe("Number of replacement transactions per (sender, nonce) pair")] + replacement_count: histogram, + #[describe("Total number of nonce-slot replacements observed")] + nonce_replacements: counter, } diff --git a/crates/execution/txpool-tracing/src/subscription.rs b/crates/execution/txpool-tracing/src/subscription.rs index 8d0611e0cb..44f2728e9d 100644 --- a/crates/execution/txpool-tracing/src/subscription.rs +++ b/crates/execution/txpool-tracing/src/subscription.rs @@ -7,11 +7,11 @@ use futures::StreamExt; use reth_node_api::NodePrimitives; use reth_provider::CanonStateNotification; use reth_tracing::tracing::debug; -use reth_transaction_pool::TransactionPool; +use reth_transaction_pool::{FullTransactionEvent, TransactionPool}; use tokio::sync::broadcast::Receiver; use tokio_stream::wrappers::BroadcastStream; -use crate::tracker::Tracker; +use crate::{NonceSlot, tracker::Tracker}; /// Subscription task that tracks transaction timing from mempool to block inclusion. /// @@ -40,8 +40,10 @@ pub async fn tracex_subscription( loop { tokio::select! { - // Track # of transactions dropped and replaced. - Some(full_event) = all_events_stream.next() => tracker.handle_event(full_event), + Some(full_event) = all_events_stream.next() => { + let nonce_slot = resolve_nonce_slot(&full_event, &pool); + tracker.handle_event(full_event, nonce_slot); + }, // Use canonical state notifications to track time to inclusion. Some(Ok(notification)) = canonical_stream.next() => { @@ -57,3 +59,14 @@ pub async fn tracex_subscription( } } } + +fn resolve_nonce_slot( + event: &FullTransactionEvent, + pool: &Pool, +) -> Option { + let tx_hash = match event { + FullTransactionEvent::Pending(hash) | FullTransactionEvent::Queued(hash, _) => hash, + _ => return None, + }; + pool.get(tx_hash).map(|tx| NonceSlot::new(tx.sender(), tx.nonce())) +} diff --git a/crates/execution/txpool-tracing/src/tracker.rs b/crates/execution/txpool-tracing/src/tracker.rs index 09206842c8..548ade88b6 100644 --- a/crates/execution/txpool-tracing/src/tracker.rs +++ b/crates/execution/txpool-tracing/src/tracker.rs @@ -16,7 +16,7 @@ use reth_provider::{CanonStateNotification, Chain}; use reth_tracing::tracing::{debug, info}; use reth_transaction_pool::{FullTransactionEvent, PoolTransaction}; -use crate::{EventLog, Pool, TxEvent, metrics::Metrics}; +use crate::{EventLog, NonceSlot, NonceSummary, Pool, TxEvent, metrics::Metrics}; /// Tracks transactions as they move through the mempool and into blocks. #[derive(Debug, Clone)] @@ -25,6 +25,10 @@ pub struct Tracker { txs: LruCache, /// Map of transaction hash to current state. tx_states: LruCache, + /// Map of tx hash to its nonce slot for reverse lookup on inclusion. + tx_nonce_slots: LruCache, + /// Tracks end-to-end lifecycle per `(sender, nonce)` across replacements. + nonce_summaries: LruCache, /// Enable `info` logs for transaction tracing. enable_logs: bool, } @@ -33,36 +37,61 @@ impl Tracker { /// Max size of the LRU caches. pub const MAX_SIZE: usize = 20_000; + /// Block inclusion duration above this threshold increments the slow counter. + const SLOW_BLOCK_INCLUSION_THRESHOLD: Duration = Duration::from_secs(3); + /// Flashblock inclusion duration above this threshold increments the slow counter. + const SLOW_FLASHBLOCK_INCLUSION_THRESHOLD: Duration = Duration::from_millis(1000); + /// Create a new tracker. pub fn new(enable_logs: bool) -> Self { + let cache_size = NonZeroUsize::new(Self::MAX_SIZE).expect("non zero"); Self { - txs: LruCache::new(NonZeroUsize::new(Self::MAX_SIZE).expect("non zero")), - tx_states: LruCache::new(NonZeroUsize::new(Self::MAX_SIZE).expect("non zero")), + txs: LruCache::new(cache_size), + tx_states: LruCache::new(cache_size), + tx_nonce_slots: LruCache::new(cache_size), + nonce_summaries: LruCache::new(cache_size), enable_logs, } } /// Parse [`FullTransactionEvent`]s and update the tracker. - pub fn handle_event(&mut self, event: FullTransactionEvent) { + /// + /// `nonce_slot` is populated by the subscription layer for events that only + /// carry a [`TxHash`] (Pending, Queued) by looking up the pool. + pub fn handle_event( + &mut self, + event: FullTransactionEvent, + nonce_slot: Option, + ) { match event { FullTransactionEvent::Pending(tx_hash) => { self.transaction_inserted(tx_hash, TxEvent::Pending); self.transaction_moved(tx_hash, Pool::Pending); + if let Some(slot) = nonce_slot { + self.track_nonce_slot(tx_hash, slot); + } } FullTransactionEvent::Queued(tx_hash, _) => { self.transaction_inserted(tx_hash, TxEvent::Queued); self.transaction_moved(tx_hash, Pool::Queued); + if let Some(slot) = nonce_slot { + self.track_nonce_slot(tx_hash, slot); + } } FullTransactionEvent::Discarded(tx_hash) => { self.transaction_completed(tx_hash, TxEvent::Dropped, Instant::now()); } FullTransactionEvent::Replaced { transaction, replaced_by } => { - let tx_hash = transaction.hash(); - self.transaction_replaced(*tx_hash, TxHash::from(replaced_by)); - } - _ => { - // Other events. + let sender = transaction.sender(); + let nonce = transaction.nonce(); + let tx_hash = *transaction.hash(); + let replaced_by = TxHash::from(replaced_by); + self.transaction_replaced(tx_hash, replaced_by); + let slot = NonceSlot::new(sender, nonce); + self.nonce_replacement(slot); + self.track_nonce_slot(replaced_by, slot); } + _ => {} } } @@ -148,9 +177,12 @@ impl Tracker { return; } - // Set pending_time if transitioning to pending - if event == TxEvent::QueuedToPending && event_log.pending_time.is_none() { + // Reset pending_time when transitioning to pending so that + // inclusion duration only measures time actually spent in the + // pending subpool, not time spent in queued/basefee. + if event == TxEvent::QueuedToPending { event_log.pending_time = Some(Instant::now()); + event_log.fb_included = false; } event_log.push(Local::now(), event); @@ -178,15 +210,20 @@ impl Tracker { // but do update the event log with the final event (i.e., included/dropped). event_log.push(Local::now(), event); - // Record `inclusion_duration` metric if transaction was pending and is now included if event == TxEvent::BlockInclusion && let Some(pending_time) = event_log.pending_time { let time_pending_to_inclusion = received_at.duration_since(pending_time); Metrics::inclusion_duration().record(time_pending_to_inclusion.as_millis() as f64); + + if time_pending_to_inclusion > Self::SLOW_BLOCK_INCLUSION_THRESHOLD { + Metrics::slow_inclusions().increment(1); + } else { + Metrics::healthy_inclusions().increment(1); + } } - // If a tx is included/dropped, log it now. + self.nonce_completed(&tx_hash, &event, received_at); self.log(&tx_hash, &event_log, &format!("Transaction {event}")); Self::record_histogram(time_in_mempool, event); } @@ -206,12 +243,17 @@ impl Tracker { return; } - // Record `fb_inclusion_duration` metric if transaction was pending if let Some(pending_time) = event_log.pending_time { let time_pending_to_fb_inclusion = received_at.duration_since(pending_time); Metrics::fb_inclusion_duration() .record(time_pending_to_fb_inclusion.as_millis() as f64); + if time_pending_to_fb_inclusion > Self::SLOW_FLASHBLOCK_INCLUSION_THRESHOLD { + Metrics::fb_slow_inclusions().increment(1); + } else { + Metrics::fb_healthy_inclusions().increment(1); + } + debug!( target: "tracex", tx_hash = ?tx_hash, @@ -234,14 +276,46 @@ impl Tracker { if self.is_overflowed(&tx_hash, &event_log) { return; } - // Keep the event log and update the tx hash. event_log.push(Local::now(), TxEvent::Replaced); + // Reset pending_time so the replacement tx measures its own + // inclusion duration rather than inheriting from the original. + event_log.pending_time = Some(Instant::now()); + event_log.fb_included = false; + self.tx_nonce_slots.pop(&tx_hash); self.txs.put(replaced_by, event_log); Self::record_histogram(time_in_mempool, TxEvent::Replaced); } } + fn track_nonce_slot(&mut self, tx_hash: TxHash, slot: NonceSlot) { + self.tx_nonce_slots.put(tx_hash, slot); + if !self.nonce_summaries.contains(&slot) { + self.nonce_summaries.put(slot, NonceSummary::new()); + } + } + + fn nonce_replacement(&mut self, slot: NonceSlot) { + if let Some(summary) = self.nonce_summaries.get_mut(&slot) { + summary.replacement_count += 1; + Metrics::nonce_replacements().increment(1); + } + } + + fn nonce_completed(&mut self, tx_hash: &TxHash, event: &TxEvent, received_at: Instant) { + let Some(slot) = self.tx_nonce_slots.pop(tx_hash) else { + return; + }; + let Some(summary) = self.nonce_summaries.pop(&slot) else { + return; + }; + if *event == TxEvent::BlockInclusion { + let e2e_duration = received_at.duration_since(summary.first_seen); + Metrics::e2e_inclusion_duration().record(e2e_duration.as_millis() as f64); + Metrics::replacement_count().record(summary.replacement_count as f64); + } + } + /// Logs an [`EventLog`] through tracing. fn log(&self, tx_hash: &TxHash, event_log: &EventLog, msg: &str) { if !self.enable_logs { @@ -277,6 +351,7 @@ impl Tracker { mod tests { use std::ops::Deref; + use alloy_primitives::Address; use base_flashblocks::FlashblocksAPI; use base_flashblocks_node::test_harness::{FlashblockBuilder, FlashblocksBuilderTestHarness}; use base_test_utils::Account; @@ -442,8 +517,11 @@ mod tests { // Insert original transaction tracker.transaction_inserted(tx_hash, TxEvent::Pending); + let original_pending_time = tracker.txs.get(&tx_hash).unwrap().pending_time; assert_eq!(tracker.txs.len(), 1); + std::thread::sleep(Duration::from_millis(1)); + // Replace transaction tracker.transaction_replaced(tx_hash, replacement_hash); @@ -451,11 +529,14 @@ mod tests { assert!(tracker.txs.get(&tx_hash).is_none()); assert!(tracker.txs.get(&replacement_hash).is_some()); - // Event log should be preserved with replacement event let event_log = tracker.txs.get(&replacement_hash).unwrap(); assert_eq!(event_log.events.len(), 2); assert_eq!(event_log.events[0].1, TxEvent::Pending); assert_eq!(event_log.events[1].1, TxEvent::Replaced); + + // pending_time should be reset, not inherited from original + assert!(event_log.pending_time.unwrap() > original_pending_time.unwrap()); + assert!(!event_log.fb_included); } #[test] @@ -499,7 +580,7 @@ mod tests { } #[test] - fn test_pending_time_set_only_once() { + fn test_pending_time_resets_on_re_promotion() { let mut tracker = Tracker::new(false); let tx_hash = TxHash::random(); @@ -516,12 +597,12 @@ mod tests { // Move back to queued tracker.transaction_moved(tx_hash, Pool::Queued); - // Move to pending again (should NOT reset pending_time) + std::thread::sleep(Duration::from_millis(1)); + tracker.transaction_moved(tx_hash, Pool::Pending); let second_pending_time = tracker.txs.get(&tx_hash).unwrap().pending_time; - // pending_time should be the same as the first time - assert_eq!(first_pending_time, second_pending_time); + assert!(second_pending_time.unwrap() > first_pending_time.unwrap()); } #[test] @@ -589,6 +670,72 @@ mod tests { assert!(tracker.txs.get(&tx_hash2).is_some()); } + #[test] + fn test_fb_included_resets_on_re_promotion() { + let mut tracker = Tracker::new(false); + let tx_hash = TxHash::random(); + + tracker.transaction_inserted(tx_hash, TxEvent::Pending); + tracker.transaction_moved(tx_hash, Pool::Pending); + + // Mark as fb-included + tracker.transaction_fb_included(tx_hash, Instant::now()); + assert!(tracker.txs.get(&tx_hash).unwrap().fb_included); + + // Demote to queued, then re-promote + tracker.transaction_moved(tx_hash, Pool::Queued); + tracker.transaction_moved(tx_hash, Pool::Pending); + + // fb_included should be reset so the new pending stint gets measured + assert!(!tracker.txs.get(&tx_hash).unwrap().fb_included); + } + + #[test] + fn test_nonce_tracking_simple_inclusion() { + let mut tracker = Tracker::new(false); + let tx_hash = TxHash::random(); + let sender = Address::random(); + let nonce = 42u64; + let slot = NonceSlot::new(sender, nonce); + + tracker.transaction_inserted(tx_hash, TxEvent::Pending); + tracker.track_nonce_slot(tx_hash, slot); + + assert!(tracker.nonce_summaries.contains(&slot)); + assert!(tracker.tx_nonce_slots.contains(&tx_hash)); + + tracker.nonce_completed(&tx_hash, &TxEvent::BlockInclusion, Instant::now()); + + assert!(!tracker.nonce_summaries.contains(&slot)); + assert!(!tracker.tx_nonce_slots.contains(&tx_hash)); + } + + #[test] + fn test_nonce_tracking_with_replacement() { + let mut tracker = Tracker::new(false); + let original_hash = TxHash::random(); + let replacement_hash = TxHash::random(); + let sender = Address::random(); + let nonce = 7u64; + let slot = NonceSlot::new(sender, nonce); + + tracker.transaction_inserted(original_hash, TxEvent::Pending); + tracker.track_nonce_slot(original_hash, slot); + + tracker.transaction_replaced(original_hash, replacement_hash); + tracker.nonce_replacement(slot); + tracker.track_nonce_slot(replacement_hash, slot); + + let summary = tracker.nonce_summaries.get(&slot).unwrap(); + assert_eq!(summary.replacement_count, 1); + + // Original hash slot mapping should be gone (overwritten by replacement) + assert_eq!(*tracker.tx_nonce_slots.get(&replacement_hash).unwrap(), slot); + + tracker.nonce_completed(&replacement_hash, &TxEvent::BlockInclusion, Instant::now()); + assert!(!tracker.nonce_summaries.contains(&slot)); + } + #[test] fn test_fb_inclusion_recorded_only_once() { let mut tracker = Tracker::new(false); diff --git a/crates/execution/txpool/src/transaction.rs b/crates/execution/txpool/src/transaction.rs index b7a1cfa3db..153e12e856 100644 --- a/crates/execution/txpool/src/transaction.rs +++ b/crates/execution/txpool/src/transaction.rs @@ -165,6 +165,10 @@ where self.inner.transaction().clone() } + fn consensus_ref(&self) -> Recovered<&Self::Consensus> { + self.inner.transaction().as_recovered_ref() + } + fn into_consensus(self) -> Recovered { self.inner.transaction } diff --git a/crates/execution/txpool/src/validator.rs b/crates/execution/txpool/src/validator.rs index a915b7f23a..0c539fbb00 100644 --- a/crates/execution/txpool/src/validator.rs +++ b/crates/execution/txpool/src/validator.rs @@ -9,6 +9,7 @@ use std::{ use alloy_consensus::{BlockHeader, Transaction}; use alloy_primitives::U256; use base_common_chains::Upgrades; +use base_common_consensus::EIP8130_TX_TYPE_ID; use base_common_evm::{BaseSpecId, L1BlockInfo}; use base_common_genesis::DaFootprintGasScalarUpdate; use parking_lot::RwLock; @@ -187,6 +188,7 @@ where /// This behaves the same as [`EthTransactionValidator::validate_one_with_state`], but in /// addition applies Base-specific validity checks: /// - ensures tx is not eip4844 + /// - ensures tx is not eip8130 (account abstraction; no validation/execution path exists yet) /// - ensures that the account has enough balance to cover the L1 gas cost pub async fn validate_one_with_state( &self, @@ -201,6 +203,13 @@ where ); } + if transaction.ty() == EIP8130_TX_TYPE_ID { + return TransactionValidationOutcome::Invalid( + transaction, + InvalidTransactionError::TxTypeNotSupported.into(), + ); + } + let outcome = self.inner.validate_one_with_state(origin, transaction, state); self.apply_base_checks(outcome) diff --git a/crates/firehose-flashblocks/src/dispatcher.rs b/crates/firehose-flashblocks/src/dispatcher.rs new file mode 100644 index 0000000000..4e59c1551a --- /dev/null +++ b/crates/firehose-flashblocks/src/dispatcher.rs @@ -0,0 +1,173 @@ +//! Single-consumer command queue that serializes every mutating event to the +//! [`FirehoseFlashblocksProcessor`]. +//! +//! The processor's in-flight state (`accumulated_db`, the stored-flashblock buffer and the +//! per-block index bookkeeping) is driven by three independent producers in production: the +//! WebSocket flashblock stream and the two canonical-block signal sources wired in +//! `bin/node/src/firehose.rs` — the early in-engine notification and the post-commit +//! canonical-state broadcast. Previously each producer called the processor directly from its +//! own task, serialized only by the processor's internal `state` mutex — which `process_inner` +//! deliberately releases across the (~100 ms) EVM execution. That open window let a canonical +//! signal mutate or `reset` the very state being executed against, leaving `accumulated_db` +//! inconsistent with the transactions just emitted and producing wrong state roots (and, on the +//! emission side, duplicate `is_final` FIRE BLOCKs that the `final_part_sent` guard could not +//! dedup under the race). +//! +//! Funnelling all three producers through one channel drained by a single consumer task removes +//! the concurrency entirely: commands are applied in strict arrival order, each to completion, +//! before the next is dequeued. No producer ever touches `ProcessorState` directly. + +use std::sync::Arc; + +use alloy_consensus::Header; +use alloy_primitives::B256; +use base_common_chains::Upgrades; +use base_common_flashblocks::Flashblock; +use base_flashblocks::FlashblocksReceiver; +use reth_chainspec::{ChainSpecProvider, EthChainSpec}; +use reth_provider::{BlockReaderIdExt, StateProviderFactory}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tracing::{debug, error, warn}; + +use crate::{FirehoseFlashblocksProcessor, FlashblockPeekClassifier}; + +/// A single mutating event applied to the processor by the consumer task, in arrival order. +#[derive(Debug)] +pub enum ProcessorCommand { + /// A flashblock whose peek-derived classification (`squash`, `is_final_expected_hash`) was + /// computed at ingress while the WS subscriber's peek reference was still live. + /// + /// The flashblock is boxed to avoid a large size imbalance with the small + /// [`Self::CanonicalBlock`] variant. + Flashblock { + /// The received flashblock. + flashblock: Box, + /// Whether execution+emission should be deferred to the next non-squashed flashblock. + squash: bool, + /// `Some(expected_parent_hash)` when the peek identified this flashblock as the final + /// partial for its block. + is_final_expected_hash: Option, + }, + /// A canonical-block notification (number + hash) from any signal source. + CanonicalBlock { + /// Canonical block number. + number: u64, + /// Canonical block hash. + hash: B256, + }, +} + +/// Ingress handle for the WebSocket flashblock stream. Implements [`FlashblocksReceiver`] by +/// classifying the peek and enqueuing a [`ProcessorCommand::Flashblock`]; processing happens +/// later on the single consumer task. +#[derive(Debug, Clone)] +pub struct FlashblockEnqueuer { + tx: UnboundedSender, +} + +impl FlashblockEnqueuer { + /// Wraps a command-queue sender as a flashblock ingress handle. + pub const fn new(tx: UnboundedSender) -> Self { + Self { tx } + } +} + +impl FlashblocksReceiver for FlashblockEnqueuer { + fn on_flashblock_received(&self, flashblock: Flashblock) { + // Equivalent to the peek path with an empty peek: `classify(_, None)` yields + // `(squash = false, is_final_expected_hash = None)`. + self.on_flashblock_received_with_peek(flashblock, None); + } + + fn on_flashblock_received_with_peek(&self, flashblock: Flashblock, peek: Option<&Flashblock>) { + let block = flashblock.metadata.block_number; + let index = flashblock.index; + let (squash, is_final_expected_hash) = FlashblockPeekClassifier::classify(&flashblock, peek); + let command = ProcessorCommand::Flashblock { + flashblock: Box::new(flashblock), + squash, + is_final_expected_hash, + }; + if self.tx.send(command).is_err() { + warn!(block, index, "firehose flashblocks command queue closed; dropping flashblock"); + } + } +} + +/// Ingress handle for canonical-block signals. Cloned once per signal source (the early +/// in-engine notification and the post-commit canonical-state broadcast both hold a clone). +#[derive(Debug, Clone)] +pub struct CanonicalBlockSender { + tx: UnboundedSender, +} + +impl CanonicalBlockSender { + /// Wraps a command-queue sender as a canonical-signal ingress handle. + pub const fn new(tx: UnboundedSender) -> Self { + Self { tx } + } + + /// Enqueues a canonical-block notification for the consumer task to apply in arrival order. + pub fn send(&self, number: u64, hash: B256) { + if self.tx.send(ProcessorCommand::CanonicalBlock { number, hash }).is_err() { + warn!(block = number, "firehose flashblocks command queue closed; dropping canonical signal"); + } + } +} + +/// Drains [`ProcessorCommand`]s from the queue and applies each to the processor to completion, +/// one at a time — the only place that calls the processor's mutating methods in production. +#[derive(Debug)] +pub struct FirehoseFlashblocksDispatcher { + processor: Arc>, +} + +impl FirehoseFlashblocksDispatcher +where + Client: StateProviderFactory + + ChainSpecProvider + Upgrades> + + BlockReaderIdExt

+ + Clone + + Send + + Sync + + 'static, +{ + /// Creates a dispatcher bound to `processor`. + pub const fn new(processor: Arc>) -> Self { + Self { processor } + } + + /// Consumes commands from `rx` until every sender has dropped, applying each to completion + /// before the next is dequeued. + /// + /// Each command's synchronous handler runs on the blocking pool via + /// [`tokio::task::spawn_blocking`], and the consumer `await`s it before pulling the next + /// command. This keeps the (~100 ms) state-root trie traversal off the runtime's worker + /// threads — so it never starves other tasks — while still applying commands strictly in + /// arrival order. `spawn_blocking` closures run within the runtime context, so the + /// processor's per-block speculative state-root precompute (which spawns through + /// [`tokio::runtime::Handle::try_current`]) keeps working. + pub async fn run(self, mut rx: UnboundedReceiver) { + while let Some(command) = rx.recv().await { + let processor = Arc::clone(&self.processor); + let outcome = tokio::task::spawn_blocking(move || match command { + ProcessorCommand::Flashblock { flashblock, squash, is_final_expected_hash } => { + processor.process(*flashblock, squash, is_final_expected_hash); + } + ProcessorCommand::CanonicalBlock { number, hash } => { + processor.on_canonical_block(number, hash); + } + }) + .await; + if let Err(err) = outcome { + // A handler panic means the processor's state mutex is almost certainly + // poisoned, so every subsequent command would panic too — continuing would + // just spin logging errors. Stop draining instead: dropping `rx` makes the + // ingress handles' sends fail fast (warn-and-drop) rather than pile up. + error!(error = %err, "firehose flashblocks command handler panicked; stopping dispatcher"); + break; + } + } + debug!("firehose flashblocks command queue closed; dispatcher consumer exiting"); + } +} diff --git a/crates/firehose-flashblocks/src/lib.rs b/crates/firehose-flashblocks/src/lib.rs index ce6a0701f3..0dc8b27009 100644 --- a/crates/firehose-flashblocks/src/lib.rs +++ b/crates/firehose-flashblocks/src/lib.rs @@ -9,7 +9,12 @@ mod tracer; pub use tracer::{FLASHBLOCK_TRACER_ID, FlashblocksTracerHandle}; mod processor; -pub use processor::{ClockFn, FirehoseFlashblocksProcessor}; +pub use processor::{ClockFn, FirehoseFlashblocksProcessor, FlashblockPeekClassifier}; + +mod dispatcher; +pub use dispatcher::{ + CanonicalBlockSender, FirehoseFlashblocksDispatcher, FlashblockEnqueuer, ProcessorCommand, +}; mod streamer; pub use streamer::FirehoseFlashblocksStreamer; diff --git a/crates/firehose-flashblocks/src/processor.rs b/crates/firehose-flashblocks/src/processor.rs index 4ca10560a2..4b7e0a9715 100644 --- a/crates/firehose-flashblocks/src/processor.rs +++ b/crates/firehose-flashblocks/src/processor.rs @@ -74,6 +74,58 @@ type BoxedStateProvider = Box; /// cache (which holds the committed effects of all prior flashblocks). type AccumulatedDb = State>; +/// Classifies a freshly-received flashblock against the WS subscriber's 1-element peek window, +/// producing `(squash, is_final_expected_hash)` for [`FirehoseFlashblocksProcessor::process`]. +/// +/// Lives outside the processor (and is free of the `Client` type parameter) so the ingress +/// layer can classify at the moment the peek reference is still live — before the owned +/// flashblock is handed to the serialized command queue — without depending on the processor's +/// provider bounds. +#[derive(Debug)] +pub struct FlashblockPeekClassifier; + +impl FlashblockPeekClassifier { + /// Classifies the peeked next message and produces `(squash, is_final_expected_hash)`. + /// + /// Exactly one of the two values is "active" (or neither, if the peek is empty or + /// unrelated): + /// + /// - **Squash** — `(true, None)`: the current flashblock is a delta (`index > 0`) and + /// the peek shows another message for the same block (a higher-index delta or a + /// same-block restart base). The current flashblock's data is accumulated into + /// `stored_flashblocks`, but EVM execution and FIRE BLOCK emission are deferred to + /// the next non-squashed flashblock. Geth's strict version at `controller.go:394-396` + /// only squashes on a same-block restart base; this implementation extends to also + /// squash same-block higher-index deltas. + /// + /// - **is_final** — `(false, Some(expected_parent_hash))`: the peek shows the base of + /// the immediately next block (`peek.block_number == current.block_number + 1` and + /// `peek.index == 0`). The current flashblock will execute through the EVM, and just + /// before the FIRE BLOCK is flushed the processor will recompute the canonical block + /// hash from the post-execution state and override it on the tracer via + /// `set_final_flash_block`. The wire emission becomes a single FIRE BLOCK stamped + /// `idx + 1000` and sealed with the recomputed hash — matching geth's peek path at + /// `controller.go:398-405`. + /// + /// - **None** — `(false, None)`: peek is absent or unrelated; the current flashblock + /// executes and emits as a non-final partial. + pub fn classify(current: &Flashblock, peek: Option<&Flashblock>) -> (bool, Option) { + let Some(peek) = peek else { return (false, None) }; + let cur_block = current.metadata.block_number; + let peek_block = peek.metadata.block_number; + if peek_block == cur_block && current.index > 0 { + return (true, None); + } + if peek_block == cur_block + 1 + && peek.index == 0 + && let Some(base) = peek.base.as_ref() + { + return (false, Some(base.parent_hash)); + } + (false, None) + } +} + /// Result of [`FirehoseFlashblocksProcessor::execute_flashblock`]. /// /// Both flags are needed by callers to drive post-execution state mutations: @@ -430,7 +482,19 @@ where /// Process a single flashblock event. Errors are logged and swallowed: the processor clears /// its in-flight state and accumulated DB so the next base flashblock restarts tracking. /// - /// `squash` carries the verdict of [`Self::classify_peek`]: when `true`, the validator + /// # Serialization invariant + /// + /// This must only ever be driven from a single thread, interleaved with no other call to + /// `process` or [`Self::on_canonical_block`]. In production that is guaranteed by routing + /// every event through the single-consumer + /// [`FirehoseFlashblocksDispatcher`](crate::FirehoseFlashblocksDispatcher) command queue. + /// `process_inner` deliberately releases the `state` mutex across EVM execution, so calling + /// this concurrently from multiple producers reintroduces the data race it was built to + /// prevent (a canonical signal mutating/resetting the in-flight state mid-execution, yielding + /// wrong state roots and duplicate is_final emissions). Do not call it directly outside that + /// serialized path. + /// + /// `squash` carries the verdict of [`FlashblockPeekClassifier::classify`]: when `true`, the validator /// still accepts the message into `stored_flashblocks` (so its transactions are not /// lost) but EVM execution and FIRE BLOCK emission are deferred to the next /// non-squashed flashblock — which will gather and execute all the held-back @@ -442,7 +506,12 @@ where /// [`firehose_tracer::Tracer::set_final_flash_block`] with the locally-recomputed hash /// and state_root before the FIRE BLOCK is flushed, matching geth's /// `Firehose.SetFinalFlashBlock` + `OnBlockEnd` pattern. - fn process(&self, flashblock: Flashblock, squash: bool, is_final_expected_hash: Option) { + pub fn process( + &self, + flashblock: Flashblock, + squash: bool, + is_final_expected_hash: Option, + ) { if let Err(err) = self.process_inner(flashblock, squash, is_final_expected_hash) { error!(error = %err, "flashblock processing failed; resetting state and waiting for next base"); let mut state = self.state.lock().expect("flashblock state mutex poisoned"); @@ -450,46 +519,6 @@ where } } - /// Classifies the peeked next message and produces `(squash, is_final_expected_hash)`. - /// - /// Exactly one of the two values is "active" (or neither, if the peek is empty or - /// unrelated): - /// - /// - **Squash** — `(true, None)`: the current flashblock is a delta (`index > 0`) and - /// the peek shows another message for the same block (a higher-index delta or a - /// same-block restart base). The current flashblock's data is accumulated into - /// `stored_flashblocks`, but EVM execution and FIRE BLOCK emission are deferred to - /// the next non-squashed flashblock. Geth's strict version at `controller.go:394-396` - /// only squashes on a same-block restart base; this implementation extends to also - /// squash same-block higher-index deltas. - /// - /// - **is_final** — `(false, Some(expected_parent_hash))`: the peek shows the base of - /// the immediately next block (`peek.block_number == current.block_number + 1` and - /// `peek.index == 0`). The current flashblock will execute through the EVM, and just - /// before the FIRE BLOCK is flushed the processor will recompute the canonical block - /// hash from the post-execution state and override it on the tracer via - /// `set_final_flash_block`. The wire emission becomes a single FIRE BLOCK stamped - /// `idx + 1000` and sealed with the recomputed hash — matching geth's peek path at - /// `controller.go:398-405`. - /// - /// - **None** — `(false, None)`: peek is absent or unrelated; the current flashblock - /// executes and emits as a non-final partial. - fn classify_peek(current: &Flashblock, peek: Option<&Flashblock>) -> (bool, Option) { - let Some(peek) = peek else { return (false, None) }; - let cur_block = current.metadata.block_number; - let peek_block = peek.metadata.block_number; - if peek_block == cur_block && current.index > 0 { - return (true, None); - } - if peek_block == cur_block + 1 - && peek.index == 0 - && let Some(base) = peek.base.as_ref() - { - return (false, Some(base.parent_hash)); - } - (false, None) - } - /// Returns `true` if a new-block base's `parent_hash` is consistent with the most /// recently observed canonical-block notification (or if there is no canonical /// reference point to validate against yet). Returns `false` when the canonical @@ -912,7 +941,6 @@ where State::builder() .with_database(StateProviderDatabase::new(provider)) .with_bundle_update() - .without_state_clear() .build(), ); } @@ -1338,7 +1366,6 @@ where let mut accumulated_db = State::builder() .with_database(StateProviderDatabase::new(provider)) .with_bundle_update() - .without_state_clear() .build(); // Merge all buffered flashblocks into a single execute+emit. Downstream @@ -1848,7 +1875,7 @@ where } fn on_flashblock_received_with_peek(&self, flashblock: Flashblock, peek: Option<&Flashblock>) { - let (squash, is_final_expected_hash) = Self::classify_peek(&flashblock, peek); + let (squash, is_final_expected_hash) = FlashblockPeekClassifier::classify(&flashblock, peek); self.process(flashblock, squash, is_final_expected_hash); } } diff --git a/crates/firehose-flashblocks/src/streamer.rs b/crates/firehose-flashblocks/src/streamer.rs index b6792e15e6..1520c14ec7 100644 --- a/crates/firehose-flashblocks/src/streamer.rs +++ b/crates/firehose-flashblocks/src/streamer.rs @@ -1,6 +1,6 @@ //! Top-level wiring: combines [`FirehoseFlashblocksProcessor`] with the existing //! [`base_flashblocks::FlashblocksSubscriber`] so a single `start()` call spawns the WebSocket -//! reader, dispatch loop, and per-flashblock Firehose emission task. +//! reader, the serialized command-queue consumer, and per-flashblock Firehose emission. use std::sync::Arc; @@ -9,16 +9,27 @@ use base_common_chains::Upgrades; use base_flashblocks::FlashblocksSubscriber; use reth_chainspec::{ChainSpecProvider, EthChainSpec}; use reth_provider::{BlockReaderIdExt, StateProviderFactory}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use url::Url; -use crate::FirehoseFlashblocksProcessor; +use crate::{ + CanonicalBlockSender, FirehoseFlashblocksDispatcher, FirehoseFlashblocksProcessor, + FlashblockEnqueuer, ProcessorCommand, +}; /// Owns the [`FirehoseFlashblocksProcessor`] + WebSocket subscriber and exposes a single /// `start()` entrypoint to be called from the node-started hook of the node binary. +/// +/// All mutating events — WebSocket flashblocks and every canonical-block signal — are funnelled +/// through a single command queue drained by one consumer task, so the processor's state is +/// only ever mutated serially. Callers obtain a [`CanonicalBlockSender`] via [`Self::canonical_block_sender`] +/// to feed canonical-block notifications into that same queue. #[derive(Debug)] pub struct FirehoseFlashblocksStreamer { processor: Arc>, ws_url: Url, + command_tx: UnboundedSender, + command_rx: Option>, } impl FirehoseFlashblocksStreamer @@ -32,24 +43,36 @@ where + 'static, { /// Constructs the streamer with a pre-built processor (which already carries its dedicated - /// tracer) and the WebSocket URL to subscribe to. + /// tracer) and the WebSocket URL to subscribe to. The command queue is created here so + /// [`Self::canonical_block_sender`] is available before [`Self::start`] is called. pub fn new(processor: FirehoseFlashblocksProcessor, ws_url: Url) -> Self { - Self { processor: Arc::new(processor), ws_url } + let (command_tx, command_rx) = mpsc::unbounded_channel(); + Self { processor: Arc::new(processor), ws_url, command_tx, command_rx: Some(command_rx) } } - /// Returns a clone of the shared processor handle. Useful for callers that need to wire - /// additional notification streams to the processor (e.g. a canonical-state subscription - /// driving [`FirehoseFlashblocksProcessor::on_canonical_block`]) alongside the WebSocket - /// subscriber. - pub fn processor(&self) -> Arc> { - Arc::clone(&self.processor) + /// Returns a sender that feeds canonical-block notifications into the shared command queue. + /// Clone it once per signal source (e.g. the early in-engine notification and the + /// post-commit canonical-state broadcast). + pub fn canonical_block_sender(&self) -> CanonicalBlockSender { + CanonicalBlockSender::new(self.command_tx.clone()) } - /// Spawns the subscriber. The subscriber owns its own reconnect-backoff loop, so this call - /// returns immediately and the resulting tasks run for the lifetime of the tokio runtime. - pub fn start(self) { - let mut subscriber = - FlashblocksSubscriber::new(Arc::clone(&self.processor), self.ws_url.clone()); + /// Spawns the command-queue consumer and the WebSocket subscriber. The subscriber owns its + /// own reconnect-backoff loop, so this call returns immediately and the resulting tasks run + /// for the lifetime of the tokio runtime. Must be called from within a tokio runtime. + pub fn start(mut self) { + let command_rx = self + .command_rx + .take() + .expect("FirehoseFlashblocksStreamer::start must be called exactly once"); + + // Single consumer: the only task that mutates the processor's state. + let dispatcher = FirehoseFlashblocksDispatcher::new(Arc::clone(&self.processor)); + tokio::spawn(dispatcher.run(command_rx)); + + // WebSocket flashblocks enqueue into the same serialized queue. + let enqueuer = Arc::new(FlashblockEnqueuer::new(self.command_tx.clone())); + let mut subscriber = FlashblocksSubscriber::new(enqueuer, self.ws_url.clone()); subscriber.start(); } } diff --git a/crates/firehose-flashblocks/tests/dispatcher_serialization.rs b/crates/firehose-flashblocks/tests/dispatcher_serialization.rs new file mode 100644 index 0000000000..a59aacabbb --- /dev/null +++ b/crates/firehose-flashblocks/tests/dispatcher_serialization.rs @@ -0,0 +1,124 @@ +//! Regression tests for the single serialized command queue +//! ([`base_firehose_flashblocks::FirehoseFlashblocksDispatcher`]). +//! +//! In production the processor's in-flight state is fed by three independent tasks: the WebSocket +//! flashblock stream and the two canonical-block signal sources (the early in-engine notification +//! and the post-commit canonical-state broadcast). Previously each called the processor directly, +//! serialized only by an internal mutex that `process_inner` releases across EVM execution — so a +//! canonical signal could mutate or reset the very state being executed against, corrupting +//! `accumulated_db` (wrong state roots) and emitting duplicate `is_final` FIRE BLOCKs. +//! +//! The fix funnels all three sources through one queue drained by a single consumer task. These +//! tests drive that real queue path via [`framework::run_flashblock_sequence_via_dispatcher`]. + +mod framework; + +use base_execution_chainspec::BaseChainSpec; + +use framework::{ + FireEvent, GenesisClient, assembled_block_hash, assert_fire_events_metadata_eq, canonical_block, + flash_base, flash_delta, hash, parse_fire_events, run_flashblock_sequence_via_dispatcher, + test_genesis, +}; + +/// Two canonical signals for the same block — exactly what the early in-engine notification and +/// the post-commit canonical-state broadcast deliver — must produce a **single** `is_final` FIRE +/// BLOCK. Under the old concurrent wiring the two `on_canonical_block` calls could race and +/// double-emit (the `FirstOfNextBlock` fallback never set `final_part_sent`); routed through the +/// serialized queue they are applied in order, so the first emits is_final and sets +/// `final_part_sent`, and the second hits the "already-finalized" no-op branch. +#[tokio::test(flavor = "multi_thread")] +async fn duplicate_canonical_signals_emit_single_is_final() { + let genesis = test_genesis(); + let genesis_hash = BaseChainSpec::from_genesis(genesis.clone()).inner.genesis_hash(); + let client = GenesisClient::new(genesis); + let ts = 0x67d00000u64; + + let placeholder = + vec![flash_base(1, hash("1a"), genesis_hash, ts + 2), flash_delta(1, hash("any"), 1)]; + let recomputed_block1_hash = assembled_block_hash(&placeholder); + + let raw = run_flashblock_sequence_via_dispatcher( + client, + vec![ + flash_base(1, hash("1a"), genesis_hash, ts + 2), + flash_delta(1, hash("wire-hash"), 1), + // Early in-engine canonical signal for block 1. + canonical_block(1, recomputed_block1_hash), + // Post-commit canonical-state broadcast for the SAME block — a duplicate. + canonical_block(1, recomputed_block1_hash), + ], + ts, + ) + .await; + + let events: Vec = parse_fire_events(&raw) + .into_iter() + .filter(|e| matches!(e, FireEvent::FlashBlock { .. })) + .collect(); + + assert_fire_events_metadata_eq( + &events, + &[ + // Base(1) squashed; delta(1,1) emits as non-final, carrying base+delta txs. + FireEvent::flash_block(1, hash("wire-hash"), 1, false), + // First canonical signal recomputes block 1's hash and emits is_final (idx 1002). + FireEvent::flash_block(1, recomputed_block1_hash, 2, true), + // Second (duplicate) canonical signal: no further emission. + ], + ); + + let is_final_count = + events.iter().filter(|e| matches!(e, FireEvent::FlashBlock { is_final: true, .. })).count(); + assert_eq!(is_final_count, 1, "duplicate canonical signals must emit exactly one is_final"); +} + +/// A canonical(N) signal enqueued between block N's finalization and block N+1's base must apply +/// in strict arrival order as a harmless no-op, and block N+1 must bootstrap on the carried-forward +/// state and emit normally — proving the extra canonical command neither reset nor corrupted the +/// in-flight state. Here block 1 is finalized by the peek-driven path (block 2's base is already +/// queued, so the WS peek catches the transition); the interleaved canonical(1) then lands on the +/// already-finalized block and is dropped by the `final_part_sent` guard. +#[tokio::test(flavor = "multi_thread")] +async fn canonical_interleaved_with_deltas_preserves_next_block() { + let genesis = test_genesis(); + let genesis_hash = BaseChainSpec::from_genesis(genesis.clone()).inner.genesis_hash(); + let client = GenesisClient::new(genesis); + let ts = 0x67d00000u64; + + let block1 = + vec![flash_base(1, hash("1a"), genesis_hash, ts + 2), flash_delta(1, hash("w1"), 1)]; + let recomputed_block1_hash = assembled_block_hash(&block1); + + let raw = run_flashblock_sequence_via_dispatcher( + client, + vec![ + flash_base(1, hash("1a"), genesis_hash, ts + 2), + flash_delta(1, hash("w1"), 1), + // Early canonical(1) arriving before block 2's base — finalizes block 1. + canonical_block(1, recomputed_block1_hash), + // Block 2 builds on block 1's recomputed hash. + flash_base(2, hash("2a"), recomputed_block1_hash, ts + 4), + flash_delta(2, hash("w2"), 1), + ], + ts, + ) + .await; + + let events: Vec = parse_fire_events(&raw) + .into_iter() + .filter(|e| matches!(e, FireEvent::FlashBlock { .. })) + .collect(); + + assert_fire_events_metadata_eq( + &events, + &[ + // Block 1's delta is the final partial (peek sees block 2's base): single is_final + // emission at idx 1, sealed with the recomputed hash. The interleaved canonical(1) + // signal is a serialized no-op. + FireEvent::flash_block(1, recomputed_block1_hash, 1, true), + // Block 2 proceeds normally on top of the (uncorrupted) carried-forward state. + FireEvent::flash_block(2, hash("w2"), 1, false), + ], + ); +} diff --git a/crates/firehose-flashblocks/tests/framework/mod.rs b/crates/firehose-flashblocks/tests/framework/mod.rs index fe6655b82a..d1d53643ec 100644 --- a/crates/firehose-flashblocks/tests/framework/mod.rs +++ b/crates/firehose-flashblocks/tests/framework/mod.rs @@ -32,7 +32,8 @@ use base_common_flashblocks::{ }; use base_execution_chainspec::BaseChainSpec; use base_firehose_flashblocks::{ - ClockFn, FirehoseFlashblocksProcessor, FlashblocksTracerHandle, + CanonicalBlockSender, ClockFn, FirehoseFlashblocksDispatcher, FirehoseFlashblocksProcessor, + FlashblockEnqueuer, FlashblocksTracerHandle, }; use base_flashblocks::{BlockAssembler, FlashblocksReceiver}; use base64::Engine as _; @@ -598,7 +599,12 @@ impl StateProofProvider for GenesisStateProvider { Err(ProviderError::UnsupportedProvider) } - fn witness(&self, _i: TrieInput, _t: HashedPostState) -> ProviderResult> { + fn witness( + &self, + _i: TrieInput, + _t: HashedPostState, + _mode: reth_trie::ExecutionWitnessMode, + ) -> ProviderResult> { Err(ProviderError::UnsupportedProvider) } } @@ -617,14 +623,6 @@ impl StateProvider for GenesisStateProvider { ) -> ProviderResult> { self.0.storage(addr, key) } - - fn storage_by_hashed_key( - &self, - addr: Address, - hk: StorageKey, - ) -> ProviderResult> { - self.0.storage_by_hashed_key(addr, hk) - } } impl BytecodeReader for GenesisStateProvider { @@ -1689,3 +1687,84 @@ fn run_flashblock_sequence_internal( (output, processor) } + +/// Drives a [`FirehoseFlashblocksProcessor`] through `events` using the **production** serialized +/// command queue (the [`FirehoseFlashblocksDispatcher`] + [`FlashblockEnqueuer`] + +/// [`CanonicalBlockSender`] path) rather than calling the processor's methods directly. +/// +/// Every canonical block in `events` is marked available up front, then a single consumer task is +/// spawned and every event is enqueued in list order from one producer. Because enqueueing +/// touches no shared state, the consumer observes the commands in exactly the enqueued order, so +/// the run is deterministic. This mirrors the real wiring where flashblocks and the two canonical +/// signal sources funnel into one queue and are applied strictly in arrival order — never +/// concurrently. (It deliberately does not model the pending/replay availability-timing path; +/// fixtures should keep each block's parent available, i.e. exercise the fast/bootstrap path.) +/// +/// Returns the raw flashblock-tracer output, prefixed with a single `# SOURCE FLASH` marker so +/// [`parse_fire_events`] classifies every line as a [`FireEvent::FlashBlock`]. +pub(crate) async fn run_flashblock_sequence_via_dispatcher( + client: GenesisClient, + events: Vec, + now_secs: u64, +) -> Vec { + let flash_buffer = InMemoryBuffer::new(); + let chain_id = client.chain_spec().chain().id(); + + let flash_writer: Box = Box::new(flash_buffer.clone()); + let tracer_handle = FlashblocksTracerHandle::with_writer( + Config { chain_client: ChainClient::Reth, ..Default::default() }, + ChainConfig::new(chain_id), + flash_writer, + ); + + let clock: ClockFn = std::sync::Arc::new(move || now_secs); + let processor = std::sync::Arc::new(FirehoseFlashblocksProcessor::with_clock( + client.clone(), + tracer_handle, + clock, + )); + + // Pre-mark every canonical block available so the consumer never hits the pending/replay + // availability race; enqueueing then mutates no shared state and the FIFO order is exact. + for event in &events { + if let TestEvent::CanonicalBlock { block_number, .. } = event { + client.mark_canonical_block_available(*block_number); + } + } + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let dispatcher = FirehoseFlashblocksDispatcher::new(std::sync::Arc::clone(&processor)); + let consumer = tokio::spawn(dispatcher.run(rx)); + + let enqueuer = FlashblockEnqueuer::new(tx.clone()); + let canonical_block_sender = CanonicalBlockSender::new(tx.clone()); + // Only `enqueuer` / `canonical_block_sender` keep the channel open from here on. + drop(tx); + + for (idx, event) in events.iter().enumerate() { + match event.clone() { + TestEvent::Flashblock(fb) => { + // The value the production subscriber's 1-element peek slot would surface: the + // next queued flashblock, skipping canonical markers (which don't live on the + // flashblock WS channel). + let peek = events.iter().skip(idx + 1).find_map(|e| match e { + TestEvent::Flashblock(next) => Some(next.as_ref()), + TestEvent::CanonicalBlock { .. } => None, + }); + enqueuer.on_flashblock_received_with_peek(*fb, peek); + } + TestEvent::CanonicalBlock { block_number, block_hash } => { + canonical_block_sender.send(block_number, block_hash); + } + } + } + + // Close the channel so the consumer drains the backlog and exits, then wait for it. + drop(enqueuer); + drop(canonical_block_sender); + consumer.await.expect("dispatcher consumer task panicked"); + + let mut output = b"# SOURCE FLASH\n".to_vec(); + output.extend_from_slice(&flash_buffer.get_bytes()); + output +} diff --git a/crates/infra/audit/Cargo.toml b/crates/infra/audit/Cargo.toml index 60a4819938..edde4fd8be 100644 --- a/crates/infra/audit/Cargo.toml +++ b/crates/infra/audit/Cargo.toml @@ -26,7 +26,6 @@ serde = { workspace = true, features = ["std", "derive"] } alloy-consensus = { workspace = true, features = ["std"] } base-metrics = { workspace = true, features = ["metrics"] } alloy-primitives = { workspace = true, features = ["map-foldhash", "serde"] } -rdkafka = { workspace = true, features = ["tokio", "libz", "zstd", "ssl-vendored"] } aws-sdk-s3 = { workspace = true, features = ["rustls", "default-https-client", "rt-tokio"] } moka = { workspace = true, features = ["sync"] } jsonrpsee-types.workspace = true @@ -35,7 +34,7 @@ jsonrpsee-types.workspace = true base-bundles = { workspace = true, features = ["test-utils"] } testcontainers = { workspace = true, features = ["blocking"] } aws-config = { workspace = true, features = ["default-https-client", "rt-tokio"] } -testcontainers-modules = { workspace = true, features = ["postgres", "kafka", "minio"] } +testcontainers-modules = { workspace = true, features = ["postgres", "minio"] } [features] default = [ "metrics" ] diff --git a/crates/infra/audit/README.md b/crates/infra/audit/README.md index 43038e8cae..7b76965339 100644 --- a/crates/infra/audit/README.md +++ b/crates/infra/audit/README.md @@ -5,10 +5,9 @@ Audit library for tracking and archiving bundle events. ## Overview Provides event publishing, storage, and retrieval for bundle lifecycle events. `AuditConnector` -wires an event receiver to a publisher, `KafkaBundleEventPublisher` publishes events to Kafka, -and `S3EventReaderWriter` archives events to S3 for long-term retention. `KafkaAuditLogReader` -enables replaying the event history. Also exposes `LoggingBundleEventPublisher` for local -development. +wires an event receiver to a publisher, `RpcBundleEventPublisher` publishes events over RPC, +and `S3EventReaderWriter` archives events to S3 for long-term retention. Also exposes +`LoggingBundleEventPublisher` for local development. ## Usage @@ -20,11 +19,10 @@ audit-archiver-lib = { workspace = true } ``` ```rust,ignore -use audit_archiver_lib::{AuditConnector, KafkaBundleEventPublisher}; +use audit_archiver_lib::{AuditConnector, RpcBundleEventPublisher}; -let publisher = KafkaBundleEventPublisher::new(kafka_config).await?; -let connector = AuditConnector::new(event_rx, publisher); -connector.run().await; +let publisher = RpcBundleEventPublisher::new(rpc_url, timeout)?; +AuditConnector::connect_batched(event_rx, publisher, batch_size, batch_wait); ``` ## License diff --git a/crates/infra/audit/src/archiver.rs b/crates/infra/audit/src/archiver.rs index 81004a4ee5..87343a410e 100644 --- a/crates/infra/audit/src/archiver.rs +++ b/crates/infra/audit/src/archiver.rs @@ -18,7 +18,7 @@ use crate::{ storage::EventWriter, }; -/// Archives audit events from a generic [`EventReader`] (Kafka, RPC, etc.) to +/// Archives audit events from a generic [`EventReader`] to /// an [`EventWriter`] (typically S3) via a worker pool. pub struct AuditArchiver where @@ -119,7 +119,7 @@ where let read_start = Instant::now(); match self.reader.read_event().await { Ok(event) => { - Metrics::kafka_read_duration().record(read_start.elapsed().as_secs_f64()); + Metrics::read_duration().record(read_start.elapsed().as_secs_f64()); let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -138,7 +138,7 @@ where if let Err(e) = self.reader.commit().await { error!(error = %e, "Failed to commit message"); } - Metrics::kafka_commit_duration().record(commit_start.elapsed().as_secs_f64()); + Metrics::commit_duration().record(commit_start.elapsed().as_secs_f64()); } Err(e) => { error!(error = %e, "Error reading events"); diff --git a/crates/infra/audit/src/kafka_config.rs b/crates/infra/audit/src/kafka_config.rs deleted file mode 100644 index a023176f1b..0000000000 --- a/crates/infra/audit/src/kafka_config.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::{collections::HashMap, fs}; - -/// Loads Kafka configuration from a Java-style properties file. -pub fn load_kafka_config_from_file( - properties_file_path: &str, -) -> Result, std::io::Error> { - let kafka_properties = fs::read_to_string(properties_file_path)?; - - let mut config = HashMap::new(); - - for line in kafka_properties.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if let Some((key, value)) = line.split_once('=') { - config.insert(key.trim().to_string(), value.trim().to_string()); - } - } - - Ok(config) -} diff --git a/crates/infra/audit/src/lib.rs b/crates/infra/audit/src/lib.rs index f4e5ac6861..38306bc0f1 100644 --- a/crates/infra/audit/src/lib.rs +++ b/crates/infra/audit/src/lib.rs @@ -10,19 +10,14 @@ mod archiver; pub use archiver::AuditArchiver; -mod kafka_config; -pub use kafka_config::load_kafka_config_from_file; - mod metrics; pub use metrics::Metrics; mod publisher; -pub use publisher::{BundleEventPublisher, KafkaBundleEventPublisher, LoggingBundleEventPublisher}; +pub use publisher::{BundleEventPublisher, LoggingBundleEventPublisher}; mod reader; -pub use reader::{ - Event, EventReader, KafkaAuditLogReader, assign_topic_partition, create_kafka_consumer, -}; +pub use reader::{Event, EventReader}; mod rpc; pub use rpc::{AuditArchiverApiServer, AuditArchiverRpc}; diff --git a/crates/infra/audit/src/metrics.rs b/crates/infra/audit/src/metrics.rs index 0a74ab1db2..13334d5b7a 100644 --- a/crates/infra/audit/src/metrics.rs +++ b/crates/infra/audit/src/metrics.rs @@ -1,4 +1,4 @@ -//! Metrics for audit operations including Kafka reads, S3 writes, and event processing. +//! Metrics for audit operations including event reads, S3 writes, and event processing. base_metrics::define_metrics! { tips_audit @@ -6,10 +6,10 @@ base_metrics::define_metrics! { archive_event_duration: histogram, #[describe("Age of event when processed (now - event timestamp)")] event_age: histogram, - #[describe("Duration of Kafka read_event")] - kafka_read_duration: histogram, - #[describe("Duration of Kafka commit")] - kafka_commit_duration: histogram, + #[describe("Duration of read_event")] + read_duration: histogram, + #[describe("Duration of event commit")] + commit_duration: histogram, #[describe("Duration of update_bundle_history")] update_bundle_history_duration: histogram, #[describe("Duration of update all transaction indexes")] diff --git a/crates/infra/audit/src/publisher.rs b/crates/infra/audit/src/publisher.rs index 88fe47cae0..d4fef6fee0 100644 --- a/crates/infra/audit/src/publisher.rs +++ b/crates/infra/audit/src/publisher.rs @@ -1,7 +1,6 @@ use anyhow::Result; use async_trait::async_trait; -use rdkafka::producer::{FutureProducer, FutureRecord}; -use tracing::{debug, error, info}; +use tracing::info; use crate::types::BundleEvent; @@ -15,71 +14,6 @@ pub trait BundleEventPublisher: Send + Sync { async fn publish_all(&self, events: Vec) -> Result<()>; } -/// Publishes bundle events to Kafka. -#[derive(Clone)] -pub struct KafkaBundleEventPublisher { - producer: FutureProducer, - topic: String, -} - -impl std::fmt::Debug for KafkaBundleEventPublisher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("KafkaBundleEventPublisher") - .field("topic", &self.topic) - .finish_non_exhaustive() - } -} - -impl KafkaBundleEventPublisher { - /// Creates a new Kafka bundle event publisher. - pub const fn new(producer: FutureProducer, topic: String) -> Self { - Self { producer, topic } - } - - async fn send_event(&self, event: &BundleEvent) -> Result<()> { - let bundle_id = event.bundle_id(); - let key = event.generate_event_key(); - let payload = serde_json::to_vec(event)?; - - let record = FutureRecord::to(&self.topic).key(&key).payload(&payload); - - match self.producer.send(record, tokio::time::Duration::from_secs(5)).await { - Ok(_) => { - debug!( - bundle_id = %bundle_id, - topic = %self.topic, - payload_size = payload.len(), - "successfully published event" - ); - Ok(()) - } - Err((err, _)) => { - error!( - bundle_id = %bundle_id, - topic = %self.topic, - error = %err, - "failed to publish event" - ); - Err(anyhow::anyhow!("Failed to publish event: {err}")) - } - } - } -} - -#[async_trait] -impl BundleEventPublisher for KafkaBundleEventPublisher { - async fn publish(&self, event: BundleEvent) -> Result<()> { - self.send_event(&event).await - } - - async fn publish_all(&self, events: Vec) -> Result<()> { - for event in events { - self.send_event(&event).await?; - } - Ok(()) - } -} - /// Publishes bundle events to logs (for testing/debugging). #[derive(Clone, Debug)] pub struct LoggingBundleEventPublisher; diff --git a/crates/infra/audit/src/reader.rs b/crates/infra/audit/src/reader.rs index d5b8c717cc..6f4740d02a 100644 --- a/crates/infra/audit/src/reader.rs +++ b/crates/infra/audit/src/reader.rs @@ -1,35 +1,9 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - use anyhow::Result; use async_trait::async_trait; -use rdkafka::{ - Timestamp, TopicPartitionList, - config::ClientConfig, - consumer::{Consumer, StreamConsumer}, - message::Message, -}; -use tokio::time::sleep; -use tracing::{error, info}; - -use crate::{load_kafka_config_from_file, types::BundleEvent}; - -/// Creates a Kafka consumer from a properties file. -pub fn create_kafka_consumer(kafka_properties_file: &str) -> Result { - let client_config: ClientConfig = - ClientConfig::from_iter(load_kafka_config_from_file(kafka_properties_file)?); - let consumer: StreamConsumer = client_config.create()?; - Ok(consumer) -} -/// Assigns a topic partition to a consumer. -pub fn assign_topic_partition(consumer: &StreamConsumer, topic: &str) -> Result<()> { - let mut tpl = TopicPartitionList::new(); - tpl.add_partition(topic, 0); - consumer.assign(&tpl)?; - Ok(()) -} +use crate::types::BundleEvent; -/// A bundle event with metadata from Kafka. +/// A bundle event with metadata. #[derive(Debug, Clone)] pub struct Event { /// The event key. @@ -48,96 +22,3 @@ pub trait EventReader { /// Commits the last read message. async fn commit(&mut self) -> Result<()>; } - -/// Reads bundle audit events from Kafka. -pub struct KafkaAuditLogReader { - consumer: StreamConsumer, - topic: String, - last_message_offset: Option, - last_message_partition: Option, -} - -impl std::fmt::Debug for KafkaAuditLogReader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("KafkaAuditLogReader") - .field("topic", &self.topic) - .field("last_message_offset", &self.last_message_offset) - .field("last_message_partition", &self.last_message_partition) - .finish_non_exhaustive() - } -} - -impl KafkaAuditLogReader { - /// Creates a new Kafka audit log reader. - pub fn new(consumer: StreamConsumer, topic: String) -> Result { - consumer.subscribe(&[&topic])?; - Ok(Self { consumer, topic, last_message_offset: None, last_message_partition: None }) - } -} - -#[async_trait] -impl EventReader for KafkaAuditLogReader { - async fn read_event(&mut self) -> Result { - match self.consumer.recv().await { - Ok(message) => { - let payload = - message.payload().ok_or_else(|| anyhow::anyhow!("Message has no payload"))?; - - // Extract Kafka timestamp, use current time as fallback - let timestamp = match message.timestamp() { - Timestamp::CreateTime(millis) | Timestamp::LogAppendTime(millis) => millis, - Timestamp::NotAvailable => { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() - as i64 - } - }; - - let event: BundleEvent = serde_json::from_slice(payload)?; - - info!( - bundle_id = %event.bundle_id(), - tx_ids = ?event.transaction_ids(), - timestamp = timestamp, - offset = message.offset(), - partition = message.partition(), - "Received event with timestamp" - ); - - self.last_message_offset = Some(message.offset()); - self.last_message_partition = Some(message.partition()); - - let key = message - .key() - .map(|k| String::from_utf8_lossy(k).to_string()) - .ok_or_else(|| anyhow::anyhow!("Message missing required key"))?; - - let event_result = Event { key, event, timestamp }; - - Ok(event_result) - } - Err(e) => { - error!(error = %e, "Error receiving message from Kafka"); - sleep(Duration::from_secs(1)).await; - Err(e.into()) - } - } - } - - async fn commit(&mut self) -> Result<()> { - if let (Some(offset), Some(partition)) = - (self.last_message_offset, self.last_message_partition) - { - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&self.topic, partition, rdkafka::Offset::Offset(offset + 1))?; - self.consumer.commit(&tpl, rdkafka::consumer::CommitMode::Async)?; - } - Ok(()) - } -} - -impl KafkaAuditLogReader { - /// Returns the topic this reader is subscribed to. - pub fn topic(&self) -> &str { - &self.topic - } -} diff --git a/crates/infra/audit/src/types.rs b/crates/infra/audit/src/types.rs index afde2654e8..471bf6cef6 100644 --- a/crates/infra/audit/src/types.rs +++ b/crates/infra/audit/src/types.rs @@ -123,7 +123,7 @@ impl BundleEvent { } } - /// Generates the event key used as both the Kafka message key and S3 object name. + /// Generates the event key used as the S3 object name. /// /// For `Received` events, derived from `bundle_hash` so that the same /// bundle on different ingress pods produces the same key. diff --git a/crates/infra/audit/tests/common/mod.rs b/crates/infra/audit/tests/common/mod.rs index 5067c42031..5ef076dbed 100644 --- a/crates/infra/audit/tests/common/mod.rs +++ b/crates/infra/audit/tests/common/mod.rs @@ -1,19 +1,13 @@ -//! Common test harness for audit integration tests with Kafka and S3 fixtures. +//! Common test harness for audit integration tests with S3 fixtures. -use rdkafka::{ClientConfig, consumer::StreamConsumer, producer::FutureProducer}; use testcontainers::runners::AsyncRunner; -use testcontainers_modules::{kafka, kafka::Kafka, minio::MinIO}; +use testcontainers_modules::minio::MinIO; use uuid::Uuid; pub(crate) struct TestHarness { pub s3_client: aws_sdk_s3::Client, pub bucket_name: String, - #[allow(dead_code)] - pub kafka_producer: FutureProducer, - #[allow(dead_code)] - pub kafka_consumer: StreamConsumer, _minio_container: testcontainers::ContainerAsync, - _kafka_container: testcontainers::ContainerAsync, } impl TestHarness { @@ -41,32 +35,6 @@ impl TestHarness { s3_client.create_bucket().bucket(&bucket_name).send().await?; - let kafka_container = Kafka::default().start().await?; - let bootstrap_servers = - format!("127.0.0.1:{}", kafka_container.get_host_port_ipv4(kafka::KAFKA_PORT).await?); - - let kafka_producer = ClientConfig::new() - .set("bootstrap.servers", &bootstrap_servers) - .set("message.timeout.ms", "5000") - .create::() - .expect("Failed to create Kafka FutureProducer"); - - let kafka_consumer = ClientConfig::new() - .set("group.id", "testcontainer-rs") - .set("bootstrap.servers", &bootstrap_servers) - .set("session.timeout.ms", "6000") - .set("enable.auto.commit", "false") - .set("auto.offset.reset", "earliest") - .create::() - .expect("Failed to create Kafka StreamConsumer"); - - Ok(Self { - s3_client, - bucket_name, - kafka_producer, - kafka_consumer, - _minio_container: minio_container, - _kafka_container: kafka_container, - }) + Ok(Self { s3_client, bucket_name, _minio_container: minio_container }) } } diff --git a/crates/infra/audit/tests/integration_tests.rs b/crates/infra/audit/tests/integration_tests.rs deleted file mode 100644 index 954a1f2e64..0000000000 --- a/crates/infra/audit/tests/integration_tests.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Integration tests for the Kafka publisher and S3 archiver pipeline. - -use std::time::Duration; - -use audit_archiver_lib::{ - AuditArchiver, BundleEvent, BundleEventPublisher, BundleEventS3Reader, KafkaAuditLogReader, - KafkaBundleEventPublisher, S3EventReaderWriter, -}; -use base_bundles::{BundleExtensions, test_utils::create_bundle_from_txn_data}; -use uuid::Uuid; -mod common; -use common::TestHarness; - -#[tokio::test] -#[ignore = "TODO doesn't appear to work with minio, should test against a real S3 bucket"] -async fn system_test_kafka_publisher_s3_archiver_integration() -> anyhow::Result<()> { - let harness = TestHarness::new().await?; - let topic = "test-mempool-events"; - - let s3_writer = - S3EventReaderWriter::new(harness.s3_client.clone(), harness.bucket_name.clone()); - - let bundle = create_bundle_from_txn_data(); - let test_bundle_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, bundle.bundle_hash().as_slice()); - let test_events = - [BundleEvent::Received { bundle_id: test_bundle_id, bundle: Box::new(bundle.clone()) }]; - - let publisher = KafkaBundleEventPublisher::new(harness.kafka_producer, topic.to_string()); - - for event in &test_events { - publisher.publish(event.clone()).await?; - } - - let mut consumer = AuditArchiver::new( - KafkaAuditLogReader::new(harness.kafka_consumer, topic.to_string())?, - s3_writer.clone(), - 1, - 100, - false, - ); - - tokio::spawn(async move { - consumer.run().await.expect("error running consumer"); - }); - - // Wait for the messages to be received - let mut counter = 0; - loop { - counter += 1; - if counter > 10 { - panic!("unable to complete archiving within the deadline"); - } - - tokio::time::sleep(Duration::from_secs(1)).await; - let bundle_key = format!("{}", bundle.bundle_hash()); - let bundle_history = s3_writer.get_bundle_history(&bundle_key).await?; - - if let Some(history) = bundle_history { - if history.history.len() == test_events.len() { - break; - } - continue; - } - continue; - } - - Ok(()) -} diff --git a/crates/infra/basectl/src/app/core.rs b/crates/infra/basectl/src/app/core.rs index dbd3f877c1..cdfc7c7eaf 100644 --- a/crates/infra/basectl/src/app/core.rs +++ b/crates/infra/basectl/src/app/core.rs @@ -8,6 +8,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph}, }; use tokio::sync::oneshot; +use url::Url; use super::{Action, Resources, Router, View, ViewId, runner::start_background_services}; use crate::{ @@ -47,6 +48,9 @@ pub struct App { network_picker: Option, /// Pending async network-load result. `Some` while a switch is in flight. pending_network: Option>>, + /// Bootstrap conductor RPC URL from `--conductor-rpc`. Forwarded to background + /// services on every network switch so discovery survives the rebuild. + conductor_rpc: Option, } impl fmt::Debug for App { @@ -64,7 +68,7 @@ impl fmt::Debug for App { impl App { /// Creates a new application with the given resources and initial view. - pub fn new(resources: Resources, initial_view: ViewId) -> Self { + pub fn new(resources: Resources, initial_view: ViewId, conductor_rpc: Option) -> Self { Self { router: Router::new(initial_view), resources, @@ -72,6 +76,7 @@ impl App { view_cache: HashMap::new(), network_picker: None, pending_network: None, + conductor_rpc, } } @@ -242,9 +247,18 @@ impl App { self.resources.toasts.push(Toast::info(format!("Connecting to {name}…"))); let (tx, rx) = oneshot::channel(); self.pending_network = Some(rx); + let conductor_rpc = self.conductor_rpc.clone(); tokio::spawn(async move { - let result = MonitoringConfig::load(&name).await; - let _ = tx.send(result); + let mut load = MonitoringConfig::load(&name).await; + if let (Ok(config), Some(bootstrap)) = (load.as_mut(), conductor_rpc.as_ref()) + && config.conductors.is_none() + { + let detect_rpc = config.detect_rpc_for(Some(bootstrap)); + if let Some(detected) = MonitoringConfig::detect_name_from_rpc(&detect_rpc).await { + config.name = detected; + } + } + let _ = tx.send(load); }); } @@ -293,7 +307,7 @@ impl App { // Replace resources entirely — dropping old receivers causes background // tasks from the previous network to exit naturally on their next send. self.resources = Resources::new(new_config.clone()); - start_background_services(&new_config, &mut self.resources); + start_background_services(&new_config, &mut self.resources, self.conductor_rpc.clone()); // Discard all cached view state so views re-initialise for the new network. self.view_cache.clear(); self.router = Router::new(ViewId::Home); diff --git a/crates/infra/basectl/src/app/mod.rs b/crates/infra/basectl/src/app/mod.rs index c88d23f79b..bd165336ce 100644 --- a/crates/infra/basectl/src/app/mod.rs +++ b/crates/infra/basectl/src/app/mod.rs @@ -7,7 +7,9 @@ mod core; pub use core::App; mod resources; -pub use resources::{ConductorState, DaState, FlashState, ProofsState, Resources, ValidatorState}; +pub use resources::{ + ConductorState, DaState, FlashState, ProofsState, Resources, SourceLabel, ValidatorState, +}; mod router; pub use router::{Router, ViewId}; @@ -21,6 +23,7 @@ pub use view::View; /// TUI view implementations. mod views; pub use views::{ - CommandCenterView, ConductorView, ConfigView, DaMonitorView, FlashblocksView, HomeView, - ProofsView, TransactionPane, UpgradesView, create_view, + ActionMenuItem, CommandCenterView, ConductorView, ConfigView, ConfirmButton, DaMonitorView, + FlashblocksView, HomeView, Overlay, PendingAction, ProofsView, TransactionPane, UpgradesView, + create_view, }; diff --git a/crates/infra/basectl/src/app/resources.rs b/crates/infra/basectl/src/app/resources.rs index 44db77b30b..c0e45906f6 100644 --- a/crates/infra/basectl/src/app/resources.rs +++ b/crates/infra/basectl/src/app/resources.rs @@ -1,15 +1,21 @@ -use std::collections::{HashSet, VecDeque}; +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + time::Instant, +}; use base_common_flashblocks::Flashblock; use base_common_genesis::SystemConfig; +use base_consensus_rpc::ClusterMembership; use tokio::sync::{mpsc, watch}; +use url::Url; use crate::{ commands::{DaTracker, FlashblockEntry, LoadingState}, config::{ConductorNodeConfig, MonitoringConfig}, rpc::{ - BacklogFetchResult, BlockDaInfo, ConductorNodeStatus, L1BlockInfo, L1ConnectionMode, - ProofsSnapshot, TimestampedFlashblock, ValidatorNodeStatus, + BacklogFetchResult, BlockDaInfo, ConductorNodeStatus, ConductorPollUpdate, L1BlockInfo, + L1ConnectionMode, ProofsSnapshot, TimestampedFlashblock, ValidatorNodeStatus, }, tui::ToastState, }; @@ -17,28 +23,71 @@ use crate::{ const MAX_FLASH_BLOCKS: usize = 30; const MAX_RECENT_DA_FLASHBLOCK_IDS: usize = 512; +/// Origin label for the conductor cluster node list, surfaced in the TUI. +#[derive(Debug, Clone, Default)] +pub enum SourceLabel { + /// Hand-configured node list (devnet, custom YAML). + #[default] + Static, + /// Bootstrapped from a single conductor RPC and refreshed from raft membership. + Discovered { + /// Bootstrap conductor RPC URL. + bootstrap: Url, + /// Wall-clock time of the most recent successful membership refresh. + last_refresh: Instant, + }, +} + /// State for HA conductor cluster monitoring. #[derive(Debug, Default)] pub struct ConductorState { /// Most recent status snapshot for each conductor node. pub nodes: Vec, /// Original per-node configs, used to look up each node's `flashblocks_ws` URL. + /// In `Discover` mode this is rebuilt every time the poller emits a + /// `NodeListRefreshed` update. nodes_config: Vec, - rx: Option>>, + rx: Option>, /// Sender half of the flashblocks URL watch channel. When set, `poll` /// derives the current leader's flashblocks endpoint from the polled /// status and pushes a new value whenever the leader changes. This /// removes the need for a separate `run_conductor_leader_url_tracker` /// task that would duplicate the `conductor_leader` RPC calls. fb_url_tx: Option>, + /// Most recent raft cluster membership snapshot. Shared by `Arc` with the + /// poller so a membership change is a single allocation, not a deep copy. + pub cluster_membership: Option>, + /// Whether the active node list comes from a static config or live discovery. + pub source_label: SourceLabel, } impl ConductorState { - /// Sets the channel for receiving conductor status updates. - pub fn set_channel(&mut self, rx: mpsc::Receiver>) { + /// Sets the channel for receiving conductor poll updates. + pub fn set_channel(&mut self, rx: mpsc::Receiver) { self.rx = Some(rx); } + /// Sets the source label (static vs discovered) for UI display. + pub fn set_source_label(&mut self, label: SourceLabel) { + self.source_label = label; + } + + /// Returns the active per-node configs. In `Static` mode this is the + /// configured list; in `Discover` mode it is the list synthesised from the + /// last `clusterMembership` snapshot. The conductor view uses this to + /// dispatch mutations (pause, resume, transfer, …) without re-reading the + /// stale `MonitoringConfig.conductors` list, which is `None` in `Discover`. + pub fn nodes_config(&self) -> &[ConductorNodeConfig] { + &self.nodes_config + } + + /// Seeds the per-node configs directly (used in `Discover` mode so the view + /// can dispatch mutations against the bootstrap node before the first + /// `clusterMembership` snapshot arrives). + pub fn set_nodes_config(&mut self, nodes_config: Vec) { + self.nodes_config = nodes_config; + } + /// Registers the node configs and URL sender used to track leader URL changes. /// /// After this is called, every `poll` will push the leader's `flashblocks_ws` @@ -52,13 +101,21 @@ impl ConductorState { self.fb_url_tx = Some(tx); } - /// Drains the latest status snapshot from the background poller, then + /// Drains all pending poll updates, keeping the most recent values, then /// pushes the leader's flashblocks URL into the watch channel if it changed. pub fn poll(&mut self) { let Some(ref mut rx) = self.rx else { return }; - // Drain all pending updates, keeping only the most recent snapshot. - while let Ok(statuses) = rx.try_recv() { - self.nodes = statuses; + while let Ok(update) = rx.try_recv() { + match update { + ConductorPollUpdate::Status(statuses) => self.nodes = statuses, + ConductorPollUpdate::Membership(m) => { + self.cluster_membership = Some(m); + if let SourceLabel::Discovered { last_refresh, .. } = &mut self.source_label { + *last_refresh = Instant::now(); + } + } + ConductorPollUpdate::NodeListRefreshed(nodes) => self.nodes_config = nodes, + } } self.push_leader_url(); } diff --git a/crates/infra/basectl/src/app/runner.rs b/crates/infra/basectl/src/app/runner.rs index 94fc57fc35..d549af2df1 100644 --- a/crates/infra/basectl/src/app/runner.rs +++ b/crates/infra/basectl/src/app/runner.rs @@ -1,16 +1,17 @@ -use std::io::Write; +use std::{io::Write, time::Instant}; use anyhow::Result; use base_common_flashblocks::Flashblock; use base_common_genesis::SystemConfig; use tokio::sync::{mpsc, watch}; +use url::Url; -use super::{App, Resources, ViewId, views::create_view}; +use super::{App, Resources, SourceLabel, ViewId, views::create_view}; use crate::{ - config::MonitoringConfig, + config::{ConductorSource, MonitoringConfig}, l1_client::fetch_full_system_config, rpc::{ - BacklogFetchResult, BlockDaInfo, ConductorNodeStatus, L1BlockInfo, L1ConnectionMode, + BacklogFetchResult, BlockDaInfo, ConductorPollUpdate, L1BlockInfo, L1ConnectionMode, ProofsSnapshot, TimestampedFlashblock, ValidatorNodeStatus, fetch_initial_backlog_with_progress, run_block_fetcher, run_conductor_poller, run_flashblock_ws, run_flashblock_ws_timestamped, run_l1_blob_watcher, run_proofs_poller, @@ -20,21 +21,66 @@ use crate::{ }; /// Launches the TUI application starting from the specified view and network. -pub async fn run_app(initial_view: ViewId, network: &str) -> Result<()> { - let config = MonitoringConfig::load(network).await?; +/// +/// `conductor_rpc` is the optional `--conductor-rpc` CLI override; when set it +/// forces the conductor source into `Discover` mode regardless of config. +pub async fn run_app( + initial_view: ViewId, + network: &str, + conductor_rpc: Option, +) -> Result<()> { + let mut config = MonitoringConfig::load(network).await?; + if config.conductors.is_none() + && let Some(bootstrap) = conductor_rpc.as_ref() + { + let detect_rpc = config.detect_rpc_for(Some(bootstrap)); + if let Some(detected) = MonitoringConfig::detect_name_from_rpc(&detect_rpc).await { + config.name = detected; + } + } let mut resources = Resources::new(config.clone()); - start_background_services(&config, &mut resources); - let app = App::new(resources, initial_view); + start_background_services(&config, &mut resources, conductor_rpc.clone()); + let app = App::new(resources, initial_view, conductor_rpc); app.run(create_view).await } +/// Resolves the active conductor source from CLI flag and config. +/// +/// Precedence: hand-configured `conductors` list > CLI `--conductor-rpc` flag > +/// `discovery.bootstrap_rpc` from config. Static config wins so local devnet +/// (which ships with a hardcoded 3-node list) isn't accidentally clobbered by +/// the default `--conductor-rpc` value. Returns `None` when no source is +/// configured (conductor view will simply show no nodes). +fn resolve_conductor_source( + cli_flag: Option, + config: &MonitoringConfig, +) -> Option { + if let Some(nodes) = config.conductors.clone() { + return Some(ConductorSource::Static(nodes)); + } + if let Some(bootstrap) = cli_flag { + let ports = config.discovery.as_ref().map(|d| d.ports.clone()).unwrap_or_default(); + return Some(ConductorSource::Discover { bootstrap, ports }); + } + if let Some(d) = config.discovery.as_ref() + && let Some(bootstrap) = d.bootstrap_rpc.clone() + { + return Some(ConductorSource::Discover { bootstrap, ports: d.ports.clone() }); + } + None +} + /// Starts all background data-fetching services, wiring their channels into `resources`. /// /// Spawns tokio tasks for flashblock streams, L1 blob watching, DA backlog loading, /// safe-head polling, system config fetching, conductor polling, validator polling, /// and proof monitoring. All tasks communicate back through channels stored in /// `resources`. -pub fn start_background_services(config: &MonitoringConfig, resources: &mut Resources) { +pub fn start_background_services( + config: &MonitoringConfig, + resources: &mut Resources, + conductor_rpc: Option, +) { let (fb_tx, fb_rx) = mpsc::channel::(100); let (da_fb_tx, da_fb_rx) = mpsc::channel::(100); let (sync_tx, sync_rx) = mpsc::channel::(10); @@ -106,17 +152,36 @@ pub fn start_background_services(config: &MonitoringConfig, resources: &mut Reso } }); - if let Some(conductor_nodes) = config.conductors.clone() { - let (conductor_tx, conductor_rx) = mpsc::channel::>(4); + if let Some(source) = resolve_conductor_source(conductor_rpc, config) { + let (conductor_tx, conductor_rx) = mpsc::channel::(8); resources.conductor.set_channel(conductor_rx); - tokio::spawn(run_conductor_poller(conductor_nodes.clone(), conductor_tx)); - + resources.conductor.set_source_label(match &source { + ConductorSource::Static(_) => SourceLabel::Static, + ConductorSource::Discover { bootstrap, .. } => SourceLabel::Discovered { + bootstrap: bootstrap.clone(), + last_refresh: Instant::now(), + }, + }); // Wire the URL sender into ConductorState so that the existing // conductor poll (200 ms) drives flashblocks URL changes instead of // a separate task that would duplicate the conductor_leader RPCs. - if conductor_nodes.iter().any(|n| n.flashblocks_ws.is_some()) { - resources.conductor.set_url_sender(conductor_nodes, fb_url_tx); + // Discovered peers carry no flashblocks_ws endpoints, so this only + // applies to statically configured clusters (devnet today). + match &source { + ConductorSource::Static(nodes) => { + if nodes.iter().any(|n| n.flashblocks_ws.is_some()) { + resources.conductor.set_url_sender(nodes.clone(), fb_url_tx); + } else { + resources.conductor.set_nodes_config(nodes.clone()); + } + } + ConductorSource::Discover { .. } => { + if let Some(bootstrap) = source.bootstrap_node() { + resources.conductor.set_nodes_config(vec![bootstrap]); + } + } } + tokio::spawn(run_conductor_poller(source, conductor_tx)); } if let Some(validator_nodes) = config.validators.clone() { diff --git a/crates/infra/basectl/src/app/views/conductor.rs b/crates/infra/basectl/src/app/views/conductor.rs index 477c9e6d41..9fbc877108 100644 --- a/crates/infra/basectl/src/app/views/conductor.rs +++ b/crates/infra/basectl/src/app/views/conductor.rs @@ -1,10 +1,11 @@ -use std::collections::HashMap; +use std::{collections::HashMap, str::FromStr}; -use crossterm::event::{KeyCode, KeyEvent}; +use alloy_primitives::B256; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, prelude::*, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}, + widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap}, }; use tokio::sync::mpsc; @@ -12,87 +13,409 @@ use crate::{ app::{Action, Resources, View}, commands::COLOR_BASE_BLUE, rpc::{ - ConductorNodeStatus, PausedPeers, ValidatorNodeStatus, pause_sequencer_node, - restart_conductor_node, transfer_conductor_leader, unpause_sequencer_node, + ConductorNodeStatus, PausedPeers, ValidatorNodeStatus, conductor_pause_all_nodes, + conductor_pause_node, conductor_resume_all_nodes, conductor_resume_node, + pause_sequencer_node, restart_conductor_node, start_sequencer_node, stop_sequencer_node, + transfer_conductor_leader, unpause_sequencer_node, }, tui::{Keybinding, Toast}, }; const KEYBINDINGS: &[Keybinding] = &[ Keybinding { key: "←/→", description: "Select node" }, - Keybinding { key: "t", description: "Transfer (any peer)" }, - Keybinding { key: "Enter", description: "Transfer to selected" }, - Keybinding { key: "r", description: "Restart selected node" }, - Keybinding { key: "p", description: "Pause/unpause conductor" }, + Keybinding { key: "Enter", description: "Open action menu" }, + Keybinding { key: "t", description: "Transfer leader (any)" }, + Keybinding { key: "P", description: "Pause conductor on all nodes" }, + Keybinding { key: "R", description: "Resume conductor on all nodes" }, Keybinding { key: "Esc", description: "Back to home" }, Keybinding { key: "?", description: "Toggle help" }, ]; type PauseRx = Option<(String, mpsc::Receiver>)>; -/// HA conductor cluster status view. +/// Items rendered in the per-node action menu. +/// +/// Each variant maps either to a [`PendingAction`] (after confirmation) or to a +/// transition into [`Overlay::HashInput`] (for inputs that require a value). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ActionMenuItem { + /// Transfer leadership to an unspecified healthy peer. + TransferLeaderAny, + /// Transfer leadership to the currently-selected node. + TransferLeaderHere, + /// Pause the conductor's control loop on the selected node. + ConductorPause, + /// Resume the conductor's control loop on the selected node. + ConductorResume, + /// Start the sequencer on the selected node at a chosen unsafe head. + StartSequencer, + /// Stop the sequencer on the selected node. + StopSequencer, + /// Toggle soft P2P isolation (disconnect / reconnect every CL+EL peer). + P2PToggle, + /// Restart the EL/CL/conductor docker containers in dependency order. + RestartContainers, +} + +const MENU_ITEMS: &[ActionMenuItem] = &[ + ActionMenuItem::TransferLeaderAny, + ActionMenuItem::TransferLeaderHere, + ActionMenuItem::ConductorPause, + ActionMenuItem::ConductorResume, + ActionMenuItem::StartSequencer, + ActionMenuItem::StopSequencer, + ActionMenuItem::P2PToggle, + ActionMenuItem::RestartContainers, +]; + +impl ActionMenuItem { + /// Returns the menu label, contextualized by node state where relevant. + pub const fn label(self, _node: &ConductorNodeStatus, is_p2p_isolated: bool) -> &'static str { + match self { + Self::TransferLeaderAny => "Transfer leader (any peer)", + Self::TransferLeaderHere => "Transfer leader here", + Self::ConductorPause => "Conductor pause", + Self::ConductorResume => "Conductor resume", + Self::StartSequencer => "Start sequencer…", + Self::StopSequencer => "Stop sequencer", + Self::P2PToggle => { + if is_p2p_isolated { + "P2P reconnect" + } else { + "P2P isolate" + } + } + Self::RestartContainers => "Restart containers", + } + } + + /// Returns whether the action makes sense given the current node state. + /// + /// Disabled items remain visible (greyed out) so operators always see the + /// full menu and don't have to guess what's missing. + pub fn enabled(self, node: &ConductorNodeStatus, _is_p2p_isolated: bool) -> bool { + match self { + Self::TransferLeaderHere => node.is_leader == Some(false), + Self::ConductorPause => node.conductor_paused == Some(false), + Self::ConductorResume => node.conductor_paused == Some(true), + Self::StartSequencer => { + node.is_leader == Some(true) && node.sequencer_active == Some(false) + } + Self::StopSequencer => node.sequencer_active == Some(true), + Self::RestartContainers | Self::P2PToggle => !node.discovered, + Self::TransferLeaderAny => true, + } + } +} + +/// Yes / No selector inside the confirmation overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmButton { + /// Confirm and execute the action. + Yes, + /// Cancel and return to the previous overlay. + No, +} + +/// A mutation queued behind a confirmation prompt. +#[derive(Debug, Clone)] +pub enum PendingAction { + /// Transfer leadership to any healthy peer. + TransferAny, + /// Transfer leadership to the named node. + TransferTo(String), + /// Restart docker containers on the named node. + RestartNode(String), + /// Soft-isolate the named node by disconnecting every CL+EL peer. + P2PIsolate(String), + /// Reconnect a previously isolated node using its saved peers. + P2PReconnect(String), + /// Pause the conductor's control loop on the named node. + ConductorPause(String), + /// Resume the conductor's control loop on the named node. + ConductorResume(String), + /// Pause the conductor's control loop on every node in the cluster. + /// + /// Carries the node count so the confirmation prompt can show it. + ConductorPauseAll(usize), + /// Resume the conductor's control loop on every node in the cluster. + ConductorResumeAll(usize), + /// Start the sequencer at the given unsafe head hash. + StartSequencer { + /// Target conductor / sequencer node name. + node: String, + /// Unsafe head hash to start from. Server rejects [`B256::ZERO`]. + hash: B256, + }, + /// Stop the sequencer on the named node. + StopSequencer(String), +} + +impl PendingAction { + /// Human-readable description shown inside the confirmation overlay. + pub fn description(&self) -> String { + match self { + Self::TransferAny => "Transfer leadership to any healthy peer?".to_string(), + Self::TransferTo(name) => format!("Transfer leadership to {name}?"), + Self::RestartNode(name) => format!("Restart EL/CL/conductor containers on {name}?"), + Self::P2PIsolate(name) => { + format!("Disconnect every CL+EL peer on {name}? (soft pause)") + } + Self::P2PReconnect(name) => format!("Reconnect saved peers on {name}?"), + Self::ConductorPause(name) => format!("Pause conductor control loop on {name}?"), + Self::ConductorResume(name) => format!("Resume conductor control loop on {name}?"), + Self::ConductorPauseAll(count) => { + format!("Pause conductor control loop on ALL {count} nodes?") + } + Self::ConductorResumeAll(count) => { + format!("Resume conductor control loop on ALL {count} nodes?") + } + Self::StartSequencer { node, hash } => { + format!("Start sequencer on {node} at {hash}?") + } + Self::StopSequencer(name) => format!("Stop sequencer on {name}?"), + } + } + + /// Whether the action is destructive enough to warrant a red confirm button. + pub const fn is_destructive(&self) -> bool { + matches!( + self, + Self::TransferAny + | Self::TransferTo(_) + | Self::RestartNode(_) + | Self::P2PIsolate(_) + | Self::ConductorPause(_) + | Self::ConductorPauseAll(_) + | Self::StopSequencer(_) + ) + } +} + +/// Modal overlay state for [`ConductorView`]. +/// +/// Only one overlay can be active at a time. While active, key handling and +/// rendering route through the overlay's branch instead of the underlying +/// status table. +#[derive(Debug, Default)] +pub enum Overlay { + /// No overlay; status table is interactive. + #[default] + None, + /// Per-node action menu, with `cursor` indexing into [`MENU_ITEMS`]. + ActionMenu { + /// Currently-highlighted menu item index. + cursor: usize, + }, + /// Yes/No confirmation prompt for a pending action. + Confirm { + /// The action to execute on `Yes`. + action: PendingAction, + /// Currently-highlighted confirm button. + button: ConfirmButton, + }, + /// Free-text hex input used for `admin_startSequencer`. + HashInput { + /// Target node name (carried so we can spawn the mutation directly). + node: String, + /// Current input buffer (without the leading `0x`). + input: String, + /// Cursor offset within `input`. + cursor: usize, + /// True when the buffer was prefilled from a poll snapshot. + prefilled: bool, + }, +} + +/// HA conductor cluster status view with per-node action overlay. /// /// Renders a fixed grid with one column per conductor node and rows for -/// role (leader / follower / offline), unsafe/safe/finalized L2 block, and P2P peer count. -/// The user can navigate columns with `←`/`→` and trigger leadership transfers -/// with `t` (any peer) or `Enter` (selected node). A footer bar always shows -/// the available key bindings. When no conductor configuration is present -/// (e.g. mainnet), a placeholder message is shown instead. +/// role, conductor state (paused / stopped / healthy / sequencer-active), +/// CL block heads, and EL block heads. The user navigates columns with +/// `←`/`→` and opens a per-node action menu with `Enter`. Mutating actions +/// are gated behind a `Yes` / `No` confirmation overlay; `Start sequencer` +/// additionally prompts for an unsafe head hash, prefilled from the latest +/// poll snapshot. #[derive(Debug, Default)] pub struct ConductorView { selected: usize, + overlay: Overlay, op_pending: bool, - /// In-flight result channel for transfer / restart operations. + /// In-flight result channel for any mutation returning `Result` + /// (transfer, restart, conductor pause/resume, sequencer start/stop). op_rx: Option>>, - /// In-flight result channel for pause operations. - /// Carries `(node_name, result)` where `Ok` includes the peers that were saved. + /// In-flight result channel for the soft P2P-isolate operation. + /// Carries `(node_name, result)` where `Ok` includes the saved peers. pause_rx: PauseRx, - /// In-flight result channel for unpause operations. + /// In-flight result channel for the soft P2P-reconnect operation. unpause_rx: Option>>, - /// Saved peer lists for each paused node, keyed by node name. - /// Presence in this map means the node is currently paused. + /// Name of the node currently being reconnected, if any. Used to remove the saved + /// peer list from `paused_node_peers` only after a successful reconnect, so a + /// failed RPC leaves the saved peers intact for retry. + reconnecting_node: Option, + /// Saved peer lists for each soft-isolated node, keyed by node name. + /// Presence in this map means the node is currently P2P-isolated. paused_node_peers: HashMap, } impl ConductorView { - /// Creates a new conductor view. + /// Creates a new conductor view with no overlay open. pub fn new() -> Self { Self::default() } - fn start_transfer(&mut self, resources: &Resources, target_name: Option) { - let Some(ref nodes) = resources.config.conductors else { return }; - let (tx, rx) = mpsc::channel(1); - self.op_rx = Some(rx); - self.op_pending = true; - tokio::spawn(transfer_conductor_leader(nodes.clone(), target_name, tx)); + const fn is_overlay_open(&self) -> bool { + !matches!(self.overlay, Overlay::None) } - fn start_restart(&mut self, resources: &Resources) { - let Some(ref nodes) = resources.config.conductors else { return }; - let idx = self.selected.min(nodes.len().saturating_sub(1)); - let node = nodes[idx].clone(); - let (tx, rx) = mpsc::channel(1); - self.op_rx = Some(rx); - self.op_pending = true; - tokio::spawn(restart_conductor_node(node, tx)); + fn close_overlay(&mut self) { + self.overlay = Overlay::None; + } + + fn selected_node<'a>( + &self, + nodes: &'a [ConductorNodeStatus], + ) -> Option<&'a ConductorNodeStatus> { + if nodes.is_empty() { None } else { Some(&nodes[self.selected.min(nodes.len() - 1)]) } + } + + fn open_action_menu(&mut self) { + self.overlay = Overlay::ActionMenu { cursor: 0 }; } - fn start_pause_toggle(&mut self, resources: &Resources) { - let Some(ref nodes) = resources.config.conductors else { return }; - let idx = self.selected.min(nodes.len().saturating_sub(1)); - let node = nodes[idx].clone(); + /// Resolves a menu item into an overlay transition (or a no-op). + fn select_menu_item( + &mut self, + item: ActionMenuItem, + node: &ConductorNodeStatus, + is_p2p_isolated: bool, + ) { + if !item.enabled(node, is_p2p_isolated) { + return; + } + let name = node.name.clone(); + let action = match item { + ActionMenuItem::TransferLeaderAny => Some(PendingAction::TransferAny), + ActionMenuItem::TransferLeaderHere => Some(PendingAction::TransferTo(name)), + ActionMenuItem::ConductorPause => Some(PendingAction::ConductorPause(name)), + ActionMenuItem::ConductorResume => Some(PendingAction::ConductorResume(name)), + ActionMenuItem::StopSequencer => Some(PendingAction::StopSequencer(name)), + ActionMenuItem::RestartContainers => Some(PendingAction::RestartNode(name)), + ActionMenuItem::P2PToggle => Some(if is_p2p_isolated { + PendingAction::P2PReconnect(name) + } else { + PendingAction::P2PIsolate(name) + }), + ActionMenuItem::StartSequencer => { + let (input, prefilled) = node + .unsafe_l2_hash + .map_or_else(|| (String::new(), false), |h| (format!("{h:x}"), true)); + let cursor = input.len(); + self.overlay = Overlay::HashInput { node: name, input, cursor, prefilled }; + None + } + }; + if let Some(action) = action { + self.overlay = Overlay::Confirm { action, button: ConfirmButton::No }; + } + } + + /// Spawns the mutation behind a confirmed action and switches to single-flight. + fn execute(&mut self, action: PendingAction, resources: &Resources) { + let nodes_cfg = resources.conductor.nodes_config(); + if nodes_cfg.is_empty() { + return; + } self.op_pending = true; - if let Some(peers) = self.paused_node_peers.remove(&node.name) { - // Already paused — unpause by reconnecting saved peers. - let (tx, rx) = mpsc::channel(1); - self.unpause_rx = Some(rx); - tokio::spawn(unpause_sequencer_node(node, peers, tx)); - } else { - // Not paused — disconnect all peers and save them. - let (tx, rx) = mpsc::channel(1); - self.pause_rx = Some((node.name.clone(), rx)); - tokio::spawn(pause_sequencer_node(node, tx)); + self.close_overlay(); + + match action { + PendingAction::TransferAny => { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(transfer_conductor_leader(nodes_cfg.to_vec(), None, tx)); + } + PendingAction::TransferTo(target) => { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(transfer_conductor_leader(nodes_cfg.to_vec(), Some(target), tx)); + } + PendingAction::RestartNode(name) => { + if let Some(node) = nodes_cfg.iter().find(|n| n.name == name).cloned() { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(restart_conductor_node(node, tx)); + } else { + self.op_pending = false; + } + } + PendingAction::P2PIsolate(name) => { + if let Some(node) = nodes_cfg.iter().find(|n| n.name == name).cloned() { + let (tx, rx) = mpsc::channel(1); + self.pause_rx = Some((node.name.clone(), rx)); + tokio::spawn(pause_sequencer_node(node, tx)); + } else { + self.op_pending = false; + } + } + PendingAction::P2PReconnect(name) => { + let node = nodes_cfg.iter().find(|n| n.name == name).cloned(); + let peers = self.paused_node_peers.get(&name).cloned(); + if let (Some(node), Some(peers)) = (node, peers) { + let (tx, rx) = mpsc::channel(1); + self.unpause_rx = Some(rx); + self.reconnecting_node = Some(name); + tokio::spawn(unpause_sequencer_node(node, peers, tx)); + } else { + self.op_pending = false; + } + } + PendingAction::ConductorPause(name) => { + if let Some(node) = nodes_cfg.iter().find(|n| n.name == name).cloned() { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(conductor_pause_node(node, tx)); + } else { + self.op_pending = false; + } + } + PendingAction::ConductorResume(name) => { + if let Some(node) = nodes_cfg.iter().find(|n| n.name == name).cloned() { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(conductor_resume_node(node, tx)); + } else { + self.op_pending = false; + } + } + PendingAction::ConductorPauseAll(_) => { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(conductor_pause_all_nodes(nodes_cfg.to_vec(), tx)); + } + PendingAction::ConductorResumeAll(_) => { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(conductor_resume_all_nodes(nodes_cfg.to_vec(), tx)); + } + PendingAction::StartSequencer { node: name, hash } => { + if let Some(node) = nodes_cfg.iter().find(|n| n.name == name).cloned() { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(start_sequencer_node(node, hash, tx)); + } else { + self.op_pending = false; + } + } + PendingAction::StopSequencer(name) => { + if let Some(node) = nodes_cfg.iter().find(|n| n.name == name).cloned() { + let (tx, rx) = mpsc::channel(1); + self.op_rx = Some(rx); + tokio::spawn(stop_sequencer_node(node, tx)); + } else { + self.op_pending = false; + } + } } } } @@ -102,6 +425,21 @@ impl View for ConductorView { KEYBINDINGS } + fn consumes_esc(&self) -> bool { + self.is_overlay_open() + } + + fn consumes_quit(&self) -> bool { + self.is_overlay_open() + } + + fn captures_char_input(&self) -> bool { + // Any open overlay handles its own keys (including Confirm's y/n shortcuts and + // ActionMenu's j/k navigation), so the framework should not intercept Char keys + // for its own bindings while an overlay is open. + self.is_overlay_open() + } + fn tick(&mut self, resources: &mut Resources) -> Action { if let Some(ref mut rx) = self.op_rx && let Ok(result) = rx.try_recv() @@ -133,8 +471,14 @@ impl View for ConductorView { { self.op_pending = false; self.unpause_rx = None; + let node_name = self.reconnecting_node.take(); match result { - Ok(msg) => resources.toasts.push(Toast::info(msg)), + Ok(msg) => { + if let Some(name) = node_name { + self.paused_node_peers.remove(&name); + } + resources.toasts.push(Toast::info(msg)); + } Err(msg) => resources.toasts.push(Toast::warning(msg)), } } @@ -145,6 +489,124 @@ impl View for ConductorView { fn handle_key(&mut self, key: KeyEvent, resources: &mut Resources) -> Action { let node_count = resources.conductor.nodes.len(); + match &mut self.overlay { + Overlay::HashInput { node: target, input, cursor, prefilled } => { + match key.code { + KeyCode::Esc => self.close_overlay(), + KeyCode::Backspace => { + if *cursor > 0 { + *cursor -= 1; + input.remove(*cursor); + *prefilled = false; + } + } + KeyCode::Left => { + if *cursor > 0 { + *cursor -= 1; + } + } + KeyCode::Right => { + if *cursor < input.len() { + *cursor += 1; + } + } + KeyCode::Home => *cursor = 0, + KeyCode::End => *cursor = input.len(), + KeyCode::F(5) => { + if let Some(hash) = resources + .conductor + .nodes + .iter() + .find(|n| n.name == *target) + .and_then(|n| n.unsafe_l2_hash) + { + *input = format!("{hash:x}"); + *cursor = input.len(); + *prefilled = true; + } + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + if c.is_ascii_hexdigit() && input.len() < 64 { + input.insert(*cursor, c); + *cursor += 1; + *prefilled = false; + } + } + KeyCode::Enter => { + if let Some(hash) = parse_hex_hash(input) { + if hash == B256::ZERO { + resources + .toasts + .push(Toast::warning("Refusing to start at zero hash")); + } else { + let target_clone = target.clone(); + self.overlay = Overlay::Confirm { + action: PendingAction::StartSequencer { + node: target_clone, + hash, + }, + button: ConfirmButton::No, + }; + } + } else { + resources.toasts.push(Toast::warning( + "Invalid hash: need 64 hex chars (with or without 0x)".to_string(), + )); + } + } + _ => {} + } + return Action::None; + } + Overlay::Confirm { action, button } => { + match key.code { + KeyCode::Esc | KeyCode::Char('n' | 'N') => self.close_overlay(), + KeyCode::Left | KeyCode::Right | KeyCode::Tab => { + *button = match *button { + ConfirmButton::Yes => ConfirmButton::No, + ConfirmButton::No => ConfirmButton::Yes, + }; + } + KeyCode::Char('y' | 'Y') => { + let action = action.clone(); + self.execute(action, resources); + } + KeyCode::Enter => match button { + ConfirmButton::Yes => { + let action = action.clone(); + self.execute(action, resources); + } + ConfirmButton::No => self.close_overlay(), + }, + _ => {} + } + return Action::None; + } + Overlay::ActionMenu { cursor } => { + match key.code { + KeyCode::Esc => self.overlay = Overlay::None, + KeyCode::Up | KeyCode::Char('k') => { + *cursor = (*cursor + MENU_ITEMS.len() - 1) % MENU_ITEMS.len(); + } + KeyCode::Down | KeyCode::Char('j') => { + *cursor = (*cursor + 1) % MENU_ITEMS.len(); + } + KeyCode::Enter => { + let cursor_idx = *cursor; + if let Some(node) = self.selected_node(&resources.conductor.nodes).cloned() + { + let item = MENU_ITEMS[cursor_idx]; + let is_p2p_isolated = self.paused_node_peers.contains_key(&node.name); + self.select_menu_item(item, &node, is_p2p_isolated); + } + } + _ => {} + } + return Action::None; + } + Overlay::None => {} + } + match key.code { KeyCode::Left | KeyCode::Char('h') if node_count > 0 => { self.selected = (self.selected + node_count - 1) % node_count; @@ -152,19 +614,26 @@ impl View for ConductorView { KeyCode::Right | KeyCode::Char('l') if node_count > 0 => { self.selected = (self.selected + 1) % node_count; } - KeyCode::Char('t') if !self.op_pending => { - self.start_transfer(resources, None); - } KeyCode::Enter if !self.op_pending && node_count > 0 => { - let idx = self.selected.min(node_count - 1); - let target = resources.conductor.nodes[idx].name.clone(); - self.start_transfer(resources, Some(target)); + self.open_action_menu(); + } + KeyCode::Char('t') if !self.op_pending => { + self.overlay = Overlay::Confirm { + action: PendingAction::TransferAny, + button: ConfirmButton::No, + }; } - KeyCode::Char('r') if !self.op_pending && node_count > 0 => { - self.start_restart(resources); + KeyCode::Char('P') if !self.op_pending && node_count > 0 => { + self.overlay = Overlay::Confirm { + action: PendingAction::ConductorPauseAll(node_count), + button: ConfirmButton::No, + }; } - KeyCode::Char('p') if !self.op_pending && node_count > 0 => { - self.start_pause_toggle(resources); + KeyCode::Char('R') if !self.op_pending && node_count > 0 => { + self.overlay = Overlay::Confirm { + action: PendingAction::ConductorResumeAll(node_count), + button: ConfirmButton::No, + }; } _ => {} } @@ -199,17 +668,10 @@ impl View for ConductorView { ); } } else { - // Conductor table: 2 border + 1 header + 16 data rows = 19 lines. - // Validator table: 2 border + 1 header + 14 data rows = 17 lines. - let conductor_height = 19u16; - let validator_height = 17u16; + let conductor_height = 25u16; let sections = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(conductor_height), - Constraint::Length(validator_height), - Constraint::Min(0), - ]) + .constraints([Constraint::Length(conductor_height), Constraint::Min(0)]) .split(content_area); if nodes.is_empty() { @@ -228,10 +690,31 @@ impl View for ConductorView { render_validator_table(frame, sections[1], validators); } - render_footer(frame, footer_area, self.op_pending); + render_footer(frame, footer_area, &self.overlay, self.op_pending); + + match &self.overlay { + Overlay::None => {} + Overlay::ActionMenu { cursor } => { + if let Some(node) = self.selected_node(nodes) { + let is_p2p_isolated = self.paused_node_peers.contains_key(&node.name); + render_action_menu(frame, area, node, *cursor, is_p2p_isolated); + } + } + Overlay::Confirm { action, button } => { + render_confirm(frame, area, action, *button); + } + Overlay::HashInput { node, input, cursor, prefilled } => { + render_hash_input(frame, area, node, input, *cursor, *prefilled); + } + } } } +/// Parses a hex-encoded 32-byte hash, accepting both `0x`-prefixed and bare forms. +fn parse_hex_hash(input: &str) -> Option { + B256::from_str(input).ok() +} + fn render_unconfigured(f: &mut Frame<'_>, area: Rect) { let block = Block::default() .title(" HA Conductor ") @@ -253,53 +736,267 @@ fn render_unconfigured(f: &mut Frame<'_>, area: Rect) { f.render_widget(msg, chunks[1]); } -fn render_footer(f: &mut Frame<'_>, area: Rect, op_pending: bool) { +fn render_footer(f: &mut Frame<'_>, area: Rect, overlay: &Overlay, op_pending: bool) { let key_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); let desc_style = Style::default().fg(Color::DarkGray); - let sep_style = Style::default().fg(Color::DarkGray); - - let sep = Span::styled(" │ ", sep_style); + let sep = Span::styled(" │ ", desc_style); - let mut spans = vec![ - Span::styled("[Esc]", key_style), - Span::raw(" "), - Span::styled("back", desc_style), - sep.clone(), - Span::styled("[←/→]", key_style), - Span::raw(" "), - Span::styled("select node", desc_style), - ]; - - spans.push(sep.clone()); - if op_pending { - spans.push(Span::styled("working…", Style::default().fg(Color::Yellow))); - } else { - spans.push(Span::styled("[t]", key_style)); + let mut spans: Vec> = Vec::new(); + let push_pair = |spans: &mut Vec>, key: &'static str, desc: &'static str| { + spans.push(Span::styled(format!("[{key}]"), key_style)); spans.push(Span::raw(" ")); - spans.push(Span::styled("transfer to any peer", desc_style)); - spans.push(sep.clone()); - spans.push(Span::styled("[Enter]", key_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled("transfer to selected", desc_style)); - spans.push(sep.clone()); - spans.push(Span::styled("[r]", key_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled("restart selected", desc_style)); - spans.push(sep.clone()); - spans.push(Span::styled("[p]", key_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled("pause/unpause conductor", desc_style)); + spans.push(Span::styled(desc, desc_style)); + }; + + match overlay { + Overlay::None => { + push_pair(&mut spans, "Esc", "back"); + spans.push(sep.clone()); + push_pair(&mut spans, "←/→", "select node"); + spans.push(sep.clone()); + if op_pending { + spans.push(Span::styled("working…", Style::default().fg(Color::Yellow))); + } else { + push_pair(&mut spans, "Enter", "actions"); + spans.push(sep.clone()); + push_pair(&mut spans, "t", "transfer (any)"); + spans.push(sep.clone()); + push_pair(&mut spans, "P", "pause all"); + spans.push(sep.clone()); + push_pair(&mut spans, "R", "resume all"); + } + } + Overlay::ActionMenu { .. } => { + push_pair(&mut spans, "↑/↓", "move"); + spans.push(sep.clone()); + push_pair(&mut spans, "Enter", "select"); + spans.push(sep.clone()); + push_pair(&mut spans, "Esc", "cancel"); + } + Overlay::Confirm { .. } => { + push_pair(&mut spans, "←/→", "Yes / No"); + spans.push(sep.clone()); + push_pair(&mut spans, "Enter", "confirm"); + spans.push(sep.clone()); + push_pair(&mut spans, "y/n", "shortcut"); + spans.push(sep.clone()); + push_pair(&mut spans, "Esc", "cancel"); + } + Overlay::HashInput { .. } => { + push_pair(&mut spans, "0-9 a-f", "hex"); + spans.push(sep.clone()); + push_pair(&mut spans, "F5", "refresh prefill"); + spans.push(sep.clone()); + push_pair(&mut spans, "Enter", "confirm"); + spans.push(sep.clone()); + push_pair(&mut spans, "Esc", "cancel"); + } } spans.push(sep); - spans.push(Span::styled("[?]", key_style)); - spans.push(Span::raw(" ")); - spans.push(Span::styled("help", desc_style)); + push_pair(&mut spans, "?", "help"); let footer = Paragraph::new(Line::from(spans)); f.render_widget(footer, area); } +fn render_action_menu( + f: &mut Frame<'_>, + area: Rect, + node: &ConductorNodeStatus, + cursor: usize, + is_p2p_isolated: bool, +) { + let popup_w = 44u16.min(area.width.saturating_sub(4)); + let popup_h = (MENU_ITEMS.len() as u16 + 5).min(area.height.saturating_sub(4)); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup = Rect { x, y, width: popup_w, height: popup_h }; + + f.render_widget(Clear, popup); + + let title = format!(" Actions: {} ", node.name); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(COLOR_BASE_BLUE)); + let inner = block.inner(popup); + f.render_widget(block, popup); + + let mut lines: Vec> = Vec::with_capacity(MENU_ITEMS.len() + 2); + for (i, item) in MENU_ITEMS.iter().enumerate() { + let enabled = item.enabled(node, is_p2p_isolated); + let label = item.label(node, is_p2p_isolated); + let marker = if i == cursor { "› " } else { " " }; + let style = match (i == cursor, enabled) { + (true, true) => Style::default() + .fg(COLOR_BASE_BLUE) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + (true, false) => Style::default().fg(Color::DarkGray).add_modifier(Modifier::REVERSED), + (false, true) => Style::default().fg(Color::White), + (false, false) => Style::default().fg(Color::DarkGray), + }; + lines.push(Line::from(vec![Span::styled(format!("{marker}{label}"), style)])); + } + + lines.push(Line::raw("")); + lines.push(Line::from(vec![Span::styled( + " ↑/↓ move Enter select Esc cancel", + Style::default().fg(Color::DarkGray), + )])); + + f.render_widget(Paragraph::new(lines), inner); +} + +fn render_confirm(f: &mut Frame<'_>, area: Rect, action: &PendingAction, button: ConfirmButton) { + let popup_w = 60u16.min(area.width.saturating_sub(4)); + let popup_h = 8u16.min(area.height.saturating_sub(4)); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup = Rect { x, y, width: popup_w, height: popup_h }; + + f.render_widget(Clear, popup); + + let block = Block::default() + .title(" Confirm ") + .borders(Borders::ALL) + .border_style(Style::default().fg(COLOR_BASE_BLUE)); + let inner = block.inner(popup); + f.render_widget(block, popup); + + let body = Paragraph::new(action.description()) + .style(Style::default().fg(Color::White)) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + f.render_widget(body, layout[0]); + + let yes_color = if action.is_destructive() { Color::Red } else { Color::Green }; + let yes_style = match button { + ConfirmButton::Yes => { + Style::default().fg(yes_color).add_modifier(Modifier::BOLD | Modifier::REVERSED) + } + ConfirmButton::No => Style::default().fg(yes_color), + }; + let no_style = match button { + ConfirmButton::No => { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD | Modifier::REVERSED) + } + ConfirmButton::Yes => Style::default().fg(Color::White), + }; + + let buttons = Line::from(vec![ + Span::styled("[ Yes ]", yes_style), + Span::raw(" "), + Span::styled("[ No ]", no_style), + ]); + f.render_widget(Paragraph::new(buttons).alignment(Alignment::Center), layout[2]); + + let hint = Line::from(vec![Span::styled( + "←/→ select Enter confirm y/n shortcut Esc cancel", + Style::default().fg(Color::DarkGray), + )]); + f.render_widget(Paragraph::new(hint).alignment(Alignment::Center), layout[3]); +} + +fn render_hash_input( + f: &mut Frame<'_>, + area: Rect, + node: &str, + input: &str, + cursor: usize, + prefilled: bool, +) { + let popup_w = 76u16.min(area.width.saturating_sub(4)); + let popup_h = 9u16.min(area.height.saturating_sub(4)); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup = Rect { x, y, width: popup_w, height: popup_h }; + + f.render_widget(Clear, popup); + + let title = format!(" Start sequencer on {node} "); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(COLOR_BASE_BLUE)); + let inner = block.inner(popup); + f.render_widget(block, popup); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let prompt = Paragraph::new(Line::from(vec![Span::styled( + "Unsafe head hash (64 hex chars; 0x shown automatically)", + Style::default().fg(Color::White), + )])); + f.render_widget(prompt, layout[0]); + + let trimmed = trim_prefix(input); + let display = format!("0x{trimmed}"); + let valid = parse_hex_hash(input).is_some_and(|h| h != B256::ZERO); + let progress = format!("({} / 64 hex chars)", trimmed.len()); + let progress_color = if valid { Color::Green } else { Color::Yellow }; + + let value_style = + if valid { Style::default().fg(Color::Green) } else { Style::default().fg(Color::White) }; + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled(display, value_style)])), + layout[2], + ); + + // `cursor` is bounded by the input length, which the key handler caps at 64 + // hex chars, so the conversion never truncates in practice; the saturating + // cast guards against future changes to that invariant. + let cursor_col = inner.x + 2 + u16::try_from(cursor).unwrap_or(u16::MAX); + let cursor_col = cursor_col.min(inner.x + inner.width.saturating_sub(1)); + f.set_cursor_position((cursor_col, layout[2].y)); + + let progress_line = Paragraph::new(Line::from(vec![Span::styled( + progress, + Style::default().fg(progress_color), + )])); + f.render_widget(progress_line, layout[3]); + + if prefilled { + let prefill_hint = Paragraph::new(Line::from(vec![Span::styled( + "Prefilled from latest poll. F5 to refresh, edit to override.", + Style::default().fg(Color::Cyan), + )])); + f.render_widget(prefill_hint, layout[5]); + } + + let hint = Paragraph::new(Line::from(vec![Span::styled( + "Enter confirm F5 refresh Esc cancel", + Style::default().fg(Color::DarkGray), + )])); + f.render_widget(hint, layout[6]); +} + +fn trim_prefix(input: &str) -> &str { + input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")).unwrap_or(input) +} + fn render_cluster_table( f: &mut Frame<'_>, area: Rect, @@ -318,10 +1015,17 @@ fn render_cluster_table( let inner = block.inner(area); f.render_widget(block, area); - // Column widths: one fixed label column + one equal-width column per node. + let inner_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner); + render_pause_all_banner(f, inner_chunks[0], nodes, op_pending); + let inner = inner_chunks[2]; + + debug_assert!(!nodes.is_empty(), "render_cluster_table requires at least one node"); let node_count = nodes.len(); let label_pct = 15u16; - let node_pct = (100u16 - label_pct) / node_count as u16; + let node_pct = (100u16 - label_pct) / node_count.max(1) as u16; let mut constraints = vec![Constraint::Percentage(label_pct)]; for _ in 0..node_count { @@ -340,7 +1044,6 @@ fn render_cluster_table( let mut header_cells = vec![Cell::from("")]; for (i, node) in nodes.iter().enumerate() { let is_selected = i == selected; - // Role-driven color; selection adds underline independently. let role_color = match node.is_leader { Some(true) => Color::Yellow, Some(false) => Color::DarkGray, @@ -351,7 +1054,8 @@ fn render_cluster_table( mods |= Modifier::UNDERLINED; } let style = Style::default().fg(role_color).add_modifier(mods); - header_cells.push(Cell::from(node.name.as_str()).style(style)); + let label = if node.discovered { format!("{} (d)", node.name) } else { node.name.clone() }; + header_cells.push(Cell::from(label).style(style)); } let header = Row::new(header_cells) .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) @@ -363,7 +1067,7 @@ fn render_cluster_table( ]; for node in nodes { let (label, style) = if paused_nodes.contains_key(&node.name) { - ("⏸ paused", Style::default().fg(Color::Cyan)) + ("⏸ isolated", Style::default().fg(Color::Cyan)) } else { match node.is_leader { Some(true) => { @@ -377,6 +1081,75 @@ fn render_cluster_table( } let role_row = Row::new(role_cells).height(1); + // ── Active row (conductor) ───────────────────────────────────────────── + let mut active_cells = vec![ + Cell::from(" Active") + .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]; + for node in nodes { + let (label, style) = if paused_nodes.contains_key(&node.name) { + (" isolated", Style::default().fg(Color::Cyan)) + } else { + match (node.is_leader, node.conductor_active) { + (Some(true), Some(true)) => (" yes", Style::default().fg(Color::Green)), + (Some(true), Some(false)) => (" no", Style::default().fg(Color::Red)), + (Some(false), Some(false)) => (" no", Style::default().fg(Color::DarkGray)), + (Some(false), Some(true)) => (" yes", Style::default().fg(Color::Yellow)), + _ => (" ?", Style::default().fg(Color::DarkGray)), + } + }; + active_cells.push(Cell::from(label).style(style)); + } + let active_row = Row::new(active_cells).height(1); + + let paused_row = bool_row( + " Paused", + nodes, + |n| n.conductor_paused, + Color::Cyan, + Color::Green, + (" yes", " no"), + ); + + let stopped_row = bool_row( + " Stopped", + nodes, + |n| n.conductor_stopped, + Color::Red, + Color::Green, + (" yes", " no"), + ); + + let healthy_row = bool_row( + " Healthy", + nodes, + |n| n.sequencer_healthy, + Color::Green, + Color::Red, + (" yes", " no"), + ); + + // ── Seq active row (admin RPC) ───────────────────────────────────────── + let mut seq_active_cells = vec![ + Cell::from(" Seq active") + .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]; + for node in nodes { + let (label, style) = if paused_nodes.contains_key(&node.name) { + (" isolated", Style::default().fg(Color::Cyan)) + } else { + match (node.is_leader, node.sequencer_active) { + (Some(true), Some(true)) => (" yes", Style::default().fg(Color::Green)), + (Some(true), Some(false)) => (" no", Style::default().fg(Color::Red)), + (Some(false), Some(false)) => (" no", Style::default().fg(Color::DarkGray)), + (Some(false), Some(true)) => (" yes", Style::default().fg(Color::Yellow)), + _ => (" ?", Style::default().fg(Color::DarkGray)), + } + }; + seq_active_cells.push(Cell::from(label).style(style)); + } + let seq_active_row = Row::new(seq_active_cells).height(1); + // ── Unsafe L2 row ────────────────────────────────────────────────────── let mut l2_cells = vec![ Cell::from(" Unsafe L2") @@ -407,7 +1180,6 @@ fn render_cluster_table( } Some(h) => { let hex = format!("{h:x}"); - // Fork: same block number as leader but different hash. let is_fork = leader_unsafe .is_some_and(|(lnum, lhash)| node.unsafe_l2_block == Some(lnum) && h != lhash); if is_fork { @@ -483,33 +1255,6 @@ fn render_cluster_table( } let finalized_l2_row = Row::new(finalized_l2_cells).height(1); - // ── Active row (conductor) ───────────────────────────────────────────── - // `conductor_active` = "sequencer is currently sequencing". - // Followers stop their sequencer intentionally — active=false is expected. - // Only flag red when the *leader* reports active=false. - let mut active_cells = vec![ - Cell::from(" Active") - .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), - ]; - for node in nodes { - let (label, style) = if paused_nodes.contains_key(&node.name) { - (" paused", Style::default().fg(Color::Cyan)) - } else { - match (node.is_leader, node.conductor_active) { - (Some(true), Some(true)) => (" yes", Style::default().fg(Color::Green)), - (Some(true), Some(false)) => (" no", Style::default().fg(Color::Red)), - (Some(false), Some(false)) => (" stopped", Style::default().fg(Color::DarkGray)), - (Some(false), Some(true)) => (" active?", Style::default().fg(Color::Yellow)), - _ => (" ?", Style::default().fg(Color::DarkGray)), - } - }; - active_cells.push(Cell::from(label).style(style)); - } - let active_row = Row::new(active_cells).height(1); - - // ── CL section header ────────────────────────────────────────────────── - let cl_section = section_row("CL", node_count); - // ── L1 derivation row ────────────────────────────────────────────────── let mut l1_cells = vec![ Cell::from(" L1 Derived") @@ -543,9 +1288,6 @@ fn render_cluster_table( } let cl_peers_row = Row::new(cl_peers_cells).height(1); - // ── EL section header ────────────────────────────────────────────────── - let el_section = section_row("EL", node_count); - // ── EL block row ─────────────────────────────────────────────────────── let mut el_block_cells = vec![ Cell::from(" Block").style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), @@ -592,12 +1334,18 @@ fn render_cluster_table( } let el_peers_row = Row::new(el_peers_cells).height(1); + let cl_section = section_row("CL", node_count); + let el_section = section_row("EL", node_count); let spacer = Row::new(vec![Cell::from("")]).height(1); let rows = vec![ // ── Conductor ──────────────────────────────────────────────────── role_row, active_row, + paused_row, + stopped_row, + healthy_row, + seq_active_row, // ── CL ─────────────────────────────────────────────────────────── spacer.clone(), cl_section, @@ -620,6 +1368,92 @@ fn render_cluster_table( f.render_stateful_widget(table, inner, &mut TableState::default()); } +/// Renders the cluster-wide control-loop status and the always-visible +/// `[ P ] Pause all` / `[ R ] Resume all` button strip. +/// +/// The status segment summarises `conductor_paused` across every node so the +/// affordance for "pause all" is obvious without remembering a shortcut. +fn render_pause_all_banner( + f: &mut Frame<'_>, + area: Rect, + nodes: &[ConductorNodeStatus], + op_pending: bool, +) { + let total = nodes.len(); + let paused = nodes.iter().filter(|n| n.conductor_paused == Some(true)).count(); + let known = nodes.iter().filter(|n| n.conductor_paused.is_some()).count(); + + let active = known - paused; + let (status_label, status_color) = if known == 0 { + ("control loop: status unknown".to_string(), Color::DarkGray) + } else if known < total { + ( + format!( + "control loop: PARTIAL REPORT ({paused} paused, {active} active, {} unknown of {total})", + total - known + ), + Color::DarkGray, + ) + } else if paused == total { + (format!("control loop: ALL PAUSED ({paused}/{total})"), Color::Cyan) + } else if paused == 0 { + (format!("control loop: ALL ACTIVE ({total}/{total})"), Color::Green) + } else { + (format!("control loop: MIXED ({paused}/{total} paused)"), Color::Yellow) + }; + + let key_style = + Style::default().fg(Color::Black).bg(COLOR_BASE_BLUE).add_modifier(Modifier::BOLD); + let label_style = Style::default().fg(COLOR_BASE_BLUE).add_modifier(Modifier::BOLD); + let dim = Style::default().fg(Color::DarkGray); + let working = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + + let mut spans: Vec> = vec![ + Span::styled(status_label, Style::default().fg(status_color).add_modifier(Modifier::BOLD)), + Span::styled(" · ", dim), + ]; + + if op_pending { + spans.push(Span::styled("[ working… ]", working)); + } else { + spans.push(Span::styled(" P ", key_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled("Pause all", label_style)); + spans.push(Span::styled(" ", dim)); + spans.push(Span::styled(" R ", key_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled("Resume all", label_style)); + } + + f.render_widget(Paragraph::new(Line::from(spans)).alignment(Alignment::Center), area); +} + +/// Builds a row that renders a tri-state `Option` per node. +/// +/// `true_color` and `false_color` style the corresponding labels; +/// `None` always renders as a grey `?`. +fn bool_row<'a>( + label: &'static str, + nodes: &[ConductorNodeStatus], + extract: impl Fn(&ConductorNodeStatus) -> Option, + true_color: Color, + false_color: Color, + labels: (&'static str, &'static str), +) -> Row<'a> { + let mut cells = vec![ + Cell::from(label).style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]; + for node in nodes { + let (text, style) = match extract(node) { + Some(true) => (labels.0, Style::default().fg(true_color)), + Some(false) => (labels.1, Style::default().fg(false_color)), + None => (" ?", Style::default().fg(Color::DarkGray)), + }; + cells.push(Cell::from(text).style(style)); + } + Row::new(cells).height(1) +} + fn render_validator_table(f: &mut Frame<'_>, area: Rect, nodes: &[ValidatorNodeStatus]) { let block = Block::default() .title(" Validators ") @@ -629,9 +1463,10 @@ fn render_validator_table(f: &mut Frame<'_>, area: Rect, nodes: &[ValidatorNodeS let inner = block.inner(area); f.render_widget(block, area); + debug_assert!(!nodes.is_empty(), "render_validator_table requires at least one node"); let node_count = nodes.len(); let label_pct = 15u16; - let node_pct = (100u16 - label_pct) / node_count as u16; + let node_pct = (100u16 - label_pct) / node_count.max(1) as u16; let mut constraints = vec![Constraint::Percentage(label_pct)]; for _ in 0..node_count { @@ -648,6 +1483,20 @@ fn render_validator_table(f: &mut Frame<'_>, area: Rect, nodes: &[ValidatorNodeS .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)) .height(1); + // ── Binary row ──────────────────────────────────────────────────────── + let mut binary_cells = vec![ + Cell::from(" Binary") + .style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ]; + for node in nodes { + let (label, style) = node.binary.as_ref().map_or_else( + || (" ?".to_string(), Style::default().fg(Color::DarkGray)), + |binary| (format!(" {binary}"), Style::default().fg(Color::Cyan)), + ); + binary_cells.push(Cell::from(label).style(style)); + } + let binary_row = Row::new(binary_cells).height(1); + // ── CL section header ────────────────────────────────────────────────── let cl_section = section_row("CL", node_count); @@ -809,6 +1658,7 @@ fn render_validator_table(f: &mut Frame<'_>, area: Rect, nodes: &[ValidatorNodeS let spacer = Row::new(vec![Cell::from("")]).height(1); let rows = vec![ + binary_row, // ── CL ─────────────────────────────────────────────────────────── spacer.clone(), cl_section, @@ -844,3 +1694,35 @@ fn section_row(label: &str, node_count: usize) -> Row<'static> { } Row::new(cells).height(1) } + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn parses_bare_hex_hash() { + let parsed = parse_hex_hash(SAMPLE_HEX).expect("bare 64-char hex parses"); + assert_eq!(parsed, B256::from_str(SAMPLE_HEX).unwrap()); + } + + #[test] + fn parses_0x_prefixed_hex_hash() { + let prefixed = format!("0x{SAMPLE_HEX}"); + let parsed = parse_hex_hash(&prefixed).expect("0x-prefixed 64-char hex parses"); + assert_eq!(parsed, B256::from_str(SAMPLE_HEX).unwrap()); + } + + #[test] + fn rejects_wrong_length() { + assert!(parse_hex_hash("dead").is_none()); + assert!(parse_hex_hash(&format!("0x{SAMPLE_HEX}ff")).is_none()); + } + + #[test] + fn rejects_non_hex() { + let bad = "g".repeat(64); + assert!(parse_hex_hash(&bad).is_none()); + } +} diff --git a/crates/infra/basectl/src/app/views/home.rs b/crates/infra/basectl/src/app/views/home.rs index 84db362605..a34c2bd37f 100644 --- a/crates/infra/basectl/src/app/views/home.rs +++ b/crates/infra/basectl/src/app/views/home.rs @@ -64,7 +64,7 @@ const MENU_ITEMS: &[MenuItem] = &[ key: 'h', label: "HA Conductor", description: "Monitor HA conductor cluster", - badge: Some("devnet-only"), + badge: None, view_id: Some(ViewId::Conductor), }, MenuItem { @@ -308,13 +308,8 @@ const fn menu_height(columns: usize) -> u16 { (menu_row_count(columns) as u16).saturating_mul(MENU_ITEM_HEIGHT) } -fn badge_style(badge: &str) -> Style { - match badge { - "devnet-only" => { - Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD) - } - _ => Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD), - } +fn badge_style(_badge: &str) -> Style { + Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD) } fn truncate_description(description: &str, width: u16) -> String { diff --git a/crates/infra/basectl/src/app/views/mod.rs b/crates/infra/basectl/src/app/views/mod.rs index 118805b7f1..3294b8aedf 100644 --- a/crates/infra/basectl/src/app/views/mod.rs +++ b/crates/infra/basectl/src/app/views/mod.rs @@ -4,7 +4,7 @@ mod command_center; pub use command_center::CommandCenterView; mod conductor; -pub use conductor::ConductorView; +pub use conductor::{ActionMenuItem, ConductorView, ConfirmButton, Overlay, PendingAction}; mod config; pub use config::ConfigView; diff --git a/crates/infra/basectl/src/config.rs b/crates/infra/basectl/src/config.rs index b9da203323..16531229e7 100644 --- a/crates/infra/basectl/src/config.rs +++ b/crates/infra/basectl/src/config.rs @@ -3,9 +3,10 @@ use std::path::PathBuf; use alloy_primitives::Address; use alloy_provider::{Provider, ProviderBuilder}; use anyhow::{Context, Result}; -use base_common_chains::Registry; +use base_common_chains::{ChainConfig, rollup_config}; use base_common_genesis::RollupConfig; use serde::{Deserialize, Serialize}; +use tracing::warn; use url::Url; /// Configuration for proof system monitoring (proposer + dispute games). @@ -22,6 +23,9 @@ pub struct ProofsConfig { pub struct ValidatorNodeConfig { /// Human-readable name for this node (e.g. "base-client"). pub name: String, + /// Human-readable binary/process description shown in the TUI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary: Option, /// Consensus-layer JSON-RPC endpoint (serves `optimism_*` and `opp2p_*` methods). pub cl_rpc: Url, /// Execution-layer JSON-RPC endpoint for this node. @@ -78,6 +82,174 @@ pub struct ConductorNodeConfig { pub flashblocks_ws: Option, } +/// Conductor cluster discovery configuration. +/// +/// When set, basectl can bootstrap a conductor cluster view from a single +/// RPC endpoint by calling `conductor_clusterMembership` and synthesising +/// per-peer `ConductorNodeConfig` entries via [`DiscoveryPorts`] templates. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryConfig { + /// Bootstrap conductor RPC URL. + /// + /// basectl will hit this URL first to learn the live raft membership and + /// then poll all discovered peers. May be overridden by `--conductor-rpc`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bootstrap_rpc: Option, + /// Port templates used when rebuilding per-peer JSON-RPC URLs from the + /// raft binary protocol addresses returned by `conductor_clusterMembership`. + #[serde(default)] + pub ports: DiscoveryPorts, +} + +/// Port templates used to derive per-peer JSON-RPC URLs from raft addresses. +/// +/// `conductor_clusterMembership` returns each peer's *raft binary protocol* +/// address (e.g. `op-conductor-1:5051`), not its JSON-RPC URL. basectl extracts +/// the host and rebuilds JSON-RPC URLs for each service using these ports. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryPorts { + /// Conductor JSON-RPC port (default 5545). + #[serde(default = "default_conductor_rpc_port")] + pub conductor_rpc: u16, + /// Consensus-layer JSON-RPC port (default 7545). + #[serde(default = "default_cl_rpc_port")] + pub cl_rpc: u16, + /// Execution-layer JSON-RPC port (default 8545). When `None`, EL data is + /// not polled for discovered peers and shows as `—` in the UI. + #[serde(default = "default_el_rpc_port", skip_serializing_if = "Option::is_none")] + pub el_rpc: Option, +} + +impl Default for DiscoveryPorts { + fn default() -> Self { + Self { + conductor_rpc: default_conductor_rpc_port(), + cl_rpc: default_cl_rpc_port(), + el_rpc: default_el_rpc_port(), + } + } +} + +const fn default_conductor_rpc_port() -> u16 { + 5545 +} + +const fn default_cl_rpc_port() -> u16 { + 7545 +} + +const fn default_el_rpc_port() -> Option { + Some(8545) +} + +/// Origin of the conductor cluster node list used by the poller. +/// +/// `Static` is the original behaviour: the YAML/devnet config enumerates every +/// node up front. `Discover` bootstraps from a single conductor RPC URL and +/// rebuilds the peer list each tick from `conductor_clusterMembership`. +#[derive(Debug, Clone)] +pub enum ConductorSource { + /// Hand-configured node list (devnet, custom YAML). + Static(Vec), + /// Bootstrap from a single conductor RPC and derive peers via port templates. + Discover { + /// Bootstrap conductor RPC URL. + bootstrap: Url, + /// Port templates for rebuilding per-peer JSON-RPC URLs. + ports: DiscoveryPorts, + }, +} + +impl ConductorSource { + /// Returns `true` if this source bootstraps from a single RPC. + pub const fn is_discover(&self) -> bool { + matches!(self, Self::Discover { .. }) + } + + /// Returns an ephemeral single-node config for the bootstrap URL. + /// + /// Used on the very first poll cycle of a `Discover` source, before + /// `conductor_clusterMembership` has returned anything. Once membership + /// is known, [`ConductorSource::synthesize_nodes`] takes over. + pub fn bootstrap_node(&self) -> Option { + match self { + Self::Static(_) => None, + Self::Discover { bootstrap, ports } => { + let host = bootstrap.host_str().unwrap_or("localhost"); + let cl_rpc = peer_url(bootstrap, host, ports.cl_rpc); + let el_rpc = ports.el_rpc.map(|p| peer_url(bootstrap, host, p)); + Some(ConductorNodeConfig { + name: "local".to_string(), + conductor_rpc: bootstrap.clone(), + cl_rpc, + server_id: "local".to_string(), + raft_addr: String::new(), + el_rpc, + docker_conductor: None, + docker_el: None, + docker_cl: None, + flashblocks_ws: None, + }) + } + } + } + + /// Synthesises per-peer `ConductorNodeConfig` entries from raft membership. + /// + /// Returns `None` for [`ConductorSource::Static`] (those nodes are already + /// fully configured). For [`ConductorSource::Discover`], each `ServerInfo` + /// in `membership` has an `addr` field that is the raft binary protocol + /// address (e.g. `op-conductor-1:5051`); the host is extracted and the + /// JSON-RPC URLs are rebuilt from the supplied port templates. Docker + /// container names are left `None` because the local docker daemon can't + /// reach remote peers' containers; restart is also UI-disabled for + /// discovered peers. + pub fn synthesize_nodes( + &self, + membership: &base_consensus_rpc::ClusterMembership, + ) -> Option> { + let Self::Discover { bootstrap, ports } = self else { return None }; + let nodes = membership + .servers + .iter() + .map(|srv| { + let host = srv.addr.split(':').next().unwrap_or(srv.addr.as_str()); + ConductorNodeConfig { + name: srv.id.clone(), + conductor_rpc: peer_url(bootstrap, host, ports.conductor_rpc), + cl_rpc: peer_url(bootstrap, host, ports.cl_rpc), + server_id: srv.id.clone(), + raft_addr: srv.addr.clone(), + el_rpc: ports.el_rpc.map(|p| peer_url(bootstrap, host, p)), + docker_conductor: None, + docker_el: None, + docker_cl: None, + flashblocks_ws: None, + } + }) + .collect(); + Some(nodes) + } +} + +/// Builds a peer JSON-RPC URL by string interpolation against `bootstrap`'s scheme. +/// +/// Falls back to a clone of `bootstrap` and logs a warning if the resulting +/// URL fails to parse (e.g. an unexpected host shape coming back from raft). +/// Returning `bootstrap` is a safer default than panicking — the poll will +/// just hit the bootstrap node twice, which is visible to the operator. +fn peer_url(bootstrap: &Url, host: &str, port: u16) -> Url { + let scheme = bootstrap.scheme(); + let candidate = format!("{scheme}://{host}:{port}"); + match Url::parse(&candidate) { + Ok(url) => url, + Err(error) => { + warn!(host = %host, port = port, error = %error, "discovered peer host failed url parse; falling back to bootstrap"); + bootstrap.clone() + } + } +} + /// Monitoring configuration for a chain watched by basectl. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MonitoringConfig { @@ -107,6 +279,12 @@ pub struct MonitoringConfig { /// HA conductor cluster nodes, if this chain runs an op-conductor setup. #[serde(default, skip_serializing_if = "Option::is_none")] pub conductors: Option>, + /// Bootstrap configuration for runtime conductor cluster discovery. + /// + /// Used when `conductors` is `None` (or the operator passes + /// `--conductor-rpc`) to derive the peer list from a single bootstrap RPC. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub discovery: Option, /// Validator (non-sequencing) nodes to monitor alongside the conductor cluster. #[serde(default, skip_serializing_if = "Option::is_none")] pub validators: Option>, @@ -133,6 +311,46 @@ impl MonitoringConfig { _ => None, } } + + /// Returns the basectl display name for a known Base chain ID. + /// + /// Maps 8453/84532/763360 to `"mainnet"`/`"sepolia"`/`"zeronet"` so the + /// network badge agrees with what `-c` accepts on the CLI. + pub const fn name_for_chain_id(chain_id: u64) -> Option<&'static str> { + match chain_id { + 8453 => Some("mainnet"), + 84532 => Some("sepolia"), + 763360 => Some("zeronet"), + _ => None, + } + } + + /// Detects the live network name by calling `eth_chainId` on the L2 RPC. + /// + /// Returns the basectl-style name (e.g. `"mainnet"`) for known Base chain + /// IDs, or `None` when the RPC is unreachable or the chain ID is unknown. + pub async fn detect_name_from_rpc(rpc: &Url) -> Option { + let provider = ProviderBuilder::new().connect(rpc.as_str()).await.ok()?; + let chain_id = provider.get_chain_id().await.ok()?; + Self::name_for_chain_id(chain_id).map(str::to_owned) + } + + /// Returns the URL to use for `eth_chainId` network detection. + /// + /// When `conductor_rpc` is `Some`, derives the EL URL from the bootstrap + /// host and the discovery EL port template, so the badge reflects the + /// cluster basectl was pointed at instead of the preset's default RPC. + /// Falls back to `self.rpc` when URL construction fails or no bootstrap + /// is provided. + pub fn detect_rpc_for(&self, conductor_rpc: Option<&Url>) -> Url { + let Some(bootstrap) = conductor_rpc else { return self.rpc.clone() }; + let el_port = self.discovery.as_ref().and_then(|d| d.ports.el_rpc).unwrap_or(8545); + let mut candidate = bootstrap.clone(); + if candidate.set_port(Some(el_port)).is_err() { + return self.rpc.clone(); + } + candidate + } } const fn default_blob_target() -> u64 { @@ -152,6 +370,7 @@ struct MonitoringConfigOverride { batcher_address: Option
, l1_blob_target: Option, conductors: Option>, + discovery: Option, validators: Option>, proofs: Option, } @@ -183,8 +402,7 @@ impl MonitoringConfig { /// Returns the default Base mainnet configuration. pub fn mainnet() -> Self { - let rollup = - Registry::rollup_config(8453).expect("Base mainnet config missing from registry"); + let rollup = rollup_config!(ChainConfig::MAINNET); Self { name: "mainnet".to_string(), rpc: Url::parse("https://mainnet.base.org").unwrap(), @@ -195,6 +413,10 @@ impl MonitoringConfig { batcher_address: Some("0x5050F69a9786F081509234F1a7F4684b5E5b76C9".parse().unwrap()), l1_blob_target: 14, conductors: None, + discovery: Some(DiscoveryConfig { + bootstrap_rpc: None, + ports: DiscoveryPorts::default(), + }), validators: None, proofs: None, } @@ -202,8 +424,7 @@ impl MonitoringConfig { /// Returns the default Base Sepolia configuration. pub fn sepolia() -> Self { - let rollup = - Registry::rollup_config(84532).expect("Base Sepolia config missing from registry"); + let rollup = rollup_config!(ChainConfig::SEPOLIA); Self { name: "sepolia".to_string(), rpc: Url::parse("https://sepolia.base.org").unwrap(), @@ -214,6 +435,10 @@ impl MonitoringConfig { batcher_address: Some("0xfc56E7272EEBBBA5bC6c544e159483C4a38f8bA3".parse().unwrap()), l1_blob_target: 14, conductors: None, + discovery: Some(DiscoveryConfig { + bootstrap_rpc: None, + ports: DiscoveryPorts::default(), + }), validators: None, proofs: None, } @@ -276,13 +501,25 @@ impl MonitoringConfig { flashblocks_ws: Some(Url::parse("ws://localhost:11111").unwrap()), }, ]), - validators: Some(vec![ValidatorNodeConfig { - name: "base-client".to_string(), - cl_rpc: Url::parse("http://localhost:8549").unwrap(), - el_rpc: Some(Url::parse("http://localhost:8545").unwrap()), - docker_el: Some("base-client".to_string()), - docker_cl: Some("base-client-cl".to_string()), - }]), + validators: Some(vec![ + ValidatorNodeConfig { + name: "base-client".to_string(), + binary: Some("/app/base-client + /app/base-consensus".to_string()), + cl_rpc: Url::parse("http://localhost:8549").unwrap(), + el_rpc: Some(Url::parse("http://localhost:8545").unwrap()), + docker_el: Some("base-client".to_string()), + docker_cl: Some("base-client-cl".to_string()), + }, + ValidatorNodeConfig { + name: "base-rpc".to_string(), + binary: Some("/app/base".to_string()), + cl_rpc: Url::parse("http://localhost:8649").unwrap(), + el_rpc: Some(Url::parse("http://localhost:8645").unwrap()), + docker_el: Some("base-rpc".to_string()), + docker_cl: Some("base-rpc".to_string()), + }, + ]), + discovery: None, proofs: None, } } @@ -397,6 +634,7 @@ impl MonitoringConfig { batcher_address: overrides.batcher_address.or(base.batcher_address), l1_blob_target: overrides.l1_blob_target.unwrap_or(base.l1_blob_target), conductors: overrides.conductors.or(base.conductors), + discovery: overrides.discovery.or(base.discovery), validators: overrides.validators.or(base.validators), proofs: overrides.proofs.or(base.proofs), }) @@ -433,6 +671,20 @@ mod tests { assert_eq!(devnet.l1_rpc.as_str(), "http://localhost:4545/"); assert!(devnet.consensus_node_rpc.is_some()); assert_eq!(devnet.consensus_node_rpc.unwrap().as_str(), "http://localhost:7549/"); + let validators = devnet.validators.expect("devnet should include validator/RPC node"); + assert_eq!(validators.len(), 2); + assert_eq!(validators[0].name, "base-client"); + assert_eq!(validators[0].binary.as_deref(), Some("/app/base-client + /app/base-consensus")); + assert_eq!(validators[0].cl_rpc.as_str(), "http://localhost:8549/"); + assert_eq!(validators[0].el_rpc.as_ref().unwrap().as_str(), "http://localhost:8545/"); + assert_eq!(validators[0].docker_el.as_deref(), Some("base-client")); + assert_eq!(validators[0].docker_cl.as_deref(), Some("base-client-cl")); + assert_eq!(validators[1].name, "base-rpc"); + assert_eq!(validators[1].binary.as_deref(), Some("/app/base")); + assert_eq!(validators[1].cl_rpc.as_str(), "http://localhost:8649/"); + assert_eq!(validators[1].el_rpc.as_ref().unwrap().as_str(), "http://localhost:8645/"); + assert_eq!(validators[1].docker_el.as_deref(), Some("base-rpc")); + assert_eq!(validators[1].docker_cl.as_deref(), Some("base-rpc")); } #[tokio::test] diff --git a/crates/infra/basectl/src/lib.rs b/crates/infra/basectl/src/lib.rs index a295ad9a4d..d4126966cf 100644 --- a/crates/infra/basectl/src/lib.rs +++ b/crates/infra/basectl/src/lib.rs @@ -2,10 +2,11 @@ mod app; pub use app::{ - Action, App, CommandCenterView, ConductorState, ConductorView, ConfigView, DaMonitorView, - DaState, FlashState, FlashblocksView, HomeView, ProofsState, ProofsView, Resources, Router, - TransactionPane, UpgradesView, ValidatorState, View, ViewId, create_view, run_app, - run_flashblocks_json, start_background_services, + Action, ActionMenuItem, App, CommandCenterView, ConductorState, ConductorView, ConfigView, + ConfirmButton, DaMonitorView, DaState, FlashState, FlashblocksView, HomeView, Overlay, + PendingAction, ProofsState, ProofsView, Resources, Router, SourceLabel, TransactionPane, + UpgradesView, ValidatorState, View, ViewId, create_view, run_app, run_flashblocks_json, + start_background_services, }; mod commands; @@ -21,7 +22,10 @@ pub use commands::{ }; mod config; -pub use config::{ConductorNodeConfig, MonitoringConfig, ProofsConfig, ValidatorNodeConfig}; +pub use config::{ + ConductorNodeConfig, ConductorSource, DiscoveryConfig, DiscoveryPorts, MonitoringConfig, + ProofsConfig, ValidatorNodeConfig, +}; mod l1_client; pub use l1_client::fetch_full_system_config; @@ -29,12 +33,15 @@ pub use l1_client::fetch_full_system_config; mod rpc; pub use rpc::{ BacklogBlock, BacklogFetchResult, BacklogProgress, BlockDaInfo, ConductorNodeStatus, - InitialBacklog, L1BlockInfo, L1ConnectionMode, LatestProposal, PausedPeers, ProofsSnapshot, - TimestampedFlashblock, TxSummary, ValidatorNodeStatus, decode_flashblock_transactions, - fetch_block_transactions, fetch_initial_backlog_with_progress, fetch_safe_and_latest, - pause_sequencer_node, restart_conductor_node, run_block_fetcher, run_conductor_poller, - run_flashblock_ws, run_flashblock_ws_timestamped, run_l1_blob_watcher, run_proofs_poller, - run_safe_head_poller, run_validator_poller, transfer_conductor_leader, unpause_sequencer_node, + ConductorPollUpdate, InitialBacklog, L1BlockInfo, L1ConnectionMode, LatestProposal, + PausedPeers, ProofsSnapshot, TimestampedFlashblock, TxSummary, ValidatorNodeStatus, + conductor_pause_all_nodes, conductor_pause_node, conductor_resume_all_nodes, + conductor_resume_node, decode_flashblock_transactions, fetch_block_transactions, + fetch_initial_backlog_with_progress, fetch_safe_and_latest, pause_sequencer_node, + restart_conductor_node, run_block_fetcher, run_conductor_poller, run_flashblock_ws, + run_flashblock_ws_timestamped, run_l1_blob_watcher, run_proofs_poller, run_safe_head_poller, + run_validator_poller, start_sequencer_node, stop_sequencer_node, transfer_conductor_leader, + unpause_sequencer_node, }; mod tui; diff --git a/crates/infra/basectl/src/rpc.rs b/crates/infra/basectl/src/rpc.rs index 694f58ee73..53cb2721e5 100644 --- a/crates/infra/basectl/src/rpc.rs +++ b/crates/infra/basectl/src/rpc.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::BTreeSet, sync::Arc, time::Duration}; use alloy_consensus::{Transaction, transaction::SignerRecoverable}; use alloy_eips::eip2718::{Decodable2718, Encodable2718}; @@ -10,7 +10,10 @@ use anyhow::Result; use base_common_consensus::BaseTxEnvelope; use base_common_flashblocks::Flashblock; use base_common_network::Base; -use base_consensus_rpc::{BaseP2PApiClient, ConductorApiClient, RollupNodeApiClient}; +use base_consensus_rpc::{ + AdminApiClient, BaseP2PApiClient, ClusterMembership, ConductorApiClient, RollupNodeApiClient, + ServerSuffrage, +}; use futures::{StreamExt, stream}; use jsonrpsee::{core::client::ClientT, http_client::HttpClientBuilder, rpc_params}; use tokio::sync::{mpsc, watch}; @@ -19,7 +22,7 @@ use tracing::warn; use url::Url; use crate::{ - config::{ConductorNodeConfig, ProofsConfig, ValidatorNodeConfig}, + config::{ConductorNodeConfig, ConductorSource, ProofsConfig, ValidatorNodeConfig}, tui::Toast, }; @@ -688,6 +691,20 @@ pub struct ConductorNodeStatus { /// Whether the conductor's sequencer is actively sequencing (`conductor_active`). /// Expected to be `false` for followers. `None` means unreachable. pub conductor_active: Option, + /// Whether op-conductor's control loop is paused (`conductor_paused`). When paused, + /// the conductor stops driving leader election and health checks. `None` means + /// unreachable. + pub conductor_paused: Option, + /// Whether op-conductor has been fully stopped (`conductor_stopped`). `None` means + /// unreachable. + pub conductor_stopped: Option, + /// Whether the sequencer is reporting healthy via `conductor_sequencerHealthy`. + /// `None` means unreachable. + pub sequencer_healthy: Option, + /// Whether the sequencer is currently producing blocks (`admin_sequencerActive`). + /// Sourced from the consensus node's admin namespace on `cl_rpc`. `None` means + /// unreachable. + pub sequencer_active: Option, // ── CL (consensus layer) ───────────────────────────────────────────── /// Unsafe L2 block number from `optimism_syncStatus`. @@ -715,6 +732,17 @@ pub struct ConductorNodeStatus { pub el_syncing: Option, /// Number of connected EL devp2p peers from `net_peerCount`. `None` if not configured. pub el_peer_count: Option, + + // ── Cluster membership ─────────────────────────────────────────────── + /// Raft suffrage (Voter/Nonvoter) reported for this node by the most recent + /// `conductor_clusterMembership` snapshot, looked up by `server_id`. `None` + /// when membership has not yet been observed or this node is not present. + pub suffrage: Option, + /// Whether this node was synthesised from `conductor_clusterMembership` + /// (i.e. the active source is `Discover`). Used by the UI to gate actions + /// like "Restart containers" that only make sense when basectl runs on the + /// same host as the docker daemon. + pub discovered: bool, } /// Finds the current Raft leader and transfers leadership. @@ -777,6 +805,194 @@ pub async fn transfer_conductor_leader( let _ = result_tx.send(outcome.map_err(|e| e.to_string())).await; } +/// Pauses op-conductor's control loop on a single node via `conductor_pause`. +/// +/// While paused, the conductor stops driving leader election and sequencer +/// health checks, but the underlying Raft membership is preserved. Paired with +/// [`conductor_resume_node`]. +pub async fn conductor_pause_node( + node: ConductorNodeConfig, + result_tx: mpsc::Sender>, +) { + const TIMEOUT: Duration = Duration::from_secs(5); + + let outcome: anyhow::Result = async { + let client = HttpClientBuilder::default() + .request_timeout(TIMEOUT) + .build(node.conductor_rpc.as_str()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + ConductorApiClient::conductor_pause(&client).await.map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(format!("conductor paused on {}", node.name)) + } + .await; + + let _ = result_tx.send(outcome.map_err(|e| e.to_string())).await; +} + +/// Resumes op-conductor's control loop on a single node via `conductor_resume`. +pub async fn conductor_resume_node( + node: ConductorNodeConfig, + result_tx: mpsc::Sender>, +) { + const TIMEOUT: Duration = Duration::from_secs(5); + + let outcome: anyhow::Result = async { + let client = HttpClientBuilder::default() + .request_timeout(TIMEOUT) + .build(node.conductor_rpc.as_str()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + ConductorApiClient::conductor_resume(&client).await.map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(format!("conductor resumed on {}", node.name)) + } + .await; + + let _ = result_tx.send(outcome.map_err(|e| e.to_string())).await; +} + +/// Pauses op-conductor's control loop on every node in `nodes` in parallel. +/// +/// Returns a single summary string suitable for a toast. Per-node errors are +/// collated into the summary so an operator sees both the success count and +/// the names of any nodes that failed. Returns `Ok` only when every node +/// succeeded; otherwise returns `Err` with the same summary text so the TUI +/// surfaces it as a warning. +pub async fn conductor_pause_all_nodes( + nodes: Vec, + result_tx: mpsc::Sender>, +) { + let summary = fan_out_conductor_control(nodes, "paused", |client| async move { + ConductorApiClient::conductor_pause(&client).await.map_err(|e| anyhow::anyhow!("{e}")) + }) + .await; + let _ = result_tx.send(summary).await; +} + +/// Resumes op-conductor's control loop on every node in `nodes` in parallel. +/// +/// Mirrors [`conductor_pause_all_nodes`] in error handling and summary format. +pub async fn conductor_resume_all_nodes( + nodes: Vec, + result_tx: mpsc::Sender>, +) { + let summary = fan_out_conductor_control(nodes, "resumed", |client| async move { + ConductorApiClient::conductor_resume(&client).await.map_err(|e| anyhow::anyhow!("{e}")) + }) + .await; + let _ = result_tx.send(summary).await; +} + +/// Runs a per-node conductor control RPC against every node concurrently and +/// builds a single summary toast string. +/// +/// `verb` is the past-tense action ("paused" / "resumed") used in the message. +async fn fan_out_conductor_control( + nodes: Vec, + verb: &'static str, + call: F, +) -> Result +where + F: Fn(jsonrpsee::http_client::HttpClient) -> Fut + Send + Sync + Clone + 'static, + Fut: std::future::Future> + Send, +{ + const TIMEOUT: Duration = Duration::from_secs(5); + + if nodes.is_empty() { + return Err(format!("no conductor nodes to {verb}")); + } + let total = nodes.len(); + + let results: Vec<(String, anyhow::Result<()>)> = stream::iter(nodes) + .map(|node| { + let call = call.clone(); + async move { + let outcome: anyhow::Result<()> = async { + let client = HttpClientBuilder::default() + .request_timeout(TIMEOUT) + .build(node.conductor_rpc.as_str()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + call(client).await + } + .await; + (node.name, outcome) + } + }) + .buffer_unordered(total.max(1)) + .collect() + .await; + + let (ok, failures): (Vec<_>, Vec<_>) = results.into_iter().partition(|(_, r)| r.is_ok()); + let ok_count = ok.len(); + + if failures.is_empty() { + Ok(format!("conductor {verb} on {ok_count}/{total} nodes")) + } else { + let detail = failures + .iter() + .map(|(name, r)| { + let err = r.as_ref().err().map_or_else(String::new, ToString::to_string); + format!("{name}: {err}") + }) + .collect::>() + .join("; "); + Err(format!("conductor {verb} on {ok_count}/{total} nodes; failures: {detail}")) + } +} + +/// Starts the sequencer on a single node via `admin_startSequencer`. +/// +/// The `unsafe_head` hash must match the node's current engine unsafe head; the +/// server rejects mismatches and `B256::ZERO`. When op-conductor is enabled, +/// this only succeeds if the target node is the Raft leader. +pub async fn start_sequencer_node( + node: ConductorNodeConfig, + unsafe_head: B256, + result_tx: mpsc::Sender>, +) { + const TIMEOUT: Duration = Duration::from_secs(5); + + let outcome: anyhow::Result = async { + if unsafe_head == B256::ZERO { + return Err(anyhow::anyhow!("unsafe_head must not be zero")); + } + let client = HttpClientBuilder::default() + .request_timeout(TIMEOUT) + .build(node.cl_rpc.as_str()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + AdminApiClient::admin_start_sequencer(&client, unsafe_head) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(format!("sequencer started on {} at {unsafe_head}", node.name)) + } + .await; + + let _ = result_tx.send(outcome.map_err(|e| e.to_string())).await; +} + +/// Stops the sequencer on a single node via `admin_stopSequencer`. +/// +/// Returns the unsafe head hash captured at the moment the sequencer was +/// stopped, suitable for passing back into [`start_sequencer_node`] later. +pub async fn stop_sequencer_node( + node: ConductorNodeConfig, + result_tx: mpsc::Sender>, +) { + const TIMEOUT: Duration = Duration::from_secs(5); + + let outcome: anyhow::Result = async { + let client = HttpClientBuilder::default() + .request_timeout(TIMEOUT) + .build(node.cl_rpc.as_str()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let head = AdminApiClient::admin_stop_sequencer(&client) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(format!("sequencer stopped on {} at {head}", node.name)) + } + .await; + + let _ = result_tx.send(outcome.map_err(|e| e.to_string())).await; +} + /// Restarts the docker containers for a single conductor cluster node. /// /// Containers are restarted in dependency order — EL → CL → conductor — @@ -954,12 +1170,21 @@ pub async fn unpause_sequencer_node( for enode in &peers.el_enodes { let r: Result = ClientT::request(&el_client, "admin_addPeer", rpc_params![enode]).await; - if r.is_ok() { + if matches!(r, Ok(true)) { el_ok += 1; } } } + if cl_ok != peers.cl_addrs.len() || el_ok != peers.el_enodes.len() { + anyhow::bail!( + "unpaused {} — reconnected {cl_ok}/{} CL peer(s), {el_ok}/{} EL peer(s); saved peers kept for retry", + node.name, + peers.cl_addrs.len(), + peers.el_enodes.len() + ); + } + Ok(format!( "unpaused {} — reconnected {cl_ok}/{} CL peer(s), {el_ok}/{} EL peer(s)", node.name, @@ -972,32 +1197,46 @@ pub async fn unpause_sequencer_node( let _ = result_tx.send(outcome.map_err(|e| e.to_string())).await; } -/// Polls all conductor nodes every 200 ms and forwards status snapshots. -/// -/// Builds one pair of HTTP clients per node (conductor RPC + CL RPC) before -/// entering the loop so connection setup cost is paid only once. Each poll -/// fires all per-node requests concurrently via [`futures::future::join_all`]. -/// Any individual RPC that times out or errors yields `None` for that field — -/// the node is shown as offline when `is_leader` is `None`. -pub async fn run_conductor_poller( - nodes: Vec, - tx: mpsc::Sender>, -) { - const POLL_INTERVAL: Duration = Duration::from_millis(200); - const RPC_TIMEOUT: Duration = Duration::from_millis(500); +/// Updates emitted by [`run_conductor_poller`] on every poll cycle. +#[derive(Debug, Clone)] +pub enum ConductorPollUpdate { + /// Latest per-node status snapshot. + Status(Vec), + /// Raft cluster membership reported by one of the polled nodes. Emitted + /// only when the membership `version` advances. Wrapped in `Arc` so the + /// poller and the UI state share the snapshot without deep-copying the + /// server list on every change. + Membership(Arc), + /// New peer list synthesised from a `Discover` source after a membership + /// change. Subscribers may use this to update displayed config (e.g. + /// flashblocks URL routing) without restarting the poller. + NodeListRefreshed(Vec), +} - let clients: Vec<(String, _, _, _)> = nodes - .into_iter() +type ConductorClientTuple = ( + String, + String, + jsonrpsee::http_client::HttpClient, + jsonrpsee::http_client::HttpClient, + Option, +); + +fn build_conductor_clients( + nodes: &[ConductorNodeConfig], + timeout: Duration, +) -> Vec { + nodes + .iter() .filter_map(|node| { let conductor_client = HttpClientBuilder::default() - .request_timeout(RPC_TIMEOUT) + .request_timeout(timeout) .build(node.conductor_rpc.as_str()) .inspect_err(|e| { warn!(error = %e, node = %node.name, "failed to build conductor HTTP client"); }) .ok()?; let cl_client = HttpClientBuilder::default() - .request_timeout(RPC_TIMEOUT) + .request_timeout(timeout) .build(node.cl_rpc.as_str()) .inspect_err(|e| { warn!(error = %e, node = %node.name, "failed to build CL HTTP client"); @@ -1005,16 +1244,49 @@ pub async fn run_conductor_poller( .ok()?; let el_client = node.el_rpc.as_ref().and_then(|url| { HttpClientBuilder::default() - .request_timeout(RPC_TIMEOUT) + .request_timeout(timeout) .build(url.as_str()) .inspect_err(|e| { warn!(error = %e, node = %node.name, "failed to build EL HTTP client"); }) .ok() }); - Some((node.name, conductor_client, cl_client, el_client)) + Some(( + node.name.clone(), + node.server_id.clone(), + conductor_client, + cl_client, + el_client, + )) }) - .collect(); + .collect() +} + +/// Polls every conductor in the active source every 200 ms and forwards updates. +/// +/// Builds one pair of HTTP clients per node (conductor RPC + CL RPC) so connection +/// setup cost is paid only once per node lifetime. Each poll fires all per-node +/// requests concurrently via [`futures::future::join_all`]; any individual RPC that +/// times out or errors yields `None` for that field — the node is shown as offline +/// when `is_leader` is `None`. +/// +/// On every tick the poller also calls `conductor_clusterMembership` on one node +/// (round-robin) and, when the membership version advances, emits a `Membership` +/// update. For `Discover` sources, the synthesised peer list is rebuilt from the +/// new membership and the per-node clients are recreated in place. +pub async fn run_conductor_poller(source: ConductorSource, tx: mpsc::Sender) { + const POLL_INTERVAL: Duration = Duration::from_millis(200); + const RPC_TIMEOUT: Duration = Duration::from_millis(500); + + let discovered = source.is_discover(); + + let mut current_nodes: Vec = match &source { + ConductorSource::Static(nodes) => nodes.clone(), + ConductorSource::Discover { .. } => source.bootstrap_node().into_iter().collect(), + }; + let mut clients = build_conductor_clients(¤t_nodes, RPC_TIMEOUT); + let mut last_membership: Option> = None; + let mut membership_round_robin: usize = 0; let mut interval = tokio::time::interval(POLL_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -1022,13 +1294,32 @@ pub async fn run_conductor_poller( loop { interval.tick().await; - let statuses = futures::future::join_all(clients.iter().map( - |(name, conductor_client, cl_client, el_client)| async move { + let membership_target = if clients.is_empty() { + None + } else { + let idx = membership_round_robin % clients.len(); + membership_round_robin = membership_round_robin.wrapping_add(1); + Some(idx) + }; + + let membership_fut = async { + let idx = membership_target?; + let (_, _, conductor_client, _, _) = &clients[idx]; + ConductorApiClient::conductor_cluster_membership(conductor_client).await.ok() + }; + + let membership_for_lookup = last_membership.as_ref(); + let statuses_fut = futures::future::join_all(clients.iter().map( + |(name, server_id, conductor_client, cl_client, el_client)| async move { // Fire all RPCs concurrently so a single timed-out node does not - // stall the poll for the full sum of all call timeouts (7 × 500 ms). + // stall the poll for the full sum of all call timeouts (11 × 500 ms). let ( is_leader, conductor_active, + conductor_paused, + conductor_stopped, + sequencer_healthy, + sequencer_active, sync, cl_peer_stats, el_block_r, @@ -1037,6 +1328,10 @@ pub async fn run_conductor_poller( ) = tokio::join!( ConductorApiClient::conductor_leader(conductor_client), ConductorApiClient::conductor_active(conductor_client), + ConductorApiClient::conductor_paused(conductor_client), + ConductorApiClient::conductor_stopped(conductor_client), + ConductorApiClient::conductor_sequencer_healthy(conductor_client), + AdminApiClient::admin_sequencer_active(cl_client), RollupNodeApiClient::sync_status(cl_client), BaseP2PApiClient::opp2p_peer_stats(cl_client), async { @@ -1069,10 +1364,17 @@ pub async fn run_conductor_poller( ); let sync = sync.ok(); + let suffrage = membership_for_lookup + .and_then(|m| m.servers.iter().find(|s| s.id == *server_id)) + .map(|s| s.suffrage); ConductorNodeStatus { name: name.clone(), is_leader: is_leader.ok(), conductor_active: conductor_active.ok(), + conductor_paused: conductor_paused.ok(), + conductor_stopped: conductor_stopped.ok(), + sequencer_healthy: sequencer_healthy.ok(), + sequencer_active: sequencer_active.ok(), unsafe_l2_block: sync.as_ref().map(|s| s.unsafe_l2.block_info.number), unsafe_l2_hash: sync.as_ref().map(|s| s.unsafe_l2.block_info.hash), safe_l2_block: sync.as_ref().map(|s| s.safe_l2.block_info.number), @@ -1084,14 +1386,47 @@ pub async fn run_conductor_poller( el_block: el_block_r, el_syncing: el_syncing_r, el_peer_count: el_peers_r, + suffrage, + discovered, } }, - )) - .await; + )); - if tx.send(statuses).await.is_err() { + let (statuses, new_membership) = tokio::join!(statuses_fut, membership_fut); + + // Send Status first so the UI flushes the statuses keyed to the + // current node set before we potentially swap that set out below. + if tx.send(ConductorPollUpdate::Status(statuses)).await.is_err() { break; } + + if let Some(membership) = new_membership { + let changed = + last_membership.as_ref().is_none_or(|prev| prev.version != membership.version); + if changed { + let membership = Arc::new(membership); + if tx.send(ConductorPollUpdate::Membership(Arc::clone(&membership))).await.is_err() + { + break; + } + if let Some(synthesized) = source.synthesize_nodes(&membership) { + let old_ids: BTreeSet<_> = current_nodes.iter().map(|n| &n.server_id).collect(); + let new_ids: BTreeSet<_> = synthesized.iter().map(|n| &n.server_id).collect(); + if old_ids != new_ids && !synthesized.is_empty() { + current_nodes = synthesized.clone(); + clients = build_conductor_clients(¤t_nodes, RPC_TIMEOUT); + if tx + .send(ConductorPollUpdate::NodeListRefreshed(synthesized)) + .await + .is_err() + { + break; + } + } + } + last_membership = Some(membership); + } + } } } @@ -1100,6 +1435,8 @@ pub async fn run_conductor_poller( pub struct ValidatorNodeStatus { /// Human-readable name for this node. pub name: String, + /// Human-readable binary/process description shown in the TUI. + pub binary: Option, // ── CL (consensus layer) ───────────────────────────────────────────── /// Unsafe L2 block number from `optimism_syncStatus`. @@ -1136,7 +1473,7 @@ pub async fn run_validator_poller( const POLL_INTERVAL: Duration = Duration::from_millis(200); const RPC_TIMEOUT: Duration = Duration::from_millis(500); - let clients: Vec<(String, _, _)> = nodes + let clients: Vec<(String, Option, _, _)> = nodes .into_iter() .filter_map(|node| { let cl_client = HttpClientBuilder::default() @@ -1155,7 +1492,7 @@ pub async fn run_validator_poller( }) .ok() }); - Some((node.name, cl_client, el_client)) + Some((node.name, node.binary, cl_client, el_client)) }) .collect(); @@ -1166,7 +1503,7 @@ pub async fn run_validator_poller( interval.tick().await; let statuses = futures::future::join_all(clients.iter().map( - |(name, cl_client, el_client)| async move { + |(name, binary, cl_client, el_client)| async move { let (sync, cl_peer_stats, el_block_r, el_syncing_r, el_peers_r) = tokio::join!( RollupNodeApiClient::sync_status(cl_client), BaseP2PApiClient::opp2p_peer_stats(cl_client), @@ -1202,6 +1539,7 @@ pub async fn run_validator_poller( let sync = sync.ok(); ValidatorNodeStatus { name: name.clone(), + binary: binary.clone(), unsafe_l2_block: sync.as_ref().map(|s| s.unsafe_l2.block_info.number), unsafe_l2_hash: sync.as_ref().map(|s| s.unsafe_l2.block_info.hash), safe_l2_block: sync.as_ref().map(|s| s.safe_l2.block_info.number), diff --git a/crates/infra/ingress-rpc/Cargo.toml b/crates/infra/ingress-rpc/Cargo.toml index b4888baf1f..c0108a5ff5 100644 --- a/crates/infra/ingress-rpc/Cargo.toml +++ b/crates/infra/ingress-rpc/Cargo.toml @@ -15,11 +15,13 @@ url.workspace = true base-common-evm.workspace = true async-trait.workspace = true base-bundles.workspace = true +alloy-rpc-types-eth.workspace = true alloy-signer-local.workspace = true base-execution-evm.workspace = true base-common-network.workspace = true audit-archiver-lib.workspace = true reth-rpc-eth-types.workspace = true +reth-rpc-server-types.workspace = true uuid = { workspace = true, features = ["v5"] } metrics = { workspace = true, optional = true } anyhow = { workspace = true, features = ["std"] } @@ -31,13 +33,11 @@ serde = { workspace = true, features = ["derive", "std"] } alloy-consensus = { workspace = true, features = ["std"] } base-metrics = { workspace = true, features = ["metrics"] } alloy-provider = { workspace = true, features = ["reqwest"] } -backon = { workspace = true, features = ["std", "tokio-sleep"] } clap = { workspace = true, features = ["std", "derive", "env"] } jsonrpsee = { workspace = true, features = ["server", "macros"] } axum = { workspace = true, features = ["tokio", "http1", "json"] } alloy-primitives = { workspace = true, features = ["map-foldhash", "serde"] } base-common-consensus = { workspace = true, features = ["std", "k256", "serde"] } -rdkafka = { workspace = true, features = ["tokio", "libz", "zstd", "ssl-vendored"] } [dev-dependencies] wiremock.workspace = true diff --git a/crates/infra/ingress-rpc/README.md b/crates/infra/ingress-rpc/README.md index 207966b6c8..f7790c055f 100644 --- a/crates/infra/ingress-rpc/README.md +++ b/crates/infra/ingress-rpc/README.md @@ -6,9 +6,8 @@ Ingress RPC library. Handles incoming transaction and bundle submission for the Base block builder pipeline. `IngressService` exposes a JSON-RPC endpoint that validates bundles (`validate_bundle`), -meters them via `BuilderConnector`, and routes accepted transactions to Kafka -(`KafkaMessageQueue`) or the mempool. Also provides `HealthServer` for liveness checks and -`Metrics` for request tracking. +meters them via `BuilderConnector`, and routes accepted transactions to the mempool. Also +provides `HealthServer` for liveness checks and `Metrics` for request tracking. ## Usage diff --git a/crates/infra/ingress-rpc/src/lib.rs b/crates/infra/ingress-rpc/src/lib.rs index 29bb332726..baf0d960e9 100644 --- a/crates/infra/ingress-rpc/src/lib.rs +++ b/crates/infra/ingress-rpc/src/lib.rs @@ -8,10 +8,6 @@ pub use health::HealthServer; mod metrics; pub use metrics::Metrics; -/// Kafka message queue publishing. -mod queue; -pub use queue::{BundleQueuePublisher, KafkaMessageQueue, MessageQueue}; - /// Core RPC service implementation. mod service; pub use service::{IngressApiServer, IngressService, Providers}; @@ -20,12 +16,11 @@ pub use service::{IngressApiServer, IngressService, Providers}; mod validation; use std::{ net::{IpAddr, SocketAddr}, - str::FromStr, sync::Arc, }; use alloy_primitives::TxHash; -use alloy_provider::{Provider, ProviderBuilder, RootProvider}; +use alloy_provider::{Provider, RootProvider}; use base_bundles::MeterBundleResponse; use base_common_network::Base; use clap::Args; @@ -37,35 +32,6 @@ use tracing::{debug, error, info, warn}; use url::Url; pub use validation::{AccountInfo, AccountInfoLookup, L1BlockInfoLookup, validate_bundle}; -/// Method used to submit transactions to the mempool and/or Kafka. -#[derive(Debug, Clone, Copy)] -pub enum TxSubmissionMethod { - /// Submit via the mempool RPC only. - Mempool, - /// Submit via Kafka only. - Kafka, - /// Submit via both mempool RPC and Kafka. - MempoolAndKafka, - /// Do not submit transactions. - None, -} - -impl FromStr for TxSubmissionMethod { - type Err = String; - - fn from_str(s: &str) -> Result { - match s { - "mempool" => Ok(Self::Mempool), - "kafka" => Ok(Self::Kafka), - "mempool,kafka" | "kafka,mempool" => Ok(Self::MempoolAndKafka), - "none" => Ok(Self::None), - _ => Err(format!( - "Invalid submission method: '{s}'. Valid options: mempool, kafka, mempool,kafka, kafka,mempool, none" - )), - } - } -} - /// Configuration for the tips ingress RPC service. #[derive(Args, Debug, Clone)] pub struct Config { @@ -81,30 +47,6 @@ pub struct Config { #[arg(long, env = "TIPS_INGRESS_RPC_MEMPOOL")] pub mempool_url: Url, - /// Method to submit transactions to the mempool - #[arg(long, env = "TIPS_INGRESS_TX_SUBMISSION_METHOD", default_value = "mempool")] - pub tx_submission_method: TxSubmissionMethod, - - /// Kafka brokers for publishing mempool events - #[arg(long, env = "TIPS_INGRESS_KAFKA_INGRESS_PROPERTIES_FILE")] - pub ingress_kafka_properties: String, - - /// Kafka topic for queuing transactions before the DB Writer - #[arg(long, env = "TIPS_INGRESS_KAFKA_INGRESS_TOPIC", default_value = "tips-ingress")] - pub ingress_topic: String, - - /// Deprecated: audit events are now published over RPC. Accepted for - /// backward compatibility with existing deploy configs and ignored at - /// runtime (a deprecation warning is logged when set). - #[arg(long, env = "TIPS_INGRESS_KAFKA_AUDIT_PROPERTIES_FILE")] - pub audit_kafka_properties: Option, - - /// Deprecated: audit events are now published over RPC. Accepted for - /// backward compatibility with existing deploy configs and ignored at - /// runtime (a deprecation warning is logged when set). - #[arg(long, env = "TIPS_INGRESS_KAFKA_AUDIT_TOPIC")] - pub audit_topic: Option, - /// URL of the audit-archiver RPC endpoint that receives bundle events via /// `base_persistBatchedBundleEvent`. #[arg(long, env = "TIPS_INGRESS_AUDIT_RPC_URL")] @@ -169,7 +111,7 @@ pub struct Config { /// Capacity of the bounded audit event channel. /// /// When the channel is full, new audit events are dropped to avoid blocking - /// the RPC handler. Size this to handle peak tx throughput × Kafka stall time. + /// the RPC handler. #[arg(long, env = "TIPS_INGRESS_AUDIT_CHANNEL_CAPACITY", default_value = "512")] pub audit_channel_capacity: usize, @@ -192,11 +134,8 @@ impl BuilderConnector { /// that slow responses don't block the recv loop and risk broadcast channel /// lag. pub fn connect(metering_rx: broadcast::Receiver, builder_rpc: Url) { - let rpc_url: Arc = Arc::from(builder_rpc.as_str()); - let builder: RootProvider = ProviderBuilder::new() - .disable_recommended_fillers() - .network::() - .connect_http(builder_rpc); + let rpc_url = builder_rpc.clone(); + let builder: RootProvider = RootProvider::new_http(builder_rpc); tokio::spawn(async move { let mut event_rx = metering_rx; @@ -227,7 +166,7 @@ impl BuilderConnector { break; }; let builder = builder.clone(); - let url = Arc::clone(&rpc_url); + let url = rpc_url.clone(); join_set.spawn(async move { match builder .client() diff --git a/crates/infra/ingress-rpc/src/metrics.rs b/crates/infra/ingress-rpc/src/metrics.rs index 2e8d8e2ccd..bde840f74e 100644 --- a/crates/infra/ingress-rpc/src/metrics.rs +++ b/crates/infra/ingress-rpc/src/metrics.rs @@ -10,8 +10,6 @@ base_metrics::define_metrics! { successful_simulations: counter, #[describe("Number of bundles that failed simulation")] failed_simulations: counter, - #[describe("Number of bundles sent to kafka")] - sent_to_kafka: counter, #[describe("Number of transactions sent to mempool")] sent_to_mempool: counter, #[describe("Duration of validate_tx")] diff --git a/crates/infra/ingress-rpc/src/queue.rs b/crates/infra/ingress-rpc/src/queue.rs deleted file mode 100644 index f0eba36209..0000000000 --- a/crates/infra/ingress-rpc/src/queue.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::sync::Arc; - -use alloy_primitives::B256; -use anyhow::Result; -use async_trait::async_trait; -use backon::{ExponentialBuilder, Retryable}; -use base_bundles::AcceptedBundle; -use rdkafka::producer::{FutureProducer, FutureRecord}; -use tokio::time::Duration; -use tracing::{error, info}; - -/// Trait for publishing messages to a queue backend. -#[async_trait] -pub trait MessageQueue: Send + Sync { - /// Publishes a message with the given key and payload to the specified topic. - async fn publish(&self, topic: &str, key: &str, payload: &[u8]) -> Result<()>; -} - -/// Kafka-backed message queue implementation. -pub struct KafkaMessageQueue { - producer: FutureProducer, -} - -impl std::fmt::Debug for KafkaMessageQueue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("KafkaMessageQueue").finish_non_exhaustive() - } -} - -impl KafkaMessageQueue { - /// Creates a new Kafka message queue with the given producer. - pub const fn new(producer: FutureProducer) -> Self { - Self { producer } - } -} - -#[async_trait] -impl MessageQueue for KafkaMessageQueue { - async fn publish(&self, topic: &str, key: &str, payload: &[u8]) -> Result<()> { - let enqueue = || async { - let record = FutureRecord::to(topic).key(key).payload(payload); - - match self.producer.send(record, Duration::from_secs(5)).await { - Ok(delivery) => { - info!( - key = %key, - partition = delivery.partition, - offset = delivery.offset, - topic = %topic, - "Successfully enqueued message" - ); - Ok(()) - } - Err((err, _)) => { - error!( - key = key, - error = %err, - topic = topic, - "Failed to enqueue message" - ); - Err(anyhow::anyhow!("Failed to enqueue bundle: {err}")) - } - } - }; - - enqueue - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_millis(100)) - .with_max_delay(Duration::from_secs(5)) - .with_max_times(3), - ) - .notify(|err: &anyhow::Error, dur: Duration| { - info!(error = ?err, delay = ?dur, "retrying to enqueue message"); - }) - .await - } -} - -/// Publishes accepted bundles to a message queue topic. -#[derive(Debug)] -pub struct BundleQueuePublisher { - queue: Arc, - topic: String, -} - -impl BundleQueuePublisher { - /// Creates a new publisher targeting the given queue and topic. - pub const fn new(queue: Arc, topic: String) -> Self { - Self { queue, topic } - } - - /// Publishes the bundle with its hash as the message key. - pub async fn publish(&self, bundle: &AcceptedBundle, hash: &B256) -> Result<()> { - let key = hash.to_string(); - let payload = serde_json::to_vec(bundle)?; - self.queue.publish(&self.topic, &key, &payload).await - } -} - -#[cfg(test)] -mod tests { - use base_bundles::{ - AcceptedBundle, Bundle, BundleExtensions, test_utils::create_test_meter_bundle_response, - }; - use rdkafka::config::ClientConfig; - use tokio::time::{Duration, Instant}; - - use super::*; - - fn create_test_bundle() -> Bundle { - Bundle::default() - } - - #[tokio::test] - async fn test_backoff_retry_logic() { - // use an invalid broker address to trigger the backoff logic - let producer = ClientConfig::new() - .set("bootstrap.servers", "localhost:9999") - .set("message.timeout.ms", "100") - .create() - .expect("Producer creation failed"); - - let publisher = KafkaMessageQueue::new(producer); - let bundle = create_test_bundle(); - let accepted_bundle = - AcceptedBundle::new(bundle.try_into().unwrap(), create_test_meter_bundle_response()); - let bundle_hash = &accepted_bundle.bundle_hash(); - - let start = Instant::now(); - let result = publisher - .publish( - "tips-ingress-rpc", - bundle_hash.to_string().as_str(), - &serde_json::to_vec(&accepted_bundle).unwrap(), - ) - .await; - let elapsed = start.elapsed(); - - // the backoff tries at minimum 100ms, so verify we tried at least once - assert!(result.is_err()); - assert!(elapsed >= Duration::from_millis(100)); - } -} diff --git a/crates/infra/ingress-rpc/src/service.rs b/crates/infra/ingress-rpc/src/service.rs index 02fdd6b145..b37353582a 100644 --- a/crates/infra/ingress-rpc/src/service.rs +++ b/crates/infra/ingress-rpc/src/service.rs @@ -6,9 +6,10 @@ use std::{ use alloy_consensus::transaction::{Recovered, SignerRecoverable}; use alloy_primitives::{B256, Bytes}; use alloy_provider::{Provider, RootProvider, network::eip2718::Decodable2718}; +use alloy_rpc_types_eth::error::EthRpcErrorCode; use audit_archiver_lib::BundleEvent; use base_bundles::{AcceptedBundle, Bundle, BundleExtensions, MeterBundleResponse, ParsedBundle}; -use base_common_consensus::BaseTxEnvelope; +use base_common_consensus::{BaseTxEnvelope, EIP8130_REJECTION_MSG}; use base_common_network::Base; use jsonrpsee::{ core::{RpcResult, async_trait}, @@ -16,17 +17,14 @@ use jsonrpsee::{ }; use moka::future::Cache; use reth_rpc_eth_types::EthApiError; +use reth_rpc_server_types::result::rpc_err; use tokio::{ sync::{broadcast, mpsc}, time::{Duration, Instant, timeout}, }; use tracing::{debug, info, warn}; -use crate::{ - Config, TxSubmissionMethod, - metrics::Metrics, - queue::{BundleQueuePublisher, MessageQueue}, -}; +use crate::{Config, metrics::Metrics}; /// RPC providers for different endpoints. #[derive(Debug)] @@ -47,12 +45,10 @@ pub trait IngressApi { } /// Core ingress RPC service that handles transaction submission. -pub struct IngressService { +pub struct IngressService { mempool_provider: Arc>, simulation_provider: Arc>, raw_tx_forward_provider: Option>>, - tx_submission_method: TxSubmissionMethod, - bundle_queue_publisher: BundleQueuePublisher, audit_channel: mpsc::Sender, send_transaction_default_lifetime_seconds: u64, block_time_milliseconds: u64, @@ -62,17 +58,16 @@ pub struct IngressService { send_to_builder: bool, } -impl std::fmt::Debug for IngressService { +impl std::fmt::Debug for IngressService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("IngressService").finish_non_exhaustive() } } -impl IngressService { +impl IngressService { /// Creates a new ingress service with the given providers and configuration. pub fn new( providers: Providers, - queue: Q, audit_channel: mpsc::Sender, builder_tx: broadcast::Sender, config: Config, @@ -80,7 +75,6 @@ impl IngressService { let mempool_provider = Arc::new(providers.mempool); let simulation_provider = Arc::new(providers.simulation); let raw_tx_forward_provider = providers.raw_tx_forward.map(Arc::new); - let queue_connection = Arc::new(queue); // A TTL cache to deduplicate bundles with the same Bundle ID let bundle_cache = @@ -89,11 +83,6 @@ impl IngressService { mempool_provider, simulation_provider, raw_tx_forward_provider, - tx_submission_method: config.tx_submission_method, - bundle_queue_publisher: BundleQueuePublisher::new( - queue_connection, - config.ingress_topic, - ), audit_channel, send_transaction_default_lifetime_seconds: config .send_transaction_default_lifetime_seconds, @@ -107,22 +96,13 @@ impl IngressService { } #[async_trait] -impl IngressApiServer for IngressService { +impl IngressApiServer for IngressService { async fn send_raw_transaction(&self, data: Bytes) -> RpcResult { let start = Instant::now(); let transaction = self.get_tx(&data).await?; Metrics::transactions_received().increment(1); - let send_to_kafka = matches!( - self.tx_submission_method, - TxSubmissionMethod::Kafka | TxSubmissionMethod::MempoolAndKafka - ); - let send_to_mempool = matches!( - self.tx_submission_method, - TxSubmissionMethod::Mempool | TxSubmissionMethod::MempoolAndKafka - ); - // Forward before metering if let Some(forward_provider) = self.raw_tx_forward_provider.clone() { Metrics::raw_tx_forwards_total().increment(1); @@ -159,7 +139,7 @@ impl IngressApiServer for IngressService { if self.bundle_cache.get(bundle_hash).await.is_some() { debug!( - message = "Duplicate bundle detected, skipping Kafka publish", + message = "Duplicate bundle detected, skipping", bundle_hash = %bundle_hash, transaction_hash = %transaction.tx_hash(), ); @@ -210,28 +190,14 @@ impl IngressApiServer for IngressService { let accepted_bundle = AcceptedBundle::new(parsed_bundle, meter_bundle_response.unwrap_or_default()); - if send_to_kafka { - if let Err(e) = - self.bundle_queue_publisher.publish(&accepted_bundle, bundle_hash).await - { - warn!(message = "Failed to publish Queue::enqueue_bundle", bundle_hash = %bundle_hash, error = %e); + let response = self.mempool_provider.send_raw_transaction(data.iter().as_slice()).await; + match response { + Ok(_) => { + Metrics::sent_to_mempool().increment(1); + debug!(message = "sent transaction to the mempool", hash=%transaction.tx_hash()); } - - Metrics::sent_to_kafka().increment(1); - info!(message="queued singleton bundle", txn_hash=%transaction.tx_hash()); - } - - if send_to_mempool { - let response = - self.mempool_provider.send_raw_transaction(data.iter().as_slice()).await; - match response { - Ok(_) => { - Metrics::sent_to_mempool().increment(1); - debug!(message = "sent transaction to the mempool", hash=%transaction.tx_hash()); - } - Err(e) => { - warn!(message = "Failed to send raw transaction to mempool", error = %e); - } + Err(e) => { + warn!(message = "Failed to send raw transaction to mempool", error = %e); } } @@ -250,7 +216,7 @@ impl IngressApiServer for IngressService { } } -impl IngressService { +impl IngressService { async fn get_tx(&self, data: &Bytes) -> RpcResult> { if data.is_empty() { return Err(EthApiError::EmptyRawTransactionData.into_rpc_err()); @@ -259,6 +225,18 @@ impl IngressService { let envelope = BaseTxEnvelope::decode_2718_exact(data.iter().as_slice()) .map_err(|_| EthApiError::FailedToDecodeSignedTransaction.into_rpc_err())?; + if envelope.is_eip8130() { + // Mirror the rejection used by `BaseEthApi::send_raw_transaction` so both + // ingress surfaces return the same code (-32003, TransactionRejected) and + // the same wording. Message is sourced from `base-common-consensus` to + // prevent drift with `BaseInvalidTransactionError::Eip8130NotAccepted`. + return Err(rpc_err( + EthRpcErrorCode::TransactionRejected.code(), + EIP8130_REJECTION_MSG, + None, + )); + } + let transaction = envelope .try_into_recovered() .map_err(|_| EthApiError::FailedToDecodeSignedTransaction.into_rpc_err())?; @@ -342,34 +320,19 @@ mod tests { }; use alloy_provider::RootProvider; - use anyhow::Result; - use async_trait::async_trait; use base_bundles::test_utils::create_test_meter_bundle_response; use tokio::sync::{broadcast, mpsc}; use url::Url; use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; use super::*; - use crate::{Config, TxSubmissionMethod, queue::MessageQueue}; - struct MockQueue; - - #[async_trait] - impl MessageQueue for MockQueue { - async fn publish(&self, _topic: &str, _key: &str, _payload: &[u8]) -> Result<()> { - Ok(()) - } - } + use crate::Config; fn create_test_config(mock_server: &MockServer) -> Config { Config { address: IpAddr::from([127, 0, 0, 1]), port: 8080, mempool_url: Url::parse("http://localhost:3000").unwrap(), - tx_submission_method: TxSubmissionMethod::Mempool, - ingress_kafka_properties: String::new(), - ingress_topic: String::new(), - audit_kafka_properties: Some(String::new()), - audit_topic: Some(String::new()), send_transaction_default_lifetime_seconds: 300, simulation_rpc: mock_server.uri().parse().unwrap(), block_time_milliseconds: 1000, @@ -466,7 +429,7 @@ mod tests { let (audit_tx, _audit_rx) = mpsc::channel(512); let (builder_tx, _builder_rx) = broadcast::channel(1); - let service = IngressService::new(providers, MockQueue, audit_tx, builder_tx, config); + let service = IngressService::new(providers, audit_tx, builder_tx, config); let bundle = Bundle::default(); let bundle_hash = B256::default(); @@ -508,8 +471,7 @@ mod tests { .mount(&forward_server) .await; - let mut config = create_test_config(&simulation_server); - config.tx_submission_method = TxSubmissionMethod::Kafka; // Skip mempool send + let config = create_test_config(&simulation_server); let providers = Providers { mempool: RootProvider::new_http(simulation_server.uri().parse().unwrap()), @@ -520,7 +482,7 @@ mod tests { let (audit_tx, _audit_rx) = mpsc::channel(512); let (builder_tx, _builder_rx) = broadcast::channel(1); - let service = IngressService::new(providers, MockQueue, audit_tx, builder_tx, config); + let service = IngressService::new(providers, audit_tx, builder_tx, config); // Valid signed transaction bytes let tx_bytes = Bytes::from_str("0x02f86c0d010183072335825208940000000000000000000000000000000000000000872386f26fc1000080c001a0cdb9e4f2f1ba53f9429077e7055e078cf599786e29059cd80c5e0e923bb2c114a01c90e29201e031baf1da66296c3a5c15c200bcb5e6c34da2f05f7d1778f8be07").unwrap(); diff --git a/crates/infra/load-tests/Justfile b/crates/infra/load-tests/Justfile index c7e69f6ae6..8468cfd3f9 100644 --- a/crates/infra/load-tests/Justfile +++ b/crates/infra/load-tests/Justfile @@ -1,11 +1,12 @@ # Load test runner - transaction submission for network load testing # # Usage: -# just load-test devnet - Load test local devnet (uses Anvil Account #1) -# just load-test devnet --continuous - Load test devnet indefinitely (Ctrl-C to stop) -# just load-test devnet-continuous (jldc) - Alias: devnet continuous mode -# FUNDER_KEY=0x... just load-test sepolia - Load test sepolia -# FUNDER_KEY=0x... just load-test recover sepolia - Recover funds from sepolia test accounts +# just load-test run - Load test devnet (uses Anvil Account #1) +# just load-test run sepolia - Load test sepolia (requires FUNDER_KEY) +# just load-test continuous - Load test devnet indefinitely (Ctrl-C to stop) +# just load-test continuous sepolia - Load test sepolia indefinitely +# just load-test recover - Recover funds from devnet test accounts +# just load-test recover sepolia - Recover funds from sepolia test accounts set positional-arguments := true set working-directory := '../../..' @@ -14,19 +15,18 @@ set working-directory := '../../..' default: @just --justfile {{source_file()}} --list -# Run load test against devnet (local) - uses Anvil Account #1 -devnet *args: - FUNDER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d cargo run -p base-load-tester-bin --bin base-load-tester -- crates/infra/load-tests/examples/devnet.yaml {{args}} +# Run load test against a network (devnet uses Anvil Account #1 by default) +run network='devnet' *args: + #!/usr/bin/env bash + if [ -z "${FUNDER_KEY:-}" ] && [ "{{network}}" = "devnet" ]; then + export FUNDER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + fi + cargo run -p base-load-tester-bin --bin base-load-tester -- crates/infra/load-tests/examples/{{network}}.yaml {{args}} -# Run load test against devnet indefinitely (Ctrl-C to stop) -devnet-continuous: - FUNDER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d cargo run -p base-load-tester-bin --bin base-load-tester -- crates/infra/load-tests/examples/devnet.yaml --continuous +# Run load test against a network indefinitely (Ctrl-C to stop) +continuous network='devnet': + just --justfile {{source_file()}} run {{network}} --continuous -# Run load test against sepolia network (requires FUNDER_KEY env var) -sepolia *args: - cargo run -p base-load-tester-bin --bin base-load-tester -- crates/infra/load-tests/examples/sepolia.yaml {{args}} - -# Recover/drain funds from load test accounts back to funder (requires FUNDER_KEY env var) -# Usage: just load-test recover (e.g., just load-test recover sepolia) -recover network: - cargo run -p base-load-tester-bin --bin base-load-tester -- crates/infra/load-tests/examples/{{network}}.yaml --drain-only +# Recover/drain funds from load test accounts back to funder +recover network='devnet': + just --justfile {{source_file()}} run {{network}} --drain-only diff --git a/crates/infra/load-tests/README.md b/crates/infra/load-tests/README.md index 7e04d7dc7f..13589cc0f3 100644 --- a/crates/infra/load-tests/README.md +++ b/crates/infra/load-tests/README.md @@ -19,10 +19,10 @@ Load testing and benchmarking framework for Base infrastructure. ```bash # Run load test against local devnet (uses Anvil Account #1) -just load-test devnet +just load-test run # Run load test against sepolia (requires funded key) -FUNDER_KEY=0x... just load-test sepolia +FUNDER_KEY=0x... just load-test run sepolia ``` Or run directly with cargo: diff --git a/crates/infra/mempool-rebroadcaster/Cargo.toml b/crates/infra/mempool-rebroadcaster/Cargo.toml deleted file mode 100644 index 5452cfcef1..0000000000 --- a/crates/infra/mempool-rebroadcaster/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "mempool-rebroadcaster" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lib] -path = "src/lib.rs" - -[lints] -workspace = true - -[dependencies] -tokio = { workspace = true } -serde = { workspace = true, features = ["std"] } -tracing = { workspace = true, features = ["std"] } -serde_json = { workspace = true, features = ["std"] } - -# alloy -alloy-primitives.workspace = true -alloy-trie = { workspace = true, features = ["std"] } -alloy-eips = { workspace = true, features = ["std"] } -alloy-consensus = { workspace = true, features = ["std"] } -alloy-rpc-types = { workspace = true, features = ["txpool"] } -alloy-rpc-types-eth = { workspace = true, features = ["std"] } -alloy-provider = { workspace = true, features = ["txpool-api"] } diff --git a/crates/infra/mempool-rebroadcaster/README.md b/crates/infra/mempool-rebroadcaster/README.md deleted file mode 100644 index 2cc48cbcd6..0000000000 --- a/crates/infra/mempool-rebroadcaster/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# `mempool-rebroadcaster` - -Mempool rebroadcaster library. - -## Overview - -Subscribes to mempool state changes and rebroadcasts pending transactions to network peers. -`Rebroadcaster` processes `TxpoolDiff` events — tracking new arrivals and removals — and -forwards transactions that should be propagated, ensuring they reach all connected nodes even -when initial gossip is incomplete. - -## Usage - -Add the dependency to your `Cargo.toml`: - -```toml -[dependencies] -mempool-rebroadcaster = { workspace = true } -``` - -```rust,ignore -use mempool_rebroadcaster::Rebroadcaster; - -let rebroadcaster = Rebroadcaster::new(peers, pool); -rebroadcaster.run().await; -``` - -## License - -Licensed under the [MIT License](https://github.com/base/base/blob/main/LICENSE). diff --git a/crates/infra/mempool-rebroadcaster/src/lib.rs b/crates/infra/mempool-rebroadcaster/src/lib.rs deleted file mode 100644 index 319c423e14..0000000000 --- a/crates/infra/mempool-rebroadcaster/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -#![doc = include_str!("../README.md")] - -mod rebroadcaster; - -pub use rebroadcaster::{Rebroadcaster, RebroadcasterResult, TxpoolDiff}; diff --git a/crates/infra/mempool-rebroadcaster/src/rebroadcaster.rs b/crates/infra/mempool-rebroadcaster/src/rebroadcaster.rs deleted file mode 100644 index aad6cde468..0000000000 --- a/crates/infra/mempool-rebroadcaster/src/rebroadcaster.rs +++ /dev/null @@ -1,308 +0,0 @@ -use std::{collections::HashMap, error::Error}; - -use alloy_consensus::Transaction; -use alloy_eips::{ - Encodable2718, - eip2718::{EIP1559_TX_TYPE_ID, EIP2930_TX_TYPE_ID, EIP7702_TX_TYPE_ID, LEGACY_TX_TYPE_ID}, -}; -use alloy_primitives::B256; -use alloy_provider::{Provider, ProviderBuilder, RootProvider, ext::TxPoolApi}; -use alloy_rpc_types::txpool::TxpoolContent; -use alloy_rpc_types_eth::{BlockId, Transaction as RpcTransaction}; -use tracing::{debug, error, info, warn}; - -const IGNORED_ERRORS: [&str; 3] = - ["transaction underpriced", "replacement transaction underpriced", "already known"]; - -/// Synchronizes transaction pools between geth and reth nodes by rebroadcasting -/// transactions that exist in one mempool but not the other. -#[derive(Debug, Clone)] -pub struct Rebroadcaster { - geth_provider: RootProvider, - reth_provider: RootProvider, -} - -/// Result counters from a single rebroadcast run. -#[derive(Debug, Clone)] -pub struct RebroadcasterResult { - /// Number of transactions successfully sent from geth to reth. - pub success_geth_to_reth: u32, - /// Number of transactions successfully sent from reth to geth. - pub success_reth_to_geth: u32, - /// Number of unexpected failures when sending from geth to reth. - pub unexpected_failed_geth_to_reth: u32, - /// Number of unexpected failures when sending from reth to geth. - pub unexpected_failed_reth_to_geth: u32, -} - -/// The difference between two transaction pools, identifying transactions -/// present in one but missing from the other. -#[derive(Debug)] -pub struct TxpoolDiff { - /// Transactions found in the geth mempool but absent from reth. - pub in_geth_not_in_reth: Vec, - /// Transactions found in the reth mempool but absent from geth. - pub in_reth_not_in_geth: Vec, -} - -impl Rebroadcaster { - /// Creates a new [`Rebroadcaster`] connected to the given geth and reth HTTP endpoints. - pub fn new(geth_endpoint: String, reth_endpoint: String) -> Self { - let geth_provider = ProviderBuilder::new() - .disable_recommended_fillers() - .connect_http(geth_endpoint.parse().expect("Invalid geth endpoint")); - - let reth_provider = ProviderBuilder::new() - .disable_recommended_fillers() - .connect_http(reth_endpoint.parse().expect("Invalid reth endpoint")); - - Self { geth_provider, reth_provider } - } - - /// Executes a single rebroadcast cycle: fetches mempool contents from both nodes, - /// filters underpriced transactions, computes the diff, and rebroadcasts missing - /// transactions in each direction. - pub async fn run(&self) -> Result> { - let (base_fee, gas_price) = self.fetch_network_fees().await?; - let (geth_mempool_contents, reth_mempool_contents) = self.fetch_mempool_contents().await?; - - let (geth_pending_count, geth_queued_count) = self.count_txns(&geth_mempool_contents); - let (reth_pending_count, reth_queued_count) = self.count_txns(&reth_mempool_contents); - - info!( - geth_pending_count, - geth_queued_count, reth_pending_count, reth_queued_count, "txn counts" - ); - - let filtered_geth_mempool_contents = - self.filter_underpriced_txns(&geth_mempool_contents, base_fee, gas_price); - let filtered_reth_mempool_contents = - self.filter_underpriced_txns(&reth_mempool_contents, base_fee, gas_price); - - let (filtered_geth_pending_count, filtered_geth_queued_count) = - self.count_txns(&filtered_geth_mempool_contents); - let (filtered_reth_pending_count, filtered_reth_queued_count) = - self.count_txns(&filtered_reth_mempool_contents); - - info!( - filtered_geth_pending_count, - filtered_geth_queued_count, - filtered_reth_pending_count, - filtered_reth_queued_count, - "filtered txn counts" - ); - - let diff = - self.compute_diff(&filtered_geth_mempool_contents, &filtered_reth_mempool_contents); - - let mut output = RebroadcasterResult { - success_geth_to_reth: 0, - success_reth_to_geth: 0, - unexpected_failed_geth_to_reth: 0, - unexpected_failed_reth_to_geth: 0, - }; - - for txn in diff.in_geth_not_in_reth { - let hash = txn.as_recovered().hash(); - let sender = txn.as_recovered().signer().to_string(); - debug!(tx = ?hash, "broadcasting txn found in geth but not in reth"); - let result = self - .reth_provider - .send_raw_transaction(txn.clone().into_signed().into_encoded().encoded_bytes()) - .await; - - if let Err(e) = result { - let err_msg = e.as_error_resp().unwrap().message.to_string(); - if !IGNORED_ERRORS.contains(&err_msg.as_str()) { - output.unexpected_failed_geth_to_reth += 1; - error!( - tx = ?hash, - error = ?err_msg, - from = sender, - "error sending txn from geth to reth" - ); - } - continue; - } - - output.success_geth_to_reth += 1; - } - - for txn in diff.in_reth_not_in_geth { - let hash = txn.as_recovered().hash(); - let sender = txn.as_recovered().signer().to_string(); - debug!(tx = ?hash, "broadcasting txn found in reth but not in geth"); - let result = self - .geth_provider - .send_raw_transaction(txn.clone().into_signed().into_encoded().encoded_bytes()) - .await; - - if let Err(e) = result { - let err_msg = e.as_error_resp().unwrap().message.to_string(); - if !IGNORED_ERRORS.contains(&err_msg.as_str()) { - output.unexpected_failed_reth_to_geth += 1; - error!( - tx = ?hash, - error = ?err_msg, - from = sender, - "error sending txn from reth to geth" - ); - } - continue; - } - - output.success_reth_to_geth += 1; - } - - Ok(output) - } - - async fn fetch_network_fees(&self) -> Result<(u128, u128), Box> { - let latest_block = self - .geth_provider - .get_block(BlockId::latest()) - .hashes() - .await? - .expect("Failed to get latest block"); - - let gas_price = self.geth_provider.get_gas_price().await?; - let base_fee: u128 = latest_block.header.base_fee_per_gas.map_or(gas_price, |v| v.into()); - - Ok((base_fee, gas_price)) - } - - async fn fetch_mempool_contents( - &self, - ) -> Result<(TxpoolContent, TxpoolContent), Box> { - let (geth_mempool_contents, reth_mempool_contents) = - tokio::join!(self.geth_provider.txpool_content(), self.reth_provider.txpool_content(),); - let geth_mempool_contents = geth_mempool_contents?; - let reth_mempool_contents = reth_mempool_contents?; - - Ok((geth_mempool_contents, reth_mempool_contents)) - } - - /// Returns a copy of the given mempool contents with underpriced transactions removed. - pub fn filter_underpriced_txns( - &self, - content: &TxpoolContent, - base_fee: u128, - gas_price: u128, - ) -> TxpoolContent { - let mut filtered_content = content.clone(); - - for (account, nonce_txns) in &content.pending { - for (nonce, txn) in nonce_txns { - if self.is_underpriced(txn, base_fee, gas_price) { - filtered_content.pending.get_mut(account).unwrap().remove(nonce); - } - } - - if filtered_content.pending.get(account).unwrap().is_empty() { - filtered_content.pending.remove(account); - } - } - - for (account, nonce_txns) in &content.queued { - for (nonce, txn) in nonce_txns { - if self.is_underpriced(txn, base_fee, gas_price) { - filtered_content.queued.get_mut(account).unwrap().remove(nonce); - } - } - - if filtered_content.queued.get(account).unwrap().is_empty() { - filtered_content.queued.remove(account); - } - } - - filtered_content - } - - fn is_underpriced(&self, txn: &dyn Transaction, base_fee: u128, gas_price: u128) -> bool { - match txn.ty() { - LEGACY_TX_TYPE_ID | EIP2930_TX_TYPE_ID => { - if txn.gas_price().is_none() { - return true; - } - txn.gas_price().unwrap() < gas_price - } - EIP1559_TX_TYPE_ID | EIP7702_TX_TYPE_ID => { - if txn.max_priority_fee_per_gas().is_none() { - return true; - } - txn.max_fee_per_gas() < base_fee - } - _ => { - warn!( - tx_type = ?txn.ty(), - "unknown transaction type, treating as underpriced" - ); - true - } - } - } - - fn count_txns(&self, mempool: &TxpoolContent) -> (usize, usize) { - let mut pending_count = 0; - let mut queued_count = 0; - - for nonce_txns in mempool.pending.values() { - pending_count += nonce_txns.len(); - } - - for nonce_txns in mempool.queued.values() { - queued_count += nonce_txns.len(); - } - - (pending_count, queued_count) - } - - /// Computes the symmetric difference between two mempool snapshots, returning - /// transactions unique to each pool sorted by nonce. - pub fn compute_diff( - &self, - geth_mempool: &TxpoolContent, - reth_mempool: &TxpoolContent, - ) -> TxpoolDiff { - let mut diff = - TxpoolDiff { in_geth_not_in_reth: Vec::new(), in_reth_not_in_geth: Vec::new() }; - - let geth_hashes = self.txns_by_hash(geth_mempool); - let reth_hashes = self.txns_by_hash(reth_mempool); - - for (hash, txn) in &geth_hashes { - if !reth_hashes.contains_key(hash) { - diff.in_geth_not_in_reth.push(txn.clone()); - } - } - - for (hash, txn) in &reth_hashes { - if !geth_hashes.contains_key(hash) { - diff.in_reth_not_in_geth.push(txn.clone()); - } - } - - diff.in_geth_not_in_reth.sort_by_key(|txn| txn.as_recovered().nonce()); - diff.in_reth_not_in_geth.sort_by_key(|txn| txn.as_recovered().nonce()); - - diff - } - - fn txns_by_hash(&self, mempool: &TxpoolContent) -> HashMap { - let mut txns_by_hash = HashMap::new(); - - for nonce_txns in mempool.pending.values() { - for txn in nonce_txns.values() { - txns_by_hash.insert(*txn.as_recovered().hash(), txn.clone()); - } - } - - for nonce_txns in mempool.queued.values() { - for txn in nonce_txns.values() { - txns_by_hash.insert(*txn.as_recovered().hash(), txn.clone()); - } - } - - txns_by_hash - } -} diff --git a/crates/infra/mempool-rebroadcaster/testdata/geth_mempool.json b/crates/infra/mempool-rebroadcaster/testdata/geth_mempool.json deleted file mode 100644 index 50f565dc28..0000000000 --- a/crates/infra/mempool-rebroadcaster/testdata/geth_mempool.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "pending": { - "0x093a8609AEeE706EE4BBea65c88a4CF1fF34E476": { - "97226": { - "blockHash": null, - "blockNumber": null, - "from": "0x093a8609aeee706ee4bbea65c88a4cf1ff34e476", - "gas": "0xc3500", - "gasPrice": "0x1ab3f00", - "hash": "0x2d4bce6f850ef7ef164585400506893595460fac2fa8f5631de42667896a8b9a", - "input": "0x1fff991f000000000000000000000000d0dce944b03f175094a26a3ebade54ffa39117ca0000000000000000000000005084459c750e911b5112586cca962f2de015ab07000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000003e0000000000000000000000000000000000000000000000000000000000000058000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000006400000000000000000000000015f6bdae3d2c1da6c1c782c382bdc8a6f5b90052000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000027100000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164af72634f000000000000000000000000f525ff21c370beb8d9f5c12dc0da2b583f4b949f0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000ffffffffffffffc50000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000342710015084459c750e911b5112586cca962f2de015ab078000000000c8dd5eeaff7bd481ad55db083062b13a3cdf0a68cc000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064c876d21d000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da9561570000000000000000000000005084459c750e911b5112586cca962f2de015ab0700000000000000000000000000000000539738bb0f40688798d25ef1c71c72a900000000000000000000000000000000000000000000000000000000", - "nonce": "0x17bca", - "to": "0xf525ff21c370beb8d9f5c12dc0da2b583f4b949f", - "transactionIndex": null, - "value": "0xe8d4a51000", - "type": "0x0", - "chainId": "0x2105", - "v": "0x422d", - "r": "0x35548343a596bce504e2118ad9835951dff9efa1b110a56593244b7b12624c04", - "s": "0x60142bdebf70a69bea4af85c1a78994804e48fe73e02099b7da73cd494eb3b05" - }, - "97227": { - "blockHash": null, - "blockNumber": null, - "from": "0x093a8609aeee706ee4bbea65c88a4cf1ff34e476", - "gas": "0xc3500", - "gasPrice": "0x1ab3f00", - "hash": "0x2ddc43a753e327f21b8feab2f83db4a9add29b353519f7c28c217aa677b7671f", - "input": "0x1fff991f000000000000000000000000561f6e69be8926c5cb86b9197b0a4152ccac1bc60000000000000000000000005084459c750e911b5112586cca962f2de015ab07000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000003e0000000000000000000000000000000000000000000000000000000000000058000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000006400000000000000000000000015f6bdae3d2c1da6c1c782c382bdc8a6f5b90052000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000027100000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164af72634f000000000000000000000000f525ff21c370beb8d9f5c12dc0da2b583f4b949f0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000ffffffffffffffc50000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000342710015084459c750e911b5112586cca962f2de015ab078000000000c8dd5eeaff7bd481ad55db083062b13a3cdf0a68cc000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064c876d21d000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da9561570000000000000000000000005084459c750e911b5112586cca962f2de015ab0700000000000000000000000000000000539738bb0f40688798d25ef1c71c72a900000000000000000000000000000000000000000000000000000000", - "nonce": "0x17bcb", - "to": "0xf525ff21c370beb8d9f5c12dc0da2b583f4b949f", - "transactionIndex": null, - "value": "0xe8d4a51000", - "type": "0x0", - "chainId": "0x2105", - "v": "0x422e", - "r": "0x1763fab33f872cbc25b24dcf16acd061daebf00d37f2f2f4a312e47416c33c46", - "s": "0x2f9aff4a26a1ce6b25556e2653da09808b8221b68980e6fe028cb5dd3c40a739" - }, - "97228": { - "blockHash": null, - "blockNumber": null, - "from": "0x093a8609aeee706ee4bbea65c88a4cf1ff34e476", - "gas": "0xc3500", - "gasPrice": "0x1ab3f00", - "hash": "0x30e74220b9769c0eb68f95908d7f9370728a9369e6fa67e734546254c8ebe5ef", - "input": "0x1fff991f000000000000000000000000a6e5829d79bf6f3d0f2bf6e722201221db016c570000000000000000000000005084459c750e911b5112586cca962f2de015ab07000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000003e0000000000000000000000000000000000000000000000000000000000000058000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000006400000000000000000000000015f6bdae3d2c1da6c1c782c382bdc8a6f5b90052000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000027100000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164af72634f000000000000000000000000f525ff21c370beb8d9f5c12dc0da2b583f4b949f0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000ffffffffffffffc50000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000342710015084459c750e911b5112586cca962f2de015ab078000000000c8dd5eeaff7bd481ad55db083062b13a3cdf0a68cc000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064c876d21d000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da9561570000000000000000000000005084459c750e911b5112586cca962f2de015ab0700000000000000000000000000000000539738bb0f40688798d25ef1c71c72a900000000000000000000000000000000000000000000000000000000", - "nonce": "0x17bcc", - "to": "0xf525ff21c370beb8d9f5c12dc0da2b583f4b949f", - "transactionIndex": null, - "value": "0xe8d4a51000", - "type": "0x0", - "chainId": "0x2105", - "v": "0x422d", - "r": "0x2da684a887c0cb6d01eac9e3b915700a46de1a5b5b5a57361607738e6241ac66", - "s": "0x4d21b45ae9fdb5ce0dbe1f0e29e9a4ba00cbf4895fecfae81c17e1a6fa46702b" - }, - "97229": { - "blockHash": null, - "blockNumber": null, - "from": "0x093a8609aeee706ee4bbea65c88a4cf1ff34e476", - "gas": "0xc3500", - "gasPrice": "0x1ab3f00", - "hash": "0x872447406779378c9650847a63ffcc2e29e870aa38f2c9fdb30834212d78f860", - "input": "0x1fff991f0000000000000000000000009f6c48794257b93f9984b4379049e04e51be71770000000000000000000000005084459c750e911b5112586cca962f2de015ab07000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000003e0000000000000000000000000000000000000000000000000000000000000058000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000006400000000000000000000000015f6bdae3d2c1da6c1c782c382bdc8a6f5b90052000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000027100000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164af72634f000000000000000000000000f525ff21c370beb8d9f5c12dc0da2b583f4b949f0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000ffffffffffffffc50000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000342710015084459c750e911b5112586cca962f2de015ab078000000000c8dd5eeaff7bd481ad55db083062b13a3cdf0a68cc000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064c876d21d000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da9561570000000000000000000000005084459c750e911b5112586cca962f2de015ab0700000000000000000000000000000000539738bb0f40688798d25ef1c71c72a900000000000000000000000000000000000000000000000000000000", - "nonce": "0x17bcd", - "to": "0xf525ff21c370beb8d9f5c12dc0da2b583f4b949f", - "transactionIndex": null, - "value": "0xe8d4a51000", - "type": "0x0", - "chainId": "0x2105", - "v": "0x422e", - "r": "0x1da52fb4002df4187001a3b1e59fa491b1e6992f61dd377d028e0d523db7dcb9", - "s": "0x1f7287657a6fad2ce3a021df6c849937444fc876934b2a56cf9047ec94a3d07d" - }, - "97230": { - "blockHash": null, - "blockNumber": null, - "from": "0x093a8609aeee706ee4bbea65c88a4cf1ff34e476", - "gas": "0xc3500", - "gasPrice": "0x1ab3f00", - "hash": "0x4c8a6e278cc52d2b8a53e69e372918fe6980f2d27caa9595ac2434ba0b28cbec", - "input": "0x1fff991f0000000000000000000000004715bfaa486cf14c158c79742b92144a07f362b30000000000000000000000005084459c750e911b5112586cca962f2de015ab07000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000003e0000000000000000000000000000000000000000000000000000000000000058000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000006400000000000000000000000015f6bdae3d2c1da6c1c782c382bdc8a6f5b90052000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee000000000000000000000000000000000000000000000000000000000000000f000000000000000000000000ad01c20d5886137e056775af56915de824c8fce5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010438c9c147000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee00000000000000000000000000000000000000000000000000000000000027100000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000024d0e30db0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164af72634f000000000000000000000000f525ff21c370beb8d9f5c12dc0da2b583f4b949f0000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000271000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000ffffffffffffffc50000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000342710015084459c750e911b5112586cca962f2de015ab078000000000c8dd5eeaff7bd481ad55db083062b13a3cdf0a68cc000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064c876d21d000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da9561570000000000000000000000005084459c750e911b5112586cca962f2de015ab0700000000000000000000000000000000539738bb0f40688798d25ef1c71c72a900000000000000000000000000000000000000000000000000000000", - "nonce": "0x17bce", - "to": "0xf525ff21c370beb8d9f5c12dc0da2b583f4b949f", - "transactionIndex": null, - "value": "0xe8d4a51000", - "type": "0x0", - "chainId": "0x2105", - "v": "0x422d", - "r": "0x54b048674b872ba4064e74451724d8d9ae0823dca5a481d8f4ee9d26f413c4ae", - "s": "0x4c1658aeb763427af5607440b80d66929e379a28713d5dcd47250a963fe10217" - } - } - }, - "queued": {} -} diff --git a/crates/infra/mempool-rebroadcaster/testdata/reth_mempool.json b/crates/infra/mempool-rebroadcaster/testdata/reth_mempool.json deleted file mode 100644 index f9df996fd0..0000000000 --- a/crates/infra/mempool-rebroadcaster/testdata/reth_mempool.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "pending": {}, - "queued": {} -} diff --git a/crates/infra/mempool-rebroadcaster/tests/e2e_tests.rs b/crates/infra/mempool-rebroadcaster/tests/e2e_tests.rs deleted file mode 100644 index 9646c928dc..0000000000 --- a/crates/infra/mempool-rebroadcaster/tests/e2e_tests.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! End-to-end tests for the mempool rebroadcaster. - -use std::path::Path; - -use alloy_rpc_types::txpool::TxpoolContent; -use mempool_rebroadcaster::Rebroadcaster; - -fn load_static_mempool_content>( - filepath: P, -) -> Result> { - let data = std::fs::read_to_string(filepath)?; - let content: TxpoolContent = serde_json::from_str(&data)?; - Ok(content) -} - -#[tokio::test] -async fn test_e2e_static_data() { - // Load static test data - let geth_mempool = load_static_mempool_content("testdata/geth_mempool.json") - .expect("Failed to load geth mempool data"); - - let reth_mempool = load_static_mempool_content("testdata/reth_mempool.json") - .expect("Failed to load reth mempool data"); - - // Use constant network fees for testing (same as Go version) - let base_fee = 0x2601ff_u128; // 0x2601ff - let gas_price = 0x36daa7_u128; // 0x36daa7 - - // Create a rebroadcaster instance for testing (endpoints don't matter for this test) - let rebroadcaster = Rebroadcaster::new( - "http://localhost:8545".to_string(), - "http://localhost:8546".to_string(), - ); - - // Apply filtering logic (same as production) - let filtered_geth_mempool = - rebroadcaster.filter_underpriced_txns(&geth_mempool, base_fee, gas_price); - let filtered_reth_mempool = - rebroadcaster.filter_underpriced_txns(&reth_mempool, base_fee, gas_price); - - // Compute diff (same as production) - let diff = rebroadcaster.compute_diff(&filtered_geth_mempool, &filtered_reth_mempool); - - // Expected results: Since reth mempool is empty and geth has 5 transactions, - // all 5 should be in in_geth_not_in_reth (sorted by nonce) - let expected_missing_hashes = [ - "0x2d4bce6f850ef7ef164585400506893595460fac2fa8f5631de42667896a8b9a", // nonce 97226 - "0x2ddc43a753e327f21b8feab2f83db4a9add29b353519f7c28c217aa677b7671f", // nonce 97227 - "0x30e74220b9769c0eb68f95908d7f9370728a9369e6fa67e734546254c8ebe5ef", // nonce 97228 - "0x872447406779378c9650847a63ffcc2e29e870aa38f2c9fdb30834212d78f860", // nonce 97229 - "0x4c8a6e278cc52d2b8a53e69e372918fe6980f2d27caa9595ac2434ba0b28cbec", // nonce 97230 - ]; - - // Assert transaction count - assert_eq!( - expected_missing_hashes.len(), - diff.in_geth_not_in_reth.len(), - "in_geth_not_in_reth count should match expected" - ); - - // Assert no transactions in reth but not in geth (since reth is empty) - assert_eq!( - 0, - diff.in_reth_not_in_geth.len(), - "in_reth_not_in_geth should be empty since reth mempool is empty" - ); - - // Assert transactions are sorted by nonce and match expected values - for (i, tx) in diff.in_geth_not_in_reth.iter().enumerate() { - let tx_hash = format!("{:#x}", tx.as_recovered().hash()); - assert_eq!( - expected_missing_hashes[i], tx_hash, - "Transaction {i} hash should match expected" - ); - } -} - -#[tokio::test] -async fn test_e2e_filtering_logic() { - // Test that underpriced transactions are properly filtered - // Load the same data but with higher base fees to test filtering - let geth_mempool = load_static_mempool_content("testdata/geth_mempool.json") - .expect("Failed to load geth mempool data"); - - // Set very high base fee that should filter out our transactions - let very_high_base_fee = u128::MAX; - let very_high_gas_price = u128::MAX; - - // Create a rebroadcaster instance for testing - let rebroadcaster = Rebroadcaster::new( - "http://localhost:8545".to_string(), - "http://localhost:8546".to_string(), - ); - - // Apply filtering with very high fees - let filtered_geth_mempool = rebroadcaster.filter_underpriced_txns( - &geth_mempool, - very_high_base_fee, - very_high_gas_price, - ); - - // Assert all transactions are filtered out due to high base fee - let total_pending = - filtered_geth_mempool.pending.values().map(|nonce_txs| nonce_txs.len()).sum::(); - let total_queued = - filtered_geth_mempool.queued.values().map(|nonce_txs| nonce_txs.len()).sum::(); - - assert_eq!( - 0, - total_pending + total_queued, - "All transactions should be filtered out with very high base fee" - ); -} diff --git a/crates/infra/snapshotter/Cargo.toml b/crates/infra/snapshotter/Cargo.toml new file mode 100644 index 0000000000..d7e372e21c --- /dev/null +++ b/crates/infra/snapshotter/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "base-snapshotter" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +tar = { workspace = true } +zstd = { workspace = true } +anyhow = { workspace = true } +blake3 = { workspace = true } +rayon = { workspace = true } +bollard = { workspace = true } +futures = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "fs"] } +clap = { workspace = true, features = ["derive", "env"] } +serde = { workspace = true, features = ["std", "derive"] } +aws-sdk-s3 = { workspace = true, features = ["rustls", "default-https-client", "rt-tokio"] } + +[dev-dependencies] +futures = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +bollard = { workspace = true } +serial_test = { workspace = true } +tokio = { workspace = true, features = ["full"] } +testcontainers = { workspace = true, features = ["blocking", "host-port-exposure"] } +aws-config = { workspace = true, features = ["default-https-client", "rt-tokio"] } +testcontainers-modules = { workspace = true, features = ["minio"] } diff --git a/crates/infra/snapshotter/README.md b/crates/infra/snapshotter/README.md new file mode 100644 index 0000000000..1c25c5377f --- /dev/null +++ b/crates/infra/snapshotter/README.md @@ -0,0 +1,37 @@ +# `base-snapshotter` + +Sidecar for generating and uploading reth node snapshots to S3-compatible storage. + +## Overview + +Runs alongside a Base execution layer node (base-node-reth) and orchestrates periodic snapshot +creation. `Snapshotter` coordinates the full lifecycle: stopping the EL container via the Docker +socket, generating a snapshot manifest and chunk archives using reth's `SnapshotManifestCommand`, +uploading all artifacts to an S3-compatible store (e.g. Cloudflare R2), then restarting the EL. + +The Docker socket (`/var/run/docker.sock`) is volume-mounted into the sidecar container, giving +it control over sibling containers on the host. + +## Usage + +Add the dependency to your `Cargo.toml`: + +```toml +[dependencies] +base-snapshotter = { workspace = true } +``` + +```rust,ignore +use base_snapshotter::{DockerContainerManager, Snapshotter, SnapshotUploader, SnapshotterConfig}; + +let config = SnapshotterConfig::parse(); +let container_manager = DockerContainerManager::new(&config.docker_socket)?; + +// ... create s3_client and uploader ... +let snapshotter = Snapshotter::new(container_manager, uploader, config); +snapshotter.run().await?; +``` + +## License + +Licensed under the [MIT License](https://github.com/base/base/blob/main/LICENSE). diff --git a/crates/infra/snapshotter/src/config.rs b/crates/infra/snapshotter/src/config.rs new file mode 100644 index 0000000000..3bb86943e2 --- /dev/null +++ b/crates/infra/snapshotter/src/config.rs @@ -0,0 +1,86 @@ +//! CLI configuration for the snapshotter sidecar. + +use std::path::PathBuf; + +use clap::{Parser, ValueEnum}; + +/// How the S3/R2 client is configured. +#[derive(Debug, Clone, ValueEnum)] +pub enum S3ConfigType { + /// Uses the standard AWS credential chain (IAM roles, env vars, `~/.aws/credentials`). + Aws, + /// Explicit endpoint, access key, and secret key via CLI args or env vars. + Manual, +} + +/// Configuration for the snapshotter sidecar. +#[derive(Debug, Parser)] +#[command( + name = "base-snapshotter", + about = "Snapshot and upload reth node data to S3-compatible storage" +)] +pub struct SnapshotterConfig { + /// Docker container name of the execution layer node to stop/start. + #[arg(long)] + pub container_name: String, + + /// Source datadir containing the reth node data (static files + DB). + #[arg(long, short = 'd')] + pub source_datadir: PathBuf, + + /// Output directory for snapshot archives and manifest. + /// + /// A unique subdirectory is created per run. + #[arg(long, short = 'o')] + pub output_dir: PathBuf, + + /// S3-compatible bucket name. + #[arg(long)] + pub bucket: String, + + /// Key prefix within the bucket (e.g. `mainnet` or `sepolia`). + #[arg(long, default_value = "")] + pub prefix: String, + + /// Chain ID for the snapshot manifest. + #[arg(long, default_value = "8453")] + pub chain_id: u64, + + /// Block number for the snapshot. Auto-inferred from the DB if omitted. + #[arg(long)] + pub block: Option, + + /// Blocks per archive file. Auto-inferred from header static files if omitted. + #[arg(long)] + pub blocks_per_file: Option, + + /// Maximum number of threads for snapshot archive creation. + /// + /// Defaults to half the available CPUs. + #[arg(long)] + pub snapshot_threads: Option, + + /// Docker socket path. + #[arg(long, default_value = "/var/run/docker.sock")] + pub docker_socket: String, + + /// S3 client configuration mode. + #[arg(long, env = "SNAPSHOTTER_S3_CONFIG_TYPE", default_value = "aws")] + pub s3_config_type: S3ConfigType, + + /// S3 endpoint URL (for R2 or `MinIO`). Required for `manual` config type. + #[arg(long, env = "SNAPSHOTTER_S3_ENDPOINT")] + pub s3_endpoint: Option, + + /// S3 region. + #[arg(long, env = "SNAPSHOTTER_S3_REGION", default_value = "us-east-1")] + pub s3_region: String, + + /// S3 access key ID. Required for `manual` config type. + #[arg(long, env = "SNAPSHOTTER_S3_ACCESS_KEY_ID")] + pub s3_access_key_id: Option, + + /// S3 secret access key. Required for `manual` config type. + #[arg(long, env = "SNAPSHOTTER_S3_SECRET_ACCESS_KEY")] + pub s3_secret_access_key: Option, +} diff --git a/crates/infra/snapshotter/src/container.rs b/crates/infra/snapshotter/src/container.rs new file mode 100644 index 0000000000..bcea77290b --- /dev/null +++ b/crates/infra/snapshotter/src/container.rs @@ -0,0 +1,108 @@ +//! Container lifecycle management via the Docker socket. + +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use bollard::{ + Docker, + query_parameters::{ + InspectContainerOptions, StartContainerOptions, StopContainerOptionsBuilder, + }, +}; +use tracing::info; + +/// Manages the lifecycle of a container (stop/start with state verification). +#[async_trait] +pub trait ContainerManager: Send + Sync { + /// Stops the container and verifies it is no longer running. + async fn stop(&self, container_name: &str) -> Result<()>; + + /// Starts the container and verifies it is running. + async fn start(&self, container_name: &str) -> Result<()>; + + /// Returns `true` if the container is currently running. + async fn is_running(&self, container_name: &str) -> Result; +} + +#[async_trait] +impl ContainerManager for Arc { + async fn stop(&self, container_name: &str) -> Result<()> { + (**self).stop(container_name).await + } + + async fn start(&self, container_name: &str) -> Result<()> { + (**self).start(container_name).await + } + + async fn is_running(&self, container_name: &str) -> Result { + (**self).is_running(container_name).await + } +} + +/// Docker-based container manager that communicates via the Docker socket. +/// +/// The socket path (e.g. `/var/run/docker.sock`) is volume-mounted into the +/// sidecar container, giving it control over sibling containers on the host. +#[derive(Debug)] +pub struct DockerContainerManager { + client: Docker, +} + +impl DockerContainerManager { + /// Connects to the Docker daemon via a Unix socket. + pub fn new(socket_path: &str) -> Result { + let client = Docker::connect_with_socket(socket_path, 120, bollard::API_DEFAULT_VERSION) + .with_context(|| format!("failed to connect to Docker socket at {socket_path}"))?; + Ok(Self { client }) + } +} + +#[async_trait] +impl ContainerManager for DockerContainerManager { + async fn stop(&self, container_name: &str) -> Result<()> { + info!(container = %container_name, "stopping container"); + + let opts = StopContainerOptionsBuilder::new().t(30).build(); + self.client + .stop_container(container_name, Some(opts)) + .await + .with_context(|| format!("failed to stop container {container_name}"))?; + + let running = self.is_running(container_name).await?; + if running { + bail!("container {container_name} is still running after stop request"); + } + + info!(container = %container_name, "container stopped and verified"); + Ok(()) + } + + async fn start(&self, container_name: &str) -> Result<()> { + info!(container = %container_name, "starting container"); + + self.client + .start_container(container_name, None::) + .await + .with_context(|| format!("failed to start container {container_name}"))?; + + let running = self.is_running(container_name).await?; + if !running { + bail!("container {container_name} is not running after start request"); + } + + info!(container = %container_name, "container started"); + Ok(()) + } + + async fn is_running(&self, container_name: &str) -> Result { + let info = self + .client + .inspect_container(container_name, None::) + .await + .with_context(|| format!("failed to inspect container {container_name}"))?; + + let running = info.state.and_then(|s| s.running).unwrap_or(false); + Ok(running) + } +} diff --git a/crates/infra/snapshotter/src/lib.rs b/crates/infra/snapshotter/src/lib.rs new file mode 100644 index 0000000000..f256c54a3c --- /dev/null +++ b/crates/infra/snapshotter/src/lib.rs @@ -0,0 +1,23 @@ +#![doc = include_str!("../README.md")] +#![doc( + html_logo_url = "https://avatars.githubusercontent.com/u/16627100?s=200&v=4", + html_favicon_url = "https://avatars.githubusercontent.com/u/16627100?s=200&v=4", + issue_tracker_base_url = "https://github.com/base/base/issues/" +)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod config; +pub use config::{S3ConfigType, SnapshotterConfig}; + +mod container; +pub use container::{ContainerManager, DockerContainerManager}; + +mod snapshot; +pub use snapshot::{OutputFileChecksum, SnapshotGenerator, SnapshotManifest}; + +mod upload; +pub use upload::{SnapshotUploader, UploadStrategy}; + +mod orchestrator; +pub use orchestrator::Snapshotter; diff --git a/crates/infra/snapshotter/src/orchestrator.rs b/crates/infra/snapshotter/src/orchestrator.rs new file mode 100644 index 0000000000..fd402eb473 --- /dev/null +++ b/crates/infra/snapshotter/src/orchestrator.rs @@ -0,0 +1,146 @@ +//! Orchestrates the full snapshot lifecycle with a restart safety guard. + +use std::{ + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail}; +use tracing::{error, info}; + +use crate::{ + SnapshotterConfig, container::ContainerManager, snapshot::SnapshotGenerator, + upload::SnapshotUploader, +}; + +/// Orchestrates the full snapshot flow: stop EL → generate → upload → restart EL. +/// +/// The EL container is always restarted, even if snapshot generation or upload +/// fails. This prevents leaving the node in a stopped state on errors. +pub struct Snapshotter { + container_manager: C, + uploader: SnapshotUploader, + config: SnapshotterConfig, +} + +impl std::fmt::Debug for Snapshotter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Snapshotter").field("config", &self.config).finish_non_exhaustive() + } +} + +impl Snapshotter { + /// Creates a new snapshotter with the given container manager and uploader. + pub const fn new( + container_manager: C, + uploader: SnapshotUploader, + config: SnapshotterConfig, + ) -> Self { + Self { container_manager, uploader, config } + } + + /// Executes the full snapshot lifecycle. + /// + /// 1. Stops the EL container + /// 2. Verifies the container is stopped + /// 3. Generates snapshot archives + /// 4. Uploads to S3/R2 + /// 5. Restarts the EL container (always, even on failure) + pub async fn run(&self) -> Result<()> { + let stop_result = self.container_manager.stop(&self.config.container_name).await; + + let result = match stop_result { + Ok(()) => self.generate_and_upload().await, + Err(e) => Err(e).context("failed to stop EL container"), + }; + + let restart_result = self.container_manager.start(&self.config.container_name).await; + + if let Err(ref restart_err) = restart_result { + error!( + error = %restart_err, + container = %self.config.container_name, + "CRITICAL: failed to restart EL container after snapshot" + ); + } + + match (result, restart_result) { + (Ok(()), Ok(())) => { + info!("snapshot lifecycle complete"); + Ok(()) + } + (Err(snapshot_err), Ok(())) => { + Err(snapshot_err).context("snapshot failed but EL container was restarted") + } + (Ok(()), Err(restart_err)) => { + bail!( + "snapshot succeeded but EL container restart failed: {restart_err}. \ + MANUAL INTERVENTION REQUIRED." + ) + } + (Err(snapshot_err), Err(restart_err)) => { + bail!( + "snapshot failed ({snapshot_err}) AND EL container restart failed \ + ({restart_err}). MANUAL INTERVENTION REQUIRED." + ) + } + } + } + + /// Generates snapshot archives and uploads them. Separated from `run` so + /// the restart guard logic stays clean. + async fn generate_and_upload(&self) -> Result<()> { + let run_timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let run_output_dir = create_run_output_dir(&self.config.output_dir, run_timestamp)?; + + let remote_static_files = self.uploader.list_remote_static_files().await?; + + info!(remote_files = remote_static_files.len(), "fetched remote static file listing"); + + let source_datadir = self.config.source_datadir.clone(); + let output_dir_for_gen = run_output_dir.clone(); + let chain_id = self.config.chain_id; + let block = self.config.block; + let blocks_per_file = self.config.blocks_per_file; + let remote_for_gen = remote_static_files.clone(); + + let files = tokio::task::spawn_blocking(move || { + SnapshotGenerator::generate_manifest( + &source_datadir, + &output_dir_for_gen, + chain_id, + block, + blocks_per_file, + &remote_for_gen, + ) + }) + .await + .context("snapshot generation task panicked")? + .context("snapshot generation failed")?; + + if files.is_empty() { + bail!("snapshot generation produced no files"); + } + + self.uploader + .upload(&run_output_dir, &files, run_timestamp, &remote_static_files) + .await + .context("snapshot upload failed")?; + + info!(output_dir = %run_output_dir.display(), "cleaning up local artifacts"); + if let Err(e) = tokio::fs::remove_dir_all(&run_output_dir).await { + error!(error = %e, "failed to clean up output directory"); + } + + Ok(()) + } +} + +/// Creates a unique run output directory using the provided timestamp. +fn create_run_output_dir(base: &std::path::Path, timestamp: u64) -> Result { + let run_dir = base.join(format!("run-{timestamp}")); + std::fs::create_dir_all(&run_dir) + .with_context(|| format!("failed to create run dir {}", run_dir.display()))?; + Ok(run_dir) +} diff --git a/crates/infra/snapshotter/src/snapshot.rs b/crates/infra/snapshotter/src/snapshot.rs new file mode 100644 index 0000000000..236b702101 --- /dev/null +++ b/crates/infra/snapshotter/src/snapshot.rs @@ -0,0 +1,794 @@ +//! Snapshot archive generation with selective compression. +//! +//! Archive creation, BLAKE3 hashing, and manifest structure are derived from +//! [reth](https://github.com/paradigmxyz/reth) (`crates/cli/commands/src/download/manifest.rs`, +//! commit `d58c6e3`, tag `v2.1.0`), licensed under Apache-2.0. +//! +//! Modified to support skipping compression of finalized static file chunks +//! that already exist in remote storage. Only the tip chunk and a configurable +//! buffer of recent chunks are compressed. + +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + io::Read, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use tracing::info; + +/// Default blocks per static file segment. +const DEFAULT_BLOCKS_PER_FILE: u64 = 500_000; + +/// Number of extra chunks beyond the tip to compress as a safety buffer. +const EXTRA_CHUNKS_BUFFER: u64 = 2; + +/// Maximum number of chunks allowed before bailing to prevent OOM. +/// At 500k blocks per file, 100k chunks covers 50 billion blocks. +const MAX_CHUNKS: u64 = 100_000; + +/// Static file component types that produce chunked archives. +const CHUNKED_COMPONENTS: &[(&str, &str)] = &[ + ("headers", "headers"), + ("transactions", "transactions"), + ("transaction_senders", "transaction-senders"), + ("receipts", "receipts"), + ("account_changesets", "account-change-sets"), + ("storage_changesets", "storage-change-sets"), +]; + +/// A snapshot manifest describing available components. +/// +/// Matches reth's `SnapshotManifest` JSON format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotManifest { + /// Block number this snapshot was taken at. + pub block: u64, + /// Chain ID. + pub chain_id: u64, + /// Storage version. + pub storage_version: u64, + /// Unix timestamp. + pub timestamp: u64, + /// Available snapshot components. + pub components: BTreeMap, +} + +/// Checksum metadata for an extracted file within an archive. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutputFileChecksum { + /// Relative path under the target datadir. + pub path: String, + /// File size in bytes. + pub size: u64, + /// BLAKE3 checksum. + pub blake3: String, +} + +/// Generates snapshot archives with selective compression. +/// +/// Static file chunks whose block ranges are in `skip_ranges` are not +/// compressed or written to the output directory. +#[derive(Debug)] +pub struct SnapshotGenerator; + +impl SnapshotGenerator { + /// Generates snapshot archives, skipping compression for chunks in `skip_ranges`. + /// + /// `skip_ranges` contains `(start, end)` block ranges that already exist + /// remotely and don't need to be re-compressed. + /// + /// Returns the list of files created in the output directory. + /// + /// From + pub fn generate_manifest( + source_datadir: &Path, + output_dir: &Path, + chain_id: u64, + block: Option, + blocks_per_file: Option, + remote_static_files: &HashMap, + ) -> Result> { + std::fs::create_dir_all(output_dir) + .with_context(|| format!("failed to create output dir {}", output_dir.display()))?; + + let blocks_per_file = blocks_per_file.unwrap_or(DEFAULT_BLOCKS_PER_FILE); + let block = match block { + Some(b) => b, + None => infer_block_from_headers(source_datadir)?, + }; + + let remote_filenames: HashSet<&str> = + remote_static_files.keys().map(String::as_str).collect(); + let skip_ranges = Self::compute_skip_ranges(&remote_filenames, block, blocks_per_file)?; + + info!( + source = %source_datadir.display(), + output = %output_dir.display(), + chain_id, + block, + blocks_per_file, + skip_count = skip_ranges.len(), + "generating snapshot archives" + ); + + let static_files_dir = source_datadir.join("static_files"); + let static_dir = + if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() }; + let dir_listing = read_static_dir(&static_dir)?; + + let mut components = BTreeMap::new(); + + let num_chunks = block.div_ceil(blocks_per_file); + if num_chunks > MAX_CHUNKS { + bail!( + "too many chunks ({num_chunks}) for block {block} with blocks_per_file \ + {blocks_per_file} — increase --blocks-per-file or check --block" + ); + } + + for &(key, segment_name) in CHUNKED_COMPONENTS { + let mut planned = Vec::new(); + let mut found_any = false; + let mut chunk_skipped = vec![false; num_chunks as usize]; + + for i in 0..num_chunks { + let start = i * blocks_per_file; + let end = start.checked_add(blocks_per_file - 1).context("block range overflow")?; + let source_files = filter_source_files(&dir_listing, segment_name, start, end); + + if source_files.is_empty() { + if found_any { + bail!("missing source files for {key} chunk {start}-{end}"); + } + continue; + } + found_any = true; + + if skip_ranges.contains(&(start, end)) { + chunk_skipped[i as usize] = true; + continue; + } + + planned.push(PlannedChunk { + chunk_idx: i, + archive_path: output_dir.join(chunk_filename(key, start, end)), + source_files, + }); + } + + if !found_any { + info!(component = key, "no static files found, skipping component"); + } else { + let packaged: Vec = planned + .into_par_iter() + .map(|p| { + let output_files = write_chunk_archive(&p.archive_path, &p.source_files)?; + let size = std::fs::metadata(&p.archive_path)?.len(); + Ok(PackagedChunk { chunk_idx: p.chunk_idx, size, output_files }) + }) + .collect::>>()?; + + let mut chunk_sizes = vec![0u64; num_chunks as usize]; + let mut chunk_decompressed = vec![0u64; num_chunks as usize]; + let mut chunk_output_files: Vec> = + (0..num_chunks).map(|_| Vec::new()).collect(); + + for p in packaged { + let idx = p.chunk_idx as usize; + chunk_sizes[idx] = p.size; + chunk_decompressed[idx] = p.output_files.iter().map(|f| f.size).sum(); + chunk_output_files[idx] = p.output_files; + } + + let total_size: u64 = chunk_sizes.iter().sum(); + info!( + component = key, + compressed_size = total_size, + total_blocks = block, + "packaged chunked component" + ); + + components.insert( + key.to_string(), + serde_json::json!({ + "blocks_per_file": blocks_per_file, + "total_blocks": block, + "chunk_sizes": chunk_sizes, + "chunk_decompressed_sizes": chunk_decompressed, + "chunk_output_files": chunk_output_files, + "chunk_skipped": chunk_skipped, + }), + ); + } + } + + let state_files = state_source_files(source_datadir)?; + let (state_size, state_output_files) = + package_single_component(output_dir, "state.tar.zst", &state_files)?; + components.insert( + "state".to_string(), + serde_json::json!({ + "file": "state.tar.zst", + "size": state_size, + "decompressed_size": state_output_files.iter().map(|f| f.size).sum::(), + "output_files": state_output_files, + }), + ); + + let rocksdb_files = rocksdb_source_files(source_datadir)?; + if !rocksdb_files.is_empty() { + let (rocksdb_size, rocksdb_output_files) = + package_single_component(output_dir, "rocksdb_indices.tar.zst", &rocksdb_files)?; + components.insert( + "rocksdb_indices".to_string(), + serde_json::json!({ + "file": "rocksdb_indices.tar.zst", + "size": rocksdb_size, + "decompressed_size": rocksdb_output_files.iter().map(|f| f.size).sum::(), + "output_files": rocksdb_output_files, + }), + ); + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before UNIX epoch")? + .as_secs(); + + let manifest = + SnapshotManifest { block, chain_id, storage_version: 2, timestamp, components }; + + let manifest_path = output_dir.join("manifest.json"); + std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; + info!(block, components = manifest.components.len(), "manifest written"); + + let files = collect_output_files(output_dir)?; + info!(file_count = files.len(), "snapshot generation complete"); + Ok(files) + } + + /// Determines which chunk ranges can be skipped based on what already exists + /// remotely. Keeps the tip chunk and `EXTRA_CHUNKS_BUFFER` additional chunks. + pub fn compute_skip_ranges( + remote_filenames: &HashSet<&str>, + block: u64, + blocks_per_file: u64, + ) -> Result> { + let num_chunks = block.div_ceil(blocks_per_file); + let keep_from = num_chunks.saturating_sub(1 + EXTRA_CHUNKS_BUFFER); + + let mut skip = HashSet::new(); + for i in 0..num_chunks { + if i >= keep_from { + continue; + } + let start = i * blocks_per_file; + let end = start + .checked_add(blocks_per_file - 1) + .context("block range overflow in skip computation")?; + + let dominated_by_remote = CHUNKED_COMPONENTS.iter().all(|&(key, _)| { + let filename = chunk_filename(key, start, end); + remote_filenames.contains(filename.as_str()) + }); + + if dominated_by_remote { + skip.insert((start, end)); + } + } + + Ok(skip) + } +} + +fn chunk_filename(component_key: &str, start: u64, end: u64) -> String { + format!("{component_key}-{start}-{end}.tar.zst") +} + +/// Infers the snapshot block from the highest header static file range. +fn infer_block_from_headers(source_datadir: &Path) -> Result { + let static_files_dir = source_datadir.join("static_files"); + let dir = + if static_files_dir.exists() { static_files_dir } else { source_datadir.to_path_buf() }; + + let mut max_end = None; + for entry in + std::fs::read_dir(&dir).with_context(|| format!("failed to read {}", dir.display()))? + { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if let Some(range) = parse_headers_range(&name) { + max_end = Some(max_end.map_or(range.1, |prev: u64| prev.max(range.1))); + } + } + + max_end.ok_or_else(|| anyhow::anyhow!("no header static files found to infer --block")) +} + +fn parse_headers_range(file_name: &str) -> Option<(u64, u64)> { + let remainder = file_name.strip_prefix("static_file_headers_")?; + let (start, end_with_suffix) = remainder.split_once('_')?; + let start = start.parse::().ok()?; + let end_digits: String = end_with_suffix.chars().take_while(|ch| ch.is_ascii_digit()).collect(); + let end = end_digits.parse::().ok()?; + Some((start, end)) +} + +struct PlannedChunk { + chunk_idx: u64, + archive_path: PathBuf, + source_files: Vec, +} + +struct PackagedChunk { + chunk_idx: u64, + size: u64, + output_files: Vec, +} + +struct PlannedFile { + source_path: PathBuf, + relative_path: PathBuf, +} + +/// Cached directory entry: (filename, full path). +type DirEntry = (String, PathBuf); + +/// Reads a directory once, returning all file entries as (name, path) pairs. +fn read_static_dir(dir: &Path) -> Result> { + let mut entries = Vec::new(); + for entry in + std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().to_string(); + entries.push((name, entry.path())); + } + entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + Ok(entries) +} + +/// Filters the cached directory listing for files matching a chunk prefix. +fn filter_source_files( + dir_listing: &[DirEntry], + segment_name: &str, + start: u64, + end: u64, +) -> Vec { + let prefix = format!("static_file_{segment_name}_{start}_{end}"); + dir_listing + .iter() + .filter(|(name, _)| name.starts_with(&prefix)) + .map(|(_, path)| path.clone()) + .collect() +} + +fn state_source_files(source_datadir: &Path) -> Result> { + let db_dir = source_datadir.join("db"); + if db_dir.exists() { + return collect_files_recursive(&db_dir, Path::new("db")); + } + + if looks_like_db_dir(source_datadir)? { + return collect_files_recursive(source_datadir, Path::new("db")); + } + + bail!("could not find source state DB directory under {}", source_datadir.display()) +} + +fn rocksdb_source_files(source_datadir: &Path) -> Result> { + let rocksdb_dir = source_datadir.join("rocksdb"); + if !rocksdb_dir.exists() { + return Ok(Vec::new()); + } + collect_files_recursive(&rocksdb_dir, Path::new("rocksdb")) +} + +fn looks_like_db_dir(path: &Path) -> Result { + let entries = match std::fs::read_dir(path) { + Ok(entries) => entries, + Err(_) => return Ok(false), + }; + for entry in entries { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name == "mdbx.dat" || name == "lock.mdb" || name == "data.mdb" { + return Ok(true); + } + } + Ok(false) +} + +fn collect_files_recursive(root: &Path, output_prefix: &Path) -> Result> { + let mut files = Vec::new(); + collect_files_inner(root, root, output_prefix, &mut files)?; + files.sort_unstable_by(|a, b| a.relative_path.cmp(&b.relative_path)); + Ok(files) +} + +fn collect_files_inner( + root: &Path, + dir: &Path, + output_prefix: &Path, + files: &mut Vec, +) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let ft = entry.file_type()?; + if ft.is_dir() { + collect_files_inner(root, &path, output_prefix, files)?; + } else if ft.is_file() { + let relative = path.strip_prefix(root)?.to_path_buf(); + files.push(PlannedFile { + source_path: path, + relative_path: output_prefix.join(relative), + }); + } + } + Ok(()) +} + +fn package_single_component( + output_dir: &Path, + archive_name: &str, + files: &[PlannedFile], +) -> Result<(u64, Vec)> { + if files.is_empty() { + bail!("cannot package empty archive: {archive_name}"); + } + let archive_path = output_dir.join(archive_name); + let output_files = write_archive_from_planned_files(&archive_path, files)?; + let size = std::fs::metadata(&archive_path)?.len(); + Ok((size, output_files)) +} + +fn write_chunk_archive(path: &Path, source_files: &[PathBuf]) -> Result> { + let planned: Vec = source_files + .iter() + .map(|p| { + let file_name = + p.file_name().ok_or_else(|| anyhow::anyhow!("invalid path: {}", p.display()))?; + Ok(PlannedFile { + source_path: p.clone(), + relative_path: PathBuf::from("static_files").join(file_name), + }) + }) + .collect::>>()?; + + write_archive_from_planned_files(path, &planned) +} + +fn write_archive_from_planned_files( + path: &Path, + files: &[PlannedFile], +) -> Result> { + let file = std::fs::File::create(path)?; + let mut encoder = zstd::Encoder::new(file, 0)?; + encoder.include_checksum(true)?; + let mut builder = tar::Builder::new(encoder); + + let mut output_files = Vec::with_capacity(files.len()); + for planned in files { + let expected_size = std::fs::metadata(&planned.source_path)?.len(); + let mut header = tar::Header::new_gnu(); + header.set_size(expected_size); + header.set_mode(0o644); + header.set_cksum(); + + let source_file = std::fs::File::open(&planned.source_path)?; + let mut reader = HashingReader::new(source_file); + builder.append_data(&mut header, &planned.relative_path, &mut reader)?; + + if reader.bytes_read != expected_size { + bail!( + "file size changed during archiving: {} (expected {expected_size}, read {})", + planned.source_path.display(), + reader.bytes_read + ); + } + + output_files.push(OutputFileChecksum { + path: planned.relative_path.to_string_lossy().to_string(), + size: reader.bytes_read, + blake3: reader.finalize(), + }); + } + + let encoder = builder.into_inner()?; + encoder.finish()?; + + Ok(output_files) +} + +struct HashingReader { + inner: R, + hasher: blake3::Hasher, + bytes_read: u64, +} + +impl HashingReader { + fn new(inner: R) -> Self { + Self { inner, hasher: blake3::Hasher::new(), bytes_read: 0 } + } + + fn finalize(self) -> String { + self.hasher.finalize().to_hex().to_string() + } +} + +impl Read for HashingReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let n = self.inner.read(buf)?; + if n > 0 { + self.bytes_read += n as u64; + self.hasher.update(&buf[..n]); + } + Ok(n) + } +} + +/// Collects all files in the output directory (non-recursive). +fn collect_output_files(dir: &Path) -> Result> { + let mut files = Vec::new(); + for entry in + std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? + { + let entry = entry?; + if entry.file_type()?.is_file() { + files.push(entry.path()); + } + } + files.sort_unstable(); + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_headers_range_valid() { + assert_eq!(parse_headers_range("static_file_headers_0_499999"), Some((0, 499_999))); + assert_eq!( + parse_headers_range("static_file_headers_500000_999999"), + Some((500_000, 999_999)) + ); + } + + #[test] + fn parse_headers_range_with_suffix() { + assert_eq!( + parse_headers_range("static_file_headers_500000_999999.jar"), + Some((500_000, 999_999)) + ); + } + + #[test] + fn parse_headers_range_non_header_files() { + assert_eq!(parse_headers_range("static_file_transactions_0_499999"), None); + assert_eq!(parse_headers_range("mdbx.dat"), None); + assert_eq!(parse_headers_range(""), None); + } + + #[test] + fn infer_block_from_headers_uses_max_end() { + let dir = tempfile::tempdir().unwrap(); + let sf = dir.path().join("static_files"); + std::fs::create_dir_all(&sf).unwrap(); + std::fs::write(sf.join("static_file_headers_0_499999"), []).unwrap(); + std::fs::write(sf.join("static_file_headers_500000_999999"), []).unwrap(); + + assert_eq!(infer_block_from_headers(dir.path()).unwrap(), 999_999); + } + + #[test] + fn infer_block_from_headers_fails_when_no_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("static_files")).unwrap(); + + assert!(infer_block_from_headers(dir.path()).is_err()); + } + + #[test] + fn compute_skip_ranges_skips_finalized_chunks() { + let mut remote = HashSet::new(); + for &(key, _) in CHUNKED_COMPONENTS { + remote.insert(chunk_filename(key, 0, 499_999)); + remote.insert(chunk_filename(key, 500_000, 999_999)); + remote.insert(chunk_filename(key, 1_000_000, 1_499_999)); + remote.insert(chunk_filename(key, 1_500_000, 1_999_999)); + } + + // block=2_000_000, blocks_per_file=500_000 → 4 chunks (indices 0-3) + // tip = chunk 3, buffer = 2 → keep chunks 1,2,3 → skip chunk 0 + let refs: HashSet<&str> = remote.iter().map(String::as_str).collect(); + let skip = SnapshotGenerator::compute_skip_ranges(&refs, 2_000_000, 500_000).unwrap(); + + assert!(skip.contains(&(0, 499_999)), "chunk 0 should be skipped"); + assert!(!skip.contains(&(500_000, 999_999)), "chunk 1 should NOT be skipped (in buffer)"); + assert!( + !skip.contains(&(1_000_000, 1_499_999)), + "chunk 2 should NOT be skipped (in buffer)" + ); + assert!(!skip.contains(&(1_500_000, 1_999_999)), "chunk 3 (tip) should NOT be skipped"); + } + + #[test] + fn compute_skip_ranges_keeps_all_when_few_chunks() { + let mut remote = HashSet::new(); + for &(key, _) in CHUNKED_COMPONENTS { + remote.insert(chunk_filename(key, 0, 499_999)); + remote.insert(chunk_filename(key, 500_000, 999_999)); + } + + // block=1_000_000 → 2 chunks, tip + buffer(2) = 3 → keep all + let refs: HashSet<&str> = remote.iter().map(String::as_str).collect(); + let skip = SnapshotGenerator::compute_skip_ranges(&refs, 1_000_000, 500_000).unwrap(); + assert!(skip.is_empty(), "should keep all chunks when count <= tip + buffer"); + } + + #[test] + fn compute_skip_ranges_skips_nothing_when_remote_empty() { + let remote = HashSet::new(); + let refs: HashSet<&str> = remote.iter().map(String::as_str).collect(); + let skip = SnapshotGenerator::compute_skip_ranges(&refs, 5_000_000, 500_000).unwrap(); + assert!(skip.is_empty(), "nothing to skip when remote is empty"); + } + + #[test] + fn compute_skip_ranges_requires_all_components_present() { + let mut remote = HashSet::new(); + // Only add headers, not other components + remote.insert(chunk_filename("headers", 0, 499_999)); + + let refs: HashSet<&str> = remote.iter().map(String::as_str).collect(); + let skip = SnapshotGenerator::compute_skip_ranges(&refs, 5_000_000, 500_000).unwrap(); + assert!( + !skip.contains(&(0, 499_999)), + "should not skip range if not all components are present remotely" + ); + } + + #[test] + fn generate_manifest_creates_state_archive() { + let source = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + let db_dir = source.path().join("db"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::write(db_dir.join("mdbx.dat"), b"state-data").unwrap(); + + let remote = HashMap::new(); + let files = SnapshotGenerator::generate_manifest( + source.path(), + output.path(), + 8453, + Some(0), + Some(500_000), + &remote, + ) + .unwrap(); + + assert!( + files.iter().any(|f| f.file_name().unwrap() == "state.tar.zst"), + "should produce state.tar.zst" + ); + assert!( + files.iter().any(|f| f.file_name().unwrap() == "manifest.json"), + "should produce manifest.json" + ); + } + + #[test] + fn generate_manifest_skips_finalized_ranges_via_remote() { + let source = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + + let db_dir = source.path().join("db"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::write(db_dir.join("mdbx.dat"), b"state").unwrap(); + + // 4 header chunks → block=2M, bpf=500k + // tip=chunk3, buffer=2 → keep 1,2,3 → skip chunk 0 + let sf = source.path().join("static_files"); + std::fs::create_dir_all(&sf).unwrap(); + for i in 0..4u64 { + let start = i * 500_000; + let end = (i + 1) * 500_000 - 1; + std::fs::write(sf.join(format!("static_file_headers_{start}_{end}")), b"data").unwrap(); + } + + // Simulate all chunked components existing remotely for range 0-499999 + let mut remote = HashMap::new(); + for &(key, _) in CHUNKED_COMPONENTS { + remote.insert(chunk_filename(key, 0, 499_999), 0u64); + } + + let files = SnapshotGenerator::generate_manifest( + source.path(), + output.path(), + 8453, + Some(2_000_000), + Some(500_000), + &remote, + ) + .unwrap(); + + let filenames: Vec = files + .iter() + .filter_map(|f| f.file_name().map(|n| n.to_string_lossy().to_string())) + .collect(); + + assert!( + !filenames.contains(&"headers-0-499999.tar.zst".to_string()), + "finalized range 0 should be skipped (all components exist remotely)" + ); + assert!( + filenames.contains(&"headers-500000-999999.tar.zst".to_string()), + "buffer range should be compressed" + ); + assert!( + filenames.contains(&"headers-1500000-1999999.tar.zst".to_string()), + "tip range should be compressed" + ); + } + + #[test] + fn manifest_includes_chunk_skipped_field() { + let source = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + + let db_dir = source.path().join("db"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::write(db_dir.join("mdbx.dat"), b"state").unwrap(); + + // 4 header chunks → skip chunk 0 + let sf = source.path().join("static_files"); + std::fs::create_dir_all(&sf).unwrap(); + for i in 0..4u64 { + let start = i * 500_000; + let end = (i + 1) * 500_000 - 1; + std::fs::write(sf.join(format!("static_file_headers_{start}_{end}")), b"data").unwrap(); + } + + let mut remote = HashMap::new(); + for &(key, _) in CHUNKED_COMPONENTS { + remote.insert(chunk_filename(key, 0, 499_999), 0u64); + } + + SnapshotGenerator::generate_manifest( + source.path(), + output.path(), + 8453, + Some(2_000_000), + Some(500_000), + &remote, + ) + .unwrap(); + + let manifest_content = + std::fs::read_to_string(output.path().join("manifest.json")).unwrap(); + let manifest: serde_json::Value = serde_json::from_str(&manifest_content).unwrap(); + + let headers = &manifest["components"]["headers"]; + let skipped = + headers["chunk_skipped"].as_array().expect("chunk_skipped should be an array"); + + assert_eq!(skipped.len(), 4, "should have 4 chunk entries"); + assert_eq!(skipped[0], true, "chunk 0 should be marked as skipped"); + assert_eq!(skipped[1], false, "chunk 1 (buffer) should not be skipped"); + assert_eq!(skipped[2], false, "chunk 2 (buffer) should not be skipped"); + assert_eq!(skipped[3], false, "chunk 3 (tip) should not be skipped"); + } +} diff --git a/crates/infra/snapshotter/src/upload.rs b/crates/infra/snapshotter/src/upload.rs new file mode 100644 index 0000000000..6bae2817cd --- /dev/null +++ b/crates/infra/snapshotter/src/upload.rs @@ -0,0 +1,454 @@ +//! S3-compatible upload for snapshot artifacts with diff-based optimization. +//! +//! Artifacts are split into two areas within the bucket: +//! +//! - `{prefix}/static_files/` — static file chunks that are immutable for finalized +//! block ranges. Only the tip chunk changes between snapshots. The uploader +//! compares local sizes against existing remote objects and skips unchanged chunks. +//! +//! - `{prefix}/{date}/` — per-run directory for mdbx state, rocksdb, and the manifest. +//! These are always re-uploaded since they change every snapshot. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use aws_sdk_s3::{ + Client as S3Client, + primitives::ByteStream, + types::{CompletedMultipartUpload, CompletedPart}, +}; +use futures::stream::{self, StreamExt, TryStreamExt}; +use tracing::{debug, info}; + +/// Maximum number of concurrent file uploads. +const MAX_CONCURRENT_UPLOADS: usize = 10; + +/// Files larger than this threshold use multipart upload. +/// S3 `put_object` has a 5 `GiB` limit; we switch well below that. +const MULTIPART_THRESHOLD: u64 = 100 * 1024 * 1024; + +/// Part size for multipart uploads (100 `MiB`). +const MULTIPART_PART_SIZE: u64 = 100 * 1024 * 1024; + +/// Determines whether a snapshot component is re-uploaded every run +/// or can be skipped when the remote copy already matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadStrategy { + /// Always upload to the per-run date directory (mdbx, rocksdb, manifest). + AlwaysUpload, + /// Upload to `static_files/`, skipping if the remote object has the same size. + DiffBySize, +} + +impl UploadStrategy { + /// Classifies a snapshot filename into its upload strategy. + /// + /// Static file chunks follow the pattern `{component}-{start}-{end}.tar.zst` + /// (e.g. `headers-0-499999.tar.zst`). These are immutable for finalized block + /// ranges and only the tip chunk changes between snapshots. + /// + /// Everything else (state, rocksdb, manifest) is always uploaded. + pub fn classify(filename: &str) -> Self { + if is_static_file_chunk(filename) { Self::DiffBySize } else { Self::AlwaysUpload } + } +} + +/// Returns `true` if the filename matches the static file chunk pattern: +/// `{component}-{start}-{end}.tar.zst`. +fn is_static_file_chunk(filename: &str) -> bool { + let Some(stem) = filename.strip_suffix(".tar.zst") else { + return false; + }; + + let parts: Vec<&str> = stem.rsplitn(3, '-').collect(); + if parts.len() < 3 { + return false; + } + + let end_ok = parts[0].parse::().is_ok(); + let start_ok = parts[1].parse::().is_ok(); + end_ok && start_ok +} + +/// Uploads snapshot artifacts to an S3-compatible store (R2, `MinIO`, etc.). +#[derive(Debug)] +pub struct SnapshotUploader { + client: S3Client, + bucket: String, + prefix: String, +} + +impl SnapshotUploader { + /// Creates a new uploader. + pub const fn new(client: S3Client, bucket: String, prefix: String) -> Self { + Self { client, bucket, prefix } + } + + /// Lists remote static files with their sizes. Call once and pass the result + /// to both `generate_manifest` (for skip ranges) and `upload` (for diff). + pub async fn list_remote_static_files(&self) -> Result> { + self.list_remote_objects(&self.static_files_prefix()).await + } + + /// Uploads snapshot artifacts with diff-based optimization. + /// + /// `remote_static_files` is the pre-fetched listing from `list_remote_static_files`. + /// Static file chunks go to `{prefix}/static_files/` and are skipped if the + /// remote object already exists with the same size. State, rocksdb, and + /// manifest go to `{prefix}/{date}/` and are always re-uploaded. + /// `manifest.json` is uploaded last as the "snapshot complete" signal. + pub async fn upload( + &self, + output_dir: &Path, + files: &[PathBuf], + timestamp: u64, + remote_static_files: &HashMap, + ) -> Result { + let static_prefix = self.static_files_prefix(); + let run_prefix = self.run_prefix(timestamp); + + info!( + run_prefix = %run_prefix, + static_prefix = %static_prefix, + file_count = files.len(), + bucket = %self.bucket, + "uploading snapshot artifacts" + ); + + let manifest_path = output_dir.join("manifest.json"); + let mut static_uploads = Vec::new(); + let mut run_uploads = Vec::new(); + let mut skipped = 0u64; + + for file in files { + if file == &manifest_path { + continue; + } + + let file_name = file + .file_name() + .ok_or_else(|| anyhow::anyhow!("invalid file path: {}", file.display()))? + .to_string_lossy() + .to_string(); + + let local_size = tokio::fs::metadata(file).await?.len(); + let strategy = UploadStrategy::classify(&file_name); + + match strategy { + UploadStrategy::DiffBySize => { + if let Some(&remote_size) = remote_static_files.get(&file_name) { + if remote_size == local_size { + debug!(file = %file_name, size = local_size, "skipping static file (size matches)"); + skipped += 1; + continue; + } + debug!(file = %file_name, local_size, remote_size, "re-uploading static file (size mismatch)"); + } + static_uploads.push(file.clone()); + } + UploadStrategy::AlwaysUpload => { + run_uploads.push(file.clone()); + } + } + } + + info!( + static_uploads = static_uploads.len(), + run_uploads = run_uploads.len(), + skipped, + "diff analysis complete" + ); + + let static_prefix_ref = &static_prefix; + stream::iter(static_uploads) + .map(|file| async move { self.upload_file(&file, static_prefix_ref).await }) + .buffer_unordered(MAX_CONCURRENT_UPLOADS) + .try_collect::>() + .await?; + + let run_prefix_ref = &run_prefix; + stream::iter(run_uploads) + .map(|file| async move { self.upload_file(&file, run_prefix_ref).await }) + .buffer_unordered(MAX_CONCURRENT_UPLOADS) + .try_collect::>() + .await?; + + if manifest_path.exists() { + self.upload_file(&manifest_path, &run_prefix).await?; + } + + info!(run_prefix = %run_prefix, skipped, "upload complete"); + Ok(run_prefix) + } + + /// Returns the `{prefix}/static_files` key prefix. + fn static_files_prefix(&self) -> String { + if self.prefix.is_empty() { + "static_files".to_string() + } else { + format!("{}/static_files", self.prefix) + } + } + + /// Returns the `{prefix}/{timestamp}` key prefix for a run. + fn run_prefix(&self, timestamp: u64) -> String { + if self.prefix.is_empty() { + timestamp.to_string() + } else { + format!("{}/{timestamp}", self.prefix) + } + } + + /// Lists all objects under a prefix in the bucket, returning filename → size. + async fn list_remote_objects(&self, prefix: &str) -> Result> { + let prefix_with_slash = format!("{prefix}/"); + let mut remote = HashMap::new(); + let mut continuation_token = None; + + loop { + let mut req = + self.client.list_objects_v2().bucket(&self.bucket).prefix(&prefix_with_slash); + + if let Some(token) = continuation_token.take() { + req = req.continuation_token(token); + } + + let resp = req + .send() + .await + .with_context(|| format!("failed to list objects under {prefix_with_slash}"))?; + + for obj in resp.contents() { + if let Some(key) = obj.key() { + let filename = key.strip_prefix(&prefix_with_slash).unwrap_or(key).to_string(); + let size: u64 = obj.size.unwrap_or(0).try_into().unwrap_or(0); + remote.insert(filename, size); + } + } + + if resp.is_truncated() == Some(true) { + continuation_token = resp.next_continuation_token().map(String::from); + } else { + break; + } + } + + debug!(prefix = %prefix, count = remote.len(), "listed remote objects"); + Ok(remote) + } + + /// Uploads a single file, using multipart upload for files above the threshold. + async fn upload_file(&self, file_path: &Path, dest_prefix: &str) -> Result<()> { + let file_name = file_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("invalid file path: {}", file_path.display()))? + .to_string_lossy(); + + let key = format!("{dest_prefix}/{file_name}"); + let file_size = tokio::fs::metadata(file_path).await?.len(); + + if file_size > MULTIPART_THRESHOLD { + debug!(key = %key, size = file_size, "uploading file (multipart)"); + self.upload_multipart(file_path, &key, file_size).await + } else { + debug!(key = %key, size = file_size, "uploading file"); + self.upload_single(file_path, &key).await + } + } + + async fn upload_single(&self, file_path: &Path, key: &str) -> Result<()> { + let body = ByteStream::from_path(file_path) + .await + .with_context(|| format!("failed to read {}", file_path.display()))?; + + self.client + .put_object() + .bucket(&self.bucket) + .key(key) + .body(body) + .send() + .await + .with_context(|| format!("failed to upload {key}"))?; + + Ok(()) + } + + async fn upload_multipart(&self, file_path: &Path, key: &str, file_size: u64) -> Result<()> { + let create_resp = self + .client + .create_multipart_upload() + .bucket(&self.bucket) + .key(key) + .send() + .await + .with_context(|| format!("failed to initiate multipart upload for {key}"))?; + + let upload_id = create_resp + .upload_id() + .ok_or_else(|| anyhow::anyhow!("no upload_id returned for {key}"))? + .to_string(); + + let result = self.upload_parts(file_path, key, &upload_id, file_size).await; + + match result { + Ok(parts) => { + let completed = CompletedMultipartUpload::builder().set_parts(Some(parts)).build(); + + self.client + .complete_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(&upload_id) + .multipart_upload(completed) + .send() + .await + .with_context(|| format!("failed to complete multipart upload for {key}"))?; + + Ok(()) + } + Err(e) => { + self.client + .abort_multipart_upload() + .bucket(&self.bucket) + .key(key) + .upload_id(&upload_id) + .send() + .await + .ok(); + + Err(e) + } + } + } + + async fn upload_parts( + &self, + file_path: &Path, + key: &str, + upload_id: &str, + file_size: u64, + ) -> Result> { + let planned: Vec<(u64, i32)> = std::iter::successors(Some(0u64), |&offset| { + let next = offset + MULTIPART_PART_SIZE; + (next < file_size).then_some(next) + }) + .zip(1i32..) + .collect(); + + if planned.is_empty() { + bail!("no parts to upload for {key}"); + } + + let mut completed: Vec = stream::iter(planned) + .map(|(offset, part_number)| { + let length = std::cmp::min(MULTIPART_PART_SIZE, file_size - offset); + async move { + self.upload_single_part(file_path, key, upload_id, part_number, offset, length) + .await + } + }) + .buffer_unordered(MAX_CONCURRENT_UPLOADS) + .try_collect() + .await?; + + completed.sort_unstable_by_key(|p| p.part_number); + Ok(completed) + } + + async fn upload_single_part( + &self, + file_path: &Path, + key: &str, + upload_id: &str, + part_number: i32, + offset: u64, + length: u64, + ) -> Result { + let body = ByteStream::read_from() + .path(file_path) + .offset(offset) + .length(aws_sdk_s3::primitives::Length::Exact(length)) + .build() + .await + .with_context(|| { + format!("failed to read part {part_number} of {}", file_path.display()) + })?; + + let upload_resp = self + .client + .upload_part() + .bucket(&self.bucket) + .key(key) + .upload_id(upload_id) + .part_number(part_number) + .body(body) + .send() + .await + .with_context(|| format!("failed to upload part {part_number} of {key}"))?; + + let e_tag = upload_resp + .e_tag() + .ok_or_else(|| anyhow::anyhow!("no ETag for part {part_number} of {key}"))? + .to_string(); + + Ok(CompletedPart::builder().part_number(part_number).e_tag(e_tag).build()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_file_chunks_are_diff_eligible() { + assert_eq!( + UploadStrategy::classify("headers-0-499999.tar.zst"), + UploadStrategy::DiffBySize + ); + assert_eq!( + UploadStrategy::classify("transactions-500000-999999.tar.zst"), + UploadStrategy::DiffBySize + ); + assert_eq!( + UploadStrategy::classify("receipts-9500000-9999999.tar.zst"), + UploadStrategy::DiffBySize + ); + assert_eq!( + UploadStrategy::classify("account_changesets-0-499999.tar.zst"), + UploadStrategy::DiffBySize + ); + assert_eq!( + UploadStrategy::classify("storage_changesets-1000000-1499999.tar.zst"), + UploadStrategy::DiffBySize + ); + assert_eq!( + UploadStrategy::classify("transaction_senders-0-499999.tar.zst"), + UploadStrategy::DiffBySize + ); + } + + #[test] + fn non_chunk_files_always_upload() { + assert_eq!(UploadStrategy::classify("state.tar.zst"), UploadStrategy::AlwaysUpload); + assert_eq!( + UploadStrategy::classify("rocksdb_indices.tar.zst"), + UploadStrategy::AlwaysUpload + ); + assert_eq!(UploadStrategy::classify("manifest.json"), UploadStrategy::AlwaysUpload); + assert_eq!(UploadStrategy::classify("random-file.txt"), UploadStrategy::AlwaysUpload); + } + + #[test] + fn is_static_file_chunk_edge_cases() { + assert!(!is_static_file_chunk("state.tar.zst")); + assert!(!is_static_file_chunk("headers.tar.zst")); + assert!(!is_static_file_chunk("headers-abc-def.tar.zst")); + assert!(!is_static_file_chunk("headers-0-499999.tar.gz")); + assert!(!is_static_file_chunk("headers-0-499999")); + assert!(is_static_file_chunk("headers-0-499999.tar.zst")); + assert!(is_static_file_chunk("custom_component-100-200.tar.zst")); + } +} diff --git a/crates/infra/snapshotter/tests/common/mod.rs b/crates/infra/snapshotter/tests/common/mod.rs new file mode 100644 index 0000000000..1f6097be87 --- /dev/null +++ b/crates/infra/snapshotter/tests/common/mod.rs @@ -0,0 +1,42 @@ +//! Common test harness for snapshotter integration tests with `MinIO`. + +use anyhow::Result; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::minio::MinIO; + +pub(crate) struct TestHarness { + pub storage_client: aws_sdk_s3::Client, + pub bucket_name: String, + _minio_container: testcontainers::ContainerAsync, +} + +impl TestHarness { + pub(crate) async fn new() -> Result { + let minio_container = MinIO::default().start().await?; + let storage_port = minio_container.get_host_port_ipv4(9000).await?; + let storage_endpoint = format!("http://127.0.0.1:{storage_port}"); + + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region("us-east-1") + .endpoint_url(&storage_endpoint) + .credentials_provider(aws_sdk_s3::config::Credentials::new( + "minioadmin", + "minioadmin", + None, + None, + "test", + )) + .load() + .await; + + let storage_client = aws_sdk_s3::Client::from_conf( + aws_sdk_s3::config::Builder::from(&config).force_path_style(true).build(), + ); + + let bucket_name = format!("test-snapshots-{}", std::process::id()); + + storage_client.create_bucket().bucket(&bucket_name).send().await?; + + Ok(Self { storage_client, bucket_name, _minio_container: minio_container }) + } +} diff --git a/crates/infra/snapshotter/tests/e2e_test.rs b/crates/infra/snapshotter/tests/e2e_test.rs new file mode 100644 index 0000000000..5845d175c3 --- /dev/null +++ b/crates/infra/snapshotter/tests/e2e_test.rs @@ -0,0 +1,663 @@ +//! E2E tests for the snapshotter upload flow using `MinIO`. + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::atomic::{AtomicBool, Ordering}, +}; + +use anyhow::Result; +use async_trait::async_trait; +use base_snapshotter::{ + ContainerManager, DockerContainerManager, SnapshotGenerator, SnapshotUploader, +}; +use bollard::{ + Docker, + models::ContainerCreateBody, + query_parameters::{ + CreateContainerOptionsBuilder, CreateImageOptionsBuilder, RemoveContainerOptions, + StartContainerOptions, StopContainerOptionsBuilder as StopBuilder, + }, +}; +use futures::StreamExt; +use serial_test::serial; + +mod common; +use common::TestHarness; + +struct MockContainerManager { + running: AtomicBool, + stop_called: AtomicBool, + start_called: AtomicBool, +} + +impl MockContainerManager { + const fn new() -> Self { + Self { + running: AtomicBool::new(true), + stop_called: AtomicBool::new(false), + start_called: AtomicBool::new(false), + } + } + + fn was_stopped(&self) -> bool { + self.stop_called.load(Ordering::Relaxed) + } + + fn was_started(&self) -> bool { + self.start_called.load(Ordering::Relaxed) + } +} + +#[async_trait] +impl ContainerManager for MockContainerManager { + async fn stop(&self, _container_name: &str) -> Result<()> { + self.running.store(false, Ordering::Relaxed); + self.stop_called.store(true, Ordering::Relaxed); + Ok(()) + } + + async fn start(&self, _container_name: &str) -> Result<()> { + self.running.store(true, Ordering::Relaxed); + self.start_called.store(true, Ordering::Relaxed); + Ok(()) + } + + async fn is_running(&self, _container_name: &str) -> Result { + Ok(self.running.load(Ordering::Relaxed)) + } +} + +/// Builds a realistic fake snapshot matching reth's `SnapshotManifest` format. +/// +/// Modeled after the real manifests served at `snapshots-r2.reth.rs`. +fn create_fake_snapshot(dir: &Path, block: u64) -> Result> { + std::fs::create_dir_all(dir)?; + + let blocks_per_file = 500_000u64; + let num_chunks = block.div_ceil(blocks_per_file); + + let chunk_sizes: Vec = (0..num_chunks).map(|i| 1_000_000 + i * 500_000).collect(); + let chunk_decompressed: Vec = chunk_sizes.iter().map(|s| s * 2).collect(); + let chunk_output_files: Vec = (0..num_chunks) + .map(|i| { + let start = i * blocks_per_file; + let end = (i + 1) * blocks_per_file - 1; + serde_json::json!([ + { + "path": format!("static_files/static_file_headers_{start}_{end}"), + "size": chunk_decompressed[i as usize] / 2, + "blake3": format!("fake-blake3-headers-{i}") + }, + { + "path": format!("static_files/static_file_headers_{start}_{end}.off"), + "size": chunk_decompressed[i as usize] / 2, + "blake3": format!("fake-blake3-headers-off-{i}") + } + ]) + }) + .collect(); + + let chunked_component = |total_blocks| { + serde_json::json!({ + "blocks_per_file": blocks_per_file, + "total_blocks": total_blocks, + "chunk_sizes": chunk_sizes, + "chunk_decompressed_sizes": chunk_decompressed, + "chunk_output_files": chunk_output_files + }) + }; + + let manifest = serde_json::json!({ + "block": block, + "chain_id": 8453, + "storage_version": 2, + "timestamp": 1700000000u64, + "reth_version": "2.1.0 (d58c6e3)", + "components": { + "state": { + "file": "state.tar.zst", + "size": 152_129_557_628u64, + "decompressed_size": 304_259_115_256u64, + "output_files": [{"path": "db/mdbx.dat", "size": 304_259_115_256u64, "blake3": "fake-blake3-mdbx"}] + }, + "headers": chunked_component(block), + "transactions": chunked_component(block), + "transaction_senders": chunked_component(block), + "receipts": chunked_component(block), + "account_changesets": chunked_component(block), + "storage_changesets": chunked_component(block), + "rocksdb_indices": { + "file": "rocksdb_indices.tar.zst", + "size": 226_377_256_076u64, + "decompressed_size": 452_754_512_152u64, + "output_files": [{"path": "rocksdb/CURRENT", "size": 16, "blake3": "fake-blake3-rocksdb-current"}] + } + } + }); + + let manifest_path = dir.join("manifest.json"); + std::fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; + + let mut files = vec![manifest_path]; + + std::fs::write(dir.join("state.tar.zst"), b"fake-state-archive")?; + files.push(dir.join("state.tar.zst")); + + std::fs::write(dir.join("rocksdb_indices.tar.zst"), b"fake-rocksdb-archive")?; + files.push(dir.join("rocksdb_indices.tar.zst")); + + for component in [ + "headers", + "transactions", + "transaction_senders", + "receipts", + "account_changesets", + "storage_changesets", + ] { + for i in 0..num_chunks { + let start = i * blocks_per_file; + let end = (i + 1) * blocks_per_file - 1; + let filename = format!("{component}-{start}-{end}.tar.zst"); + std::fs::write(dir.join(&filename), format!("fake-{component}-chunk-{i}").as_bytes())?; + files.push(dir.join(&filename)); + } + } + + files.sort_unstable(); + Ok(files) +} + +#[tokio::test] +#[serial] +async fn upload_artifacts_to_minio() -> Result<()> { + let harness = TestHarness::new().await?; + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + "mainnet".to_string(), + ); + + let tmp = tempfile::tempdir()?; + let output_dir = tmp.path().join("output"); + let files = create_fake_snapshot(&output_dir, 1_000_000)?; + + let upload_prefix = uploader + .upload(&output_dir, &files, 1_700_000_000, &std::collections::HashMap::new()) + .await?; + assert_eq!(upload_prefix, "mainnet/1700000000", "run prefix should be date-based"); + + let s3 = &harness.storage_client; + let bucket = &harness.bucket_name; + + // Verify always-upload files go to {prefix}/{date}/ + let state_body = get_object_bytes(s3, bucket, "mainnet/1700000000/state.tar.zst").await?; + assert_eq!(state_body, b"fake-state-archive", "state should be in date dir"); + + let rocksdb_body = + get_object_bytes(s3, bucket, "mainnet/1700000000/rocksdb_indices.tar.zst").await?; + assert_eq!(rocksdb_body, b"fake-rocksdb-archive", "rocksdb should be in date dir"); + + let manifest_body = get_object_bytes(s3, bucket, "mainnet/1700000000/manifest.json").await?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest_body)?; + assert_eq!(manifest["block"], 1_000_000, "manifest block mismatch"); + assert_eq!(manifest["chain_id"], 8453, "manifest chain_id mismatch"); + + let components = manifest["components"].as_object().expect("components should be an object"); + assert_eq!(components.len(), 8, "should have all 8 component types"); + + // Verify static file chunks go to {prefix}/static_files/ + for component in ["headers", "transactions", "receipts"] { + for chunk_idx in 0..2u64 { + let start = chunk_idx * 500_000; + let end = (chunk_idx + 1) * 500_000 - 1; + let key = format!("mainnet/static_files/{component}-{start}-{end}.tar.zst"); + let body = get_object_bytes(s3, bucket, &key).await?; + let expected = format!("fake-{component}-chunk-{chunk_idx}"); + assert_eq!( + body, + expected.as_bytes(), + "{component} chunk {chunk_idx} should be in static_files/" + ); + } + } + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn upload_with_empty_prefix() -> Result<()> { + let harness = TestHarness::new().await?; + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + String::new(), + ); + + let tmp = tempfile::tempdir()?; + let output_dir = tmp.path().join("output"); + let files = create_fake_snapshot(&output_dir, 100)?; + + let upload_prefix = uploader + .upload(&output_dir, &files, 1_700_000_000, &std::collections::HashMap::new()) + .await?; + assert_eq!(upload_prefix, "1700000000", "empty prefix should produce bare date"); + + let s3 = &harness.storage_client; + let bucket = &harness.bucket_name; + + let state_body = get_object_bytes(s3, bucket, "1700000000/state.tar.zst").await?; + assert_eq!(state_body, b"fake-state-archive", "state should be in date dir"); + + let rocksdb_body = get_object_bytes(s3, bucket, "1700000000/rocksdb_indices.tar.zst").await?; + assert_eq!(rocksdb_body, b"fake-rocksdb-archive", "rocksdb should be in date dir"); + + let manifest_body = get_object_bytes(s3, bucket, "1700000000/manifest.json").await?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest_body)?; + assert_eq!(manifest["block"], 100, "manifest should be in date dir"); + + let headers_body = + get_object_bytes(s3, bucket, "static_files/headers-0-499999.tar.zst").await?; + assert_eq!(headers_body, b"fake-headers-chunk-0", "headers chunk 0 should be in static_files/"); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn diff_upload_skips_unchanged_static_file_chunks() -> Result<()> { + let harness = TestHarness::new().await?; + let s3 = &harness.storage_client; + let bucket = &harness.bucket_name; + + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + "diff-test".to_string(), + ); + + // Pre-seed static_files/ with finalized chunks from a previous run + let preexisting: &[(&str, &[u8])] = &[ + ("headers-0-499999.tar.zst", b"finalized-headers-0"), + ("headers-500000-999999.tar.zst", b"finalized-headers-1"), + ("transactions-0-499999.tar.zst", b"finalized-txs-0"), + ("transactions-500000-999999.tar.zst", b"finalized-txs-1"), + ("receipts-0-499999.tar.zst", b"finalized-receipts-0"), + ("receipts-500000-999999.tar.zst", b"finalized-receipts-1"), + ("account_changesets-0-499999.tar.zst", b"finalized-acc-cs-0"), + ("storage_changesets-0-499999.tar.zst", b"finalized-stor-cs-0"), + ]; + + for (name, data) in preexisting { + let key = format!("diff-test/static_files/{name}"); + s3.put_object() + .bucket(bucket) + .key(&key) + .body(aws_sdk_s3::primitives::ByteStream::from(data.to_vec())) + .send() + .await?; + } + + let tmp = tempfile::tempdir()?; + let output_dir = tmp.path().join("output"); + std::fs::create_dir_all(&output_dir)?; + + let manifest = serde_json::json!({"block": 1_000_000, "chain_id": 8453, "storage_version": 2}); + std::fs::write(output_dir.join("manifest.json"), serde_json::to_string(&manifest)?)?; + + // AlwaysUpload: mdbx + rocksdb + std::fs::write(output_dir.join("state.tar.zst"), b"new-mdbx-state-data")?; + std::fs::write(output_dir.join("rocksdb_indices.tar.zst"), b"new-rocksdb-data")?; + + // DiffBySize: finalized chunks with SAME size → should be SKIPPED + for &(name, data) in preexisting { + std::fs::write(output_dir.join(name), data)?; + } + + // DiffBySize: new tip chunks → should be UPLOADED + std::fs::write(output_dir.join("headers-1000000-1499999.tar.zst"), b"new-tip-headers")?; + std::fs::write(output_dir.join("transactions-1000000-1499999.tar.zst"), b"new-tip-txs")?; + std::fs::write(output_dir.join("receipts-1000000-1499999.tar.zst"), b"new-tip-receipts")?; + std::fs::write(output_dir.join("account_changesets-500000-999999.tar.zst"), b"new-tip-acc-cs")?; + std::fs::write( + output_dir.join("storage_changesets-500000-999999.tar.zst"), + b"new-tip-stor-cs", + )?; + + let mut files: Vec = std::fs::read_dir(&output_dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_file()) + .collect(); + files.sort_unstable(); + + let remote_listing = uploader.list_remote_static_files().await?; + let upload_prefix = + uploader.upload(&output_dir, &files, 1_700_000_000, &remote_listing).await?; + assert_eq!(upload_prefix, "diff-test/1700000000"); + + // Verify AlwaysUpload: mdbx + rocksdb in date dir + let state_body = get_object_bytes(s3, bucket, "diff-test/1700000000/state.tar.zst").await?; + assert_eq!(state_body, b"new-mdbx-state-data", "mdbx should be in date dir"); + + let rocksdb_body = + get_object_bytes(s3, bucket, "diff-test/1700000000/rocksdb_indices.tar.zst").await?; + assert_eq!(rocksdb_body, b"new-rocksdb-data", "rocksdb should be in date dir"); + + // Verify DiffBySize SKIPPED: finalized chunks retain original content in static_files/ + for (name, original_data) in preexisting { + let body = get_object_bytes(s3, bucket, &format!("diff-test/static_files/{name}")).await?; + assert_eq!(body.as_slice(), *original_data, "finalized chunk {name} should be unchanged"); + } + + // Verify DiffBySize UPLOADED: new tip chunks in static_files/ + let tip_checks: &[(&str, &[u8])] = &[ + ("headers-1000000-1499999.tar.zst", b"new-tip-headers"), + ("transactions-1000000-1499999.tar.zst", b"new-tip-txs"), + ("receipts-1000000-1499999.tar.zst", b"new-tip-receipts"), + ("account_changesets-500000-999999.tar.zst", b"new-tip-acc-cs"), + ("storage_changesets-500000-999999.tar.zst", b"new-tip-stor-cs"), + ]; + for (name, expected) in tip_checks { + let body = get_object_bytes(s3, bucket, &format!("diff-test/static_files/{name}")).await?; + assert_eq!(body.as_slice(), *expected, "tip chunk {name} should be in static_files/"); + } + + // Verify manifest in date dir + let manifest_body = get_object_bytes(s3, bucket, "diff-test/1700000000/manifest.json").await?; + let parsed: serde_json::Value = serde_json::from_slice(&manifest_body)?; + assert_eq!(parsed["block"], 1_000_000, "manifest should be in date dir"); + + Ok(()) +} + +/// E2E test: creates a real datadir with mdbx + static files, skips compression +/// for a finalized chunk range, and verifies only the tip chunk is compressed. +#[tokio::test] +#[serial] +async fn selective_compression_skips_finalized_chunks() -> Result<()> { + // Create a real datadir with mdbx + 4 header chunk ranges + // block=2M, bpf=500k → 4 chunks, tip=chunk3, buffer=2 → skip chunk 0 + let source = tempfile::tempdir()?; + let db_dir = source.path().join("db"); + std::fs::create_dir_all(&db_dir)?; + std::fs::write(db_dir.join("mdbx.dat"), b"test-state-data")?; + + let sf_dir = source.path().join("static_files"); + std::fs::create_dir_all(&sf_dir)?; + for component in [ + "headers", + "transactions", + "transaction-senders", + "receipts", + "account-change-sets", + "storage-change-sets", + ] { + for i in 0..4u64 { + let start = i * 500_000; + let end = (i + 1) * 500_000 - 1; + std::fs::write(sf_dir.join(format!("static_file_{component}_{start}_{end}")), b"data")?; + } + } + + // Simulate all chunked components existing remotely for range 0-499999 + let chunk_components = [ + "headers", + "transactions", + "transaction_senders", + "receipts", + "account_changesets", + "storage_changesets", + ]; + let mut remote: HashMap = HashMap::new(); + for component in chunk_components { + remote.insert(format!("{component}-0-499999.tar.zst"), 0); + } + + let output = tempfile::tempdir()?; + let files = SnapshotGenerator::generate_manifest( + source.path(), + output.path(), + 8453, + Some(2_000_000), + Some(500_000), + &remote, + )?; + + let filenames: Vec = files + .iter() + .filter_map(|f| f.file_name().map(|n| n.to_string_lossy().to_string())) + .collect(); + + // Skipped range: chunk 0 should NOT be compressed (all components exist remotely) + for component in chunk_components { + assert!( + !filenames.contains(&format!("{component}-0-499999.tar.zst")), + "{component} finalized range should not produce an archive" + ); + } + + // Buffer + tip ranges: should be compressed + for component in chunk_components { + assert!( + filenames.contains(&format!("{component}-500000-999999.tar.zst")), + "{component} tip range should produce an archive" + ); + } + + // Always-upload: state + manifest + assert!(filenames.contains(&"state.tar.zst".to_string()), "state should always be produced"); + assert!(filenames.contains(&"manifest.json".to_string()), "manifest should always be produced"); + + // Verify tip archive is a valid compressed file (not empty) + let tip_path = output.path().join("headers-500000-999999.tar.zst"); + assert!(std::fs::metadata(&tip_path)?.len() > 0, "tip archive should not be empty"); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn always_upload_overwrites_existing_state_and_rocksdb() -> Result<()> { + let harness = TestHarness::new().await?; + let s3 = &harness.storage_client; + let bucket = &harness.bucket_name; + + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + "overwrite-test".to_string(), + ); + + // Simulate a previous run's date dir with old state + rocksdb + let prev_files: &[(&str, &[u8])] = &[ + ("state.tar.zst", b"old-mdbx-from-yesterday"), + ("rocksdb_indices.tar.zst", b"old-rocksdb-from-yesterday"), + ("manifest.json", b"{\"block\":1500000}"), + ]; + for (name, data) in prev_files { + let key = format!("overwrite-test/1699000000/{name}"); + s3.put_object() + .bucket(bucket) + .key(&key) + .body(aws_sdk_s3::primitives::ByteStream::from(data.to_vec())) + .send() + .await?; + } + + // New snapshot + let tmp = tempfile::tempdir()?; + let output_dir = tmp.path().join("output"); + std::fs::create_dir_all(&output_dir)?; + + let manifest = serde_json::json!({"block": 2_000_000, "chain_id": 8453, "storage_version": 2}); + std::fs::write(output_dir.join("manifest.json"), serde_json::to_string(&manifest)?)?; + std::fs::write(output_dir.join("state.tar.zst"), b"fresh-mdbx-state")?; + std::fs::write(output_dir.join("rocksdb_indices.tar.zst"), b"fresh-rocksdb")?; + + let files = vec![ + output_dir.join("manifest.json"), + output_dir.join("rocksdb_indices.tar.zst"), + output_dir.join("state.tar.zst"), + ]; + + let upload_prefix = uploader + .upload(&output_dir, &files, 1_700_000_000, &std::collections::HashMap::new()) + .await?; + assert_eq!(upload_prefix, "overwrite-test/1700000000"); + + // Verify new state in new date dir + let state_body = + get_object_bytes(s3, bucket, "overwrite-test/1700000000/state.tar.zst").await?; + assert_eq!(state_body, b"fresh-mdbx-state", "state should be in new date dir"); + + // Verify new rocksdb in new date dir + let rocksdb_body = + get_object_bytes(s3, bucket, "overwrite-test/1700000000/rocksdb_indices.tar.zst").await?; + assert_eq!(rocksdb_body, b"fresh-rocksdb", "rocksdb should be in new date dir"); + + // Verify previous run's files are untouched + let old_state = get_object_bytes(s3, bucket, "overwrite-test/1699000000/state.tar.zst").await?; + assert_eq!( + old_state.as_slice(), + b"old-mdbx-from-yesterday", + "previous run should be untouched" + ); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn mock_container_manager_tracks_calls() -> Result<()> { + let manager = MockContainerManager::new(); + + assert!(!manager.was_stopped(), "should not be stopped initially"); + assert!(!manager.was_started(), "should not be started initially"); + + manager.stop("test-container").await?; + assert!(manager.was_stopped(), "should be stopped after stop()"); + + manager.start("test-container").await?; + assert!(manager.was_started(), "should be started after start()"); + + Ok(()) +} + +#[tokio::test] +#[serial] +async fn orchestrator_always_restarts_on_failure() -> Result<()> { + let harness = TestHarness::new().await?; + let manager = std::sync::Arc::new(MockContainerManager::new()); + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + "test".to_string(), + ); + + let tmp = tempfile::tempdir()?; + + let config = base_snapshotter::SnapshotterConfig { + container_name: "fake-el".to_string(), + source_datadir: tmp.path().join("nonexistent-datadir"), + output_dir: tmp.path().join("output"), + bucket: harness.bucket_name.clone(), + prefix: "test".to_string(), + chain_id: 8453, + block: Some(100), + blocks_per_file: Some(500_000), + snapshot_threads: None, + docker_socket: "/var/run/docker.sock".to_string(), + s3_config_type: base_snapshotter::S3ConfigType::Aws, + s3_endpoint: None, + s3_region: "us-east-1".to_string(), + s3_access_key_id: None, + s3_secret_access_key: None, + }; + + let snapshotter = + base_snapshotter::Snapshotter::new(std::sync::Arc::clone(&manager), uploader, config); + + let result = snapshotter.run().await; + assert!(result.is_err(), "should fail because source_datadir doesn't exist"); + assert!(manager.was_stopped(), "container should have been stopped"); + assert!(manager.was_started(), "container should always be restarted even on failure"); + + Ok(()) +} + +/// E2E test: spins up a real Docker container, stops it via bollard, +/// creates fake snapshot artifacts, uploads to `MinIO`, then restarts the container. +/// Verifies the container is running again after the full lifecycle. +#[tokio::test] +#[serial] +async fn e2e_stop_upload_restart_real_container() -> Result<()> { + let harness = TestHarness::new().await?; + + let docker = Docker::connect_with_socket_defaults() + .expect("failed to connect to Docker — is Docker running?"); + + let pull_opts = CreateImageOptionsBuilder::new().from_image("alpine").tag("latest").build(); + docker.create_image(Some(pull_opts), None, None).collect::>().await; + + let container_name = format!("snapshotter-e2e-{}", std::process::id()); + let body = ContainerCreateBody { + image: Some("alpine:latest".to_string()), + cmd: Some(vec!["sleep".to_string(), "3600".to_string()]), + ..Default::default() + }; + + let create_opts = CreateContainerOptionsBuilder::new().name(&container_name).build(); + docker.create_container(Some(create_opts), body).await?; + + docker.start_container(&container_name, None::).await?; + + let container_manager = DockerContainerManager::new("/var/run/docker.sock")?; + + assert!( + container_manager.is_running(&container_name).await?, + "container should be running before snapshotter" + ); + + container_manager.stop(&container_name).await?; + assert!(!container_manager.is_running(&container_name).await?, "should be stopped"); + + let tmp = tempfile::tempdir()?; + let output_dir = tmp.path().join("output"); + let files = create_fake_snapshot(&output_dir, 1_000_000)?; + + let uploader = SnapshotUploader::new( + harness.storage_client.clone(), + harness.bucket_name.clone(), + "e2e-test".to_string(), + ); + let upload_prefix = uploader + .upload(&output_dir, &files, 1_700_000_000, &std::collections::HashMap::new()) + .await?; + + let manifest_body = get_object_bytes( + &harness.storage_client, + &harness.bucket_name, + &format!("{upload_prefix}/manifest.json"), + ) + .await?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest_body)?; + assert_eq!(manifest["block"], 1_000_000, "uploaded manifest should have correct block"); + + container_manager.start(&container_name).await?; + assert!( + container_manager.is_running(&container_name).await?, + "container should be running after restart" + ); + + docker.stop_container(&container_name, Some(StopBuilder::new().t(5).build())).await.ok(); + docker.remove_container(&container_name, None::).await.ok(); + + Ok(()) +} + +async fn get_object_bytes(client: &aws_sdk_s3::Client, bucket: &str, key: &str) -> Result> { + let resp = client.get_object().bucket(bucket).key(key).send().await?; + let bytes = resp.body.collect().await?.into_bytes(); + Ok(bytes.to_vec()) +} diff --git a/crates/proof/challenge/src/test_utils.rs b/crates/proof/challenge/src/test_utils.rs index 20208f0a14..204a327b37 100644 --- a/crates/proof/challenge/src/test_utils.rs +++ b/crates/proof/challenge/src/test_utils.rs @@ -721,6 +721,7 @@ impl ZkProofProvider for MockZkProofProvider { status: state.proof_status, receipt: state.receipt, error_message: state.error_message, + execution_stats: None, }) } } diff --git a/crates/proof/client/src/prologue.rs b/crates/proof/client/src/prologue.rs index d4d73e65f5..e378257705 100644 --- a/crates/proof/client/src/prologue.rs +++ b/crates/proof/client/src/prologue.rs @@ -2,9 +2,9 @@ use alloc::sync::Arc; use core::fmt::Debug; use alloy_consensus::Sealed; -use alloy_evm::{EvmFactory, FromRecoveredTx, FromTxWithEncoded, revm::context::BlockEnv}; +use alloy_evm::{EvmFactory, FromRecoveredTx, FromTxWithEncoded}; use alloy_primitives::B256; -use base_common_evm::{BaseSpecId, BaseTxEnv}; +use base_common_evm::{BaseEvmFactory, BaseTxEnv}; use base_consensus_derive::EthereumDataSource; use base_proof::{ BootInfo, CachingOracle, HintType, OracleBlobProvider, OracleL1ChainProvider, @@ -17,23 +17,22 @@ use crate::{FaultProofDriver, FaultProofProgramError}; /// The prologue phase — loads boot information and initializes the derivation pipeline. #[derive(Debug)] -pub struct Prologue { +pub struct Prologue { oracle_client: P, hint_writer: H, - evm_factory: F, + evm_factory: BaseEvmFactory, } -impl Prologue +impl Prologue where P: PreimageOracleClient + Send + Sync + Clone + Debug + 'static, H: HintWriterClient + Send + Sync + Clone + Debug + 'static, - F: EvmFactory + Send + Sync + Clone + Debug + 'static, - F::Tx: FromTxWithEncoded + ::Tx: FromTxWithEncoded + FromRecoveredTx + BaseTxEnv, { /// Creates a new prologue. - pub const fn new(oracle_client: P, hint_writer: H, evm_factory: F) -> Self { + pub const fn new(oracle_client: P, hint_writer: H, evm_factory: BaseEvmFactory) -> Self { Self { oracle_client, hint_writer, evm_factory } } @@ -42,7 +41,9 @@ where /// # Errors /// /// Returns an error if boot information cannot be loaded or pipeline initialization fails. - pub async fn load(self) -> Result, FaultProofProgramError> { + pub async fn load( + self, + ) -> Result, FaultProofProgramError> { const ORACLE_LRU_SIZE: usize = 1024; let oracle = Arc::new(CachingOracle::new( @@ -51,6 +52,8 @@ where self.hint_writer.clone(), )); let boot = BootInfo::load(oracle.as_ref()).await?; + let evm_factory = + self.evm_factory.with_activation_admin_address(boot.activation_admin_address); let l1_config = boot.l1_config; let rollup_config = Arc::new(boot.rollup_config); @@ -136,7 +139,7 @@ where cursor, pipeline, l2_provider, - self.evm_factory, + evm_factory, )) } } @@ -158,3 +161,19 @@ where .await?; Ok(B256::from_slice(&output_preimage[96..128])) } + +#[cfg(test)] +mod tests { + use alloy_primitives::address; + + use super::BaseEvmFactory; + + #[test] + fn base_evm_factory_records_activation_admin_address() { + let admin = address!("331C9d37BbcebBC9dfAf98FBE3C5B8A39Dd6E771"); + + let factory = BaseEvmFactory::default().with_activation_admin_address(Some(admin)); + + assert_eq!(factory.activation_admin_address(), Some(admin)); + } +} diff --git a/crates/proof/contracts/src/aggregate_verifier.rs b/crates/proof/contracts/src/aggregate_verifier.rs index 6a732b2726..61868fe4e5 100644 --- a/crates/proof/contracts/src/aggregate_verifier.rs +++ b/crates/proof/contracts/src/aggregate_verifier.rs @@ -335,29 +335,14 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); let (root_claim, l2_seq, parent_address) = futures::try_join!( - async { - contract.rootClaim().call().await.map_err(|e| ContractError::Call { - context: "rootClaim failed".into(), - source: e, - }) - }, - async { - contract.l2SequenceNumber().call().await.map_err(|e| ContractError::Call { - context: "l2SequenceNumber failed".into(), - source: e, - }) - }, - async { - contract.parentAddress().call().await.map_err(|e| ContractError::Call { - context: "parentAddress failed".into(), - source: e, - }) - }, + async { contract_call!(contract.rootClaim().call(), "rootClaim failed") }, + async { contract_call!(contract.l2SequenceNumber().call(), "l2SequenceNumber failed") }, + async { contract_call!(contract.parentAddress().call(), "parentAddress failed") }, )?; let l2_block_number: u64 = l2_seq .try_into() - .map_err(|_| ContractError::Validation("l2SequenceNumber overflows u64".into()))?; + .map_err(|_| ContractError::validation("l2SequenceNumber overflows u64"))?; Ok(GameInfo { root_claim, l2_block_number, parent_address }) } @@ -366,14 +351,10 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - let raw: u8 = contract - .status() - .call() - .await - .map_err(|e| ContractError::Call { context: "status failed".into(), source: e })?; + let raw: u8 = contract_call!(contract.status().call(), "status failed")?; GameStatus::try_from(raw).map_err(|unknown| { - ContractError::Validation(format!( + ContractError::validation(format!( "game {game_address} returned unrecognized status {unknown}" )) }) @@ -383,64 +364,49 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .zkProver() - .call() - .await - .map_err(|e| ContractError::Call { context: "zkProver failed".into(), source: e }) + contract_call!(contract.zkProver().call(), "zkProver failed") } async fn tee_prover(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .teeProver() - .call() - .await - .map_err(|e| ContractError::Call { context: "teeProver failed".into(), source: e }) + contract_call!(contract.teeProver().call(), "teeProver failed") } async fn starting_block_number(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - let block_u256: U256 = contract.startingBlockNumber().call().await.map_err(|e| { - ContractError::Call { context: "startingBlockNumber failed".into(), source: e } - })?; + let block_u256: U256 = + contract_call!(contract.startingBlockNumber().call(), "startingBlockNumber failed")?; block_u256 .try_into() - .map_err(|_| ContractError::Validation("startingBlockNumber overflows u64".into())) + .map_err(|_| ContractError::validation("startingBlockNumber overflows u64")) } async fn l1_head(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .l1Head() - .call() - .await - .map_err(|e| ContractError::Call { context: "l1Head failed".into(), source: e }) + contract_call!(contract.l1Head().call(), "l1Head failed") } async fn read_block_interval(&self, impl_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(impl_address, &self.provider); - let interval_u256: U256 = contract.BLOCK_INTERVAL().call().await.map_err(|e| { - ContractError::Call { context: "BLOCK_INTERVAL failed".into(), source: e } - })?; + let interval_u256: U256 = + contract_call!(contract.BLOCK_INTERVAL().call(), "BLOCK_INTERVAL failed")?; let interval: u64 = interval_u256 .try_into() - .map_err(|_| ContractError::Validation("BLOCK_INTERVAL overflows u64".into()))?; + .map_err(|_| ContractError::validation("BLOCK_INTERVAL overflows u64"))?; // Also validated at startup in main.rs; duplicated here for defense-in-depth. if interval < 2 { - return Err(ContractError::Validation( - "BLOCK_INTERVAL must be at least 2 (single-block proposals are not supported)" - .into(), + return Err(ContractError::validation( + "BLOCK_INTERVAL must be at least 2 (single-block proposals are not supported)", )); } @@ -453,22 +419,17 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { ) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(impl_address, &self.provider); - let interval_u256: U256 = - contract.INTERMEDIATE_BLOCK_INTERVAL().call().await.map_err(|e| { - ContractError::Call { - context: "INTERMEDIATE_BLOCK_INTERVAL failed".into(), - source: e, - } - })?; + let interval_u256: U256 = contract_call!( + contract.INTERMEDIATE_BLOCK_INTERVAL().call(), + "INTERMEDIATE_BLOCK_INTERVAL failed" + )?; - let interval: u64 = interval_u256.try_into().map_err(|_| { - ContractError::Validation("INTERMEDIATE_BLOCK_INTERVAL overflows u64".into()) - })?; + let interval: u64 = interval_u256 + .try_into() + .map_err(|_| ContractError::validation("INTERMEDIATE_BLOCK_INTERVAL overflows u64"))?; if interval == 0 { - return Err(ContractError::Validation( - "INTERMEDIATE_BLOCK_INTERVAL cannot be 0".into(), - )); + return Err(ContractError::validation("INTERMEDIATE_BLOCK_INTERVAL cannot be 0")); } Ok(interval) @@ -481,12 +442,13 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - let raw: Bytes = contract.intermediateOutputRoots().call().await.map_err(|e| { - ContractError::Call { context: "intermediateOutputRoots failed".into(), source: e } - })?; + let raw: Bytes = contract_call!( + contract.intermediateOutputRoots().call(), + "intermediateOutputRoots failed" + )?; if !raw.len().is_multiple_of(32) { - return Err(ContractError::Validation(format!( + return Err(ContractError::validation(format!( "intermediateOutputRoots length {} is not a multiple of 32", raw.len() ))); @@ -505,10 +467,10 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - let root = - contract.intermediateOutputRoot(U256::from(index)).call().await.map_err(|e| { - ContractError::Call { context: "intermediateOutputRoot failed".into(), source: e } - })?; + let root = contract_call!( + contract.intermediateOutputRoot(U256::from(index)).call(), + "intermediateOutputRoot failed" + )?; Ok(root) } @@ -517,18 +479,13 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - let value: U256 = - contract.counteredByIntermediateRootIndexPlusOne().call().await.map_err(|e| { - ContractError::Call { - context: "counteredByIntermediateRootIndexPlusOne failed".into(), - source: e, - } - })?; + let value: U256 = contract_call!( + contract.counteredByIntermediateRootIndexPlusOne().call(), + "counteredByIntermediateRootIndexPlusOne failed" + )?; value.try_into().map_err(|_| { - ContractError::Validation( - "counteredByIntermediateRootIndexPlusOne overflows u64".into(), - ) + ContractError::validation("counteredByIntermediateRootIndexPlusOne overflows u64") }) } @@ -536,108 +493,70 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .gameOver() - .call() - .await - .map_err(|e| ContractError::Call { context: "gameOver failed".into(), source: e }) + contract_call!(contract.gameOver().call(), "gameOver failed") } async fn resolved_at(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .resolvedAt() - .call() - .await - .map_err(|e| ContractError::Call { context: "resolvedAt failed".into(), source: e }) + contract_call!(contract.resolvedAt().call(), "resolvedAt failed") } async fn bond_recipient(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .bondRecipient() - .call() - .await - .map_err(|e| ContractError::Call { context: "bondRecipient failed".into(), source: e }) + contract_call!(contract.bondRecipient().call(), "bondRecipient failed") } async fn bond_unlocked(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .bondUnlocked() - .call() - .await - .map_err(|e| ContractError::Call { context: "bondUnlocked failed".into(), source: e }) + contract_call!(contract.bondUnlocked().call(), "bondUnlocked failed") } async fn bond_claimed(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .bondClaimed() - .call() - .await - .map_err(|e| ContractError::Call { context: "bondClaimed failed".into(), source: e }) + contract_call!(contract.bondClaimed().call(), "bondClaimed failed") } async fn expected_resolution(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract.expectedResolution().call().await.map_err(|e| ContractError::Call { - context: "expectedResolution failed".into(), - source: e, - }) + contract_call!(contract.expectedResolution().call(), "expectedResolution failed") } async fn proof_count(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .proofCount() - .call() - .await - .map_err(|e| ContractError::Call { context: "proofCount failed".into(), source: e }) + contract_call!(contract.proofCount().call(), "proofCount failed") } async fn created_at(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .createdAt() - .call() - .await - .map_err(|e| ContractError::Call { context: "createdAt failed".into(), source: e }) + contract_call!(contract.createdAt().call(), "createdAt failed") } async fn delayed_weth(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract - .DELAYED_WETH() - .call() - .await - .map_err(|e| ContractError::Call { context: "DELAYED_WETH failed".into(), source: e }) + contract_call!(contract.DELAYED_WETH().call(), "DELAYED_WETH failed") } async fn anchor_state_registry(&self, game_address: Address) -> Result { let contract = IAggregateVerifier::IAggregateVerifierInstance::new(game_address, &self.provider); - contract.anchorStateRegistry().call().await.map_err(|e| ContractError::Call { - context: "anchorStateRegistry failed".into(), - source: e, - }) + contract_call!(contract.anchorStateRegistry().call(), "anchorStateRegistry failed") } async fn is_game_finalized( @@ -648,10 +567,7 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let contract = IAnchorStateRegistry::IAnchorStateRegistryInstance::new(asr_address, &self.provider); - contract.isGameFinalized(game_address).call().await.map_err(|e| ContractError::Call { - context: "isGameFinalized failed".into(), - source: e, - }) + contract_call!(contract.isGameFinalized(game_address).call(), "isGameFinalized failed") } async fn anchor_preflight( @@ -664,39 +580,28 @@ impl AggregateVerifierClient for AggregateVerifierContractClient { let (blacklisted, retired, respected, paused, anchor) = futures::try_join!( async { - contract.isGameBlacklisted(game_address).call().await.map_err(|e| { - ContractError::Call { context: "isGameBlacklisted failed".into(), source: e } - }) + contract_call!( + contract.isGameBlacklisted(game_address).call(), + "isGameBlacklisted failed" + ) }, async { - contract.isGameRetired(game_address).call().await.map_err(|e| ContractError::Call { - context: "isGameRetired failed".into(), - source: e, - }) + contract_call!(contract.isGameRetired(game_address).call(), "isGameRetired failed") }, async { - contract.isGameRespected(game_address).call().await.map_err(|e| { - ContractError::Call { context: "isGameRespected failed".into(), source: e } - }) - }, - async { - contract - .paused() - .call() - .await - .map_err(|e| ContractError::Call { context: "paused failed".into(), source: e }) - }, - async { - contract.getAnchorRoot().call().await.map_err(|e| ContractError::Call { - context: "getAnchorRoot failed".into(), - source: e, - }) + contract_call!( + contract.isGameRespected(game_address).call(), + "isGameRespected failed" + ) }, + async { contract_call!(contract.paused().call(), "paused failed") }, + async { contract_call!(contract.getAnchorRoot().call(), "getAnchorRoot failed") }, )?; - let l2_block_number: u64 = anchor.l2SequenceNumber.try_into().map_err(|_| { - ContractError::Validation("anchor l2SequenceNumber overflows u64".into()) - })?; + let l2_block_number: u64 = anchor + .l2SequenceNumber + .try_into() + .map_err(|_| ContractError::validation("anchor l2SequenceNumber overflows u64"))?; Ok(AnchorPreflight { blacklisted, diff --git a/crates/proof/contracts/src/anchor_state_registry.rs b/crates/proof/contracts/src/anchor_state_registry.rs index 0f7ba06589..57cdc1bede 100644 --- a/crates/proof/contracts/src/anchor_state_registry.rs +++ b/crates/proof/contracts/src/anchor_state_registry.rs @@ -130,28 +130,29 @@ impl AnchorStateRegistryContractClient { #[async_trait] impl AnchorStateRegistryClient for AnchorStateRegistryContractClient { async fn anchor_snapshot(&self) -> Result { - let block_number = - self.provider.get_block_number().await.map_err(|e| ContractError::Provider { - context: "get block number for anchor snapshot failed".into(), - source: e, - })?; + let block_number = self.provider.get_block_number().await.map_err(|e| { + ContractError::provider("get block number for anchor snapshot failed", e) + })?; let (anchor, anchor_game) = futures::try_join!( async { - self.contract.getAnchorRoot().block(block_number.into()).call().await.map_err(|e| { - ContractError::Call { context: "getAnchorRoot failed".into(), source: e } - }) + contract_call!( + self.contract.getAnchorRoot().block(block_number.into()).call(), + "getAnchorRoot failed" + ) }, async { - self.contract.anchorGame().block(block_number.into()).call().await.map_err(|e| { - ContractError::Call { context: "anchorGame failed".into(), source: e } - }) + contract_call!( + self.contract.anchorGame().block(block_number.into()).call(), + "anchorGame failed" + ) }, )?; - let l2_block_number: u64 = anchor.l2SequenceNumber.try_into().map_err(|_| { - ContractError::Validation("anchor l2SequenceNumber overflows u64".into()) - })?; + let l2_block_number: u64 = anchor + .l2SequenceNumber + .try_into() + .map_err(|_| ContractError::validation("anchor l2SequenceNumber overflows u64"))?; tracing::info!( block_number, diff --git a/crates/proof/contracts/src/delayed_weth.rs b/crates/proof/contracts/src/delayed_weth.rs index 07eb8cb31a..4e539c1b05 100644 --- a/crates/proof/contracts/src/delayed_weth.rs +++ b/crates/proof/contracts/src/delayed_weth.rs @@ -47,16 +47,10 @@ impl DelayedWETHContractClient { #[async_trait] impl DelayedWETHClient for DelayedWETHContractClient { async fn delay(&self) -> Result { - let delay_u256: U256 = self - .contract - .delay() - .call() - .await - .map_err(|e| ContractError::Call { context: "delay failed".into(), source: e })?; - - let delay_secs: u64 = delay_u256 - .try_into() - .map_err(|_| ContractError::Validation("delay overflows u64".into()))?; + let delay_u256: U256 = contract_call!(self.contract.delay().call(), "delay failed")?; + + let delay_secs: u64 = + delay_u256.try_into().map_err(|_| ContractError::validation("delay overflows u64"))?; Ok(Duration::from_secs(delay_secs)) } diff --git a/crates/proof/contracts/src/dispute_game_factory.rs b/crates/proof/contracts/src/dispute_game_factory.rs index fa1d1327c3..5e8f8643d3 100644 --- a/crates/proof/contracts/src/dispute_game_factory.rs +++ b/crates/proof/contracts/src/dispute_game_factory.rs @@ -111,19 +111,16 @@ impl DisputeGameFactoryContractClient { #[async_trait] impl DisputeGameFactoryClient for DisputeGameFactoryContractClient { async fn game_count(&self) -> Result { - let result = - self.contract.gameCount().call().await.map_err(|e| ContractError::Call { - context: "gameCount failed".into(), - source: e, - })?; + let result = contract_call!(self.contract.gameCount().call(), "gameCount failed")?; - result.try_into().map_err(|_| ContractError::Validation("gameCount overflows u64".into())) + result.try_into().map_err(|_| ContractError::validation("gameCount overflows u64")) } async fn game_at_index(&self, index: u64) -> Result { - let result = self.contract.gameAtIndex(U256::from(index)).call().await.map_err(|e| { - ContractError::Call { context: format!("gameAtIndex({index}) failed"), source: e } - })?; + let result = contract_call!( + self.contract.gameAtIndex(U256::from(index)).call(), + format!("gameAtIndex({index}) failed") + )?; Ok(GameAtIndex { game_type: result.gameType, @@ -133,21 +130,13 @@ impl DisputeGameFactoryClient for DisputeGameFactoryContractClient { } async fn init_bonds(&self, game_type: u32) -> Result { - let result = - self.contract.initBonds(game_type).call().await.map_err(|e| ContractError::Call { - context: "initBonds failed".into(), - source: e, - })?; + let result = contract_call!(self.contract.initBonds(game_type).call(), "initBonds failed")?; Ok(result) } async fn game_impls(&self, game_type: u32) -> Result { - let result = - self.contract.gameImpls(game_type).call().await.map_err(|e| ContractError::Call { - context: "gameImpls failed".into(), - source: e, - })?; + let result = contract_call!(self.contract.gameImpls(game_type).call(), "gameImpls failed")?; Ok(result) } @@ -158,10 +147,10 @@ impl DisputeGameFactoryClient for DisputeGameFactoryContractClient { root_claim: B256, extra_data: Bytes, ) -> Result { - let result = - self.contract.games(game_type, root_claim, extra_data).call().await.map_err(|e| { - ContractError::Call { context: "games lookup failed".into(), source: e } - })?; + let result = contract_call!( + self.contract.games(game_type, root_claim, extra_data).call(), + "games lookup failed" + )?; Ok(result.proxy) } diff --git a/crates/proof/contracts/src/error.rs b/crates/proof/contracts/src/error.rs index d20dd4c53a..ac030ca16a 100644 --- a/crates/proof/contracts/src/error.rs +++ b/crates/proof/contracts/src/error.rs @@ -27,3 +27,20 @@ pub enum ContractError { #[error("{0}")] Validation(String), } + +impl ContractError { + /// Creates an error for a failed contract call. + pub fn call(context: impl Into, source: alloy_contract::Error) -> Self { + Self::Call { context: context.into(), source } + } + + /// Creates an error for a failed provider request. + pub fn provider(context: impl Into, source: alloy_transport::TransportError) -> Self { + Self::Provider { context: context.into(), source } + } + + /// Creates an error for a failed contract value validation. + pub fn validation(context: impl Into) -> Self { + Self::Validation(context.into()) + } +} diff --git a/crates/proof/contracts/src/lib.rs b/crates/proof/contracts/src/lib.rs index af435cf4aa..3229919c8e 100644 --- a/crates/proof/contracts/src/lib.rs +++ b/crates/proof/contracts/src/lib.rs @@ -6,6 +6,9 @@ )] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#[macro_use] +mod macros; + mod aggregate_verifier; pub use aggregate_verifier::{ AggregateVerifierClient, AggregateVerifierContractClient, GameInfo, GameStatus, diff --git a/crates/proof/contracts/src/macros.rs b/crates/proof/contracts/src/macros.rs new file mode 100644 index 0000000000..41ac12e98b --- /dev/null +++ b/crates/proof/contracts/src/macros.rs @@ -0,0 +1,7 @@ +//! Macros for shared contract client boilerplate. + +macro_rules! contract_call { + ($call:expr, $context:expr) => { + $call.await.map_err(|error| $crate::ContractError::call($context, error)) + }; +} diff --git a/crates/proof/contracts/src/nitro_enclave_verifier.rs b/crates/proof/contracts/src/nitro_enclave_verifier.rs index 7cd4e6aa43..09cf5930f8 100644 --- a/crates/proof/contracts/src/nitro_enclave_verifier.rs +++ b/crates/proof/contracts/src/nitro_enclave_verifier.rs @@ -62,10 +62,10 @@ impl NitroEnclaveVerifierClient for NitroEnclaveVerifierContractClient { } async fn is_revoked(&self, cert_hash: FixedBytes<32>) -> Result { - self.contract.revokedCerts(cert_hash).call().await.map_err(|e| ContractError::Call { - context: format!("revokedCerts({cert_hash})"), - source: e, - }) + contract_call!( + self.contract.revokedCerts(cert_hash).call(), + format!("revokedCerts({cert_hash})") + ) } } diff --git a/crates/proof/contracts/src/tee_prover_registry.rs b/crates/proof/contracts/src/tee_prover_registry.rs index 53a807cdba..831f4b7008 100644 --- a/crates/proof/contracts/src/tee_prover_registry.rs +++ b/crates/proof/contracts/src/tee_prover_registry.rs @@ -68,24 +68,21 @@ impl TEEProverRegistryContractClient { #[async_trait] impl TEEProverRegistryClient for TEEProverRegistryContractClient { async fn is_valid_signer(&self, signer: Address) -> Result { - self.contract.isValidSigner(signer).call().await.map_err(|e| ContractError::Call { - context: format!("isValidSigner({signer})"), - source: e, - }) + contract_call!( + self.contract.isValidSigner(signer).call(), + format!("isValidSigner({signer})") + ) } async fn is_registered_signer(&self, signer: Address) -> Result { - self.contract.isRegisteredSigner(signer).call().await.map_err(|e| ContractError::Call { - context: format!("isRegisteredSigner({signer})"), - source: e, - }) + contract_call!( + self.contract.isRegisteredSigner(signer).call(), + format!("isRegisteredSigner({signer})") + ) } async fn get_registered_signers(&self) -> Result, ContractError> { - self.contract.getRegisteredSigners().call().await.map_err(|e| ContractError::Call { - context: "getRegisteredSigners()".into(), - source: e, - }) + contract_call!(self.contract.getRegisteredSigners().call(), "getRegisteredSigners()") } } diff --git a/crates/proof/executor/src/builder/assemble.rs b/crates/proof/executor/src/builder/assemble.rs index c2a28ef18d..8279722049 100644 --- a/crates/proof/executor/src/builder/assemble.rs +++ b/crates/proof/executor/src/builder/assemble.rs @@ -111,6 +111,8 @@ where excess_blob_gas: excess_blob_gas.and_then(|x| x.try_into().ok()), parent_beacon_block_root: attrs.payload_attributes.parent_beacon_block_root, extra_data: encoded_base_fee_params, + block_access_list_hash: None, + slot_number: None, } .seal_slow(); diff --git a/crates/proof/executor/src/builder/core.rs b/crates/proof/executor/src/builder/core.rs index 3b244c4e3f..cf46312222 100644 --- a/crates/proof/executor/src/builder/core.rs +++ b/crates/proof/executor/src/builder/core.rs @@ -138,11 +138,8 @@ where ); // Step 2. Create the executor, using the trie database. - let mut state = State::builder() - .with_database(&mut self.trie_db) - .with_bundle_update() - .without_state_clear() - .build(); + let mut state = + State::builder().with_database(&mut self.trie_db).with_bundle_update().build(); let evm = self.factory.evm_factory().create_evm(&mut state, evm_env); let ctx = BaseBlockExecutionCtx { parent_hash, diff --git a/crates/proof/executor/src/test_utils.rs b/crates/proof/executor/src/test_utils.rs index 0dae44a431..957e55a624 100644 --- a/crates/proof/executor/src/test_utils.rs +++ b/crates/proof/executor/src/test_utils.rs @@ -9,7 +9,6 @@ use alloy_rlp::Decodable; use alloy_rpc_client::RpcClient; use alloy_rpc_types_engine::PayloadAttributes; use alloy_transport_http::{Client, Http}; -use base_common_chains::Registry; use base_common_evm::BaseEvmFactory; use base_common_genesis::RollupConfig; use base_common_rpc_types_engine::BasePayloadAttributes; @@ -111,7 +110,8 @@ impl ExecutorTestFixtureCreator { /// Create a static test fixture with the configuration provided. pub async fn create_static_fixture(self) { let chain_id = self.provider.get_chain_id().await.expect("Failed to get chain ID"); - let rollup_config = Registry::rollup_config(chain_id).expect("Rollup config not found"); + let rollup_config = + base_common_chains::rollup_config!(chain_id).expect("Rollup config not found"); let executing_block = self .provider @@ -153,6 +153,7 @@ impl ExecutorTestFixtureCreator { prev_randao: executing_header.mix_hash, withdrawals: Default::default(), suggested_fee_recipient: executing_header.beneficiary, + slot_number: None, }, gas_limit: Some(executing_header.gas_limit), transactions: Some(encoded_executing_transactions), @@ -182,7 +183,7 @@ impl ExecutorTestFixtureCreator { }; let mut executor = StatelessL2Builder::new( - rollup_config, + &rollup_config, BaseEvmFactory::default(), self, NoopTrieHinter, diff --git a/crates/proof/executor/src/util.rs b/crates/proof/executor/src/util.rs index d7aa140a52..0952a6feec 100644 --- a/crates/proof/executor/src/util.rs +++ b/crates/proof/executor/src/util.rs @@ -118,6 +118,7 @@ mod test { suggested_fee_recipient: Default::default(), withdrawals: Default::default(), parent_beacon_block_root: Default::default(), + slot_number: None, }, transactions: None, no_tx_pool: None, diff --git a/crates/proof/host/src/precompiles.rs b/crates/proof/host/src/precompiles.rs index b0a34f73c2..084276a2eb 100644 --- a/crates/proof/host/src/precompiles.rs +++ b/crates/proof/host/src/precompiles.rs @@ -24,10 +24,18 @@ pub fn execute>(address: Address, input: T, gas: u64) -> Result, /// The rollup configuration for the L2 chain. /// /// Contains all the network-specific parameters needed for proper L2 block /// derivation, including genesis configuration, system addresses, gas limits, /// and hard fork activation heights. /// - /// **Security**: Loaded from registry (secure) or oracle (requires validation). + /// **Security**: Loaded from built-in config (secure) or oracle (requires validation). pub rollup_config: RollupConfig, /// An optional configuration for the l1 chain associated with the l2 chain. /// - /// **Security**: Loaded from registry (secure) or oracle (requires validation). + /// **Security**: Loaded from built-in config (secure) or oracle (requires validation). pub l1_config: ChainConfig, /// The proposer address that will submit the proof transaction on-chain. /// @@ -238,15 +244,19 @@ impl BootInfo { .map_err(OracleProviderError::SliceConversion)?, ); + let built_in_chain_config = base_common_chains::ChainConfig::by_chain_id(chain_id); + let activation_admin_address = + built_in_chain_config.and_then(|config| config.activation_admin_address); + // Attempt to load the rollup config from the chain ID. If there is no config for the chain, // fall back to loading the config from the preimage oracle. - let rollup_config = if let Some(config) = Registry::rollup_config(chain_id) { - config.clone() + let rollup_config = if let Some(config) = built_in_chain_config { + config.rollup_config() } else { warn!( target: "boot_loader", - "No rollup config found for chain ID {}, falling back to preimage oracle. This is insecure in production without additional validation!", - chain_id + chain_id, + "no built-in rollup config found for chain ID, falling back to preimage oracle; this is insecure in production without additional validation" ); let ser_cfg = oracle .get(PreimageKey::new_local(L2_ROLLUP_CONFIG_KEY.to())) @@ -255,7 +265,7 @@ impl BootInfo { serde_json::from_slice(&ser_cfg).map_err(OracleProviderError::Serde)? }; - // Registry configs should already match, but oracle-provided configs must be bound to the + // Built-in configs should already match, but oracle-provided configs must be bound to the // committed boot chain ID before any config-derived chain parameters are trusted. let rollup_config_chain_id = rollup_config.l2_chain_id.id(); if chain_id != rollup_config_chain_id { @@ -264,9 +274,15 @@ impl BootInfo { rollup_config_chain_id, }); } + // The activation registry is installed at Beryl. For built-in chains, the admin comes from + // `ChainConfig`; for oracle-provided rollup configs, do not infer an admin from untrusted + // fallback data until the admin has an explicit committed source. + if activation_admin_address.is_none() && rollup_config.hardforks.base.beryl.is_some() { + return Err(OracleProviderError::MissingActivationAdminAddress { chain_id }); + } - // Attempt to load the rollup config from the chain ID. If there is no config for the chain, - // fall back to loading the config from the preimage oracle. + // Attempt to load the L1 config from the rollup config's L1 chain ID. If there is no config + // for the chain, fall back to loading the config from the preimage oracle. let l1_config = if let Some(config) = base_common_chains::L1_CONFIGS.get(&rollup_config.l1_chain_id) { @@ -348,6 +364,7 @@ impl BootInfo { claimed_l2_output_root: l2_claim, claimed_l2_block_number: l2_claim_block, chain_id, + activation_admin_address, rollup_config, l1_config, proposer, @@ -363,7 +380,7 @@ mod tests { use alloy_primitives::B256; use async_trait::async_trait; - use base_common_chains::Registry; + use base_common_chains::ChainConfig as BaseChainConfig; use base_proof_preimage::{ PreimageKey, PreimageOracleClient, errors::{PreimageOracleError, PreimageOracleResult}, @@ -405,10 +422,25 @@ mod tests { } } + #[tokio::test] + async fn loads_activation_admin_address_from_builtin_chain_config() { + let chain_config = BaseChainConfig::ZERONET; + + let mut oracle = MockOracle::new(); + oracle.insert(L1_HEAD_KEY, B256::repeat_byte(0x11).to_vec()); + oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); + oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); + oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 40_308_263u64.to_be_bytes().to_vec()); + oracle.insert(L2_CHAIN_ID_KEY, chain_config.chain_id.to_be_bytes().to_vec()); + + let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); + + assert_eq!(boot_info.activation_admin_address, chain_config.activation_admin_address); + } + #[tokio::test] async fn rejects_oracle_rollup_config_with_mismatched_chain_id() { - let rollup_config = - Registry::rollup_config(84532).expect("Base Sepolia config should exist").clone(); + let rollup_config = base_common_chains::rollup_config!(BaseChainConfig::SEPOLIA); let mut oracle = MockOracle::new(); oracle.insert(L1_HEAD_KEY, B256::repeat_byte(0x11).to_vec()); @@ -435,8 +467,7 @@ mod tests { async fn accepts_oracle_rollup_config_with_matching_chain_id() { const ORACLE_CHAIN_ID: u64 = 999_999_999; - let rollup_config = - Registry::rollup_config(84532).expect("Base Sepolia config should exist").clone(); + let rollup_config = base_common_chains::rollup_config!(BaseChainConfig::SEPOLIA); let mut rollup_config_value = serde_json::to_value(&rollup_config).expect("rollup config should convert to value"); rollup_config_value["l2_chain_id"] = serde_json::json!(ORACLE_CHAIN_ID); @@ -455,6 +486,37 @@ mod tests { let boot_info = BootInfo::load(&oracle).await.expect("boot info should load"); assert_eq!(boot_info.chain_id, ORACLE_CHAIN_ID); + assert_eq!(boot_info.activation_admin_address, None); assert_eq!(boot_info.rollup_config.l2_chain_id.id(), ORACLE_CHAIN_ID); } + + #[tokio::test] + async fn rejects_oracle_rollup_config_with_beryl_and_no_activation_admin() { + const ORACLE_CHAIN_ID: u64 = 999_999_999; + + let rollup_config = base_common_chains::rollup_config!(BaseChainConfig::SEPOLIA); + let mut rollup_config_value = + serde_json::to_value(&rollup_config).expect("rollup config should convert to value"); + rollup_config_value["l2_chain_id"] = serde_json::json!(ORACLE_CHAIN_ID); + rollup_config_value["base"] = serde_json::json!({ "beryl": 0 }); + + let mut oracle = MockOracle::new(); + oracle.insert(L1_HEAD_KEY, B256::repeat_byte(0x11).to_vec()); + oracle.insert(L2_OUTPUT_ROOT_KEY, B256::repeat_byte(0x22).to_vec()); + oracle.insert(L2_CLAIM_KEY, B256::repeat_byte(0x33).to_vec()); + oracle.insert(L2_CLAIM_BLOCK_NUMBER_KEY, 40_308_263u64.to_be_bytes().to_vec()); + oracle.insert(L2_CHAIN_ID_KEY, ORACLE_CHAIN_ID.to_be_bytes().to_vec()); + oracle.insert( + L2_ROLLUP_CONFIG_KEY, + serde_json::to_vec(&rollup_config_value).expect("rollup config should serialize"), + ); + + let err = BootInfo::load(&oracle) + .await + .expect_err("Beryl-enabled oracle config without activation admin should fail"); + assert!(matches!( + err, + OracleProviderError::MissingActivationAdminAddress { chain_id: ORACLE_CHAIN_ID } + )); + } } diff --git a/crates/proof/proof/src/errors.rs b/crates/proof/proof/src/errors.rs index 32eaae0efe..22798785ce 100644 --- a/crates/proof/proof/src/errors.rs +++ b/crates/proof/proof/src/errors.rs @@ -123,6 +123,16 @@ pub enum OracleProviderError { /// The L2 chain ID claimed by the loaded rollup config. rollup_config_chain_id: u64, }, + /// A Beryl-enabled chain is missing a trusted activation registry admin address. + /// + /// This error occurs when proof boot data resolves a rollup config with Beryl scheduled but no + /// activation admin address from a built-in chain config. The admin affects precompile execution; + /// oracle-only Beryl configs are rejected until the admin has an explicit committed source. + #[error("Missing activation admin address for Beryl-enabled chain ID: {chain_id}")] + MissingActivationAdminAddress { + /// The chain ID whose Beryl-enabled config lacks a trusted activation admin address. + chain_id: u64, + }, /// Blob KZG commitment verification failed. /// /// This error occurs when the KZG commitment computed from a reconstructed diff --git a/crates/proof/succinct/elf/manifest.toml b/crates/proof/succinct/elf/manifest.toml index 4c276debd0..d37999baac 100644 --- a/crates/proof/succinct/elf/manifest.toml +++ b/crates/proof/succinct/elf/manifest.toml @@ -17,8 +17,8 @@ [[elfs]] name = "range-elf-embedded" -sha256 = "73baf3832f222f42b9b8b765dbf2d635a1f4cce79f71db04c8120b3b0916a05e" +sha256 = "b678508eb6d75befac282b9af9cb873d112a424f6a5fbdc4213b31628c0c2f8a" [[elfs]] name = "aggregation-elf" -sha256 = "bef8337ba75c1d72ee659455b8bdf1c15f5cb18e6cdfeda9eb8401b8627fca20" +sha256 = "baf0a889a5c493d780235b14f21e4f6c94feb8d4d8e138224f0d7d8572c944b9" diff --git a/crates/proof/succinct/programs/Cargo.lock b/crates/proof/succinct/programs/Cargo.lock index ccdf686d74..369a1d11b0 100644 --- a/crates/proof/succinct/programs/Cargo.lock +++ b/crates/proof/succinct/programs/Cargo.lock @@ -72,18 +72,17 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" +checksum = "83447eeb17816e172f1dfc0db1f9dc0b7c5d069bd1f7cecbecceb382bf931015" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "alloy-trie", "alloy-tx-macros", "auto_impl", - "borsh", "c-kzg", "derive_more", "either", @@ -93,21 +92,20 @@ dependencies = [ "secp256k1", "serde", "serde_json", - "serde_with", "thiserror", ] [[package]] name = "alloy-consensus-any" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" +checksum = "5406343e306856dc2be762700e98a16904de45dee14a07f233e742ce68daff2f" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "serde", ] @@ -132,7 +130,6 @@ checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" dependencies = [ "alloy-primitives", "alloy-rlp", - "borsh", "serde", ] @@ -144,24 +141,22 @@ checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" dependencies = [ "alloy-primitives", "alloy-rlp", - "borsh", "k256", "serde", - "serde_with", "thiserror", ] [[package]] name = "alloy-eip7928" -version = "0.3.4" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "407510740da514b694fecb44d8b3cebdc60d448f70cc5d24743e8ba273448a6e" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" dependencies = [ "alloy-primitives", "alloy-rlp", - "borsh", "once_cell", "serde", + "thiserror", ] [[package]] @@ -176,9 +171,29 @@ dependencies = [ "alloy-eip7928", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 1.8.3", + "auto_impl", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-eips" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dca4c89ace90684b4b77366d00631ed498c9af962079af2a5dbc593a0618a77" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde 2.0.5", "auto_impl", - "borsh", "c-kzg", "derive_more", "either", @@ -189,12 +204,12 @@ dependencies = [ [[package]] name = "alloy-evm" -version = "0.27.3" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b991c370ce44e70a3a9e474087e3d65e42e66f967644ad729dc4cec09a21fd09" +checksum = "c1ceeea6dcbbcd4e546b27700763a6f6c3b3fee30054209884f521078b6fda4f" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-hardforks", "alloy-primitives", "alloy-sol-types", @@ -202,21 +217,20 @@ dependencies = [ "derive_more", "revm", "thiserror", + "tracing", ] [[package]] name = "alloy-genesis" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbf9480307b09d22876efb67d30cadd9013134c21f3a17ec9f93fd7536d38024" +checksum = "ab0e0fe9e6d1120ad7bb9254c3fc2b9bc80a8df42a033fb626be6559c13d5153" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-serde", + "alloy-serde 2.0.5", "alloy-trie", - "borsh", "serde", - "serde_with", ] [[package]] @@ -233,52 +247,37 @@ dependencies = [ "serde", ] -[[package]] -name = "alloy-json-abi" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dbe713da0c737d9e5e387b0ba790eb98b14dd207fe53eef50e19a5a8ec3dac" -dependencies = [ - "alloy-primitives", - "alloy-sol-type-parser", - "serde", - "serde_json", -] - [[package]] name = "alloy-network-primitives" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" +checksum = "cd28d9bfd11729037d194f2b1d43db8642eb3f342032691f4ca96bb745479c3c" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", - "alloy-serde", + "alloy-serde 2.0.5", "serde", ] [[package]] name = "alloy-primitives" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3b431b4e72cd8bd0ec7a50b4be18e73dab74de0dba180eef171055e5d5926e" +checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "alloy-rlp", "bytes", "cfg-if", "const-hex", "derive_more", - "foldhash 0.2.0", - "hashbrown 0.16.1", - "indexmap 2.14.0", + "foldhash", + "hashbrown 0.17.1", + "indexmap", "itoa", "k256", - "keccak-asm", "paste", - "proptest", "rand 0.9.4", - "rapidhash", "ruint", "rustc-hash", "serde", @@ -309,15 +308,15 @@ dependencies = [ [[package]] name = "alloy-rpc-types-engine" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb9b97b6e7965679ad22df297dda809b11cebc13405c1b537e5cffecc95834fa" +checksum = "7eba59e1c069f168a01982f42a57797736923b76aa854194df4930be17867e1c" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "derive_more", "rand 0.8.6", "serde", @@ -326,22 +325,21 @@ dependencies = [ [[package]] name = "alloy-rpc-types-eth" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" +checksum = "175a2a5b6017d7f61b5e4b800d21215fe8e94fe729d00828e13bb6d93dcf3492" dependencies = [ "alloy-consensus", "alloy-consensus-any", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-network-primitives", "alloy-primitives", "alloy-rlp", - "alloy-serde", + "alloy-serde 2.0.5", "alloy-sol-types", "itertools 0.14.0", "serde", "serde_json", - "serde_with", "thiserror", ] @@ -356,11 +354,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "alloy-serde" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc21a8772af7d78bba286726aa245bd2ff81cd9abe230afea2e91578996831c9" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + [[package]] name = "alloy-sol-macro" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab81bab693da9bb79f7a95b64b394718259fdd7e41dceeced4cad57cb71c4f6a" +checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", @@ -372,14 +381,14 @@ dependencies = [ [[package]] name = "alloy-sol-macro-expander" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489f1620bb7e2483fb5819ed01ab6edc1d2f93939dce35a5695085a1afd1d699" +checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.14.0", + "indexmap", "proc-macro-error2", "proc-macro2", "quote", @@ -390,9 +399,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-input" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56cef806ad22d4392c5fc83cf8f2089f988eb99c7067b4e0c6f1971fc1cca318" +checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" dependencies = [ "const-hex", "dunce", @@ -404,26 +413,14 @@ dependencies = [ "syn-solidity", ] -[[package]] -name = "alloy-sol-type-parser" -version = "1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6df77fea9d6a2a75c0ef8d2acbdfd92286cc599983d3175ccdc170d3433d249" -dependencies = [ - "serde", - "winnow 0.7.15", -] - [[package]] name = "alloy-sol-types" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64612d29379782a5dde6f4b6570d9c756d734d760c0c94c254d361e678a6591f" +checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" dependencies = [ - "alloy-json-abi", "alloy-primitives", "alloy-sol-macro", - "serde", ] [[package]] @@ -444,9 +441,9 @@ dependencies = [ [[package]] name = "alloy-tx-macros" -version = "1.8.3" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" +checksum = "01a0035943b75fe1e249f52e688492d7a1b1826bc2d19b8e1d5d3c24a2ad8f50" dependencies = [ "darling", "proc-macro2", @@ -466,15 +463,6 @@ dependencies = [ "syn 1.0.109", ] -[[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 = "anyhow" version = "1.0.102" @@ -488,9 +476,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" dependencies = [ "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-ff", + "ark-serialize", + "ark-std", ] [[package]] @@ -500,8 +488,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" dependencies = [ "ark-ec", - "ark-ff 0.5.0", - "ark-std 0.5.0", + "ark-ff", + "ark-std", ] [[package]] @@ -511,10 +499,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ "ahash", - "ark-ff 0.5.0", + "ark-ff", "ark-poly", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-serialize", + "ark-std", "educe", "fnv", "hashbrown 0.15.5", @@ -525,54 +513,16 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ark-ff" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" -dependencies = [ - "ark-ff-asm 0.3.0", - "ark-ff-macros 0.3.0", - "ark-serialize 0.3.0", - "ark-std 0.3.0", - "derivative", - "num-bigint 0.4.6", - "num-traits", - "paste", - "rustc_version 0.3.3", - "zeroize", -] - -[[package]] -name = "ark-ff" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" -dependencies = [ - "ark-ff-asm 0.4.2", - "ark-ff-macros 0.4.2", - "ark-serialize 0.4.2", - "ark-std 0.4.0", - "derivative", - "digest 0.10.7", - "itertools 0.10.5", - "num-bigint 0.4.6", - "num-traits", - "paste", - "rustc_version 0.4.1", - "zeroize", -] - [[package]] name = "ark-ff" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" dependencies = [ - "ark-ff-asm 0.5.0", - "ark-ff-macros 0.5.0", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", "arrayvec", "digest 0.10.7", "educe", @@ -583,26 +533,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "ark-ff-asm" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" -dependencies = [ - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-asm" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" -dependencies = [ - "quote", - "syn 1.0.109", -] - [[package]] name = "ark-ff-asm" version = "0.5.0" @@ -613,31 +543,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ark-ff-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" -dependencies = [ - "num-bigint 0.4.6", - "num-traits", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "ark-ff-macros" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" -dependencies = [ - "num-bigint 0.4.6", - "num-traits", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "ark-ff-macros" version = "0.5.0" @@ -658,35 +563,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" dependencies = [ "ahash", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-ff", + "ark-serialize", + "ark-std", "educe", "fnv", "hashbrown 0.15.5", ] -[[package]] -name = "ark-serialize" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" -dependencies = [ - "ark-std 0.3.0", - "digest 0.9.0", -] - -[[package]] -name = "ark-serialize" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" -dependencies = [ - "ark-std 0.4.0", - "digest 0.10.7", - "num-bigint 0.4.6", -] - [[package]] name = "ark-serialize" version = "0.5.0" @@ -694,7 +578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ "ark-serialize-derive", - "ark-std 0.5.0", + "ark-std", "arrayvec", "digest 0.10.7", "num-bigint 0.4.6", @@ -711,26 +595,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "ark-std" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" -dependencies = [ - "num-traits", - "rand 0.8.6", -] - -[[package]] -name = "ark-std" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" -dependencies = [ - "num-traits", - "rand 0.8.6", -] - [[package]] name = "ark-std" version = "0.5.0" @@ -787,16 +651,16 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base-common-chains" version = "0.9.1" dependencies = [ "alloy-chains", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-genesis", "alloy-hardforks", "alloy-primitives", @@ -811,15 +675,14 @@ name = "base-common-consensus" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-eth", - "alloy-serde", + "alloy-serde 2.0.5", "bytes", "reth-codecs", - "reth-ethereum-primitives", "reth-primitives-traits", "revm", "serde", @@ -831,7 +694,7 @@ name = "base-common-evm" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "auto_impl", @@ -853,7 +716,7 @@ version = "0.9.1" dependencies = [ "alloy-chains", "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-hardforks", "alloy-primitives", "alloy-sol-types", @@ -867,7 +730,12 @@ dependencies = [ name = "base-common-precompiles" version = "0.9.1" dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", "base-common-chains", + "base-precompile-macros", + "base-precompile-storage", "revm", ] @@ -876,11 +744,11 @@ name = "base-common-rpc-types-engine" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", - "alloy-serde", + "alloy-serde 2.0.5", "base-common-consensus", "serde", "sha2", @@ -892,7 +760,7 @@ name = "base-consensus-derive" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-genesis", "alloy-primitives", "alloy-rlp", @@ -912,7 +780,7 @@ dependencies = [ name = "base-consensus-upgrades" version = "0.9.1" dependencies = [ - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "base-common-consensus", ] @@ -921,19 +789,42 @@ dependencies = [ name = "base-metrics" version = "0.9.1" +[[package]] +name = "base-precompile-macros" +version = "0.0.0" +dependencies = [ + "alloy-primitives", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base-precompile-storage" +version = "0.0.0" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", + "base-precompile-macros", + "derive_more", + "revm", + "thiserror", +] + [[package]] name = "base-proof" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-genesis", "alloy-primitives", "alloy-rlp", "alloy-trie", "ark-bls12-381", - "ark-ff 0.5.0", + "ark-ff", "async-trait", "base-common-chains", "base-common-consensus", @@ -979,7 +870,7 @@ name = "base-proof-executor" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-primitives", "alloy-rlp", @@ -1024,7 +915,7 @@ name = "base-proof-primitives" version = "0.9.1" dependencies = [ "alloy-chains", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-primitives", "async-trait", "base-common-genesis", @@ -1037,7 +928,7 @@ name = "base-proof-succinct-client-utils" version = "0.9.1" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-evm", "alloy-genesis", "alloy-primitives", @@ -1048,6 +939,7 @@ dependencies = [ "base-common-consensus", "base-common-evm", "base-common-genesis", + "base-common-precompiles", "base-consensus-derive", "base-proof", "base-proof-driver", @@ -1103,7 +995,7 @@ version = "0.9.1" dependencies = [ "alloc-no-stdlib", "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-genesis", "alloy-primitives", "alloy-rlp", @@ -1134,12 +1026,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "base64ct" version = "1.8.3" @@ -1155,21 +1041,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bitcoin-io" version = "0.1.4" @@ -1208,31 +1079,11 @@ dependencies = [ "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 = "blake2b_simd" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" -dependencies = [ - "arrayref", - "arrayvec", - "constant_time_eq", -] - [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -1252,16 +1103,12 @@ dependencies = [ ] [[package]] -name = "bls12_381" -version = "0.7.1" +name = "block-buffer" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3c196a77437e7cc2fb515ce413a6401291578b5afc8ecb29a3c7ab957f05941" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ - "ff 0.12.1", - "group 0.12.1", - "pairing 0.22.0", - "rand_core 0.6.4", - "subtle", + "hybrid-array", ] [[package]] @@ -1276,30 +1123,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "brotli" version = "8.0.2" @@ -1321,15 +1144,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byte-slice-cast" -version = "1.2.3" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecheck" @@ -1386,9 +1203,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -1402,24 +1219,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - [[package]] name = "const-default" version = "1.0.0" @@ -1428,9 +1227,9 @@ checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" [[package]] name = "const-hex" -version = "1.18.1" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -1445,29 +1244,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "const_format" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" -dependencies = [ - "const_format_proc_macros", - "konst", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "constant_time_eq" -version = "0.4.2" +name = "constant_time_eq" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" @@ -1480,12 +1258,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1515,9 +1287,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "critical-section" @@ -1525,37 +1297,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -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.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-bigint" version = "0.5.5" @@ -1578,6 +1325,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.23.0" @@ -1615,9 +1371,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1638,27 +1394,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "derive-where" version = "1.6.1" @@ -1688,30 +1423,31 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "rustc_version 0.4.1", + "rustc_version", "syn 2.0.117", "unicode-xid", ] [[package]] name = "digest" -version = "0.9.0" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "generic-array", + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.12.0", + "crypto-common 0.2.2", ] [[package]] @@ -1750,7 +1486,6 @@ dependencies = [ "rfc6979 0.4.0 (git+https://github.com/sp1-patches/signatures?tag=sp1-skip-verify-on-recovery)", "serdect", "signature", - "spki", ] [[package]] @@ -1767,9 +1502,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1789,9 +1524,9 @@ dependencies = [ "base16ct", "crypto-bigint", "digest 0.10.7", - "ff 0.13.1", + "ff", "generic-array", - "group 0.13.0", + "group", "hkdf", "pkcs8", "rand_core 0.6.4", @@ -1839,55 +1574,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "fastrlp" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "fastrlp" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" -dependencies = [ - "arrayvec", - "auto_impl", - "bytes", -] - -[[package]] -name = "ff" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" -dependencies = [ - "bitvec", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "ff" version = "0.13.1" @@ -1922,30 +1614,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[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.6", - "rustc-hex", - "static_assertions", -] - [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -2082,21 +1756,8 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", - "wasip3", ] [[package]] @@ -2105,25 +1766,13 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "group" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" -dependencies = [ - "ff 0.12.1", - "memuse", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "group" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", + "ff", "rand_core 0.6.4", "subtle", ] @@ -2134,35 +1783,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" -[[package]] -name = "halo2" -version = "0.1.0-beta.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a23c779b38253fe1538102da44ad5bd5378495a61d2c4ee18d64eaa61ae5995" -dependencies = [ - "halo2_proofs", -] - -[[package]] -name = "halo2_proofs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e925780549adee8364c7f2b685c753f6f3df23bde520c67416e93bf615933760" -dependencies = [ - "blake2b_simd", - "ff 0.12.1", - "group 0.12.1", - "pasta_curves 0.4.1", - "rand_core 0.6.4", - "rayon", -] - -[[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.5" @@ -2176,7 +1796,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", - "foldhash 0.1.5", ] [[package]] @@ -2187,16 +1806,19 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", - "serde", - "serde_core", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] [[package]] name = "heck" @@ -2244,72 +1866,20 @@ dependencies = [ ] [[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "hybrid-array" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ - "cc", + "typenum", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[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-trait-for-tuples" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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.14.0" @@ -2317,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2376,28 +1946,16 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] -[[package]] -name = "jubjub" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a575df5f985fe1cd5b2b05664ff6accfc46559032b954529fd225a2168d27b0f" -dependencies = [ - "bitvec", - "bls12_381", - "ff 0.12.1", - "group 0.12.1", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "k256" version = "0.13.4" @@ -2407,7 +1965,6 @@ dependencies = [ "ecdsa 0.16.9 (git+https://github.com/sp1-patches/signatures?tag=sp1-skip-verify-on-recovery)", "elliptic-curve", "hex", - "once_cell", "serdect", "sha2", "sp1-lib", @@ -2415,45 +1972,21 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "keccak-asm" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa468878266ad91431012b3e5ef1bf9b170eab22883503a318d46857afa4579a" -dependencies = [ - "digest 0.10.7", - "sha3-asm", -] - -[[package]] -name = "konst" -version = "0.2.20" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ - "konst_macro_rules", + "cfg-if", + "cpufeatures 0.3.0", ] -[[package]] -name = "konst_macro_rules" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" - [[package]] name = "kzg-rs" version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee8b4f55c3dedcfaa8668de1dfc8469e7a32d441c28edf225ed1f566fb32977d" dependencies = [ - "ff 0.13.1", + "ff", "hex", "rkyv", "serde", @@ -2468,21 +2001,12 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.8", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -2496,12 +2020,6 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "lock_api" version = "0.4.14" @@ -2528,9 +2046,9 @@ dependencies = [ [[package]] name = "macro-string" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", @@ -2543,12 +2061,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memuse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d97bbf43eb4f088f8ca469930cde17fa036207c9a5e02ccc5107c4e8b17c964" - [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2652,12 +2164,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - [[package]] name = "num-integer" version = "0.1.46" @@ -2725,7 +2231,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", @@ -2737,9 +2242,7 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" dependencies = [ - "alloy-rlp", "cfg-if", - "proptest", "ruint", "serde", "smallvec", @@ -2756,26 +2259,9 @@ dependencies = [ ] [[package]] -name = "op-alloy-consensus" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "736381a95471d23e267263cfcee9e1d96d30b9754a94a2819148f83379de8a86" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-serde", - "derive_more", - "serde", - "serde_with", - "thiserror", -] - -[[package]] -name = "p256" -version = "0.13.2" -source = "git+https://github.com/sp1-patches/elliptic-curves?tag=patch-p256-13.2-sp1-6.0.0#923794180d3d7716ba3eed94459d4b434f844090" +name = "p256" +version = "0.13.2" +source = "git+https://github.com/sp1-patches/elliptic-curves?tag=patch-p256-13.2-sp1-6.0.0#923794180d3d7716ba3eed94459d4b434f844090" dependencies = [ "ecdsa 0.16.9 (registry+https://github.com/rust-lang/crates.io-index)", "elliptic-curve", @@ -2787,11 +2273,11 @@ dependencies = [ [[package]] name = "p3-bn254-fr" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9abf208fbfe540d6e2a6caaa2a9a345b1c8cb23ffdcdfcc6987244525d4fc821" +checksum = "2077757c7cb514202ccb5368f521f23f5709c720599e6545c683c66e0a52d2d8" dependencies = [ - "ff 0.13.1", + "ff", "num-bigint 0.4.6", "p3-field", "p3-poseidon2", @@ -2802,9 +2288,9 @@ dependencies = [ [[package]] name = "p3-challenger" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b725b453bbb35117a1abf0ddfd900b0676063d6e4231e0fa6bb0d76018d8ad" +checksum = "b6a908924d43e4cfb93fb41c8346cac211b70314385a9037e9241f5b7f3eaf77" dependencies = [ "p3-field", "p3-maybe-rayon", @@ -2816,9 +2302,9 @@ dependencies = [ [[package]] name = "p3-dft" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56a1f81101bff744b7ebba7f4497e917a2c6716d6e62736e4a56e555a2d98cb7" +checksum = "be6408b10a2c27eb13a7d5580c546c2179a8dc7dbc10a990657311891f9b41c0" dependencies = [ "p3-field", "p3-matrix", @@ -2829,9 +2315,9 @@ dependencies = [ [[package]] name = "p3-field" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36459d4acb03d08097d713f336c7393990bb489ab19920d4f68658c7a5c10968" +checksum = "3dc75969ca3ac847f43e632ab979d59ff7a68f9eac8dbf8edcbba47fc2e1d3aa" dependencies = [ "itertools 0.12.1", "num-bigint 0.4.6", @@ -2843,24 +2329,26 @@ dependencies = [ [[package]] name = "p3-koala-bear" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1f52bcb6be38bdc8fa6b38b3434d4eedd511f361d4249fd798c6a5ef817b40" +checksum = "3a9683cd0ef68100df7c62490533047bcf19c04c4a0fa1efc9d7c1e03e31f6b3" dependencies = [ + "cfg-if", "num-bigint 0.4.6", "p3-field", "p3-mds", "p3-poseidon2", "p3-symmetric", "rand 0.8.6", + "rustc_version", "serde", ] [[package]] name = "p3-matrix" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5583e9cd136a4095a25c41a9edfdcce2dfae58ef01639317813bdbbd5b55c583" +checksum = "75c3f150ceb90e09539413bf481e618d05ee19210b4e467d2902eb82d2e15281" dependencies = [ "itertools 0.12.1", "p3-field", @@ -2873,15 +2361,15 @@ dependencies = [ [[package]] name = "p3-maybe-rayon" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e524d47a49fb4265611303339c4ef970d892817b006cc330dad18afb91e411b1" +checksum = "e0641952b42da45e1dfa2d4a2a3163e330f944ad9740942f35026c0a71a605f1" [[package]] name = "p3-mds" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f6cb8edcb276033d43769a3725570c340d2ed6f35c3cca4cddeee07718fa376" +checksum = "aa4a5f250e174dcfca5cbeac6ad75713924e7e7320e0a335e3c50b8b1f4fe8ec" dependencies = [ "itertools 0.12.1", "p3-dft", @@ -2894,9 +2382,9 @@ dependencies = [ [[package]] name = "p3-poseidon2" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a26197df2097b98ab7038d59a01e1fe1a0f545e7e04aa9436b2454b1836654f" +checksum = "522986377b2164c5f94f2dae88e0e0a3d169cc6239202ef4aeb4322d60feffd0" dependencies = [ "gcd", "p3-field", @@ -2908,9 +2396,9 @@ dependencies = [ [[package]] name = "p3-symmetric" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1d3b5202096bca57cde912fbbb9cbaedaf5ac7c42a924c7166b98709d64d21" +checksum = "9047ce85c086a9b3f118e10078f10636f7bfeed5da871a04da0b61400af8793a" dependencies = [ "itertools 0.12.1", "p3-field", @@ -2919,57 +2407,20 @@ dependencies = [ [[package]] name = "p3-util" -version = "0.3.2-succinct" +version = "0.4.3-succinct" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f0388aa6d935ca3a17444086120f393f0b2f0816010b5ff95998c1c4095e3" +checksum = "cff962f8eaa5f36e0447cee7c241f6b4b475fadf3ee61f154327a26bb4e009ba" dependencies = [ "serde", ] -[[package]] -name = "pairing" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135590d8bdba2b31346f9cd1fb2a912329f5135e832a4f422942eb6ead8b6b3b" -dependencies = [ - "group 0.12.1", -] - [[package]] name = "pairing" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" dependencies = [ - "group 0.13.0", -] - -[[package]] -name = "parity-scale-codec" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" -dependencies = [ - "arrayvec", - "bitvec", - "byte-slice-cast", - "const_format", - "impl-trait-for-tuples", - "parity-scale-codec-derive", - "rustversion", - "serde", -] - -[[package]] -name = "parity-scale-codec-derive" -version = "3.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", + "group", ] [[package]] @@ -2985,52 +2436,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pasta_curves" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc65faf8e7313b4b1fbaa9f7ca917a0eed499a9663be71477f87993604341d8" -dependencies = [ - "blake2b_simd", - "ff 0.12.1", - "group 0.12.1", - "lazy_static", - "rand 0.8.6", - "static_assertions", - "subtle", -] - -[[package]] -name = "pasta_curves" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e57598f73cc7e1b2ac63c79c517b31a0877cd7c402cdcaa311b5208de7a095" -dependencies = [ - "blake2b_simd", - "ff 0.13.1", - "group 0.13.0", - "lazy_static", - "rand 0.8.6", - "static_assertions", - "subtle", -] - [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - [[package]] name = "phf" version = "0.13.1" @@ -3102,12 +2513,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[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.21" @@ -3117,16 +2522,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primeorder" version = "0.13.1" @@ -3135,26 +2530,6 @@ dependencies = [ "elliptic-curve", ] -[[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", - "uint", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3192,16 +2567,11 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", "bitflags", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", "unarray", ] @@ -3225,12 +2595,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - [[package]] name = "quote" version = "1.0.45" @@ -3246,12 +2610,6 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - [[package]] name = "radium" version = "0.7.0" @@ -3285,7 +2643,6 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", "rand_core 0.9.5", "serde", ] @@ -3351,35 +2708,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "rapidhash" -version = "4.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" -dependencies = [ - "rustversion", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3389,32 +2717,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "rend" version = "0.5.3" @@ -3426,17 +2728,17 @@ dependencies = [ [[package]] name = "reth-codecs" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.4#2ac58a25f561827e2b816a3c1ed972194f3b2915" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce542a96bf888f31854803e80b3340bc233927743aa580838014e8a88fe0d66" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-genesis", "alloy-primitives", "alloy-trie", "bytes", "modular-bitfield", - "op-alloy-consensus", "reth-codecs-derive", "reth-zstd-compressors", "serde", @@ -3444,73 +2746,55 @@ dependencies = [ [[package]] name = "reth-codecs-derive" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.4#2ac58a25f561827e2b816a3c1ed972194f3b2915" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634c90f1cc0f9887680ca785b0b21aa961070b9465917bf65afaec56a6d005bb" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "reth-ethereum-primitives" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.4#2ac58a25f561827e2b816a3c1ed972194f3b2915" -dependencies = [ - "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "alloy-rpc-types-eth", - "alloy-serde", - "reth-codecs", - "reth-primitives-traits", - "reth-zstd-compressors", - "serde", - "serde_with", -] - [[package]] name = "reth-primitives-traits" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.4#2ac58a25f561827e2b816a3c1ed972194f3b2915" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee12e304adbacbb32248c9806ebafbe1e2811fbfefe53c5e5b710a8438b7ec0" dependencies = [ "alloy-consensus", - "alloy-eips", + "alloy-eips 2.0.5", "alloy-genesis", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-eth", "alloy-trie", - "auto_impl", "bytes", "dashmap", "derive_more", "once_cell", - "op-alloy-consensus", "reth-codecs", "revm-bytecode", "revm-primitives", "revm-state", "secp256k1", "serde", - "serde_with", "thiserror", ] [[package]] name = "reth-zstd-compressors" -version = "1.11.3" -source = "git+https://github.com/paradigmxyz/reth?tag=v1.11.4#2ac58a25f561827e2b816a3c1ed972194f3b2915" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12fafa33d2f420a9d39249a3e0357b1928d09429f30758b85280409092873b2" dependencies = [ "zstd", ] [[package]] name = "revm" -version = "34.0.0" +version = "38.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2aabdebaa535b3575231a88d72b642897ae8106cf6b0d12eafc6bfdf50abfc7" +checksum = "91202d39dbe8e8d10e9e8f2b76c30da68ecd1d25be69ba6d853ad0d03a3a398a" dependencies = [ "revm-bytecode", "revm-context", @@ -3527,9 +2811,9 @@ dependencies = [ [[package]] name = "revm-bytecode" -version = "8.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d1e5c1eaa44d39d537f668bc5c3409dc01e5c8be954da6c83370bbdf006457" +checksum = "bdbb3a3d735efa94c91f2ef6bf20a35f99a77bc78f3e25bd758336901bdf9661" dependencies = [ "bitvec", "phf", @@ -3539,9 +2823,9 @@ dependencies = [ [[package]] name = "revm-context" -version = "13.0.0" +version = "16.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "892ff3e6a566cf8d72ffb627fdced3becebbd9ba64089c25975b9b028af326a5" +checksum = "c5f68d928d8b228e0faeb1c6ed75c4fde7d124f1ddf9119b67e7a0ad4041237d" dependencies = [ "bitvec", "cfg-if", @@ -3556,9 +2840,9 @@ dependencies = [ [[package]] name = "revm-context-interface" -version = "14.0.0" +version = "17.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f61cc6d23678c4840af895b19f8acfbbd546142ec8028b6526c53cc1c16c98" +checksum = "1f3758e6167c4ba7a59a689c519a047edaefcd4c37d74f279b93ed87bc8aece4" dependencies = [ "alloy-eip2930", "alloy-eip7702", @@ -3572,11 +2856,11 @@ dependencies = [ [[package]] name = "revm-database" -version = "10.0.0" +version = "13.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529528d0b05fe646be86223032c3e77aa8b05caa2a35447d538c55965956a511" +checksum = "c281a1f11d3bcb8c0bba1199ed6bcb001d1aeb3d4fb366819e14f88723989a4e" dependencies = [ - "alloy-eips", + "alloy-eips 1.8.3", "revm-bytecode", "revm-database-interface", "revm-primitives", @@ -3586,9 +2870,9 @@ dependencies = [ [[package]] name = "revm-database-interface" -version = "9.0.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7bf93ac5b91347c057610c0d96e923db8c62807e03f036762d03e981feddc1d" +checksum = "d89efb9832a4e3742bb4ded5f7fe5bf905e8860e69427d4dfec153484fc6d304" dependencies = [ "auto_impl", "either", @@ -3600,9 +2884,9 @@ dependencies = [ [[package]] name = "revm-handler" -version = "15.0.0" +version = "18.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd0e43e815a85eded249df886c4badec869195e70cdd808a13cfca2794622d2" +checksum = "783e903d6922b7f5f9a940d1bb229530502d2924b1aed9d5ca5a94ebf065d460" dependencies = [ "auto_impl", "derive-where", @@ -3619,9 +2903,9 @@ dependencies = [ [[package]] name = "revm-inspector" -version = "15.0.0" +version = "19.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3ccad59db91ef93696536a0dbaf2f6f17cfe20d4d8843ae118edb7e97947ef" +checksum = "8216ad58422090d0daa9eb430e0a081f7ad07e7fd30681dee71f8420c99624e0" dependencies = [ "auto_impl", "either", @@ -3636,9 +2920,9 @@ dependencies = [ [[package]] name = "revm-interpreter" -version = "32.0.0" +version = "35.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11406408597bc249392d39295831c4b641b3a6f5c471a7c41104a7a1e3564c07" +checksum = "1ece9f41b69658c15d748288a4dbdfc06a63f3ce93d983af440de3f1631dce6a" dependencies = [ "revm-bytecode", "revm-context-interface", @@ -3649,20 +2933,21 @@ dependencies = [ [[package]] name = "revm-precompile" -version = "32.1.0" +version = "34.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ec11f45deec71e4945e1809736bb20d454285f9167ab53c5159dae1deb603f" +checksum = "a346a8cc6c8c39bd65306641c692191299c0a7b63d38810e39e8fe9b92378660" dependencies = [ "ark-bls12-381", "ark-bn254", "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-ff", + "ark-serialize", "arrayref", "aurora-engine-modexp", "cfg-if", "k256", "p256", + "revm-context-interface", "revm-primitives", "ripemd", "sha2", @@ -3670,9 +2955,9 @@ dependencies = [ [[package]] name = "revm-primitives" -version = "22.1.0" +version = "23.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfb5ce6cf18b118932bcdb7da05cd9c250f2cb9f64131396b55f3fe3537c35" +checksum = "0c99bda77d9661521ba0b4bc04558c6692074f01e65dd420fa3b893033d9b8a2" dependencies = [ "alloy-primitives", "num_enum", @@ -3682,9 +2967,9 @@ dependencies = [ [[package]] name = "revm-state" -version = "9.0.0" +version = "11.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311720d4f0f239b041375e7ddafdbd20032a33b7bae718562ea188e188ed9fd3" +checksum = "c32490ed687dba31c3c882beb8c20408bdd30ef96690d8f145b0ee9a87040bfe" dependencies = [ "alloy-eip7928", "bitflags", @@ -3729,8 +3014,8 @@ checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" dependencies = [ "bytecheck", "bytes", - "hashbrown 0.17.0", - "indexmap 2.14.0", + "hashbrown 0.17.1", + "indexmap", "munge", "ptr_meta", "rancor", @@ -3751,16 +3036,6 @@ dependencies = [ "syn 2.0.117", ] -[[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 = "rlsf" version = "0.2.2" @@ -3781,21 +3056,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" dependencies = [ "alloy-rlp", - "ark-ff 0.3.0", - "ark-ff 0.4.2", - "ark-ff 0.5.0", - "bytes", - "fastrlp 0.3.1", - "fastrlp 0.4.0", - "num-bigint 0.4.6", - "num-integer", - "num-traits", - "parity-scale-codec", - "primitive-types", "proptest", "rand 0.8.6", "rand 0.9.4", - "rlp", "ruint-macro", "serde_core", "valuable", @@ -3814,41 +3077,13 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" -[[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.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" -dependencies = [ - "semver 0.11.0", -] - [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.28", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "semver", ] [[package]] @@ -3857,42 +3092,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3935,30 +3134,12 @@ dependencies = [ "cc", ] -[[package]] -name = "semver" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" -dependencies = [ - "semver-parser", -] - [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -[[package]] -name = "semver-parser" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" -dependencies = [ - "pest", -] - [[package]] name = "serde" version = "1.0.228" @@ -4010,9 +3191,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4023,28 +3204,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", "serde_core", - "serde_json", "serde_with_macros", - "time", ] [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling", "proc-macro2", @@ -4074,21 +3246,12 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.8" -source = "git+https://github.com/sp1-patches/RustCrypto-hashes?tag=patch-sha3-0.10.8-sp1-6.0.0#0a16ae7acd5cd5fbb432d884bd4aae2764a18cf7" -dependencies = [ - "digest 0.10.7", - "keccak", -] - -[[package]] -name = "sha3-asm" -version = "0.1.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ - "cc", - "cfg-if", + "digest 0.11.3", + "keccak", ] [[package]] @@ -4124,9 +3287,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -4136,9 +3299,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slop-algebra" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733912d564a68ff209707e71fdc517d4ff82d4362b6a409f6a8241dfcb7a576a" +checksum = "3e8abf7cfad18c0580576e8adc01f7fa27b1cb19432e451e82950c9a445a7cfc" dependencies = [ "itertools 0.14.0", "p3-field", @@ -4147,25 +3310,24 @@ dependencies = [ [[package]] name = "slop-bn254" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a71b23ede427299e139fb822c5d0ea8fb931dc297eba0c6e2f30f774c04ebc81" +checksum = "c327e0927fabf9c0ae9a7b0333027dc04431e5981ab80d7bbe994d6c4ce35fc1" dependencies = [ - "ff 0.13.1", + "ff", "p3-bn254-fr", "serde", "slop-algebra", "slop-challenger", "slop-poseidon2", "slop-symmetric", - "zkhash", ] [[package]] name = "slop-challenger" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e4993210936ab317c0d56ee8257e1cdfe6c2fae4df1e158737f034e21d45f9" +checksum = "c263e731bb694d4465eedae7ecd6faf1f277198e751f3c209a1c4186d80d1b6b" dependencies = [ "futures", "p3-challenger", @@ -4176,9 +3338,9 @@ dependencies = [ [[package]] name = "slop-koala-bear" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8986e94b9a43d58fc8ce5bf111b0985479ab888ced923e3052fb19943f7859b4" +checksum = "0a8cdc74f04d13e738b4628312b16dfec6a2e547bde697c8bdcf556884c6e91f" dependencies = [ "lazy_static", "p3-koala-bear", @@ -4191,27 +3353,27 @@ dependencies = [ [[package]] name = "slop-poseidon2" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b06e4a24cba104a0a39740eedd97e60e8896926cc38e6a58d5866cc9811affa" +checksum = "3aedfc3bf87cf2694bd108c039d663c346c7bb807491a7db6701eb9dff5c6e5d" dependencies = [ "p3-poseidon2", ] [[package]] name = "slop-primitives" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0b66701c82f6aab97f4990b5d9ed7463beb5b5042dbe5eda5f6c71a6207b35" +checksum = "f30e6e332c3cb103541bed9f5f477b769df1b5fb6076b5e2386569c94b1475dc" dependencies = [ "slop-algebra", ] [[package]] name = "slop-symmetric" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d159948b924fd00f280064d7a049e43dceb2f26067f32fb99570d3169969ee" +checksum = "4cb1de854325a8c36a1bdfee5514e1d4c9f39290ae19eeaecb21ca6ee88d96d6" dependencies = [ "p3-symmetric", ] @@ -4239,9 +3401,9 @@ dependencies = [ [[package]] name = "sp1-primitives" -version = "6.1.0" +version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b77098dae9d62e080be3af253188c08e7e96e666423306654eede0110bf363" +checksum = "13ad00052921b993af682403b378c8fe23c40382f9790f093c8fac0f30433c5e" dependencies = [ "bincode", "blake3", @@ -4288,9 +3450,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f23e41cd36168cc2e51e5d3e35ff0c34b204d945769a65591a76286d04b51e43" dependencies = [ "cfg-if", - "ff 0.13.1", - "group 0.13.0", - "pairing 0.23.0", + "ff", + "group", + "pairing", "rand_core 0.6.4", "sp1-lib", "subtle", @@ -4366,7 +3528,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" dependencies = [ - "base64 0.13.1", + "base64", "proc-macro2", "quote", "syn 1.0.109", @@ -4397,9 +3559,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.5.7" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f425ae0b12e2f5ae65542e00898d500d4d318b4baf09f40fd0d410454e9947" +checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" dependencies = [ "paste", "proc-macro2", @@ -4413,19 +3575,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -4464,37 +3613,6 @@ dependencies = [ "num_cpus", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinyvec" version = "1.11.0" @@ -4510,36 +3628,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime", - "toml_parser", - "winnow 1.0.2", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.2", -] - [[package]] name = "tracing" version = "0.1.44" @@ -4603,24 +3691,6 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[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 = "unarray" version = "0.1.4" @@ -4679,15 +3749,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4700,23 +3761,14 @@ version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4727,9 +3779,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4737,9 +3789,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4750,106 +3802,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.28", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4859,118 +3824,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "wyz" version = "0.5.1" @@ -5020,33 +3879,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zkhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4352d1081da6922701401cdd4cbf29a2723feb4cfabb5771f6fee8e9276da1c7" -dependencies = [ - "ark-ff 0.4.2", - "ark-std 0.4.0", - "bitvec", - "blake2", - "bls12_381", - "byteorder", - "cfg-if", - "group 0.12.1", - "group 0.13.0", - "halo2", - "hex", - "jubjub", - "lazy_static", - "pasta_curves 0.5.1", - "rand 0.8.6", - "serde", - "sha2", - "sha3", - "subtle", -] - [[package]] name = "zmij" version = "1.0.21" @@ -5081,6 +3913,11 @@ dependencies = [ "pkg-config", ] +[[patch.unused]] +name = "sha3" +version = "0.10.8" +source = "git+https://github.com/sp1-patches/RustCrypto-hashes?tag=patch-sha3-0.10.8-sp1-6.0.0#0a16ae7acd5cd5fbb432d884bd4aae2764a18cf7" + [[patch.unused]] name = "substrate-bn" version = "0.6.0" diff --git a/crates/proof/succinct/programs/Cargo.toml b/crates/proof/succinct/programs/Cargo.toml index c0d0c4cd6b..eeca2611cd 100644 --- a/crates/proof/succinct/programs/Cargo.toml +++ b/crates/proof/succinct/programs/Cargo.toml @@ -46,7 +46,7 @@ sp1-zkvm = { version = "=6.1.0", features = ["verify"] } sp1-lib = { version = "=6.1.0", features = ["verify"] } # alloy (must match root Cargo.toml versions) -alloy-consensus = { version = "1.8", default-features = false } +alloy-consensus = { version = "2.0.4", default-features = false } alloy-sol-types = { version = "1.5.6", default-features = false } alloy-primitives = { version = "1.5.6", default-features = false } diff --git a/crates/proof/succinct/programs/aggregation/Cargo.toml b/crates/proof/succinct/programs/aggregation/Cargo.toml index c22fd8b710..e2e4e261d7 100644 --- a/crates/proof/succinct/programs/aggregation/Cargo.toml +++ b/crates/proof/succinct/programs/aggregation/Cargo.toml @@ -12,7 +12,7 @@ sha2.workspace = true sp1-zkvm = { workspace = true, features = ["verify"] } sp1-lib = { workspace = true, features = ["verify"] } base-proof-succinct-client-utils.workspace = true -alloy-consensus.workspace = true +alloy-consensus = { workspace = true, features = ["serde"] } alloy-primitives.workspace = true alloy-sol-types.workspace = true serde_cbor.workspace = true diff --git a/crates/proof/succinct/scripts/prove/Cargo.toml b/crates/proof/succinct/scripts/prove/Cargo.toml index 63b7473322..d414cb2cf2 100644 --- a/crates/proof/succinct/scripts/prove/Cargo.toml +++ b/crates/proof/succinct/scripts/prove/Cargo.toml @@ -38,7 +38,6 @@ base-proof-succinct-elfs.workspace = true alloy-contract.workspace = true alloy-eips.workspace = true alloy-network.workspace = true -alloy-node-bindings.workspace = true alloy-primitives.workspace = true alloy-provider = { workspace = true, features = ["reqwest"] } alloy-signer-local.workspace = true diff --git a/crates/proof/succinct/scripts/utils/Cargo.toml b/crates/proof/succinct/scripts/utils/Cargo.toml index 4238a97fbe..0a3c8f0534 100644 --- a/crates/proof/succinct/scripts/utils/Cargo.toml +++ b/crates/proof/succinct/scripts/utils/Cargo.toml @@ -43,7 +43,6 @@ path = "bin/parse_receipt.rs" # workspace alloy-network.workspace = true -alloy-node-bindings.workspace = true alloy-provider = { workspace = true, features = ["reqwest"] } alloy-transport-http = { workspace = true, features = [ "reqwest", diff --git a/crates/proof/succinct/utils/client/Cargo.toml b/crates/proof/succinct/utils/client/Cargo.toml index 89a3b06a32..034d012012 100644 --- a/crates/proof/succinct/utils/client/Cargo.toml +++ b/crates/proof/succinct/utils/client/Cargo.toml @@ -19,6 +19,7 @@ alloy-sol-types.workspace = true # Execution alloy-evm.workspace = true base-common-evm.workspace = true +base-common-precompiles.workspace = true revm.workspace = true revm-precompile.workspace = true diff --git a/crates/proof/succinct/utils/client/src/boot.rs b/crates/proof/succinct/utils/client/src/boot.rs index 12b77a14e5..eb8c73cdd9 100644 --- a/crates/proof/succinct/utils/client/src/boot.rs +++ b/crates/proof/succinct/utils/client/src/boot.rs @@ -68,13 +68,12 @@ impl BootInfoStruct { #[cfg(test)] mod tests { use alloy_primitives::{Address, b256}; - use base_common_chains::Registry; + use base_common_chains::ChainConfig; use super::*; fn boot_info(claimed_l2_block_number: u64) -> BootInfo { - let rollup_config = - Registry::rollup_config(8453).expect("Base mainnet config should exist").clone(); + let rollup_config = base_common_chains::rollup_config!(ChainConfig::MAINNET); let l1_config = base_common_chains::L1_CONFIGS .get(&rollup_config.l1_chain_id) .expect("Base mainnet L1 config should exist") @@ -86,6 +85,7 @@ mod tests { claimed_l2_output_root: B256::repeat_byte(0x33), claimed_l2_block_number, chain_id: rollup_config.l2_chain_id.id(), + activation_admin_address: ChainConfig::MAINNET.activation_admin_address, rollup_config, l1_config, proposer: Address::ZERO, @@ -126,9 +126,9 @@ mod tests { ]; for &(chain_id, expected) in cases { - let rollup = Registry::rollup_config(chain_id) + let rollup = base_common_chains::rollup_config!(chain_id) .unwrap_or_else(|| panic!("missing rollup config for chain {chain_id}")); - let got = hash_rollup_config(rollup); + let got = hash_rollup_config(&rollup); assert_eq!(got, expected, "config hash mismatch for chain {chain_id}"); } } diff --git a/crates/proof/succinct/utils/client/src/precompiles/custom.rs b/crates/proof/succinct/utils/client/src/precompiles/custom.rs index de96fb5a04..cd400b9122 100644 --- a/crates/proof/succinct/utils/client/src/precompiles/custom.rs +++ b/crates/proof/succinct/utils/client/src/precompiles/custom.rs @@ -1,7 +1,7 @@ //! Custom crypto provider for KZG proof verification. use kzg_rs::{Bytes32, Bytes48, KzgProof, KzgSettings}; -use revm::precompile::{Crypto, PrecompileError}; +use revm::precompile::{Crypto, PrecompileHalt}; /// Custom cryptography provider using kzg-rs for KZG proof verification. #[derive(Debug)] @@ -22,19 +22,19 @@ impl Crypto for CustomCrypto { y: &[u8; 32], commitment: &[u8; 48], proof: &[u8; 48], - ) -> Result<(), PrecompileError> { - let z = Bytes32::from_slice(z).map_err(|_| PrecompileError::BlobVerifyKzgProofFailed)?; - let y = Bytes32::from_slice(y).map_err(|_| PrecompileError::BlobVerifyKzgProofFailed)?; + ) -> Result<(), PrecompileHalt> { + let z = Bytes32::from_slice(z).map_err(|_| PrecompileHalt::BlobVerifyKzgProofFailed)?; + let y = Bytes32::from_slice(y).map_err(|_| PrecompileHalt::BlobVerifyKzgProofFailed)?; let commitment = Bytes48::from_slice(commitment) - .map_err(|_| PrecompileError::BlobVerifyKzgProofFailed)?; + .map_err(|_| PrecompileHalt::BlobVerifyKzgProofFailed)?; let proof = - Bytes48::from_slice(proof).map_err(|_| PrecompileError::BlobVerifyKzgProofFailed)?; + Bytes48::from_slice(proof).map_err(|_| PrecompileHalt::BlobVerifyKzgProofFailed)?; let valid = KzgProof::verify_kzg_proof(&commitment, &z, &y, &proof, &self.kzg_settings) - .map_err(|_| PrecompileError::BlobVerifyKzgProofFailed)?; + .map_err(|_| PrecompileHalt::BlobVerifyKzgProofFailed)?; if !valid { - return Err(PrecompileError::BlobVerifyKzgProofFailed); + return Err(PrecompileHalt::BlobVerifyKzgProofFailed); } Ok(()) diff --git a/crates/proof/succinct/utils/client/src/precompiles/factory.rs b/crates/proof/succinct/utils/client/src/precompiles/factory.rs index 5aaae0792f..8ab3797cda 100644 --- a/crates/proof/succinct/utils/client/src/precompiles/factory.rs +++ b/crates/proof/succinct/utils/client/src/precompiles/factory.rs @@ -1,6 +1,7 @@ //! [`EvmFactory`] implementation for the EVM in the ZKVM environment. use alloy_evm::{Database, EvmEnv, EvmFactory}; +use alloy_primitives::Address; use base_common_evm::{ BaseContext, BaseEvm, BaseHaltReason, BaseSpecId, BaseTransaction, BaseTransactionError, Builder, DefaultBase, @@ -15,13 +16,28 @@ use revm::{ use super::BaseZkvmPrecompiles; /// Factory producing [`BaseEvm`]s with ZKVM-accelerated precompile overrides enabled. -#[derive(Debug, Clone)] -pub struct ZkvmBaseEvmFactory {} +#[derive(Debug, Clone, Copy)] +pub struct ZkvmBaseEvmFactory { + /// Activation registry admin address. + activation_admin_address: Option
, +} impl ZkvmBaseEvmFactory { /// Creates a new [`ZkvmBaseEvmFactory`]. pub const fn new() -> Self { - Self {} + Self::new_with_activation_admin_address(None) + } + + /// Creates a new [`ZkvmBaseEvmFactory`] with the given activation registry admin address. + pub const fn new_with_activation_admin_address( + activation_admin_address: Option
, + ) -> Self { + Self { activation_admin_address } + } + + /// Returns the activation registry admin address. + pub const fn activation_admin_address(&self) -> Option
{ + self.activation_admin_address } } @@ -54,7 +70,10 @@ impl EvmFactory for ZkvmBaseEvmFactory { .with_cfg(input.cfg_env) .build_base() .with_inspector(NoOpInspector {}) - .with_precompiles(BaseZkvmPrecompiles::new_with_spec(spec_id)) + .with_precompiles(BaseZkvmPrecompiles::new_with_spec_and_activation_admin_address( + spec_id, + self.activation_admin_address, + )) } fn create_evm_with_inspector>>( @@ -69,6 +88,9 @@ impl EvmFactory for ZkvmBaseEvmFactory { .with_block(input.block_env) .with_cfg(input.cfg_env) .build_with_inspector(inspector) - .with_precompiles(BaseZkvmPrecompiles::new_with_spec(spec_id)) + .with_precompiles(BaseZkvmPrecompiles::new_with_spec_and_activation_admin_address( + spec_id, + self.activation_admin_address, + )) } } diff --git a/crates/proof/succinct/utils/client/src/precompiles/mod.rs b/crates/proof/succinct/utils/client/src/precompiles/mod.rs index c0510562ef..2216477994 100644 --- a/crates/proof/succinct/utils/client/src/precompiles/mod.rs +++ b/crates/proof/succinct/utils/client/src/precompiles/mod.rs @@ -1,18 +1,20 @@ //! [`PrecompileProvider`] for FPVM-accelerated rollup precompiles. -use alloc::string::String; +use alloc::{boxed::Box, string::String}; -use alloy_primitives::{Address, Bytes}; +use alloy_evm::precompiles::PrecompilesMap; +#[cfg(target_os = "zkvm")] +use alloy_evm::precompiles::{DynPrecompile, Precompile}; +use alloy_primitives::Address; use base_common_evm::{BasePrecompiles, BaseSpecId}; +use base_common_precompiles::PrecompileCallObserver; +#[cfg(any(test, target_os = "zkvm"))] +use revm::precompile::PrecompileId; use revm::{ context::{Cfg, ContextTr}, - handler::{EthPrecompiles, PrecompileProvider}, - interpreter::{CallInput, CallInputs, Gas, InstructionResult, InterpreterResult}, - precompile::{PrecompileError, Precompiles}, - primitives::hardfork::SpecId, + handler::PrecompileProvider, + interpreter::{CallInputs, InterpreterResult}, }; -#[cfg(any(test, target_os = "zkvm"))] -use revm_precompile::PrecompileId; mod custom; pub use custom::CustomCrypto; @@ -61,10 +63,6 @@ pub mod cycle_tracker { } } -fn get_or_create_precompiles(spec: BaseSpecId) -> &'static Precompiles { - BasePrecompiles::new_with_spec(spec).precompiles() -} - /// Get the cycle tracker name for a precompile by its ID. /// Returns None if the precompile is not accelerated/tracked. #[cfg(any(test, target_os = "zkvm"))] @@ -81,27 +79,100 @@ const fn get_precompile_tracker_name(id: &PrecompileId) -> Option<&'static str> } } +/// SP1 cycle-tracker observer for Base-native precompile operations. +#[derive(Debug, Default, Clone, Copy)] +pub struct Sp1CycleObserver; + +impl PrecompileCallObserver for Sp1CycleObserver { + fn start(&self, label: &'static str) { + let _ = label; + #[cfg(target_os = "zkvm")] + println!("cycle-tracker-report-start: {label}"); + } + + fn end(&self, label: &'static str) { + let _ = label; + #[cfg(target_os = "zkvm")] + println!("cycle-tracker-report-end: {label}"); + } +} + /// The ZKVM-cycle-tracking precompiles. #[derive(Debug)] pub struct BaseZkvmPrecompiles { - /// The default [`EthPrecompiles`] provider. - inner: EthPrecompiles, + /// The installed Base precompile map, with ZKVM-specific wrappers layered on top. + inner: PrecompilesMap, /// The [`BaseSpecId`] of the precompiles. spec: BaseSpecId, + /// Activation registry admin address. + activation_admin_address: Option
, } impl BaseZkvmPrecompiles { /// Create a new precompile provider with the given [`BaseSpecId`]. #[inline] pub fn new_with_spec(spec: BaseSpecId) -> Self { - let precompiles = get_or_create_precompiles(spec); - Self { inner: EthPrecompiles { precompiles, spec: SpecId::default() }, spec } + Self::new_with_spec_and_activation_admin_address(spec, None) + } + + /// Create a new precompile provider with the given [`BaseSpecId`] and activation admin. + #[inline] + pub fn new_with_spec_and_activation_admin_address( + spec: BaseSpecId, + activation_admin_address: Option
, + ) -> Self { + let inner = Self::installed_precompiles(spec, activation_admin_address); + + Self { inner, spec, activation_admin_address } } + + /// Rebuilds this provider with `activation_admin_address`. + #[inline] + pub fn with_activation_admin_address(self, activation_admin_address: Option
) -> Self { + Self::new_with_spec_and_activation_admin_address(self.spec, activation_admin_address) + } + + /// Returns the activation registry admin address. + pub const fn activation_admin_address(&self) -> Option
{ + self.activation_admin_address + } + + fn installed_precompiles( + spec: BaseSpecId, + activation_admin_address: Option
, + ) -> PrecompilesMap { + let mut precompiles = BasePrecompiles::new_with_spec(spec) + .with_activation_admin_address(activation_admin_address) + .install_with_observer(Sp1CycleObserver); + Self::install_cycle_trackers(&mut precompiles); + precompiles + } + + #[cfg(target_os = "zkvm")] + fn install_cycle_trackers(precompiles: &mut PrecompilesMap) { + precompiles.map_cacheable_precompiles(|_, precompile| { + let id = precompile.precompile_id().clone(); + if let Some(tracker_name) = get_precompile_tracker_name(&id) { + DynPrecompile::new(id, move |input| { + println!("cycle-tracker-report-start: precompile-{}", tracker_name); + let result = precompile.call(input); + println!("cycle-tracker-report-end: precompile-{}", tracker_name); + result + }) + } else { + precompile + } + }); + } + + #[cfg(not(target_os = "zkvm"))] + const fn install_cycle_trackers(_precompiles: &mut PrecompilesMap) {} } impl PrecompileProvider for BaseZkvmPrecompiles where CTX: ContextTr>, + PrecompilesMap: PrecompileProvider, { type Output = InterpreterResult; @@ -110,7 +181,8 @@ where if spec == self.spec { return false; } - *self = Self::new_with_spec(spec); + *self = + Self::new_with_spec_and_activation_admin_address(spec, self.activation_admin_address); true } @@ -120,79 +192,17 @@ where context: &mut CTX, inputs: &CallInputs, ) -> Result, String> { - let mut result = InterpreterResult { - result: InstructionResult::Return, - gas: Gas::new(inputs.gas_limit), - output: Bytes::new(), - }; - - use revm::context::LocalContextTr; - // NOTE: this snippet is refactored from the revm source code. - // See https://github.com/bluealloy/revm/blob/9bc0c04fda0891e0e8d2e2a6dfd0af81c2af18c4/crates/handler/src/precompile_provider.rs#L111-L122. - let shared_buffer; - let input_bytes = match &inputs.input { - CallInput::SharedBuffer(range) => { - shared_buffer = context.local().shared_memory_buffer_slice(range.clone()); - shared_buffer.as_deref().unwrap_or(&[]) - } - CallInput::Bytes(bytes) => bytes.0.iter().as_slice(), - }; - - // Priority: - // 1. If the precompile has an accelerated version, use that. - // 2. If the precompile is not accelerated, use the default version. - // 3. If the precompile is not found, return None. - let output = if let Some(precompile) = self.inner.precompiles.get(&inputs.bytecode_address) - { - // Track cycles for accelerated precompiles - #[cfg(target_os = "zkvm")] - let tracker_name = get_precompile_tracker_name(precompile.id()); - - #[cfg(target_os = "zkvm")] - if let Some(name) = tracker_name { - println!("cycle-tracker-report-start: precompile-{}", name); - } - - let result = precompile.execute(input_bytes, inputs.gas_limit); - - #[cfg(target_os = "zkvm")] - if let Some(name) = tracker_name { - println!("cycle-tracker-report-end: precompile-{}", name); - } - - result - } else { - return Ok(None); - }; - - match output { - Ok(output) => { - let underflow = result.gas.record_cost(output.gas_used); - assert!(underflow, "Gas underflow is not possible"); - result.result = InstructionResult::Return; - result.output = output.bytes; - } - Err(PrecompileError::Fatal(e)) => return Err(e), - Err(e) => { - result.result = if e.is_oog() { - InstructionResult::PrecompileOOG - } else { - InstructionResult::PrecompileError - }; - } - } - - Ok(Some(result)) + >::run(&mut self.inner, context, inputs) } #[inline] fn warm_addresses(&self) -> Box> { - self.inner.warm_addresses() + Box::new(self.inner.addresses().copied()) } #[inline] fn contains(&self, address: &Address) -> bool { - self.inner.contains(address) + self.inner.get(address).is_some() } } @@ -200,15 +210,19 @@ where mod tests { use alloc::vec::Vec; - use alloy_primitives::U256; + use alloy_evm::precompiles::PrecompilesMap; + use alloy_primitives::{B256, Bytes, U256}; use base_common_evm::{BaseContext, BaseUpgrade, DefaultBase as _}; + use base_common_precompiles::{ + ActivationRegistryStorage, B20FactoryStorage, B20Variant, PolicyRegistryStorage, + }; use revm::{ Context, database::EmptyDB, handler::PrecompileProvider, - interpreter::{CallInput, CallScheme, CallValue}, + interpreter::{CallInput, CallScheme, CallValue, InstructionResult}, }; - use revm_precompile::{PrecompileId, secp256r1}; + use revm_precompile::secp256r1; use super::*; @@ -227,7 +241,8 @@ mod tests { scheme: CallScheme::Call, is_static: false, return_memory_offset: 0..0, - known_bytecode: None, + known_bytecode: Default::default(), + reservoir: 0, } } @@ -312,7 +327,8 @@ mod tests { scheme: CallScheme::Call, is_static: false, return_memory_offset: 0..0, - known_bytecode: None, + known_bytecode: Default::default(), + reservoir: 0, }; let result = precompiles.run(&mut ctx, &call_inputs).unwrap(); @@ -381,11 +397,11 @@ mod tests { #[test] fn test_zkvm_precompiles_match_base_evm_precompiles() { for spec in BaseUpgrade::VARIANTS.iter().copied().map(BaseSpecId::new) { - let base_precompiles = BasePrecompiles::new_with_spec(spec); + let base_precompiles = BasePrecompiles::new_with_spec(spec).install(); let zkvm_precompiles = BaseZkvmPrecompiles::new_with_spec(spec); let base_addresses: Vec<_> = - >::warm_addresses( + >::warm_addresses( &base_precompiles, ) .collect(); @@ -413,7 +429,7 @@ mod tests { for address in &zkvm_addresses { assert!( - >::contains( + >::contains( &base_precompiles, address, ), @@ -423,6 +439,41 @@ mod tests { } } + #[test] + fn test_zkvm_precompiles_match_beryl_dynamic_installation() { + let (token_address, _) = + B20Variant::B20.compute_address(Address::repeat_byte(0x11), B256::repeat_byte(0x22)); + + let installed_addresses = [ + B20FactoryStorage::ADDRESS, + PolicyRegistryStorage::ADDRESS, + ActivationRegistryStorage::ADDRESS, + token_address, + ]; + + for (upgrade, expected) in [(BaseUpgrade::Azul, false), (BaseUpgrade::Beryl, true)] { + let spec = BaseSpecId::new(upgrade); + let base_precompiles = BasePrecompiles::new_with_spec(spec).install(); + let zkvm_precompiles = BaseZkvmPrecompiles::new_with_spec(spec); + + for address in installed_addresses { + assert_eq!( + base_precompiles.get(&address).is_some(), + expected, + "Base EVM install state changed for {address:?} at {upgrade:?}", + ); + assert_eq!( + >::contains( + &zkvm_precompiles, + &address, + ), + expected, + "ZKVM install state diverged for {address:?} at {upgrade:?}", + ); + } + } + } + #[test] fn test_tracker_keys_match_expected_format() { let expected_keys = [ @@ -453,21 +504,23 @@ mod tests { fn test_azul_uses_osaka_p256verify() { let p256_addr = *secp256r1::P256VERIFY.address(); - let jovian_set = get_or_create_precompiles(BaseSpecId::new(BaseUpgrade::Jovian)); - let azul_set = get_or_create_precompiles(BaseSpecId::new(BaseUpgrade::Azul)); + let jovian_set = BasePrecompiles::new_with_spec(BaseSpecId::new(BaseUpgrade::Jovian)); + let azul_set = BasePrecompiles::new_with_spec(BaseSpecId::new(BaseUpgrade::Azul)); - let jovian_p256 = jovian_set.get(&p256_addr).expect("JOVIAN must have P256VERIFY"); - let azul_p256 = azul_set.get(&p256_addr).expect("AZUL must have P256VERIFY"); + let jovian_p256 = + jovian_set.precompiles().get(&p256_addr).expect("JOVIAN must have P256VERIFY"); + let azul_p256 = azul_set.precompiles().get(&p256_addr).expect("AZUL must have P256VERIFY"); // Legacy P256VERIFY costs 3,450 gas. With 5,000 gas it should succeed. assert!( - jovian_p256.execute(&[], 5_000).is_ok(), + jovian_p256.execute(&[], 5_000, 0).is_ok(), "JOVIAN P256VERIFY must succeed with 5,000 gas (legacy pricing, 3,450 base fee)", ); // Osaka P256VERIFY costs 6,900 gas. With 5,000 gas it must fail with OOG. + let azul_result = azul_p256.execute(&[], 5_000, 0); assert!( - matches!(azul_p256.execute(&[], 5_000), Err(PrecompileError::OutOfGas)), + matches!(&azul_result, Ok(output) if output.halt_reason().is_some()), "AZUL P256VERIFY must fail with 5,000 gas (Osaka pricing, 6,900 base fee)", ); } diff --git a/crates/proof/succinct/utils/client/src/witness/executor.rs b/crates/proof/succinct/utils/client/src/witness/executor.rs index 8267c7ed5e..041895ebf3 100644 --- a/crates/proof/succinct/utils/client/src/witness/executor.rs +++ b/crates/proof/succinct/utils/client/src/witness/executor.rs @@ -141,6 +141,7 @@ pub trait WitnessExecutor { revm::precompile::install_crypto(CustomCrypto::default()); let boot_clone = boot.clone(); + let activation_admin_address = boot.activation_admin_address; let intermediate_block_interval = boot.intermediate_block_interval.max(1); let rollup_config = Arc::new(boot.rollup_config); @@ -149,7 +150,7 @@ pub trait WitnessExecutor { rollup_config.as_ref(), l2_provider.clone(), l2_provider, - ZkvmBaseEvmFactory::new(), + ZkvmBaseEvmFactory::new_with_activation_admin_address(activation_admin_address), None, ); let mut driver = Driver::new(cursor, executor, pipeline); diff --git a/crates/proof/succinct/utils/elfs/build.rs b/crates/proof/succinct/utils/elfs/build.rs index ffe7826500..62554d79e8 100644 --- a/crates/proof/succinct/utils/elfs/build.rs +++ b/crates/proof/succinct/utils/elfs/build.rs @@ -131,8 +131,7 @@ fn try_resolve_elf(cache_dir: &Path, entry: &ElfEntry) -> Result&2 + exit 1 + fi + + # 1. Compile the static enclave binary. + cargo build --release --locked --target x86_64-unknown-linux-musl \ + --package base-prover-nitro-enclave --bin base-prover-nitro-enclave + cp target/x86_64-unknown-linux-musl/release/base-prover-nitro-enclave "$OUT_DIR/" + + # 2. Assemble linuxkit ramdisks. user-ramdisk.yaml expects the enclave + # binary in CWD; init-ramdisk.yaml expects a `bootstrap/` directory. + WORK_DIR="$(mktemp -d)" + trap 'rm -rf "$WORK_DIR"' EXIT + cp "$OUT_DIR/base-prover-nitro-enclave" "$WORK_DIR/base-prover-nitro-enclave" + ln -sf "$BOOTSTRAP_DIR" "$WORK_DIR/bootstrap" + cd "$WORK_DIR" + linuxkit build --format kernel+initrd --no-sbom --name init-ramdisk \ + "$REPO_ROOT/etc/docker/nitro-enclave/init-ramdisk.yaml" + linuxkit build --format kernel+initrd --no-sbom --name user-ramdisk \ + "$REPO_ROOT/etc/docker/nitro-enclave/user-ramdisk.yaml" + cp init-ramdisk-initrd.img user-ramdisk-initrd.img "$OUT_DIR/" + + echo "Stage 1 artifacts in $OUT_DIR:" + ls -la "$OUT_DIR" + +# Stage 2 of the enclave build: build eif_build (from aws-nitro-enclaves-image-format), +# assemble eif.bin from the ramdisks + bootstrap kernel, build nitro-cli (from +# aws-nitro-enclaves-cli) and apply our config patch, and stage the runtime +# bundle. STAGE1_DIR must contain init-ramdisk-initrd.img and +# user-ramdisk-initrd.img (from build-enclave-binary-and-ramdisks). +# BOOTSTRAP_DIR is the AWS Nitro SDK bootstrap artifacts; this recipe reads +# `bzImage` and `bzImage.config` for the eif_build kernel inputs. The runtime +# helpers `nitro-cli-config` and `nitro-enclaves-allocator` are NOT sourced +# from BOOTSTRAP_DIR — they come from the pinned nitro-cli repo's bootstrap/ +# subdirectory after we clone and patch it below, then are copied into OUT_DIR. +# Must run in a glibc environment with cargo + git + pkg-config + libssl-dev. +build-enclave-eif-and-cli STAGE1_DIR BOOTSTRAP_DIR OUT_DIR: + #!/usr/bin/env bash + set -euxo pipefail + cd ../../.. + REPO_ROOT="$(pwd)" + STAGE1_DIR="$(cd '{{ STAGE1_DIR }}' && pwd)" + BOOTSTRAP_DIR="$(cd '{{ BOOTSTRAP_DIR }}' && pwd)" + OUT_DIR="$(mkdir -p '{{ OUT_DIR }}' && cd '{{ OUT_DIR }}' && pwd)" + + # Reproducibility: pin source date for fixed embedded timestamps. + export SOURCE_DATE_EPOCH=0 + + WORK_DIR="$(mktemp -d)" + trap 'rm -rf "$WORK_DIR"' EXIT + + # 1. Build eif_build from the pinned upstream commit using our pinned Cargo.lock. + mkdir -p "$WORK_DIR/eif-build-src" + cd "$WORK_DIR/eif-build-src" + git init -q + git remote add origin "{{ eif_build_repo }}" + git fetch --depth=1 origin "{{ eif_build_commit }}" + git reset --hard FETCH_HEAD + cp "$REPO_ROOT/etc/docker/nitro-enclave/aws-nitro-enclaves-image-format.Cargo.lock" Cargo.lock + cargo build --all --release --locked + cp target/release/eif_build "$WORK_DIR/eif_build" + + # 2. Assemble eif.bin with reproducible build-time. + cd "$WORK_DIR" + "$WORK_DIR/eif_build" \ + --kernel "$BOOTSTRAP_DIR/bzImage" \ + --kernel_config "$BOOTSTRAP_DIR/bzImage.config" \ + --cmdline "$(cat "$REPO_ROOT/etc/docker/nitro-enclave/cmdline-x86_64")" \ + --ramdisk "$STAGE1_DIR/init-ramdisk-initrd.img" \ + --ramdisk "$STAGE1_DIR/user-ramdisk-initrd.img" \ + --build-time "1970-01-01T00:00:00+00:00" \ + --output "$OUT_DIR/eif.bin" + + # 3. Build nitro-cli from the pinned upstream commit and apply our patch + # to bootstrap/nitro-cli-config (the patch is applied to the cloned tree, + # then the patched binary is copied to OUT_DIR). + mkdir -p "$WORK_DIR/nitro-cli-src" + cd "$WORK_DIR/nitro-cli-src" + git init -q + git remote add origin "{{ nitro_cli_repo }}" + git fetch --depth=1 origin "{{ nitro_cli_commit }}" + git reset --hard FETCH_HEAD + cargo build --release --locked + git apply "$REPO_ROOT/etc/docker/nitro-enclave/nitro-cli-config.patch" + cp target/release/nitro-cli "$OUT_DIR/" + cp bootstrap/nitro-cli-config "$OUT_DIR/" + cp bootstrap/nitro-enclaves-allocator "$OUT_DIR/" + + # 4. Stage runtime config + entrypoint. + cp "$REPO_ROOT/etc/docker/nitro-enclave/allocator.yaml" "$OUT_DIR/" + cp "$REPO_ROOT/etc/docker/nitro-enclave/entrypoint-enclave.sh" "$OUT_DIR/entrypoint.sh" + + echo "Stage 2 artifacts in $OUT_DIR:" + ls -la "$OUT_DIR" + # Print config hashes for all supported chains config-hashes: cargo test -p base-enclave print_real_config_hashes -- --nocapture --ignored diff --git a/crates/proof/tee/nitro-attestation-prover/Cargo.toml b/crates/proof/tee/nitro-attestation-prover/Cargo.toml index ad75c83335..adda3b9671 100644 --- a/crates/proof/tee/nitro-attestation-prover/Cargo.toml +++ b/crates/proof/tee/nitro-attestation-prover/Cargo.toml @@ -27,7 +27,6 @@ alloy-primitives.workspace = true url = { workspace = true, optional = true } tracing = { workspace = true, optional = true } boundless-market = { workspace = true, optional = true } -alloy-signer-local = { workspace = true, optional = true } risc0-ethereum-contracts = { workspace = true, optional = true } tokio = { workspace = true, features = ["rt"], optional = true } risc0-zkvm = { workspace = true, features = ["prove"], optional = true } @@ -39,7 +38,6 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [features] prove = [ - "dep:alloy-signer-local", "dep:boundless-market", "dep:risc0-ethereum-contracts", "dep:risc0-zkvm", diff --git a/crates/proof/tee/nitro-attestation-prover/src/boundless.rs b/crates/proof/tee/nitro-attestation-prover/src/boundless.rs index 49661f8b84..0c27954cf1 100644 --- a/crates/proof/tee/nitro-attestation-prover/src/boundless.rs +++ b/crates/proof/tee/nitro-attestation-prover/src/boundless.rs @@ -20,7 +20,6 @@ use std::{collections::HashSet, fmt, sync::Arc, time::Duration}; use alloy_primitives::{Address, B256, Bytes, keccak256}; -use alloy_signer_local::PrivateKeySigner; use base_proof_tee_nitro_verifier::{VerifierInput, VerifierJournal}; // `boundless-market` re-exports `alloy` (`pub use alloy`) but does not // re-export `DynProvider` directly — access it via the SDK's alloy so @@ -28,8 +27,10 @@ use base_proof_tee_nitro_verifier::{VerifierInput, VerifierJournal}; use boundless_market::alloy::providers::DynProvider; use boundless_market::{ Client, NotProvided, + alloy::signers::local::PrivateKeySigner, contracts::{Predicate, RequestId, RequestStatus}, - request_builder::{RequestParams, RequirementParams, StandardRequestBuilder}, + price_oracle::Amount, + request_builder::{OfferParams, RequestParams, RequirementParams, StandardRequestBuilder}, }; use risc0_zkvm::sha::Digest; use tokio::sync::Mutex; @@ -83,6 +84,12 @@ pub struct BoundlessProver { /// Should be set slightly below the on-chain `MAX_AGE` to account /// for clock skew and processing time. pub max_attestation_age: Duration, + /// Optional minimum Boundless offer price for each submitted proof request. + pub offer_min_price: Option, + /// Optional maximum Boundless offer price for each submitted proof request. + pub offer_max_price: Option, + /// Optional duration in seconds for Boundless price to ramp from min to max. + pub offer_ramp_up_period_secs: Option, /// Serialises the `submit_onchain` call so that concurrent proof /// requests do not race on the Boundless wallet nonce. The lock is /// released immediately after submission, allowing the long-running @@ -110,6 +117,9 @@ impl fmt::Debug for BoundlessProver { .field("trusted_certs_prefix_len", &self.trusted_certs_prefix_len) .field("max_recovery_attempts", &self.max_recovery_attempts) .field("max_attestation_age", &self.max_attestation_age) + .field("offer_min_price", &self.offer_min_price) + .field("offer_max_price", &self.offer_max_price) + .field("offer_ramp_up_period_secs", &self.offer_ramp_up_period_secs) .finish() } } @@ -153,6 +163,29 @@ impl BoundlessProver { debug.to_ascii_lowercase().contains(NEEDLE) } + /// Applies optional explicit Boundless offer pricing to request params. + fn apply_offer_config(&self, params: RequestParams) -> RequestParams { + if self.offer_min_price.is_none() + && self.offer_max_price.is_none() + && self.offer_ramp_up_period_secs.is_none() + { + return params; + } + + let mut offer = OfferParams::builder(); + if let Some(min_price) = &self.offer_min_price { + offer.min_price(min_price.clone()); + } + if let Some(max_price) = &self.offer_max_price { + offer.max_price(max_price.clone()); + } + if let Some(ramp_up_period) = self.offer_ramp_up_period_secs { + offer.ramp_up_period(ramp_up_period); + } + + params.with_offer(offer) + } + /// Fetches and ABI-encodes the set inclusion receipt for a fulfilled /// Boundless request. Shared between the recovery and fresh-submission /// paths. @@ -327,6 +360,7 @@ impl BoundlessProver { .with_requirements( RequirementParams::builder().predicate(Predicate::prefix_match(image_id, [])), ); + let params = self.apply_offer_config(params); Ok((client, params)) } @@ -704,6 +738,7 @@ mod tests { use std::str::FromStr; use alloy_primitives::Address; + use boundless_market::price_oracle::{Amount, Asset}; use rstest::{fixture, rstest}; use super::*; @@ -718,9 +753,16 @@ mod tests { const TEST_TIMEOUT: Duration = Duration::from_secs(300); const DEFAULT_TRUSTED_PREFIX: u8 = 1; const TEST_MAX_RECOVERY_ATTEMPTS: u32 = 5; + const TEST_MIN_PRICE_ETH: &str = "0.01"; + const TEST_MAX_PRICE_ETH: &str = "0.03"; + const TEST_RAMP_UP_PERIOD_SECS: u32 = 30; const TEST_MAX_ATTESTATION_AGE: Duration = Duration::from_secs(3300); + fn eth_amount(value: &str) -> Amount { + Amount::parse(value, Some(Asset::ETH)).expect("valid ETH amount") + } + #[fixture] fn prover() -> BoundlessProver { BoundlessProver { @@ -733,6 +775,9 @@ mod tests { trusted_certs_prefix_len: DEFAULT_TRUSTED_PREFIX, max_recovery_attempts: TEST_MAX_RECOVERY_ATTEMPTS, max_attestation_age: TEST_MAX_ATTESTATION_AGE, + offer_min_price: None, + offer_max_price: None, + offer_ramp_up_period_secs: None, submit_lock: Arc::new(Mutex::new(())), recovery_blocked: Arc::new(std::sync::Mutex::new(HashSet::new())), } @@ -761,6 +806,33 @@ mod tests { assert_eq!(prover.timeout, TEST_TIMEOUT); assert_eq!(prover.trusted_certs_prefix_len, DEFAULT_TRUSTED_PREFIX); assert_eq!(prover.max_recovery_attempts, TEST_MAX_RECOVERY_ATTEMPTS); + assert!(prover.offer_min_price.is_none()); + assert!(prover.offer_max_price.is_none()); + assert!(prover.offer_ramp_up_period_secs.is_none()); + } + + #[rstest] + fn apply_offer_config_preserves_default_when_unset(prover: BoundlessProver) { + let params = prover.apply_offer_config(RequestParams::new()); + + assert!(params.offer.min_price.is_none()); + assert!(params.offer.max_price.is_none()); + assert!(params.offer.ramp_up_period.is_none()); + } + + #[rstest] + fn apply_offer_config_sets_explicit_prices(mut prover: BoundlessProver) { + let min_price = eth_amount(TEST_MIN_PRICE_ETH); + let max_price = eth_amount(TEST_MAX_PRICE_ETH); + prover.offer_min_price = Some(min_price.clone()); + prover.offer_max_price = Some(max_price.clone()); + prover.offer_ramp_up_period_secs = Some(TEST_RAMP_UP_PERIOD_SECS); + + let params = prover.apply_offer_config(RequestParams::new()); + + assert_eq!(params.offer.min_price, Some(min_price)); + assert_eq!(params.offer.max_price, Some(max_price)); + assert_eq!(params.offer.ramp_up_period, Some(TEST_RAMP_UP_PERIOD_SECS)); } // ── Clone ─────────────────────────────────────────────────────────── diff --git a/crates/proof/tee/nitro-enclave/src/server.rs b/crates/proof/tee/nitro-enclave/src/server.rs index cfe4b82a3b..0817225992 100644 --- a/crates/proof/tee/nitro-enclave/src/server.rs +++ b/crates/proof/tee/nitro-enclave/src/server.rs @@ -254,7 +254,6 @@ impl Server { #[cfg(test)] mod tests { use alloy_primitives::b256; - use base_common_chains::Registry; use super::*; @@ -294,11 +293,11 @@ mod tests { } #[test] - fn config_hashes_match_registry() { + fn config_hashes_match_chain_configs() { for cfg in ChainConfig::all() { let chain_id = cfg.chain_id; - let Some(rollup) = Registry::rollup_config(chain_id) else { continue }; - let Some(mut per_chain) = PerChainConfig::from_rollup_config(rollup) else { + let rollup = base_common_chains::rollup_config!(cfg); + let Some(mut per_chain) = PerChainConfig::from_rollup_config(&rollup) else { continue; }; per_chain.force_defaults(); @@ -317,14 +316,8 @@ mod tests { fn print_real_config_hashes() { for cfg in ChainConfig::all() { let chain_id = cfg.chain_id; - let rollup = match Registry::rollup_config(chain_id) { - Some(r) => r, - None => { - println!("chain {chain_id}: skipped (no rollup config)"); - continue; - } - }; - let mut per_chain = match PerChainConfig::from_rollup_config(rollup) { + let rollup = base_common_chains::rollup_config!(cfg); + let mut per_chain = match PerChainConfig::from_rollup_config(&rollup) { Some(pc) => pc, None => { println!("chain {chain_id}: skipped (no system_config)"); diff --git a/crates/proof/tee/nitro-enclave/src/transport.rs b/crates/proof/tee/nitro-enclave/src/transport.rs index a3f40f781f..13ad2965cb 100644 --- a/crates/proof/tee/nitro-enclave/src/transport.rs +++ b/crates/proof/tee/nitro-enclave/src/transport.rs @@ -36,7 +36,7 @@ const MAX_WRITE_SIZE: usize = 28 * 1024; /// Length-prefixed bincode codec over `AsyncRead`/`AsyncWrite`. /// -/// Wire format: `[4B big-endian length][bincode payload]` +/// Wire format: `[8B big-endian length][bincode payload]` /// /// Writes are throttled to [`MAX_WRITE_SIZE`]-byte segments to avoid /// triggering a Linux kernel vsock corruption bug. @@ -52,12 +52,9 @@ impl Frame { let payload = bincode::serde::encode_to_vec(value, bincode::config::standard()) .map_err(|e| TransportError::Codec(e.to_string()))?; - let len = u32::try_from(payload.len()) - .map_err(|_| TransportError::Codec("payload exceeds u32::MAX".into()))?; - debug!(payload_bytes = payload.len(), "frame write start"); - writer.write_u32(len).await?; + writer.write_u64(payload.len() as u64).await?; Self::write_throttled(writer, &payload).await?; writer.flush().await?; @@ -67,13 +64,14 @@ impl Frame { /// Read a value from a length-prefixed bincode frame. /// - /// The peer-supplied length can be up to `u32::MAX` (~4 `GiB`). This is safe - /// because all transport peers are local (enclave ↔ host over vsock) and - /// witness bundles can legitimately be very large. + /// The peer-supplied length can be up to `u64::MAX`. This is safe because + /// all transport peers are local (enclave ↔ host over vsock) and witness + /// bundles can legitimately exceed 4 `GiB`. pub async fn read( reader: &mut (impl AsyncReadExt + Unpin), ) -> TransportResult { - let len = reader.read_u32().await? as usize; + let len = usize::try_from(reader.read_u64().await?) + .map_err(|_| TransportError::Codec("frame length exceeds u64::MAX".into()))?; debug!(payload_bytes = len, "frame read start"); diff --git a/crates/proof/tee/registrar/Cargo.toml b/crates/proof/tee/registrar/Cargo.toml index b3b35e92c8..452d7a5577 100644 --- a/crates/proof/tee/registrar/Cargo.toml +++ b/crates/proof/tee/registrar/Cargo.toml @@ -15,7 +15,7 @@ workspace = true alloy-signer.workspace = true alloy-sol-types.workspace = true alloy-primitives.workspace = true -alloy-signer-local.workspace = true +boundless-market.workspace = true # AWS aws-sdk-ec2.workspace = true diff --git a/crates/proof/tee/registrar/src/config.rs b/crates/proof/tee/registrar/src/config.rs index 40241d74d3..615475cf9e 100644 --- a/crates/proof/tee/registrar/src/config.rs +++ b/crates/proof/tee/registrar/src/config.rs @@ -1,8 +1,8 @@ use std::{net::SocketAddr, path::PathBuf, time::Duration}; use alloy_primitives::Address; -use alloy_signer_local::PrivateKeySigner; use base_tx_manager::{SignerConfig, TxManagerConfig}; +use boundless_market::{alloy::signers::local::PrivateKeySigner, price_oracle::Amount}; use url::Url; /// AWS ALB target group discovery configuration. @@ -55,6 +55,12 @@ pub struct BoundlessConfig { /// is considered stale and skipped. Should be set slightly below the /// on-chain `MAX_AGE` to account for clock skew. pub max_attestation_age: Duration, + /// Optional minimum Boundless offer price for each submitted proof request. + pub offer_min_price: Option, + /// Optional maximum Boundless offer price for each submitted proof request. + pub offer_max_price: Option, + /// Optional duration in seconds for Boundless price to ramp from min to max. + pub offer_ramp_up_period_secs: Option, } impl std::fmt::Debug for BoundlessConfig { @@ -68,6 +74,9 @@ impl std::fmt::Debug for BoundlessConfig { .field("timeout", &self.timeout) .field("max_recovery_attempts", &self.max_recovery_attempts) .field("max_attestation_age", &self.max_attestation_age) + .field("offer_min_price", &self.offer_min_price) + .field("offer_max_price", &self.offer_max_price) + .field("offer_ramp_up_period_secs", &self.offer_ramp_up_period_secs) .finish() } } diff --git a/crates/proof/tee/registrar/src/driver.rs b/crates/proof/tee/registrar/src/driver.rs index fa2e9960c5..a354490e50 100644 --- a/crates/proof/tee/registrar/src/driver.rs +++ b/crates/proof/tee/registrar/src/driver.rs @@ -5,7 +5,13 @@ //! to L1 via the [`TxManager`]. Also detects orphaned on-chain signers (those //! no longer backed by a healthy instance) and deregisters them. -use std::{collections::HashSet, error::Error, fmt, sync::Arc, time::Duration}; +use std::{ + collections::HashSet, + error::Error, + fmt, + sync::{Arc, Mutex}, + time::Duration, +}; use alloy_primitives::{Address, Bytes, FixedBytes, hex}; use alloy_sol_types::SolCall; @@ -101,6 +107,45 @@ pub struct RegistrationDriver { /// on-chain (`revokedCerts` sentinel set) cannot be re-trusted via the /// `_cacheNewCert` rewrite path. nitro_verifier: Option>, + /// Process-local set of signer addresses currently being registered. + /// + /// `try_register` reserves an entry here before its `is_registered` + /// precheck and releases it (via [`InFlightGuard`]) when it returns. + /// This closes a TOCTOU race in which two concurrent `try_register` + /// invocations for the same signer — e.g. when a rotation briefly has + /// two instances backing the same enclave signing key, or when the + /// per-enclave loop within `process_instance` resolves the same address + /// more than once — both read `is_registered == false`, both generate + /// (potentially identical) proofs, and both submit duplicate + /// registration transactions. + /// + /// The set is held across the entire registration lifecycle (including + /// the ~20 minute Boundless proof generation) so deduplication holds + /// across `step()` cycles as well as within one. + in_flight_registrations: Arc>>, +} + +/// RAII guard that removes a signer address from [`RegistrationDriver::in_flight_registrations`] +/// when dropped. +/// +/// Ensures cleanup on every exit path from `try_register` — success, +/// error, retry-exhaustion, cancellation drop, and panic — so a failed +/// or cancelled registration does not permanently block future attempts +/// for the same signer. +struct InFlightGuard { + in_flight: Arc>>, + signer: Address, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + // The critical section is a single `HashSet::remove` and cannot + // panic under normal conditions, so poisoning is effectively + // impossible. If it ever occurs, the set contents are still + // valid and cleanup must proceed. + let mut set = self.in_flight.lock().unwrap_or_else(|e| e.into_inner()); + set.remove(&self.signer); + } } impl fmt::Debug for RegistrationDriver { @@ -166,6 +211,7 @@ where config, crl_http_client, nitro_verifier, + in_flight_registrations: Arc::new(Mutex::new(HashSet::new())), }) } @@ -497,6 +543,36 @@ where enclave_index: usize, attestation_bytes: &[u8], ) -> Result<()> { + // Reserve this signer in the in-flight set before the + // `is_registered` precheck. If another concurrent task already + // owns it — e.g. a sibling `process_instance` future for a + // different prover instance that happens to back the same + // enclave signing key during a rotation — short-circuit so we + // don't race past the TOCTOU `is_registered` check, regenerate + // the proof, and submit a duplicate registration transaction. + // + // The guard is held across the entire registration (including + // the ~20 minute Boundless proof generation and the on-chain + // confirmation wait) and is released via RAII on every exit + // path: success, error, retry-exhaustion, cancellation drop, + // and panic. + let _in_flight = { + let mut set = self.in_flight_registrations.lock().unwrap_or_else(|e| e.into_inner()); + if !set.insert(signer_address) { + debug!( + signer = %signer_address, + enclave_index, + instance = %instance.instance_id, + "registration already in flight for this signer, skipping duplicate", + ); + return Ok(()); + } + InFlightGuard { + in_flight: Arc::clone(&self.in_flight_registrations), + signer: signer_address, + } + }; + if self.registry.is_registered(signer_address).await? { debug!(signer = %signer_address, "already registered, skipping"); return Ok(()); @@ -2964,6 +3040,156 @@ mod tests { assert_eq!(driver.proof_provider.call_count(), 1, "proof should be generated once"); } + // ── In-flight dedup tests ─────────────────────────────────────────── + // + // Covers the in-flight registration guard added to `try_register` to + // prevent two concurrent invocations for the same signer from racing + // past the TOCTOU `is_registered` precheck and submitting duplicate + // registration transactions. + + /// Proof provider that yields cooperatively during proof generation. + /// + /// Without yielding, the single-threaded test executor would run the + /// first concurrent future to completion before polling the second, + /// hiding any race window in `try_register`. The repeated yields here + /// guarantee a second concurrent caller is polled — and reaches its + /// own in-flight check — while the first is still mid-proof. + #[derive(Debug)] + struct YieldingProofProvider { + call_count: Arc, + } + + impl YieldingProofProvider { + fn new() -> Self { + Self { call_count: Arc::new(AtomicU32::new(0)) } + } + + fn call_count(&self) -> u32 { + self.call_count.load(Ordering::Relaxed) + } + } + + #[async_trait] + impl AttestationProofProvider for YieldingProofProvider { + async fn generate_proof( + &self, + _attestation_bytes: &[u8], + ) -> base_proof_tee_nitro_attestation_prover::Result { + self.call_count.fetch_add(1, Ordering::Relaxed); + // Yield repeatedly so any concurrent task gets polled and + // exercises the in-flight dedup path. + for _ in 0..16 { + tokio::task::yield_now().await; + } + Ok(AttestationProof { + output: Bytes::from_static(b"stub-output"), + proof_bytes: Bytes::from_static(b"stub-proof"), + }) + } + } + + /// Two concurrent `process_instance` calls that resolve to the same + /// signer address must collapse into a single registration: only one + /// proof generated, only one tx submitted, both calls return Ok. + /// + /// Models the cross-instance rotation case where two prover instances + /// briefly back the same enclave signing key, as well as the + /// intra-instance case where the per-enclave loop resolves the same + /// address more than once. + #[tokio::test] + async fn try_register_concurrent_same_signer_dedups() { + let signer_client = MockSignerClient::from_keys(&[(EP1, &HARDHAT_KEY_0)]); + let tx = FailingTxManager::with_errors(vec![]); // both attempts succeed + let registry = DynamicRegistry::never_registered(vec![]); + let proof_provider = YieldingProofProvider::new(); + let driver = retry_driver( + signer_client, + registry, + tx.clone(), + proof_provider, + CancellationToken::new(), + ); + + let inst = instance(EP1, InstanceHealthStatus::Healthy); + let (r1, r2) = tokio::join!(driver.process_instance(&inst), driver.process_instance(&inst)); + + assert!(r1.is_ok(), "first concurrent registration failed: {r1:?}"); + assert!(r2.is_ok(), "second concurrent registration failed: {r2:?}"); + assert_eq!( + tx.send_count(), + 1, + "concurrent registration of the same signer must dedup to a single tx", + ); + assert_eq!( + driver.proof_provider.call_count(), + 1, + "concurrent registration of the same signer must not regenerate the proof", + ); + } + + /// After a successful registration completes, the in-flight slot must + /// be released so a later cycle can re-register the same signer if it + /// becomes orphaned and re-discovered. Sequential calls for the same + /// signer must both execute their `is_registered` precheck (which in + /// the test mock returns false twice via `never_registered`), and both + /// submit txs — proving the guard does not leak across calls. + #[tokio::test] + async fn try_register_in_flight_slot_released_after_completion() { + let signer_client = MockSignerClient::from_keys(&[(EP1, &HARDHAT_KEY_0)]); + let tx = FailingTxManager::with_errors(vec![]); + let registry = DynamicRegistry::never_registered(vec![]); + let driver = retry_driver( + signer_client, + registry, + tx.clone(), + StubProofProvider, + CancellationToken::new(), + ); + + let inst = instance(EP1, InstanceHealthStatus::Healthy); + driver.process_instance(&inst).await.unwrap(); + driver.process_instance(&inst).await.unwrap(); + + assert_eq!( + tx.send_count(), + 2, + "sequential (non-overlapping) registrations must each submit their own tx — \ + the in-flight slot must be released when try_register returns", + ); + } + + /// A failed registration (non-retryable error from the tx manager) + /// must still release the in-flight slot. Otherwise a transient + /// failure for one signer would permanently block subsequent + /// registration attempts for that signer. + #[tokio::test] + async fn try_register_in_flight_slot_released_after_failure() { + let signer_client = MockSignerClient::from_keys(&[(EP1, &HARDHAT_KEY_0)]); + // First call fails non-retryably; second call succeeds. + let tx = FailingTxManager::with_errors(vec![TxManagerError::InsufficientFunds]); + let registry = DynamicRegistry::never_registered(vec![]); + let driver = retry_driver( + signer_client, + registry, + tx.clone(), + StubProofProvider, + CancellationToken::new(), + ); + + let inst = instance(EP1, InstanceHealthStatus::Healthy); + // First attempt: fails non-retryably (slot released on Err path). + driver.process_instance(&inst).await.unwrap(); + // Second attempt: must reach the tx manager again — proving the + // in-flight slot was released after the first call's failure. + driver.process_instance(&inst).await.unwrap(); + + assert_eq!( + tx.send_count(), + 2, + "a failed registration must release the in-flight slot so retries can proceed", + ); + } + // ── OnchainRevocationCheck tests ──────────────────────────────────── // // Covers the durable on-chain revocation pre-check (CHAIN-4194 / diff --git a/crates/proof/zk/client/README.md b/crates/proof/zk/client/README.md index 0dde6769a3..dbfa47812c 100644 --- a/crates/proof/zk/client/README.md +++ b/crates/proof/zk/client/README.md @@ -107,6 +107,7 @@ impl ZkProofProvider for MockProvider { status: ProofJobStatus::Succeeded.into(), receipt: vec![1, 2, 3], error_message: None, + execution_stats: None, }) } } diff --git a/crates/proof/zk/client/proto/zk_prover.proto b/crates/proof/zk/client/proto/zk_prover.proto index c7818b63e7..2092c0dbc8 100644 --- a/crates/proof/zk/client/proto/zk_prover.proto +++ b/crates/proof/zk/client/proto/zk_prover.proto @@ -65,6 +65,15 @@ message GetProofResponse { Status status = 1; bytes receipt = 2; // the actual zk proof, only populated if status is SUCCEEDED optional string error_message = 3; // populated when status is STATUS_FAILED + optional ExecutionStats execution_stats = 4; // populated by dry-run backends +} + +message ExecutionStats { + uint64 total_instruction_cycles = 1; + uint64 total_sp1_gas = 2; + map cycle_tracker = 3; + double witness_generation_ms = 4; + double execution_ms = 5; } message ListProofsRequest { diff --git a/crates/proof/zk/client/src/lib.rs b/crates/proof/zk/client/src/lib.rs index abc10a2cee..d50b0943b6 100644 --- a/crates/proof/zk/client/src/lib.rs +++ b/crates/proof/zk/client/src/lib.rs @@ -28,9 +28,9 @@ pub const PROVER_FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("prover_descriptor"); pub use proto::{ - GetProofRequest, GetProofResponse, ListProofsRequest, ListProofsResponse, ProofSummary, - ProofType, ProveBlockRequest, ProveBlockResponse, ReceiptType, get_proof_response, - get_proof_response::Status as ProofJobStatus, prover_service_client, + ExecutionStats, GetProofRequest, GetProofResponse, ListProofsRequest, ListProofsResponse, + ProofSummary, ProofType, ProveBlockRequest, ProveBlockResponse, ReceiptType, + get_proof_response, get_proof_response::Status as ProofJobStatus, prover_service_client, }; mod client; diff --git a/crates/proof/zk/client/tests/mock_provider.rs b/crates/proof/zk/client/tests/mock_provider.rs index dd54035c4b..e7973e7125 100644 --- a/crates/proof/zk/client/tests/mock_provider.rs +++ b/crates/proof/zk/client/tests/mock_provider.rs @@ -25,6 +25,7 @@ impl ZkProofProvider for MockZkProvider { status: ProofJobStatus::Succeeded.into(), receipt: vec![0xDE, 0xAD, 0xBE, 0xEF], error_message: None, + execution_stats: None, }) } } diff --git a/crates/proof/zk/service/src/backends/mod.rs b/crates/proof/zk/service/src/backends/mod.rs index 776a37b1ff..1b2db03e9f 100644 --- a/crates/proof/zk/service/src/backends/mod.rs +++ b/crates/proof/zk/service/src/backends/mod.rs @@ -2,8 +2,12 @@ mod op_succinct; pub use op_succinct::{ - ClusterBackend as OpSuccinctClusterBackend, MockBackend as OpSuccinctMockBackend, - NetworkBackend as OpSuccinctNetworkBackend, OpSuccinctProvider, + ClusterBackend as OpSuccinctClusterBackend, + DRY_RUN_METADATA_KEY as OP_SUCCINCT_DRY_RUN_METADATA_KEY, + DryRunBackend as OpSuccinctDryRunBackend, + EXECUTION_STATS_METADATA_KEY as OP_SUCCINCT_EXECUTION_STATS_METADATA_KEY, + MockBackend as OpSuccinctMockBackend, NetworkBackend as OpSuccinctNetworkBackend, + OpSuccinctProvider, StoredExecutionStats as OpSuccinctStoredExecutionStats, WitnessParams as OpSuccinctWitnessParams, }; diff --git a/crates/proof/zk/service/src/backends/op_succinct/dry_run.rs b/crates/proof/zk/service/src/backends/op_succinct/dry_run.rs new file mode 100644 index 0000000000..6002dca925 --- /dev/null +++ b/crates/proof/zk/service/src/backends/op_succinct/dry_run.rs @@ -0,0 +1,293 @@ +//! Dry-run backend for local SP1 execution statistics. +//! +//! This backend generates a real witness and executes the range program with +//! `MockProver`, but it does not produce or submit a proof. + +use std::{collections::HashMap, fmt}; + +use alloy_primitives::B256; +use async_trait::async_trait; +use base_proof_succinct_client_utils::client::DEFAULT_INTERMEDIATE_ROOT_INTERVAL; +use base_proof_succinct_proof_utils::get_range_elf_embedded; +use base_zk_client::{ExecutionStats, ProveBlockRequest}; +use base_zk_db::{ + ProofRequest, ProofRequestRepo, ProofSession, ProofStatus, ProofType, + SessionStatus as DbSessionStatus, UpdateProofSession, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sp1_sdk::{ + Elf, SP1Stdin, + blocking::{MockProver, Prover}, +}; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use super::provider::{OpSuccinctProvider, WitnessParams}; +use crate::backends::traits::{ + BackendType, ProofProcessingResult, ProveResult, ProvingBackend, SessionStatus, +}; + +/// Metadata key where dry-run execution stats are stored on proof sessions. +pub const EXECUTION_STATS_METADATA_KEY: &str = "execution_stats"; + +/// Metadata key indicating that a session was produced by the dry-run backend. +pub const DRY_RUN_METADATA_KEY: &str = "dry_run"; + +/// Local execution backend that returns SP1 execution statistics. +#[derive(Clone)] +pub struct DryRunBackend { + provider: OpSuccinctProvider, + base_consensus_url: String, + l1_node_url: String, + default_sequence_window: u64, +} + +/// Execution statistics persisted in proof-session metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredExecutionStats { + /// Total RISC-V instruction cycles reported by SP1. + pub total_instruction_cycles: u64, + /// Total SP1 gas reported by SP1. + pub total_sp1_gas: u64, + /// Per-section cycle tracker values reported by the range program. + pub cycle_tracker: HashMap, + /// Time spent generating the witness, in milliseconds. + pub witness_generation_ms: f64, + /// Time spent executing the SP1 range program, in milliseconds. + pub execution_ms: f64, +} + +impl From for StoredExecutionStats { + fn from(value: ExecutionStats) -> Self { + Self { + total_instruction_cycles: value.total_instruction_cycles, + total_sp1_gas: value.total_sp1_gas, + cycle_tracker: value.cycle_tracker, + witness_generation_ms: value.witness_generation_ms, + execution_ms: value.execution_ms, + } + } +} + +impl From for ExecutionStats { + fn from(value: StoredExecutionStats) -> Self { + Self { + total_instruction_cycles: value.total_instruction_cycles, + total_sp1_gas: value.total_sp1_gas, + cycle_tracker: value.cycle_tracker, + witness_generation_ms: value.witness_generation_ms, + execution_ms: value.execution_ms, + } + } +} + +impl fmt::Debug for DryRunBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DryRunBackend").finish_non_exhaustive() + } +} + +impl DryRunBackend { + /// Create a dry-run backend using the shared OP Succinct witness provider. + pub const fn new( + provider: OpSuccinctProvider, + base_consensus_url: String, + l1_node_url: String, + default_sequence_window: u64, + ) -> Self { + Self { provider, base_consensus_url, l1_node_url, default_sequence_window } + } + + async fn execute_range_program(stdin: SP1Stdin) -> anyhow::Result { + let execution_start = std::time::Instant::now(); + let (_, report) = tokio::task::spawn_blocking(move || { + info!("starting local SP1 zkVM execution"); + + let prover = MockProver::new(); + prover + .execute(Elf::Static(get_range_elf_embedded()), stdin) + .calculate_gas(true) + .deferred_proof_verification(false) + .run() + }) + .await + .map_err(|e| anyhow::anyhow!("SP1 execution task failed to join: {e}"))??; + + let execution_ms = execution_start.elapsed().as_secs_f64() * 1000.0; + let stats = ExecutionStats { + total_instruction_cycles: report.total_instruction_count(), + total_sp1_gas: report.gas().unwrap_or_else(|| { + warn!("gas calculation returned None despite calculate_gas(true)"); + 0 + }), + cycle_tracker: report.cycle_tracker.into_iter().collect(), + witness_generation_ms: 0.0, + execution_ms, + }; + + Ok(stats) + } +} + +#[async_trait] +impl ProvingBackend for DryRunBackend { + fn backend_type(&self) -> BackendType { + BackendType::OpSuccinct + } + + async fn prove(&self, request: &ProveBlockRequest) -> anyhow::Result { + if request.number_of_blocks_to_prove == 0 { + anyhow::bail!("number_of_blocks_to_prove must be > 0"); + } + + let proof_type = ProofType::try_from(request.proof_type) + .map_err(|e| anyhow::anyhow!("invalid proof_type: {e}"))?; + if proof_type == ProofType::OpSuccinctSp1ClusterSnarkGroth16 { + anyhow::bail!( + "dry-run backend only supports compressed proof types; SNARK_GROTH16 requires a proof-producing backend" + ); + } + + let start_block = request.start_block_number; + let num_blocks = request.number_of_blocks_to_prove; + let end_block = start_block.checked_add(num_blocks).ok_or_else(|| { + anyhow::anyhow!("block range overflow: start={start_block} + count={num_blocks}") + })?; + let sequence_window = request.sequence_window.unwrap_or(self.default_sequence_window); + let intermediate_root_interval = + request.intermediate_root_interval.unwrap_or(DEFAULT_INTERMEDIATE_ROOT_INTERVAL); + let l1_head: Option = request + .l1_head + .as_ref() + .map(|h| h.parse::()) + .transpose() + .map_err(|e| anyhow::anyhow!("invalid l1_head hash: {e}"))?; + + info!( + start_block = start_block, + end_block = end_block, + num_blocks = num_blocks, + sequence_window = sequence_window, + intermediate_root_interval = intermediate_root_interval, + l1_head = ?l1_head, + "starting dry-run SP1 execution" + ); + + let witness_start = std::time::Instant::now(); + let stdin = self + .provider + .generate_witness(WitnessParams { + start_block, + end_block, + sequence_window, + l1_node_url: &self.l1_node_url, + base_consensus_url: &self.base_consensus_url, + l1_head, + intermediate_root_interval, + }) + .await + .map_err(|e| { + error!( + start_block = start_block, + end_block = end_block, + error = %e, + "dry-run witness generation failed" + ); + anyhow::anyhow!("witness generation failed: {e}") + })?; + let witness_generation_ms = witness_start.elapsed().as_secs_f64() * 1000.0; + + let mut execution_stats = Self::execute_range_program(stdin).await?; + execution_stats.witness_generation_ms = witness_generation_ms; + + info!( + total_instruction_cycles = execution_stats.total_instruction_cycles, + total_sp1_gas = execution_stats.total_sp1_gas, + witness_generation_ms = witness_generation_ms, + execution_ms = execution_stats.execution_ms, + tracked_sections = execution_stats.cycle_tracker.len(), + "dry-run SP1 execution completed" + ); + + let session_id = format!("dry-run-{}", Uuid::new_v4()); + let stored_stats = StoredExecutionStats::from(execution_stats); + let metadata = json!({ + DRY_RUN_METADATA_KEY: true, + EXECUTION_STATS_METADATA_KEY: stored_stats, + }); + + Ok(ProveResult { + session_id: Some(session_id), + metadata: Some(metadata), + witness_gen_duration_ms: Some(witness_generation_ms), + }) + } + + async fn process_proof_request( + &self, + proof_request: &ProofRequest, + repo: &ProofRequestRepo, + ) -> anyhow::Result { + if proof_request.proof_type == ProofType::OpSuccinctSp1ClusterSnarkGroth16 { + return Ok(ProofProcessingResult { + status: ProofStatus::Failed, + error_message: Some( + "dry-run backend only supports compressed proof types; SNARK_GROTH16 requires a proof-producing backend" + .to_string(), + ), + }); + } + + let sessions = repo.get_sessions_for_request(proof_request.id).await?; + + if sessions.is_empty() { + return Ok(ProofProcessingResult { status: ProofStatus::Pending, error_message: None }); + } + + for session in &sessions { + if session.status == DbSessionStatus::Failed { + return Ok(ProofProcessingResult { + status: ProofStatus::Failed, + error_message: session.error_message.clone(), + }); + } + } + + let running_sessions = + sessions.iter().filter(|session| session.status == DbSessionStatus::Running); + + for session in running_sessions { + let updated = repo + .update_proof_session_if_non_terminal(UpdateProofSession { + backend_session_id: session.backend_session_id.clone(), + status: DbSessionStatus::Completed, + error_message: None, + metadata: None, + }) + .await?; + + if updated { + info!( + proof_request_id = %proof_request.id, + session_id = %session.backend_session_id, + "dry-run session completed" + ); + } + } + + let updated_sessions = repo.get_sessions_for_request(proof_request.id).await?; + let all_complete = updated_sessions.iter().all(|s| s.status == DbSessionStatus::Completed); + let status = if all_complete { ProofStatus::Succeeded } else { ProofStatus::Running }; + + Ok(ProofProcessingResult { status, error_message: None }) + } + + async fn get_session_status(&self, _session: &ProofSession) -> anyhow::Result { + Ok(SessionStatus::Completed) + } + + fn name(&self) -> &'static str { + "Dry-run (local SP1 execution stats)" + } +} diff --git a/crates/proof/zk/service/src/backends/op_succinct/mod.rs b/crates/proof/zk/service/src/backends/op_succinct/mod.rs index 88c32279af..400449800d 100644 --- a/crates/proof/zk/service/src/backends/op_succinct/mod.rs +++ b/crates/proof/zk/service/src/backends/op_succinct/mod.rs @@ -3,6 +3,11 @@ mod cluster; pub use cluster::ClusterBackend; +mod dry_run; +pub use dry_run::{ + DRY_RUN_METADATA_KEY, DryRunBackend, EXECUTION_STATS_METADATA_KEY, StoredExecutionStats, +}; + mod mock; pub use mock::MockBackend; diff --git a/crates/proof/zk/service/src/lib.rs b/crates/proof/zk/service/src/lib.rs index f6e99476d0..71733dd1d4 100644 --- a/crates/proof/zk/service/src/lib.rs +++ b/crates/proof/zk/service/src/lib.rs @@ -4,9 +4,10 @@ mod backends; pub use backends::{ ArtifactClientWrapper, ArtifactStorageConfig, BackendConfig, BackendRegistry, BackendType, - L1HeadCalculator, OpSuccinctClusterBackend, OpSuccinctMockBackend, OpSuccinctNetworkBackend, - OpSuccinctProvider, OpSuccinctWitnessParams, ProofProcessingResult, ProveResult, - ProvingBackend, SessionStatus, + L1HeadCalculator, OP_SUCCINCT_DRY_RUN_METADATA_KEY, OP_SUCCINCT_EXECUTION_STATS_METADATA_KEY, + OpSuccinctClusterBackend, OpSuccinctDryRunBackend, OpSuccinctMockBackend, + OpSuccinctNetworkBackend, OpSuccinctProvider, OpSuccinctStoredExecutionStats, + OpSuccinctWitnessParams, ProofProcessingResult, ProveResult, ProvingBackend, SessionStatus, }; pub mod metrics; diff --git a/crates/proof/zk/service/src/server/get_proof.rs b/crates/proof/zk/service/src/server/get_proof.rs index db63e329fe..a922e256cf 100644 --- a/crates/proof/zk/service/src/server/get_proof.rs +++ b/crates/proof/zk/service/src/server/get_proof.rs @@ -1,11 +1,20 @@ -use base_zk_client::{GetProofRequest, GetProofResponse, ProofJobStatus, ReceiptType}; +use base_zk_client::{ + ExecutionStats, GetProofRequest, GetProofResponse, ProofJobStatus, ReceiptType, +}; use base_zk_db::ProofStatus; use sp1_sdk::SP1ProofWithPublicValues; use tonic::{Request, Response, Status}; use tracing::{Instrument, info}; use uuid::Uuid; -use crate::{metrics, server::ProverServiceServer}; +use crate::{ + backends::{ + OP_SUCCINCT_DRY_RUN_METADATA_KEY, OP_SUCCINCT_EXECUTION_STATS_METADATA_KEY, + OpSuccinctStoredExecutionStats, + }, + metrics, + server::ProverServiceServer, +}; /// Helper function to get the appropriate receipt based on requested type. fn get_receipt_by_type( @@ -38,6 +47,16 @@ fn get_receipt_by_type( } } +fn execution_stats_from_metadata(metadata: &serde_json::Value) -> Option { + if !metadata.get(OP_SUCCINCT_DRY_RUN_METADATA_KEY)?.as_bool()? { + return None; + } + + let stats = metadata.get(OP_SUCCINCT_EXECUTION_STATS_METADATA_KEY)?; + let stored = serde_json::from_value::(stats.clone()).ok()?; + Some(stored.into()) +} + impl ProverServiceServer { /// Returns current proof status and receipt bytes for `session_id=`. pub async fn get_proof_impl( @@ -59,6 +78,40 @@ impl ProverServiceServer { result } + async fn execution_stats_for_request( + &self, + proof_request_id: Uuid, + ) -> Result, Status> { + let sessions = self + .repo + .get_sessions_for_request(proof_request_id) + .await + .map_err(|e| Status::internal(format!("Database error: {e}")))?; + + Ok(sessions + .iter() + .filter_map(|session| session.metadata.as_ref()) + .find_map(execution_stats_from_metadata)) + } + + async fn succeeded_payload( + &self, + proof_req: &base_zk_db::ProofRequest, + requested_receipt_type: ReceiptType, + ) -> Result<(Vec, Option), Status> { + if proof_req.stark_receipt.is_none() && proof_req.snark_receipt.is_none() { + // Receipt absence is only a fast path before touching session metadata; the dry-run + // marker remains the authoritative check inside `execution_stats_from_metadata`. + let execution_stats = self.execution_stats_for_request(proof_req.id).await?; + if execution_stats.is_some() { + return Ok((vec![], execution_stats)); + } + } + + let receipt = get_receipt_by_type(proof_req, requested_receipt_type)?; + Ok((receipt, None)) + } + async fn get_proof_inner( &self, request: Request, @@ -90,9 +143,9 @@ impl ProverServiceServer { .ok_or_else(|| Status::not_found("Proof request not found"))?; // Map database status to proto status - let (proto_status, receipt_bytes, error_message) = match proof_req.status { - ProofStatus::Created => (ProofJobStatus::Created, vec![], None), - ProofStatus::Pending => (ProofJobStatus::Pending, vec![], None), + let (proto_status, receipt_bytes, error_message, execution_stats) = match proof_req.status { + ProofStatus::Created => (ProofJobStatus::Created, vec![], None, None), + ProofStatus::Pending => (ProofJobStatus::Pending, vec![], None, None), ProofStatus::Running => { // Sync sessions and update proof status, with a tracing span so all // nested log lines carry proof_request_id. @@ -117,28 +170,34 @@ impl ProverServiceServer { // Map updated status to response match updated_proof_req.status { ProofStatus::Succeeded => { - let receipt = - get_receipt_by_type(&updated_proof_req, requested_receipt_type)?; - (ProofJobStatus::Succeeded, receipt, None) + let (receipt, execution_stats) = self + .succeeded_payload(&updated_proof_req, requested_receipt_type) + .await?; + (ProofJobStatus::Succeeded, receipt, None, execution_stats) } ProofStatus::Failed => { - (ProofJobStatus::Failed, vec![], updated_proof_req.error_message) + (ProofJobStatus::Failed, vec![], updated_proof_req.error_message, None) } _ => { // Still RUNNING or PENDING - (ProofJobStatus::Running, vec![], None) + (ProofJobStatus::Running, vec![], None, None) } } } ProofStatus::Succeeded => { - let receipt_buf = get_receipt_by_type(&proof_req, requested_receipt_type)?; - (ProofJobStatus::Succeeded, receipt_buf, None) + let (receipt, execution_stats) = + self.succeeded_payload(&proof_req, requested_receipt_type).await?; + (ProofJobStatus::Succeeded, receipt, None, execution_stats) } - ProofStatus::Failed => (ProofJobStatus::Failed, vec![], proof_req.error_message), + ProofStatus::Failed => (ProofJobStatus::Failed, vec![], proof_req.error_message, None), }; - let response = - GetProofResponse { status: proto_status.into(), receipt: receipt_bytes, error_message }; + let response = GetProofResponse { + status: proto_status.into(), + receipt: receipt_bytes, + error_message, + execution_stats, + }; Ok(Response::new(response)) } @@ -146,11 +205,21 @@ impl ProverServiceServer { #[cfg(test)] mod tests { + use std::collections::HashMap; + use base_zk_db::{ProofRequest, ProofType}; use chrono::Utc; use super::*; + fn metadata_with_execution_stats(stats: serde_json::Value) -> serde_json::Value { + let mut metadata = serde_json::Map::new(); + metadata + .insert(OP_SUCCINCT_DRY_RUN_METADATA_KEY.to_string(), serde_json::Value::Bool(true)); + metadata.insert(OP_SUCCINCT_EXECUTION_STATS_METADATA_KEY.to_string(), stats); + serde_json::Value::Object(metadata) + } + fn load_snark_fixture() -> Vec { let path = format!("{}/tests/fixtures/sample_snark_receipt.bin", env!("CARGO_MANIFEST_DIR")); @@ -182,6 +251,58 @@ mod tests { } } + #[test] + fn test_execution_stats_from_metadata_deserializes_stored_stats() { + let stored_stats = OpSuccinctStoredExecutionStats { + total_instruction_cycles: 100, + total_sp1_gas: 200, + cycle_tracker: HashMap::from([("range".to_string(), 42)]), + witness_generation_ms: 12.5, + execution_ms: 34.5, + }; + let metadata = + metadata_with_execution_stats(serde_json::to_value(stored_stats).expect("serialize")); + + let stats = execution_stats_from_metadata(&metadata).expect("execution stats"); + + assert_eq!(stats.total_instruction_cycles, 100); + assert_eq!(stats.total_sp1_gas, 200); + assert_eq!(stats.cycle_tracker.get("range"), Some(&42)); + assert_eq!(stats.witness_generation_ms, 12.5); + assert_eq!(stats.execution_ms, 34.5); + } + + #[test] + fn test_execution_stats_from_metadata_rejects_invalid_schema() { + let metadata = metadata_with_execution_stats(serde_json::json!({ + "total_instruction_cycles": "100", + "total_sp1_gas": 200, + "cycle_tracker": {}, + "witness_generation_ms": 12.5, + "execution_ms": 34.5, + })); + + assert!(execution_stats_from_metadata(&metadata).is_none()); + } + + #[test] + fn test_execution_stats_from_metadata_requires_dry_run_marker() { + let stored_stats = OpSuccinctStoredExecutionStats { + total_instruction_cycles: 100, + total_sp1_gas: 200, + cycle_tracker: HashMap::new(), + witness_generation_ms: 12.5, + execution_ms: 34.5, + }; + let mut metadata = serde_json::Map::new(); + metadata.insert( + OP_SUCCINCT_EXECUTION_STATS_METADATA_KEY.to_string(), + serde_json::to_value(stored_stats).expect("serialize"), + ); + + assert!(execution_stats_from_metadata(&serde_json::Value::Object(metadata)).is_none()); + } + #[test] fn test_get_receipt_stark_returns_stark_bytes() { let stark_bytes = vec![0xDE, 0xAD, 0xBE, 0xEF]; diff --git a/crates/utilities/cli/Cargo.toml b/crates/utilities/cli/Cargo.toml index 6360f5f97f..166401ee7b 100644 --- a/crates/utilities/cli/Cargo.toml +++ b/crates/utilities/cli/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] # cli -clap = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive", "env"] } # tracing tracing-appender.workspace = true diff --git a/crates/utilities/cli/src/cli.rs b/crates/utilities/cli/src/cli.rs index f17a924c3b..42d614acaa 100644 --- a/crates/utilities/cli/src/cli.rs +++ b/crates/utilities/cli/src/cli.rs @@ -30,3 +30,24 @@ macro_rules! parse_cli { <$cli_type>::from_arg_matches(&matches).expect("Parsing args") }}; } + +/// Runs a synchronous Clap CLI as a binary entry point. +#[macro_export] +macro_rules! run_cli_main { + ($cli_type:ty) => {{ + $crate::init_common!(); + + if let Err(err) = <$cli_type as ::clap::Parser>::parse().run() { + eprintln!("Error: {err:?}"); + ::std::process::exit(1); + } + }}; + (async $cli_type:ty) => {{ + $crate::init_common!(); + + if let Err(err) = <$cli_type as ::clap::Parser>::parse().run().await { + eprintln!("Error: {err:?}"); + ::std::process::exit(1); + } + }}; +} diff --git a/crates/utilities/cli/src/macros.rs b/crates/utilities/cli/src/macros.rs index ba7fd331b7..d767cc8eb5 100644 --- a/crates/utilities/cli/src/macros.rs +++ b/crates/utilities/cli/src/macros.rs @@ -43,6 +43,7 @@ macro_rules! define_metrics_args { pub struct MetricsArgs { /// Controls whether Prometheus metrics are enabled. Disabled by default. #[arg( + id = "metrics_enabled", long = "metrics.enabled", global = true, default_value_t = false, @@ -52,6 +53,7 @@ macro_rules! define_metrics_args { /// The interval for prometheus metrics collection in seconds. #[arg( + id = "metrics_interval", long = "metrics.interval", global = true, default_value = "30", diff --git a/crates/utilities/tx-manager/src/manager.rs b/crates/utilities/tx-manager/src/manager.rs index 455b84fd30..42c0baeb84 100644 --- a/crates/utilities/tx-manager/src/manager.rs +++ b/crates/utilities/tx-manager/src/manager.rs @@ -46,7 +46,10 @@ use alloy_consensus::TxEnvelope; use alloy_eips::{ BlockNumberOrTag, Decodable2718, Encodable2718, eip7594::BlobTransactionSidecarEip7594, }; -use alloy_network::{Ethereum, EthereumWallet, NetworkWallet, TransactionBuilder}; +use alloy_network::{ + Ethereum, EthereumWallet, Network, NetworkTransactionBuilder, NetworkWallet, + TransactionBuilder, TransactionBuilderError, +}; use alloy_primitives::{Address, B256, Bytes}; use alloy_provider::Provider; use alloy_rpc_types_eth::{TransactionReceipt, TransactionRequest}; @@ -745,11 +748,11 @@ where // Step 4: Build TransactionRequest. let from = self.sender_address(); let mut tx_request = TransactionRequest::default() - .with_input(candidate.tx_data.clone()) .with_max_fee_per_gas(fee_cap) .with_max_priority_fee_per_gas(tip_cap) .with_value(candidate.value) .with_chain_id(self.chain_id); + tx_request.input = Some(candidate.tx_data.clone()).into(); tx_request.set_from(from); @@ -831,9 +834,14 @@ where ); // Step 7: Sign and encode. - let sign_result = - >::build(tx_request, &self.wallet) - .await; + let sign_result: Result< + ::TxEnvelope, + TransactionBuilderError, + > = >::build( + tx_request, + &self.wallet, + ) + .await; match sign_result { Ok(envelope) => { diff --git a/deny.toml b/deny.toml index c675f49295..74c7789e4e 100644 --- a/deny.toml +++ b/deny.toml @@ -1,10 +1,6 @@ [advisories] # Ignore unmaintained/vulnerable crates that come from upstream dependencies we cannot control ignore = [ - # rustls-pemfile is unmaintained but comes from bollard -> testcontainers (dev dependency) - # No safe upgrade available, waiting for upstream to migrate to rustls-pki-types - "RUSTSEC-2025-0134", - # bincode is unmaintained but comes from reth-nippy-jar (upstream reth dependency) # No safe upgrade available "RUSTSEC-2025-0141", @@ -12,6 +8,34 @@ ignore = [ # paste is unmaintained but widely used in ecosystem (alloy, reth, etc.) # No safe upgrade available "RUSTSEC-2024-0436", + + # atomic-polyfill is unmaintained but comes from upstream reth/alloy dependencies + "RUSTSEC-2023-0089", + + # tar PAX extension vulnerability — comes from reth-cli-commands, waiting for upstream fix + "RUSTSEC-2026-0066", + + # AWS-LC X.509 vulnerabilities — transitive from aws-sdk deps, waiting for upstream fix + "RUSTSEC-2026-0044", + "RUSTSEC-2026-0048", + + # backoff is unmaintained — transitive from upstream reth/alloy dependencies + "RUSTSEC-2025-0012", + + # derivative is unmaintained — transitive from upstream reth dependencies + "RUSTSEC-2024-0388", + + # instant is unmaintained — transitive from upstream reth dependencies + "RUSTSEC-2024-0384", + + # aws-lc-sys SHAKE API — transitive from aws-sdk deps + "RUSTSEC-2026-0074", + + # lz4 decompression info leak — transitive from reth-cli-commands + "RUSTSEC-2026-0041", + + # rsa Marvin Attack — transitive from upstream dependencies + "RUSTSEC-2023-0071", ] [licenses] @@ -31,6 +55,8 @@ allow = [ "BSL-1.0", "OpenSSL", "CDLA-Permissive-2.0", + "LGPL-3.0-only", + "LGPL-3.0-or-later", ] confidence-threshold = 0.8 @@ -85,31 +111,20 @@ skip = [ "itertools", "lru", - # Crypto crates - version differences from different crypto stacks - "signature", - "base16ct", - "crypto-bigint", - "der", - "ecdsa", - "elliptic-curve", - "ff", - "group", - "p256", - "pkcs8", - "rfc6979", - "sec1", - "spki", - # System/platform crates "socket2", "bitflags", "redox_users", "windows", + "windows-collections", "windows-core", + "windows-future", "windows-implement", "windows-link", + "windows-numerics", "windows-result", "windows-strings", + "windows-threading", # Network crates "tungstenite", @@ -131,7 +146,6 @@ skip = [ "derive_more-impl", "dirs", "dirs-sys", - "ethereum_ssz", "generic-array", "indexmap", "indicatif", @@ -140,7 +154,6 @@ skip = [ "num_enum", "num_enum_derive", "ordered-float", - "pasta_curves", "petgraph", "proc-macro-crate", "gloo-timers", @@ -150,12 +163,16 @@ skip = [ "prost-build", "prost-types", "rustc-hash", - "send_wrapper", "sync_wrapper", "sysinfo", "webpki-roots", "webpki-root-certs", + # wasm-streams version mismatch from transitive deps + "wasm-streams", + + "discv5", + # libp2p dependency chain version mismatch "unsigned-varint", "if-addrs", @@ -181,11 +198,7 @@ skip = [ # rustls-platform-verifier version mismatch: jsonrpsee uses 0.5.x, reqwest uses 0.6.x "rustls-platform-verifier", - # AWS SDK version mismatches from tips aws-sdk-s3 dependency - "aws-smithy-http", - "aws-smithy-json", - - # TLS/HTTP stack version mismatches from aws-sdk/rdkafka deps + # TLS/HTTP stack version mismatches from aws-sdk deps "h2", "http", "http-body", @@ -208,20 +221,13 @@ skip = [ "half", "memoffset", - # SP1 + SP1 cluster duplicate dependency epochs - "ark-ff", - "ark-ff-asm", - "ark-ff-macros", - "ark-serialize", - "ark-std", + # SP1 + risc0/boundless transitive dependency mismatches "opentelemetry", "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry-proto", "opentelemetry_sdk", "tracing-opentelemetry", - "jsonwebtoken", - "pairing", "strum_macros", "wit-bindgen", "block-buffer", @@ -232,17 +238,50 @@ skip = [ "sha2", # risc0/boundless transitive dependency mismatches + # boundless-market v1.4.0 pulls alloy 1.x alongside our alloy 2.0 + "alloy-consensus", + "alloy-consensus-any", + "alloy-contract", + "alloy-eips", + "alloy-genesis", "alloy-hardforks", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-rpc-types-anvil", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-tx-macros", "cargo-platform", "cargo_metadata", "cpufeatures", "foreign-types", "foreign-types-shared", "num-bigint", + "ringbuffer", "tracing-subscriber", - "wasm-streams", + "untrusted", + "winnow", + + "jni", + "jni-sys", + "keccak", + "md-5", + "proptest-derive", + "ruzstd", + "sha1", + "sha3", + "twox-hash", ] [sources] diff --git a/devnet/Cargo.toml b/devnet/Cargo.toml index 14f177237d..f599474322 100644 --- a/devnet/Cargo.toml +++ b/devnet/Cargo.toml @@ -46,15 +46,22 @@ base-execution-chainspec.workspace = true reth-node-builder = { workspace = true, features = ["test-utils"] } # alloy +alloy-signer.workspace = true alloy-network.workspace = true alloy-rpc-client.workspace = true alloy-primitives.workspace = true +alloy-eips = { workspace = true, features = ["std"] } alloy-genesis = { workspace = true, features = ["std"] } +alloy-consensus = { workspace = true, features = ["std"] } +alloy-sol-types = { workspace = true, features = ["std"] } alloy-provider = { workspace = true, features = ["reqwest"] } +alloy-rpc-types-eth = { workspace = true, features = ["std"] } alloy-rpc-types-engine = { workspace = true, features = ["std"] } alloy-signer-local = { workspace = true, features = ["mnemonic"] } # base-alloy +base-common-precompiles.workspace = true +base-common-rpc-types.workspace = true base-common-network.workspace = true # tokio @@ -82,10 +89,16 @@ serde = { workspace = true, features = ["derive", "std"] } testcontainers = { workspace = true, features = ["blocking", "host-port-exposure"] } [dev-dependencies] -# alloy -alloy-signer.workspace = true -alloy-eips = { workspace = true, features = ["std"] } -alloy-consensus = { workspace = true, features = ["std"] } +# cli +clap = { workspace = true, features = ["derive"] } -# base-alloy -base-common-rpc-types.workspace = true +# proof +base-zk-client.workspace = true +base-proof-rpc.workspace = true + +# misc +indicatif.workspace = true + +[[bench]] +name = "b20_zk_proving" +harness = false diff --git a/devnet/benches/b20_zk_proving.rs b/devnet/benches/b20_zk_proving.rs new file mode 100644 index 0000000000..3b9f807eec --- /dev/null +++ b/devnet/benches/b20_zk_proving.rs @@ -0,0 +1,355 @@ +//! Local devnet benchmark for B-20 precompile ZK proving cycles. +//! +//! Run with: +//! +//! ```bash +//! cargo bench -p devnet --bench b20_zk_proving +//! ``` +//! +//! Requires a full local devnet with `base-prover-zk` running in `SP1_PROVER=dry-run` mode. + +use std::time::Duration; + +use alloy_primitives::{Address, B256, U256}; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, SolInterface}; +use base_common_precompiles::{ + ActivationFeature, ActivationRegistryStorage, B20TokenRole, B20Variant, IActivationRegistry, + IB20, +}; +use clap::Parser; +use devnet::{ + B20PrecompileClient, + config::{ANVIL_ACCOUNT_5, ANVIL_ACCOUNT_6, ANVIL_ACCOUNT_7}, +}; +use eyre::{Result, WrapErr}; +use tokio::runtime::Runtime; +use url::Url; + +pub mod common; + +use common::{ + BenchDisplay, BenchProvider, CycleReport, OperationReport, ZkProofBench, ZkProofBenchConfig, +}; + +const WORKLOAD_TXS: u64 = 10; +const INITIAL_SUPPLY: u64 = 1_000_000; +const TRANSFER_AMOUNT: u64 = 100; +const TRANSFER_WITH_MEMO_AMOUNT: u64 = 101; +const TRANSFER_FROM_AMOUNT: u64 = 102; +const TRANSFER_FROM_WITH_MEMO_AMOUNT: u64 = 103; +const ALLOWANCE_AMOUNT: u64 = 1_000; +const UPDATED_SUPPLY_CAP: u64 = 2_000_000; +const HEAVY_CONTRACT_URI: &str = + "ipfs://b20-zk-bench/metadata/heavy-interaction/with/a/longer/contract-uri/payload"; + +/// Local B-20 ZK dry-run proving benchmark. +#[derive(Debug)] +pub struct B20ZkProvingBench; + +fn main() -> Result<()> { + Runtime::new() + .wrap_err("failed to start tokio runtime")? + .block_on(B20ZkProvingBench::run(B20ZkProvingConfig::parse())) +} + +/// CLI configuration for the local B-20 ZK proving benchmark. +#[derive(Clone, Debug, Parser)] +pub struct B20ZkProvingConfig { + /// Cargo passes this flag to custom benchmark binaries. + #[arg(long = "bench", hide = true)] + pub cargo_bench: bool, + /// L2 execution RPC URL. + #[arg(long, default_value = "http://localhost:8645")] + pub l2_rpc_url: Url, + /// Rollup RPC URL. + #[arg(long, default_value = "http://localhost:8649")] + pub rollup_rpc_url: Url, + /// ZK prover RPC URL. + #[arg(long, default_value = "http://localhost:9000")] + pub zk_prover_url: Url, + /// Local devnet L2 chain ID. + #[arg(long, default_value_t = 84538453)] + pub l2_chain_id: u64, + /// Polling interval in milliseconds for block, receipt, and account funding waits. + #[arg(long = "block-poll-interval-ms", default_value = "500", value_parser = parse_duration_millis)] + pub block_poll_interval: Duration, + /// Transaction receipt timeout in seconds. + #[arg(long = "tx-receipt-timeout-secs", default_value = "60", value_parser = parse_duration_secs)] + pub tx_receipt_timeout: Duration, + /// Account funding timeout in seconds. + #[arg(long = "account-funding-timeout-secs", default_value = "15", value_parser = parse_duration_secs)] + pub account_funding_timeout: Duration, + /// Proof status polling interval in seconds. + #[arg(long = "proof-poll-interval-secs", default_value = "5", value_parser = parse_duration_secs)] + pub proof_poll_interval: Duration, + /// Proof job timeout in seconds. + #[arg(long = "proof-timeout-secs", default_value = "900", value_parser = parse_duration_secs)] + pub proof_timeout: Duration, + /// Timeout in seconds for waiting until workload blocks are safe. + #[arg(long = "safe-l2-timeout-secs", default_value = "300", value_parser = parse_duration_secs)] + pub safe_l2_timeout: Duration, +} + +fn parse_duration_millis(value: &str) -> Result { + value.parse().map(Duration::from_millis) +} + +fn parse_duration_secs(value: &str) -> Result { + value.parse().map(Duration::from_secs) +} + +/// Sends the fixed B-20 call sequence used by the benchmark. +#[derive(Debug)] +pub struct B20CallSender<'a> { + /// B-20 client signed by the benchmark admin. + pub admin_client: &'a B20PrecompileClient<'a>, + /// B-20 client signed by the benchmark spender. + pub spender_client: &'a B20PrecompileClient<'a>, + /// Benchmark token admin address. + pub admin: Address, + /// Benchmark spender address. + pub spender: Address, + /// Benchmark B-20 token address. + pub token: Address, + /// Progress display updated as calls are sent. + pub display: &'a BenchDisplay, +} + +impl B20ZkProvingBench { + /// Runs the B-20 ZK proving benchmark against a local devnet and dry-run prover. + pub async fn run(config: B20ZkProvingConfig) -> Result<()> { + let display = BenchDisplay::new("B-20 zk dry-run benchmark", WORKLOAD_TXS); + + display.setup_message("setup connecting to devnet RPCs"); + let l2_provider = BenchProvider::connect_base(config.l2_rpc_url.clone()); + let rollup_provider = BenchProvider::connect_base(config.rollup_rpc_url.clone()); + + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("failed to parse devnet admin private key")?; + let spender = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_7.private_key) + .wrap_err("failed to parse devnet spender private key")?; + display.setup_message("setup waiting for funded devnet accounts"); + BenchProvider::wait_for_balances( + &l2_provider, + [admin.address(), spender.address()], + config.block_poll_interval, + config.account_funding_timeout, + ) + .await?; + + let b20 = B20PrecompileClient::new(&l2_provider, &admin, config.l2_chain_id) + .with_receipt_timeout(config.tx_receipt_timeout); + let b20_spender = B20PrecompileClient::new(&l2_provider, &spender, config.l2_chain_id) + .with_receipt_timeout(config.tx_receipt_timeout); + display.setup_message("setup ensuring B-20 features are active"); + Self::ensure_feature_active(&b20, ActivationFeature::B20Factory.id()).await?; + Self::ensure_feature_active(&b20, ActivationFeature::B20Token.id()).await?; + + display.setup_message("setup creating benchmark B-20 token"); + let token = Self::create_b20_token(&b20, admin.address()).await?; + display.setup_message("setup waiting for benchmark token bytecode"); + b20.wait_for_token_code(token, config.tx_receipt_timeout, config.block_poll_interval) + .await?; + display.setup_done(token); + + let reports = B20CallSender { + admin_client: &b20, + spender_client: &b20_spender, + admin: admin.address(), + spender: spender.address(), + token, + display: &display, + } + .send_sequence() + .await?; + display.txs_done(); + let (first_block, last_block) = OperationReport::block_range(&reports)?; + let stats = ZkProofBench::prove_safe_block_range_with_dry_run_stats( + &rollup_provider, + config.zk_prover_url.clone(), + first_block, + last_block, + ZkProofBenchConfig { + safe_l2_timeout: config.safe_l2_timeout, + safe_l2_poll_interval: config.block_poll_interval, + proof_timeout: config.proof_timeout, + proof_poll_interval: config.proof_poll_interval, + }, + &display, + ) + .await?; + display.proof_done(&stats); + + CycleReport::print_summary( + "B-20 zk dry-run proof benchmark", + first_block, + last_block, + &reports, + &stats, + ) + } + + /// Creates the benchmark B-20 token. + pub async fn create_b20_token( + client: &B20PrecompileClient<'_>, + admin: Address, + ) -> Result
{ + let salt = B256::from(rand::random::<[u8; 32]>()); + let params = B20PrecompileClient::token_params( + "ZK Proof B20", + "ZKPB", + admin, + U256::from(INITIAL_SUPPLY), + admin, + ); + + client.create_token(B20Variant::B20, params, salt).await + } + + /// Activates `feature` if it is not already active. + pub async fn ensure_feature_active( + client: &B20PrecompileClient<'_>, + feature: B256, + ) -> Result<()> { + let output = client + .call( + ActivationRegistryStorage::ADDRESS, + IActivationRegistry::isActivatedCall { feature }, + ) + .await?; + let is_active = IActivationRegistry::isActivatedCall::abi_decode_returns(output.as_ref()) + .wrap_err("failed to decode activation registry state")?; + if !is_active { + client.activate_feature(feature).await?; + } + Ok(()) + } +} + +impl B20CallSender<'_> { + /// Sends the fixed B-20 call sequence used by the proof benchmark. + pub async fn send_sequence(&self) -> Result> { + let mut reports = Vec::new(); + + reports.push( + self.send_call( + self.admin_client, + "transfer", + IB20::transferCall { + to: ANVIL_ACCOUNT_6.address, + amount: U256::from(TRANSFER_AMOUNT), + }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "transferWithMemo", + IB20::transferWithMemoCall { + to: ANVIL_ACCOUNT_6.address, + amount: U256::from(TRANSFER_WITH_MEMO_AMOUNT), + memo: B256::repeat_byte(0x20), + }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "approve", + IB20::approveCall { spender: self.spender, amount: U256::from(ALLOWANCE_AMOUNT) }, + ) + .await?, + ); + reports.push( + self.send_call( + self.spender_client, + "transferFrom", + IB20::transferFromCall { + from: self.admin, + to: ANVIL_ACCOUNT_6.address, + amount: U256::from(TRANSFER_FROM_AMOUNT), + }, + ) + .await?, + ); + reports.push( + self.send_call( + self.spender_client, + "transferFromWithMemo", + IB20::transferFromWithMemoCall { + from: self.admin, + to: ANVIL_ACCOUNT_6.address, + amount: U256::from(TRANSFER_FROM_WITH_MEMO_AMOUNT), + memo: B256::repeat_byte(0x21), + }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "updateSupplyCap", + IB20::updateSupplyCapCall { newSupplyCap: U256::from(UPDATED_SUPPLY_CAP) }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "grantRole(metadata)", + IB20::grantRoleCall { role: B20TokenRole::Metadata.id(), account: self.admin }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "updateContractURI", + IB20::updateContractURICall { newURI: HEAVY_CONTRACT_URI.to_string() }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "updateName", + IB20::updateNameCall { newName: "ZK Proof B20 Heavy Metadata".to_string() }, + ) + .await?, + ); + reports.push( + self.send_call( + self.admin_client, + "updateSymbol", + IB20::updateSymbolCall { newSymbol: "ZKPH".to_string() }, + ) + .await?, + ); + + Ok(reports) + } + + /// Sends a signed B-20 call and records its gas, block, and tracker metadata. + pub async fn send_call( + &self, + client: &B20PrecompileClient<'_>, + operation: &'static str, + call: C, + ) -> Result + where + C: SolCall, + { + let input = call.abi_encode(); + let tracker_key = IB20::IB20Calls::abi_decode(&input) + .map_err(|_| eyre::eyre!("failed to decode B-20 cycle tracker key for {operation}"))? + .as_label(); + self.display.tx_started(operation); + let receipt = client.send_call_receipt(self.token, call, operation).await?; + let report = OperationReport::from_receipt(operation, tracker_key, receipt)?; + self.display.tx_done(&report); + Ok(report) + } +} diff --git a/devnet/benches/common/display.rs b/devnet/benches/common/display.rs new file mode 100644 index 0000000000..1af313d971 --- /dev/null +++ b/devnet/benches/common/display.rs @@ -0,0 +1,145 @@ +//! Progress display helpers for devnet benchmarks. + +use std::time::{Duration, Instant}; + +use alloy_primitives::Address; +use base_zk_client::{ExecutionStats, ProofJobStatus}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; + +use super::{CycleReport, OperationReport}; + +/// Multi-line terminal progress display for long-running devnet benchmarks. +#[derive(Debug)] +pub struct BenchDisplay { + benchmark_name: &'static str, + _multi: MultiProgress, + header: ProgressBar, + setup: ProgressBar, + txs: ProgressBar, + safe_l2: ProgressBar, + proof: ProgressBar, + started_at: Instant, +} + +impl BenchDisplay { + /// Creates a display with setup, transaction, safe L2, and proof progress rows. + pub fn new(benchmark_name: &'static str, total_txs: u64) -> Self { + let multi = MultiProgress::new(); + let header = multi.add(ProgressBar::new_spinner()); + header.set_style( + ProgressStyle::with_template("{spinner:.cyan} {msg}").expect("template is valid"), + ); + header.set_message(format!("{benchmark_name} starting...")); + header.enable_steady_tick(Duration::from_millis(120)); + + let spinner_style = + ProgressStyle::with_template(" {spinner:.cyan} {msg}").expect("template is valid"); + let setup = Self::spinner(&multi, &spinner_style, "setup waiting for devnet accounts"); + let safe_l2 = Self::spinner(&multi, &spinner_style, "safe L2 waiting for workload blocks"); + let proof = Self::spinner(&multi, &spinner_style, "prover waiting for dry-run job"); + + let txs = multi.add(ProgressBar::new(total_txs)); + txs.set_style( + ProgressStyle::with_template(" txs [{bar:40.cyan/blue}] {pos}/{len} {msg}") + .expect("template is valid") + .progress_chars("#>-"), + ); + txs.set_message("pending workload"); + + Self { + benchmark_name, + _multi: multi, + header, + setup, + txs, + safe_l2, + proof, + started_at: Instant::now(), + } + } + + /// Creates a spinner progress row with the given style and initial message. + pub fn spinner( + multi: &MultiProgress, + style: &ProgressStyle, + message: &'static str, + ) -> ProgressBar { + let pb = multi.add(ProgressBar::new_spinner()); + pb.set_style(style.clone()); + pb.set_message(message); + pb.enable_steady_tick(Duration::from_millis(120)); + pb + } + + /// Updates the setup progress row. + pub fn setup_message(&self, message: impl Into) { + self.setup.set_message(message.into()); + } + + /// Marks setup complete with the created workload target. + pub fn setup_done(&self, target: Address) { + self.setup.finish_with_message(format!("setup ready target {target}")); + } + + /// Updates the transaction row before sending a workload operation. + pub fn tx_started(&self, operation: &str) { + self.header.set_message(format!("{} sending {operation}", self.benchmark_name)); + self.txs.set_message(format!("sending {operation}")); + } + + /// Records a completed workload transaction. + pub fn tx_done(&self, report: &OperationReport) { + self.txs.inc(1); + self.txs.set_message(format!( + "last {} gas {} block {}", + report.operation, + CycleReport::fmt_u64(report.gas_used), + report.block_number, + )); + } + + /// Marks all workload transactions as included. + pub fn txs_done(&self) { + self.txs.finish_with_message("workload transactions included"); + } + + /// Updates safe L2 wait progress. + pub fn safe_l2_progress(&self, safe_block: u64, target_block: u64) { + self.header.set_message("waiting for safe L2"); + self.safe_l2.set_message(format!("safe L2 {safe_block} / target {target_block}")); + } + + /// Marks the safe L2 wait complete. + pub fn safe_l2_done(&self, block_number: u64) { + self.safe_l2.finish_with_message(format!("safe L2 reached block {block_number}")); + } + + /// Records that a proof request has been accepted. + pub fn proof_requested(&self, session_id: &str, start_block: u64, blocks: u64) { + self.header.set_message("dry-run proof in progress"); + self.proof.set_message(format!( + "session {session_id} range start {start_block} blocks {blocks}", + )); + } + + /// Updates proof polling progress. + pub fn proof_progress(&self, status: &ProofJobStatus, elapsed: Duration) { + self.proof.set_message(format!( + "status {status:?} elapsed {}", + CycleReport::fmt_duration(elapsed) + )); + } + + /// Marks proof polling complete. + pub fn proof_done(&self, stats: &ExecutionStats) { + self.proof.finish_with_message(format!( + "dry-run complete total cycles {}", + CycleReport::fmt_u64(stats.total_instruction_cycles), + )); + self.header.finish_with_message(format!( + "{} complete in {}", + self.benchmark_name, + CycleReport::fmt_duration(self.started_at.elapsed()), + )); + } +} diff --git a/devnet/benches/common/mod.rs b/devnet/benches/common/mod.rs new file mode 100644 index 0000000000..599f01ef98 --- /dev/null +++ b/devnet/benches/common/mod.rs @@ -0,0 +1,13 @@ +//! Shared utilities for devnet benchmark targets. + +mod display; +pub use display::BenchDisplay; + +mod provider; +pub use provider::BenchProvider; + +mod report; +pub use report::{CycleReport, OperationReport}; + +mod zk_proof; +pub use zk_proof::{ZkProofBench, ZkProofBenchConfig}; diff --git a/devnet/benches/common/provider.rs b/devnet/benches/common/provider.rs new file mode 100644 index 0000000000..4a01ba4880 --- /dev/null +++ b/devnet/benches/common/provider.rs @@ -0,0 +1,64 @@ +//! Provider setup helpers for devnet benchmarks. + +use std::time::Duration; + +use alloy_primitives::{Address, U256}; +use alloy_provider::{Identity, Provider, ProviderBuilder, RootProvider}; +use base_common_network::Base; +use eyre::{Result, WrapErr}; +use tokio::time::{sleep, timeout}; +use url::Url; + +/// Provider setup helpers for devnet benchmarks. +#[derive(Debug)] +pub struct BenchProvider; + +impl BenchProvider { + /// Connects an HTTP provider for the Base network type. + pub fn connect_base(url: Url) -> RootProvider { + ProviderBuilder::::default().connect_http(url) + } + + /// Waits until all provided devnet accounts are funded. + pub async fn wait_for_balances( + provider: &RootProvider, + addresses: impl IntoIterator, + poll_interval: Duration, + wait_timeout: Duration, + ) -> Result<()> { + timeout(wait_timeout, async { + for address in addresses { + loop { + let balance = provider.get_balance(address).await?; + if balance > U256::ZERO { + break; + } + sleep(poll_interval).await; + } + } + Ok::<_, eyre::Error>(()) + }) + .await + .wrap_err("timed out waiting for funded devnet accounts")? + } + + /// Waits until a devnet account is funded. + pub async fn wait_for_balance( + provider: &RootProvider, + address: Address, + poll_interval: Duration, + wait_timeout: Duration, + ) -> Result<()> { + timeout(wait_timeout, async { + loop { + let balance = provider.get_balance(address).await?; + if balance > U256::ZERO { + return Ok::<_, eyre::Error>(()); + } + sleep(poll_interval).await; + } + }) + .await + .wrap_err("timed out waiting for funded devnet account")? + } +} diff --git a/devnet/benches/common/report.rs b/devnet/benches/common/report.rs new file mode 100644 index 0000000000..a12636c57d --- /dev/null +++ b/devnet/benches/common/report.rs @@ -0,0 +1,160 @@ +//! Reporting helpers for devnet benchmark cycle output. + +use std::collections::HashSet; + +use base_zk_client::ExecutionStats; +use eyre::{Result, ensure}; + +/// Operation-level gas and cycle tracker metadata emitted by a benchmark workload. +#[derive(Clone, Copy, Debug)] +pub struct OperationReport { + /// Human-readable workload operation name. + pub operation: &'static str, + /// Cycle tracker key emitted by the ZK program for this operation. + pub tracker_key: &'static str, + /// L2 block number that included the operation transaction. + pub block_number: u64, + /// Gas used by the operation transaction. + pub gas_used: u64, +} + +impl OperationReport { + /// Builds an operation report from a transaction receipt. + pub fn from_receipt( + operation: &'static str, + tracker_key: &'static str, + receipt: impl alloy_network::ReceiptResponse, + ) -> Result { + Ok(Self { + operation, + tracker_key, + block_number: receipt + .block_number() + .ok_or_else(|| eyre::eyre!("{operation} missing block number"))?, + gas_used: receipt.gas_used(), + }) + } + + /// Returns the inclusive block range covered by the operation reports. + pub fn block_range(reports: &[Self]) -> Result<(u64, u64)> { + let first_block = reports + .iter() + .map(|report| report.block_number) + .min() + .ok_or_else(|| eyre::eyre!("benchmark workload did not send any transactions"))?; + let last_block = reports + .iter() + .map(|report| report.block_number) + .max() + .ok_or_else(|| eyre::eyre!("benchmark workload did not send any transactions"))?; + + Ok((first_block, last_block)) + } +} + +/// Cycle report formatting helpers for devnet benchmarks. +#[derive(Debug)] +pub struct CycleReport; + +impl CycleReport { + /// Prints the benchmark summary and per-operation cycle table. + pub fn print_summary( + title: &str, + first_block: u64, + last_block: u64, + reports: &[OperationReport], + stats: &ExecutionStats, + ) -> Result<()> { + println!("{title}"); + println!(" block range: {first_block}..={last_block}"); + println!(" transactions: {}", reports.len()); + println!( + " total tx gas: {}", + reports.iter().map(|report| report.gas_used).sum::() + ); + println!(" total cycles: {}", stats.total_instruction_cycles); + println!(); + Self::print_cycle_table(reports, stats) + } + + /// Prints a per-operation cycle table. + pub fn print_cycle_table(reports: &[OperationReport], stats: &ExecutionStats) -> Result<()> { + println!("cycle tracker results"); + Self::print_table_separator(); + println!( + "| {:<22} | {:>12} | {:>12} | {:<38} | {:>16} | {:>16} | {:>12} |", + "operation", "block", "tx gas", "tracker key", "cycles", "cycles/call", "cycles/gas", + ); + Self::print_table_separator(); + + for report in reports { + let tracked_cycles = + stats.cycle_tracker.get(report.tracker_key).copied().unwrap_or_default(); + let calls_for_key = + reports.iter().filter(|r| r.tracker_key == report.tracker_key).count() as u64; + let cycles_per_call = tracked_cycles / calls_for_key.max(1); + ensure!( + tracked_cycles > 0, + "dry-run report missing non-zero {} cycles; available keys: {:?}", + report.tracker_key, + stats.cycle_tracker.keys().collect::>() + ); + ensure!(report.gas_used > 0, "{} transaction reported zero gas used", report.operation); + + println!( + "| {:<22} | {:>12} | {:>12} | {:<38} | {:>16} | {:>16} | {:>12.4} |", + report.operation, + report.block_number.to_string(), + Self::fmt_u64(report.gas_used), + report.tracker_key, + Self::fmt_u64(tracked_cycles), + Self::fmt_u64(cycles_per_call), + cycles_per_call as f64 / report.gas_used as f64, + ); + } + + Self::print_table_separator(); + let unique_tracked_cycles = { + let mut seen = HashSet::new(); + reports + .iter() + .filter(|report| seen.insert(report.tracker_key)) + .map(|report| { + stats.cycle_tracker.get(report.tracker_key).copied().unwrap_or_default() + }) + .sum::() + }; + println!("tracked cycles: {}", Self::fmt_u64(unique_tracked_cycles)); + + Ok(()) + } + + /// Prints the fixed-width cycle table separator. + pub fn print_table_separator() { + println!( + "+-{:-<22}-+-{:-<12}-+-{:-<12}-+-{:-<38}-+-{:-<16}-+-{:-<16}-+-{:-<12}-+", + "", "", "", "", "", "", "", + ) + } + + /// Formats an integer with comma group separators. + pub fn fmt_u64(value: u64) -> String { + let digits = value.to_string(); + let mut formatted = String::with_capacity(digits.len() + digits.len() / 3); + for (idx, ch) in digits.chars().rev().enumerate() { + if idx > 0 && idx % 3 == 0 { + formatted.push(','); + } + formatted.push(ch); + } + formatted.chars().rev().collect() + } + + /// Formats a duration for compact benchmark progress output. + pub fn fmt_duration(duration: std::time::Duration) -> String { + let seconds = duration.as_secs(); + let minutes = seconds / 60; + let seconds = seconds % 60; + if minutes > 0 { format!("{minutes}m {seconds:02}s") } else { format!("{seconds}s") } + } +} diff --git a/devnet/benches/common/zk_proof.rs b/devnet/benches/common/zk_proof.rs new file mode 100644 index 0000000000..73eee4045f --- /dev/null +++ b/devnet/benches/common/zk_proof.rs @@ -0,0 +1,188 @@ +//! ZK proof request helpers for devnet benchmarks. + +use std::time::{Duration, Instant}; + +use alloy_eips::BlockNumberOrTag; +use alloy_primitives::B256; +use alloy_provider::RootProvider; +use base_common_network::Base; +use base_proof_rpc::OptimismRollupProviderExt; +use base_zk_client::{ + ExecutionStats, GetProofRequest, ProofJobStatus, ProofType, ProveBlockRequest, ReceiptType, + ZkProofClient, ZkProofClientConfig, +}; +use eyre::{Result, WrapErr, ensure}; +use tokio::time::{sleep, timeout}; +use url::Url; + +use super::BenchDisplay; + +/// ZK proof helpers for devnet benchmarks. +#[derive(Debug)] +pub struct ZkProofBench; + +/// Timing configuration for waiting on safe L2 blocks and ZK proof jobs. +#[derive(Clone, Copy, Debug)] +pub struct ZkProofBenchConfig { + /// Timeout for the workload block to become safe. + pub safe_l2_timeout: Duration, + /// Polling interval while waiting for safe L2. + pub safe_l2_poll_interval: Duration, + /// Timeout for the dry-run proof request. + pub proof_timeout: Duration, + /// Polling interval while waiting for proof completion. + pub proof_poll_interval: Duration, +} + +impl ZkProofBench { + /// Waits for a block range to become safe, then requests dry-run proof stats for it. + pub async fn prove_safe_block_range_with_dry_run_stats( + rollup_provider: &RootProvider, + prover_url: Url, + first_block_number: u64, + last_block_number: u64, + config: ZkProofBenchConfig, + display: &BenchDisplay, + ) -> Result { + let l1_head = Self::wait_for_safe_l2( + rollup_provider, + last_block_number, + config.safe_l2_timeout, + config.safe_l2_poll_interval, + display, + ) + .await?; + + Self::prove_block_range_with_dry_run_stats( + prover_url, + first_block_number, + last_block_number, + l1_head, + config.proof_timeout, + config.proof_poll_interval, + display, + ) + .await + } + + /// Waits for a workload block to become safe and returns the current L1 head. + pub async fn wait_for_safe_l2( + provider: &RootProvider, + block_number: u64, + wait_timeout: Duration, + poll_interval: Duration, + display: &BenchDisplay, + ) -> Result { + timeout(wait_timeout, async { + loop { + let status = provider.optimism_sync_status().await?; + display.safe_l2_progress(status.safe_l2.number, block_number); + if status.safe_l2.number >= block_number { + provider + .optimism_output_at_block(BlockNumberOrTag::Number(block_number)) + .await?; + display.safe_l2_done(block_number); + return Ok::<_, eyre::Error>(status.head_l1.hash); + } + sleep(poll_interval).await; + } + }) + .await + .wrap_err("timed out waiting for workload block to become safe")? + } + + /// Requests a dry-run proof for a block range and returns execution stats. + pub async fn prove_block_range_with_dry_run_stats( + prover_url: Url, + first_block_number: u64, + last_block_number: u64, + l1_head: B256, + proof_timeout: Duration, + poll_interval: Duration, + display: &BenchDisplay, + ) -> Result { + ensure!( + last_block_number >= first_block_number, + "invalid workload block range: {first_block_number}..={last_block_number}" + ); + let start_block_number = first_block_number + .checked_sub(1) + .ok_or_else(|| eyre::eyre!("cannot prove genesis block with one-block range"))?; + let number_of_blocks_to_prove = last_block_number - first_block_number + 1; + let client = ZkProofClient::new(&ZkProofClientConfig { + endpoint: prover_url, + connect_timeout: Duration::from_secs(10), + request_timeout: Duration::from_secs(30), + })?; + let response = client + .prove_block(ProveBlockRequest { + start_block_number, + number_of_blocks_to_prove, + sequence_window: None, + proof_type: ProofType::Compressed.into(), + session_id: None, + prover_address: None, + l1_head: Some(l1_head.to_string()), + intermediate_root_interval: None, + }) + .await?; + + display.proof_requested( + &response.session_id, + start_block_number, + number_of_blocks_to_prove, + ); + Self::poll_dry_run_stats( + &client, + response.session_id, + proof_timeout, + poll_interval, + display, + ) + .await + } + + /// Polls a dry-run proof job until it returns execution stats or times out. + pub async fn poll_dry_run_stats( + client: &ZkProofClient, + session_id: String, + proof_timeout: Duration, + poll_interval: Duration, + display: &BenchDisplay, + ) -> Result { + let timeout_session_id = session_id.clone(); + timeout(proof_timeout, async { + let start = Instant::now(); + loop { + let response = client + .get_proof(GetProofRequest { + session_id: session_id.clone(), + receipt_type: Some(ReceiptType::Stark.into()), + }) + .await?; + let status = ProofJobStatus::try_from(response.status) + .unwrap_or(ProofJobStatus::Unspecified); + display.proof_progress(&status, start.elapsed()); + + match status { + ProofJobStatus::Succeeded => { + return response.execution_stats.ok_or_else(|| { + eyre::eyre!("dry-run prover response did not include execution_stats") + }); + } + ProofJobStatus::Failed => { + return Err(eyre::eyre!( + "proof request failed: {}", + response + .error_message + .unwrap_or_else(|| "missing error message".to_string()) + )); + } + _ => sleep(poll_interval).await, + } + } + }) + .await + .wrap_err_with(|| format!("timed out waiting for proof request {timeout_session_id}"))? + } +} diff --git a/devnet/src/b20.rs b/devnet/src/b20.rs new file mode 100644 index 0000000000..db2bff13cb --- /dev/null +++ b/devnet/src/b20.rs @@ -0,0 +1,542 @@ +//! B-20 precompile RPC client helpers. + +use std::time::Duration; + +use alloy_consensus::SignableTransaction; +use alloy_eips::eip2718::Encodable2718; +use alloy_network::ReceiptResponse; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types_eth::TransactionInput; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, SolValue}; +use base_common_network::Base; +use base_common_precompiles::{ + ActivationRegistryStorage, B20FactoryStorage, B20PausableFeature, B20Variant, + IActivationRegistry, IB20, IB20Factory, +}; +use base_common_rpc_types::{BaseTransactionReceipt, BaseTransactionRequest}; +use eyre::{ContextCompat, Result, WrapErr, ensure}; +use tokio::time::{sleep, timeout}; + +/// Creation settings used by the devnet B-20 factory client. +#[derive(Debug, Clone)] +pub struct B20CreateConfig { + /// ABI-level creation params sent to `IB20Factory.createB20`. + pub create: IB20Factory::B20CreateParams, + /// Initial supply to mint during the factory init-call window. + pub initial_supply: U256, + /// Account receiving the initial supply. + pub initial_supply_recipient: Address, + /// Initial supply cap to configure during the factory init-call window. + pub supply_cap: U256, + /// Initial ERC-7572 contract URI. + pub contract_uri: String, +} + +/// RPC client for the B-20 token factory and created token precompiles. +#[derive(Debug)] +pub struct B20PrecompileClient<'a> { + provider: &'a RootProvider, + signer: &'a PrivateKeySigner, + chain_id: u64, + gas_limit: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + receipt_timeout: Duration, +} + +impl<'a> B20PrecompileClient<'a> { + /// Default gas limit used when sending B-20 transactions. + pub const DEFAULT_GAS_LIMIT: u64 = 10_000_000; + + /// Default max fee per gas used when sending B-20 transactions. + pub const DEFAULT_MAX_FEE_PER_GAS: u128 = 1_000_000_000; + + /// Default priority fee per gas used when sending B-20 transactions. + pub const DEFAULT_MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000; + + /// Default receipt timeout used after sending B-20 transactions. + pub const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(60); + + /// Creates a B-20 precompile client. + pub const fn new( + provider: &'a RootProvider, + signer: &'a PrivateKeySigner, + chain_id: u64, + ) -> Self { + Self { + provider, + signer, + chain_id, + gas_limit: Self::DEFAULT_GAS_LIMIT, + max_fee_per_gas: Self::DEFAULT_MAX_FEE_PER_GAS, + max_priority_fee_per_gas: Self::DEFAULT_MAX_PRIORITY_FEE_PER_GAS, + receipt_timeout: Self::DEFAULT_RECEIPT_TIMEOUT, + } + } + + /// Sets the gas limit used for B-20 transactions. + pub const fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit = gas_limit; + self + } + + /// Sets the receipt timeout used after sending B-20 transactions. + pub const fn with_receipt_timeout(mut self, receipt_timeout: Duration) -> Self { + self.receipt_timeout = receipt_timeout; + self + } + + /// Sets the max fee per gas used for B-20 transactions. + pub const fn with_max_fee_per_gas(mut self, max_fee_per_gas: u128) -> Self { + self.max_fee_per_gas = max_fee_per_gas; + self + } + + /// Sets the priority fee per gas used for B-20 transactions. + pub const fn with_max_priority_fee_per_gas(mut self, max_priority_fee_per_gas: u128) -> Self { + self.max_priority_fee_per_gas = max_priority_fee_per_gas; + self + } + + /// Builds the required B-20 token params for factory creation. + pub fn token_params( + name: &str, + symbol: &str, + initial_admin: Address, + initial_supply: U256, + initial_supply_recipient: Address, + ) -> B20CreateConfig { + B20CreateConfig { + create: IB20Factory::B20CreateParams { + version: B20FactoryStorage::CREATE_TOKEN_VERSION, + name: name.to_string(), + symbol: symbol.to_string(), + initialAdmin: initial_admin, + }, + initial_supply, + initial_supply_recipient, + supply_cap: U256::MAX, + contract_uri: String::new(), + } + } + + /// Creates a B-20 token through the factory and returns the deterministic token address. + pub async fn create_token( + &self, + variant: B20Variant, + params: B20CreateConfig, + salt: B256, + ) -> Result
{ + let token = self.predict_token_address(variant, salt); + let mut init_calls = Vec::new(); + if params.initial_supply > U256::ZERO { + init_calls.push( + IB20::mintCall { + to: params.initial_supply_recipient, + amount: params.initial_supply, + } + .abi_encode() + .into(), + ); + } + if params.supply_cap != U256::MAX { + init_calls.push( + IB20::updateSupplyCapCall { newSupplyCap: params.supply_cap }.abi_encode().into(), + ); + } + if !params.contract_uri.is_empty() { + init_calls.push( + IB20::updateContractURICall { newURI: params.contract_uri }.abi_encode().into(), + ); + } + let call = IB20Factory::createB20Call { + variant: variant.abi(), + salt, + params: params.create.abi_encode().into(), + initCalls: init_calls, + }; + self.send_call(B20FactoryStorage::ADDRESS, call, "create B-20 token").await?; + Ok(token) + } + + /// Activates an activation-registry feature. + pub async fn activate_feature(&self, feature: B256) -> Result<()> { + self.send_call( + ActivationRegistryStorage::ADDRESS, + IActivationRegistry::activateCall { feature }, + "activate feature", + ) + .await?; + Ok(()) + } + + /// Deactivates an activation-registry feature. + pub async fn deactivate_feature(&self, feature: B256) -> Result<()> { + self.send_call( + ActivationRegistryStorage::ADDRESS, + IActivationRegistry::deactivateCall { feature }, + "deactivate feature", + ) + .await?; + Ok(()) + } + + /// Computes the token address a factory creation call will use. + pub fn predict_token_address(&self, variant: B20Variant, salt: B256) -> Address { + variant.compute_address(self.signer.address(), salt).0 + } + + /// Waits for a created token address to return non-empty bytecode. + pub async fn wait_for_token_code( + &self, + token: Address, + wait_timeout: Duration, + poll_interval: Duration, + ) -> Result<()> { + timeout(wait_timeout, async { + loop { + let code = self.provider.get_code_at(token).await?; + if !code.is_empty() { + return Ok::<_, eyre::Error>(()); + } + sleep(poll_interval).await; + } + }) + .await + .wrap_err("Timed out waiting for B-20 token code")? + } + + /// Reads the B-20 balance for an account. + pub async fn balance_of(&self, token: Address, account: Address) -> Result { + let output = self.call(token, IB20::balanceOfCall { account }).await?; + IB20::balanceOfCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode balanceOf") + } + + /// Reads the variant encoded in a token address. + pub async fn variant_of(&self, token: Address) -> Result { + B20Variant::from_address(token).wrap_err("Token address is not a supported B-20 token") + } + + /// Reads the fixed decimals for the token variant encoded in an address. + pub async fn decimals_of(&self, token: Address) -> Result { + B20Variant::decimals_of(token).wrap_err("Token address is not a supported B-20 token") + } + + /// Mints B-20 tokens to an account. + pub async fn mint(&self, token: Address, to: Address, amount: U256) -> Result<()> { + self.send_call(token, IB20::mintCall { to, amount }, "mint B-20 token").await?; + Ok(()) + } + + /// Transfers B-20 tokens. + pub async fn transfer(&self, token: Address, to: Address, amount: U256) -> Result<()> { + self.send_call(token, IB20::transferCall { to, amount }, "transfer B-20 token").await?; + Ok(()) + } + + /// Reads the token name. + pub async fn name(&self, token: Address) -> Result { + let output = self.call(token, IB20::nameCall {}).await?; + IB20::nameCall::abi_decode_returns(output.as_ref()).wrap_err("Failed to decode name") + } + + /// Reads the token symbol. + pub async fn symbol(&self, token: Address) -> Result { + let output = self.call(token, IB20::symbolCall {}).await?; + IB20::symbolCall::abi_decode_returns(output.as_ref()).wrap_err("Failed to decode symbol") + } + + /// Reads the token total supply. + pub async fn total_supply(&self, token: Address) -> Result { + let output = self.call(token, IB20::totalSupplyCall {}).await?; + IB20::totalSupplyCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode totalSupply") + } + + /// Reads the allowance granted by `owner` to `spender`. + pub async fn allowance( + &self, + token: Address, + owner: Address, + spender: Address, + ) -> Result { + let output = self.call(token, IB20::allowanceCall { owner, spender }).await?; + IB20::allowanceCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode allowance") + } + + /// Approves `spender` to transfer up to `amount` on behalf of the signer. + pub async fn approve(&self, token: Address, spender: Address, amount: U256) -> Result<()> { + self.send_call(token, IB20::approveCall { spender, amount }, "approve B-20 spender") + .await?; + Ok(()) + } + + /// Transfers tokens from `from` to `to` using the signer's allowance. + pub async fn transfer_from( + &self, + token: Address, + from: Address, + to: Address, + amount: U256, + ) -> Result<()> { + self.send_call( + token, + IB20::transferFromCall { from, to, amount }, + "transferFrom B-20 token", + ) + .await?; + Ok(()) + } + + /// Burns tokens from the signer's balance. + pub async fn burn(&self, token: Address, amount: U256) -> Result<()> { + self.send_call(token, IB20::burnCall { amount }, "burn B-20 token").await?; + Ok(()) + } + + /// Transfers tokens with a memo tag. + pub async fn transfer_with_memo( + &self, + token: Address, + to: Address, + amount: U256, + memo: B256, + ) -> Result<()> { + self.send_call( + token, + IB20::transferWithMemoCall { to, amount, memo }, + "transferWithMemo B-20 token", + ) + .await?; + Ok(()) + } + + /// Reads the supply cap. + pub async fn supply_cap(&self, token: Address) -> Result { + let output = self.call(token, IB20::supplyCapCall {}).await?; + IB20::supplyCapCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode supplyCap") + } + + /// Updates the supply cap. + pub async fn update_supply_cap(&self, token: Address, new_cap: U256) -> Result<()> { + self.send_call( + token, + IB20::updateSupplyCapCall { newSupplyCap: new_cap }, + "updateSupplyCap B-20 token", + ) + .await?; + Ok(()) + } + + /// Updates the token name. + pub async fn update_name(&self, token: Address, new_name: &str) -> Result<()> { + self.send_call( + token, + IB20::updateNameCall { newName: new_name.to_string() }, + "updateName B-20 token", + ) + .await?; + Ok(()) + } + + /// Updates the token symbol. + pub async fn update_symbol(&self, token: Address, new_symbol: &str) -> Result<()> { + self.send_call( + token, + IB20::updateSymbolCall { newSymbol: new_symbol.to_string() }, + "updateSymbol B-20 token", + ) + .await?; + Ok(()) + } + + /// Reads the contract URI. + pub async fn contract_uri(&self, token: Address) -> Result { + let output = self.call(token, IB20::contractURICall {}).await?; + IB20::contractURICall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode contractURI") + } + + /// Updates the contract URI. + pub async fn update_contract_uri(&self, token: Address, new_uri: &str) -> Result<()> { + self.send_call( + token, + IB20::updateContractURICall { newURI: new_uri.to_string() }, + "updateContractURI B-20 token", + ) + .await?; + Ok(()) + } + + /// Reads the pause vector flags. + pub async fn paused(&self, token: Address) -> Result { + let output = self.call(token, IB20::pausedFeaturesCall {}).await?; + let features = IB20::pausedFeaturesCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode pausedFeatures")?; + Ok(features + .into_iter() + .fold(U256::ZERO, |paused, feature| paused | B20PausableFeature::mask(feature))) + } + + /// Pauses the token for the given vector flags. + pub async fn pause(&self, token: Address, vectors: U256) -> Result<()> { + let features = pausable_features_from_mask(vectors); + self.send_call(token, IB20::pauseCall { features }, "pause B-20 token").await?; + Ok(()) + } + + /// Unpauses all pause vectors on the token. + pub async fn unpause(&self, token: Address) -> Result<()> { + let features = pausable_features_from_mask(U256::from(0x0f)); + self.send_call(token, IB20::unpauseCall { features }, "unpause B-20 token").await?; + Ok(()) + } + + /// Returns true if `token` is a deployed B-20 via the factory. + pub async fn is_b20(&self, token: Address) -> Result { + let output = + self.call(B20FactoryStorage::ADDRESS, IB20Factory::isB20Call { token }).await?; + IB20Factory::isB20Call::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode isB20") + } + + /// Calls `getB20Address` on the factory precompile via RPC. + pub async fn predict_token_address_rpc( + &self, + creator: Address, + variant: B20Variant, + salt: B256, + ) -> Result
{ + let output = self + .call( + B20FactoryStorage::ADDRESS, + IB20Factory::getB20AddressCall { variant: variant.abi(), sender: creator, salt }, + ) + .await?; + IB20Factory::getB20AddressCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode getB20Address") + } + + /// Sends a transaction and returns `true` if it succeeded, `false` if it reverted. + pub async fn try_send_call(&self, to: Address, call: C, label: &'static str) -> Result + where + C: SolCall, + { + Ok(self.send_and_wait(to, Bytes::from(call.abi_encode()), label).await?.status()) + } + + /// Executes an `eth_call` against `to`. + pub async fn call(&self, to: Address, call: C) -> Result + where + C: SolCall, + { + let request = BaseTransactionRequest::default() + .from(self.signer.address()) + .to(to) + .input(TransactionInput::new(Bytes::from(call.abi_encode()))); + + self.provider.call(request).await.wrap_err("B-20 eth_call failed") + } + + /// Signs, sends, and waits for a transaction against `to`. + pub async fn send_call(&self, to: Address, call: C, label: &'static str) -> Result<()> + where + C: SolCall, + { + self.send_call_receipt(to, call, label).await?; + Ok(()) + } + + /// Signs, sends, and waits for a successful transaction receipt against `to`. + pub async fn send_call_receipt( + &self, + to: Address, + call: C, + label: &'static str, + ) -> Result + where + C: SolCall, + { + let receipt = self.send_and_wait(to, Bytes::from(call.abi_encode()), label).await?; + ensure!(receipt.status(), "{label} transaction reverted"); + ensure!(receipt.inner.to == Some(to), "{label} receipt target mismatch"); + Ok(receipt) + } + + /// Signs, sends, and polls until a receipt is available. + /// + /// All error messages use `label`. Both `send_call` and `try_send_call` delegate here so + /// the nonce-fetch / sign / send / poll-receipt pipeline stays in one place. + async fn send_and_wait( + &self, + to: Address, + input: Bytes, + label: &'static str, + ) -> Result { + let nonce = self.provider.get_transaction_count(self.signer.address()).await?; + let (raw_tx, expected_tx_hash) = self.create_signed_tx(to, nonce, input).wrap_err(label)?; + + let pending_tx = self + .provider + .send_raw_transaction(&raw_tx) + .await + .wrap_err_with(|| format!("Failed to send {label} transaction"))?; + let tx_hash = *pending_tx.tx_hash(); + ensure!(tx_hash == expected_tx_hash, "{label} transaction hash mismatch"); + + timeout(self.receipt_timeout, async { + loop { + if let Some(receipt) = self.provider.get_transaction_receipt(tx_hash).await? { + return Ok::<_, eyre::Error>(receipt); + } + sleep(Duration::from_secs(1)).await; + } + }) + .await + .wrap_err_with(|| format!("{label} receipt timed out"))? + .wrap_err_with(|| format!("Failed to get {label} receipt")) + } + + /// Creates a signed transaction targeting `to`. + pub fn create_signed_tx(&self, to: Address, nonce: u64, input: Bytes) -> Result<(Bytes, B256)> { + let tx_request = BaseTransactionRequest::default() + .from(self.signer.address()) + .to(to) + .value(U256::ZERO) + .transaction_type(2) + .gas_limit(self.gas_limit) + .max_fee_per_gas(self.max_fee_per_gas) + .max_priority_fee_per_gas(self.max_priority_fee_per_gas) + .chain_id(self.chain_id) + .nonce(nonce) + .input(TransactionInput::new(input)); + + let tx = tx_request + .build_typed_tx() + .map_err(|tx| eyre::eyre!("invalid B-20 transaction request: {tx:?}"))?; + let signature = self.signer.sign_hash_sync(&tx.signature_hash())?; + let signed_tx = tx.into_signed(signature); + let tx_hash = *signed_tx.hash(); + let raw_tx = signed_tx.encoded_2718().into(); + + Ok((raw_tx, tx_hash)) + } +} + +fn pausable_features_from_mask(mask: U256) -> Vec { + [ + IB20::PausableFeature::TRANSFER, + IB20::PausableFeature::MINT, + IB20::PausableFeature::BURN, + IB20::PausableFeature::REDEEM, + ] + .into_iter() + .filter(|feature| (mask & B20PausableFeature::mask(*feature)) != U256::ZERO) + .collect() +} diff --git a/devnet/src/l2/in_process_consensus.rs b/devnet/src/l2/in_process_consensus.rs index 2df001ebab..7f14373382 100644 --- a/devnet/src/l2/in_process_consensus.rs +++ b/devnet/src/l2/in_process_consensus.rs @@ -179,9 +179,7 @@ impl InProcessConsensus { if config.mode == NodeMode::Sequencer { builder = builder.with_sequencer_config(SequencerConfig { sequencer_stopped: config.sequencer_stopped, - sequencer_recovery_mode: false, - conductor_rpc_url: None, - l1_conf_delay: 0, + ..Default::default() }); } diff --git a/devnet/src/lib.rs b/devnet/src/lib.rs index 5335658211..6d51d2abd8 100644 --- a/devnet/src/lib.rs +++ b/devnet/src/lib.rs @@ -10,6 +10,9 @@ mod utils; pub use utils::unique_name; +mod b20; +pub use b20::{B20CreateConfig, B20PrecompileClient}; + pub mod config; pub mod containers; pub mod deployer; diff --git a/devnet/src/setup/container.rs b/devnet/src/setup/container.rs index b1885bc567..b328d31fc4 100644 --- a/devnet/src/setup/container.rs +++ b/devnet/src/setup/container.rs @@ -1,4 +1,11 @@ -use std::{path::PathBuf, process::Command, time::Duration}; +use std::{ + fs, + io::ErrorKind, + path::PathBuf, + process::Command, + thread, + time::{Duration, Instant}, +}; use eyre::{Result, WrapErr, ensure}; use testcontainers::{ @@ -13,6 +20,9 @@ use crate::{ }; const SETUP_IMAGE_TAG: &str = "devnet-setup:local"; +const SETUP_IMAGE_BUILD_LOCK_DIR: &str = "base-devnet-setup-image-build.lock"; +const SETUP_IMAGE_BUILD_LOCK_TIMEOUT: Duration = Duration::from_secs(600); +const SETUP_IMAGE_BUILD_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(500); const DEPLOY_TIMEOUT_SECS: u64 = 300; /// Builder enode ID @@ -118,6 +128,8 @@ pub struct SetupContainer { chain_id: u64, l2_chain_id: u64, slot_duration: u64, + base_azul_activation_block: Option, + base_beryl_activation_block: Option, network_name: Option, } @@ -129,6 +141,8 @@ impl SetupContainer { chain_id: 1337, l2_chain_id: 84538453, slot_duration: 2, + base_azul_activation_block: None, + base_beryl_activation_block: None, network_name: None, } } @@ -151,6 +165,18 @@ impl SetupContainer { self } + /// Sets the L2 block number at which Base Azul activates. + pub const fn with_base_azul_activation_block(mut self, block: u64) -> Self { + self.base_azul_activation_block = Some(block); + self + } + + /// Sets the L2 block number at which Base Beryl activates. + pub const fn with_base_beryl_activation_block(mut self, block: u64) -> Self { + self.base_beryl_activation_block = Some(block); + self + } + /// Sets the Docker network name. pub fn with_network_name(mut self, network_name: impl Into) -> Self { self.network_name = Some(network_name.into()); @@ -213,7 +239,7 @@ impl SetupContainer { let image = GenericImage::new("devnet-setup", "local") .with_wait_for(WaitFor::exit(ExitWaitStrategy::default().with_exit_code(0))); - let _container = image + let mut container = image .with_network(net) .with_startup_timeout(Duration::from_secs(DEPLOY_TIMEOUT_SECS)) .with_env_var("OUTPUT_DIR", "/output/l2") @@ -234,7 +260,17 @@ impl SetupContainer { .with_env_var("L2_EL_BOOTNODE_ENODE_ID", EL_BOOTNODE_ENODE_ID) .with_env_var("L2_EL_BOOTNODE_ENODE", EL_BOOTNODE_ENODE) .with_env_var("L2_CL_BOOTNODE_P2P_KEY", CL_BOOTNODE_P2P_KEY) - .with_env_var("L2_CL_BOOTNODE_ENR_PATH", CL_BOOTNODE_ENR_PATH) + .with_env_var("L2_CL_BOOTNODE_ENR_PATH", CL_BOOTNODE_ENR_PATH); + + if let Some(block) = self.base_azul_activation_block { + container = container.with_env_var("L2_BASE_AZUL_BLOCK", block.to_string()); + } + + if let Some(block) = self.base_beryl_activation_block { + container = container.with_env_var("L2_BASE_BERYL_BLOCK", block.to_string()); + } + + let _container = container .with_mount(Mount::bind_mount(l2_output_mount, "/output/l2")) .with_mount(Mount::bind_mount(shared_mount, "/shared")) .with_cmd(["setup-l2.sh"]) @@ -250,30 +286,71 @@ impl SetupContainer { } fn ensure_setup_image_built(&self) -> Result<()> { - let image_exists = Command::new("docker") - .args(["image", "inspect", SETUP_IMAGE_TAG]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false); - - if image_exists { + let setup_image_exists = || { + Command::new("docker") + .args(["image", "inspect", SETUP_IMAGE_TAG]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }; + + if setup_image_exists() { return Ok(()); } - let repo_root = self.find_repo_root()?; - let dockerfile_path = repo_root.join("etc/docker/Dockerfile.devnet"); + let lock_dir = std::env::temp_dir().join(SETUP_IMAGE_BUILD_LOCK_DIR); + let lock_started = Instant::now(); + loop { + match fs::create_dir(&lock_dir) { + Ok(()) => break, + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + if setup_image_exists() { + return Ok(()); + } + ensure!( + lock_started.elapsed() < SETUP_IMAGE_BUILD_LOCK_TIMEOUT, + "timed out waiting for devnet setup image build lock at {}", + lock_dir.display(), + ); + thread::sleep(SETUP_IMAGE_BUILD_LOCK_POLL_INTERVAL); + } + Err(error) => { + return Err(error).wrap_err("Failed to acquire devnet setup image build lock"); + } + } + } + + let build_result = (|| { + if setup_image_exists() { + return Ok(()); + } - ensure!(dockerfile_path.exists(), "etc/docker/Dockerfile.devnet not found"); + let repo_root = self.find_repo_root()?; + let dockerfile_path = repo_root.join("etc/docker/Dockerfile.devnet"); - let status = Command::new("docker") - .args(["build", "-t", SETUP_IMAGE_TAG, "-f", "etc/docker/Dockerfile.devnet", "."]) - .current_dir(&repo_root) - .status() - .wrap_err("Failed to run docker build")?; + ensure!(dockerfile_path.exists(), "etc/docker/Dockerfile.devnet not found"); - ensure!(status.success(), "docker build failed"); + let status = Command::new("docker") + .args(["build", "-t", SETUP_IMAGE_TAG, "-f", "etc/docker/Dockerfile.devnet", "."]) + .current_dir(&repo_root) + .status() + .wrap_err("Failed to run docker build")?; - Ok(()) + ensure!(status.success(), "docker build failed"); + + Ok(()) + })(); + + let cleanup_result = + fs::remove_dir(&lock_dir).wrap_err("Failed to release devnet setup image build lock"); + + match build_result { + Ok(()) => cleanup_result, + Err(error) => { + let _ = cleanup_result; + Err(error) + } + } } fn find_repo_root(&self) -> Result { diff --git a/devnet/src/smoke.rs b/devnet/src/smoke.rs index 3f754f12a0..51735b1289 100644 --- a/devnet/src/smoke.rs +++ b/devnet/src/smoke.rs @@ -127,6 +127,8 @@ pub struct DevnetBuilder { l1_chain_id: Option, l2_chain_id: Option, slot_duration: Option, + base_azul_activation_block: Option, + base_beryl_activation_block: Option, output_dir: Option, stable_config: Option, tx_forwarding_config: Option, @@ -157,6 +159,18 @@ impl DevnetBuilder { self } + /// Sets the L2 block number at which Base Azul activates. + pub const fn with_base_azul_activation_block(mut self, block: u64) -> Self { + self.base_azul_activation_block = Some(block); + self + } + + /// Sets the L2 block number at which Base Beryl activates. + pub const fn with_base_beryl_activation_block(mut self, block: u64) -> Self { + self.base_beryl_activation_block = Some(block); + self + } + /// Sets the output directory for devnet files. pub fn with_output_dir(mut self, output_dir: PathBuf) -> Self { self.output_dir = Some(output_dir); @@ -198,6 +212,14 @@ impl DevnetBuilder { .with_l2_chain_id(l2_chain_id) .with_slot_duration(slot_duration); + if let Some(block) = self.base_azul_activation_block { + setup = setup.with_base_azul_activation_block(block); + } + + if let Some(block) = self.base_beryl_activation_block { + setup = setup.with_base_beryl_activation_block(block); + } + if let Some(ref config) = self.stable_config { setup = setup.with_network_name(&config.network_name); } diff --git a/devnet/tests/activation_registry.rs b/devnet/tests/activation_registry.rs new file mode 100644 index 0000000000..4d183107c1 --- /dev/null +++ b/devnet/tests/activation_registry.rs @@ -0,0 +1,93 @@ +//! End-to-end tests for the activation registry precompile over Base node RPC. + +mod common; + +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolCall; +use base_common_precompiles::{ActivationFeature, ActivationRegistryStorage, IActivationRegistry}; +use devnet::{ + B20PrecompileClient, + config::{ANVIL_ACCOUNT_5, ANVIL_ACCOUNT_6}, +}; +use eyre::{Result, WrapErr}; + +/// `isActivated` returns `false` for every feature id by default. +#[tokio::test] +async fn test_activation_registry_is_activated_default() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse devnet private key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let client = B20PrecompileClient::new(&provider, &admin, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + + let output = client + .call( + ActivationRegistryStorage::ADDRESS, + IActivationRegistry::isActivatedCall { feature: ActivationFeature::B20Security.id() }, + ) + .await?; + let is_activated = IActivationRegistry::isActivatedCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode isActivated")?; + + assert!(!is_activated, "feature should be inactive by default"); + + Ok(()) +} + +/// `admin()` returns the generated devnet activation admin address. +#[tokio::test] +async fn test_activation_registry_admin() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let caller = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse devnet private key")?; + common::wait_for_balance(&provider, caller.address()).await?; + + let client = B20PrecompileClient::new(&provider, &caller, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + + let output = + client.call(ActivationRegistryStorage::ADDRESS, IActivationRegistry::adminCall {}).await?; + let admin_addr = IActivationRegistry::adminCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode admin")?; + + assert_eq!(admin_addr, ANVIL_ACCOUNT_5.address); + + Ok(()) +} + +/// Calling `activate` from a non-admin account reverts with `Unauthorized`. +#[tokio::test] +async fn test_activation_registry_unauthorized_activate_reverts() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let non_admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_6.private_key) + .wrap_err("Failed to parse devnet private key")?; + common::wait_for_balance(&provider, non_admin.address()).await?; + + let client = B20PrecompileClient::new(&provider, &non_admin, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + + let succeeded = client + .try_send_call( + ActivationRegistryStorage::ADDRESS, + IActivationRegistry::activateCall { feature: ActivationFeature::B20Security.id() }, + "activate (unauthorized)", + ) + .await?; + + assert!(!succeeded, "activate from non-admin should revert"); + + // Feature remains inactive after the failed attempt. + let output = client + .call( + ActivationRegistryStorage::ADDRESS, + IActivationRegistry::isActivatedCall { feature: ActivationFeature::B20Security.id() }, + ) + .await?; + let is_activated = IActivationRegistry::isActivatedCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode isActivated")?; + assert!(!is_activated, "feature should still be inactive after unauthorized activate"); + + Ok(()) +} diff --git a/devnet/tests/b20_precompile.rs b/devnet/tests/b20_precompile.rs new file mode 100644 index 0000000000..d842df3e97 --- /dev/null +++ b/devnet/tests/b20_precompile.rs @@ -0,0 +1,480 @@ +//! End-to-end tests for B-20 precompiles over Base node RPC. + +mod common; + +use alloy_primitives::{Address, B256, U256}; +use alloy_provider::RootProvider; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolValue; +use base_common_network::Base; +use base_common_precompiles::{ + ActivationFeature, B20FactoryStorage, B20TokenRole, B20Variant, IB20, IB20Factory, +}; +use devnet::{ + B20PrecompileClient, + config::{ANVIL_ACCOUNT_5, ANVIL_ACCOUNT_6, ANVIL_ACCOUNT_7}, +}; +use eyre::{Result, WrapErr}; + +const TOKEN_DECIMALS: u8 = 18; +const INITIAL_SUPPLY: u64 = 1_000_000_000; +const TRANSFER_AMOUNT: u64 = 100_000_000; +const MINT_AMOUNT: u64 = 500_000; +const BURN_AMOUNT: u64 = 200_000; +const APPROVE_AMOUNT: u64 = 50_000_000; +const SPENDER_TRANSFER_AMOUNT: u64 = 30_000_000; +const MEMO_TRANSFER_AMOUNT: u64 = 111_000; +const INITIAL_SUPPLY_CAP: u64 = 2_000_000_000; +const PAUSE_TRANSFER_AMOUNT: u64 = 10_000; + +async fn activated_b20_client<'a>( + provider: &'a RootProvider, + admin: &'a PrivateKeySigner, +) -> Result> { + let b20 = B20PrecompileClient::new(provider, admin, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + b20.activate_feature(ActivationFeature::B20Factory.id()).await?; + b20.activate_feature(ActivationFeature::B20Token.id()).await?; + Ok(b20) +} + +#[tokio::test] +async fn test_b20_factory_create_and_transfer_via_rpc() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse devnet private key")?; + let recipient = ANVIL_ACCOUNT_6.address; + + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x42); + let params = B20PrecompileClient::token_params( + "Devnet B20", + "DB20", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + assert_eq!(b20.variant_of(token).await?, B20Variant::B20); + assert_eq!(b20.decimals_of(token).await?, TOKEN_DECIMALS); + + let admin_balance_before = b20.balance_of(token, admin.address()).await?; + assert_eq!(admin_balance_before, U256::from(INITIAL_SUPPLY)); + + b20.transfer(token, recipient, U256::from(TRANSFER_AMOUNT)).await?; + + let admin_balance_after = b20.balance_of(token, admin.address()).await?; + let recipient_balance = b20.balance_of(token, recipient).await?; + + assert_eq!(recipient_balance, U256::from(TRANSFER_AMOUNT)); + assert_eq!(admin_balance_before - admin_balance_after, U256::from(TRANSFER_AMOUNT)); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_token_metadata() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x10); + let params = B20PrecompileClient::token_params( + "Metadata Token", + "META", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + assert_eq!(b20.name(token).await?, "Metadata Token"); + assert_eq!(b20.symbol(token).await?, "META"); + assert_eq!(b20.total_supply(token).await?, U256::from(INITIAL_SUPPLY)); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_approve_and_transfer_from() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + let spender = + PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_7.private_key).wrap_err("spender key")?; + let recipient = ANVIL_ACCOUNT_6.address; + common::wait_for_balance(&provider, admin.address()).await?; + common::wait_for_balance(&provider, spender.address()).await?; + + let b20_admin = activated_b20_client(&provider, &admin).await?; + let b20_spender = B20PrecompileClient::new(&provider, &spender, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + + let salt = B256::repeat_byte(0x11); + let params = B20PrecompileClient::token_params( + "Allowance Token", + "ALLW", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + let token = b20_admin.create_token(B20Variant::B20, params, salt).await?; + b20_admin + .wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL) + .await?; + + let approve_amount = U256::from(APPROVE_AMOUNT); + let transfer_amount = U256::from(SPENDER_TRANSFER_AMOUNT); + + b20_admin.approve(token, spender.address(), approve_amount).await?; + assert_eq!( + b20_admin.allowance(token, admin.address(), spender.address()).await?, + approve_amount + ); + + b20_spender.transfer_from(token, admin.address(), recipient, transfer_amount).await?; + + assert_eq!( + b20_admin.balance_of(token, admin.address()).await?, + U256::from(INITIAL_SUPPLY) - transfer_amount, + ); + assert_eq!(b20_admin.balance_of(token, recipient).await?, transfer_amount); + assert_eq!( + b20_admin.allowance(token, admin.address(), spender.address()).await?, + approve_amount - transfer_amount, + ); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_mint_and_burn() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x12); + let params = B20PrecompileClient::token_params( + "Mintable Token", + "MINT", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + let supply_before = b20.total_supply(token).await?; + + b20.send_call( + token, + IB20::grantRoleCall { role: B20TokenRole::Mint.id(), account: admin.address() }, + "grant B-20 mint role", + ) + .await?; + b20.send_call( + token, + IB20::grantRoleCall { role: B20TokenRole::Burn.id(), account: admin.address() }, + "grant B-20 burn role", + ) + .await?; + + let zero_mint_succeeded = b20 + .try_send_call( + token, + IB20::mintCall { to: admin.address(), amount: U256::ZERO }, + "zero amount B-20 mint", + ) + .await?; + assert!(!zero_mint_succeeded, "zero amount B-20 mint should revert"); + + let zero_burn_succeeded = b20 + .try_send_call(token, IB20::burnCall { amount: U256::ZERO }, "zero amount B-20 burn") + .await?; + assert!(!zero_burn_succeeded, "zero amount B-20 burn should revert"); + assert_eq!(b20.total_supply(token).await?, supply_before); + + b20.mint(token, admin.address(), U256::from(MINT_AMOUNT)).await?; + assert_eq!(b20.total_supply(token).await?, supply_before + U256::from(MINT_AMOUNT)); + assert_eq!( + b20.balance_of(token, admin.address()).await?, + U256::from(INITIAL_SUPPLY) + U256::from(MINT_AMOUNT), + ); + + b20.burn(token, U256::from(BURN_AMOUNT)).await?; + assert_eq!( + b20.total_supply(token).await?, + supply_before + U256::from(MINT_AMOUNT) - U256::from(BURN_AMOUNT), + ); + assert_eq!( + b20.balance_of(token, admin.address()).await?, + U256::from(INITIAL_SUPPLY) + U256::from(MINT_AMOUNT) - U256::from(BURN_AMOUNT), + ); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_transfer_with_memo() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + let recipient = ANVIL_ACCOUNT_6.address; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x13); + let params = B20PrecompileClient::token_params( + "Memo Token", + "MEMO", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + let memo = B256::repeat_byte(0xde); + let amount = U256::from(MEMO_TRANSFER_AMOUNT); + b20.transfer_with_memo(token, recipient, amount, memo).await?; + + assert_eq!(b20.balance_of(token, recipient).await?, amount); + assert_eq!(b20.balance_of(token, admin.address()).await?, U256::from(INITIAL_SUPPLY) - amount,); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_supply_cap() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x14); + let mut params = B20PrecompileClient::token_params( + "Capped Token", + "CAP", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + params.supply_cap = U256::from(INITIAL_SUPPLY_CAP); + + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + assert_eq!(b20.supply_cap(token).await?, U256::from(INITIAL_SUPPLY_CAP)); + + // Cap below current total supply reverts. + assert!( + !b20.try_send_call( + token, + IB20::updateSupplyCapCall { newSupplyCap: U256::from(INITIAL_SUPPLY - 1) }, + "updateSupplyCap below current supply", + ) + .await?, + "updateSupplyCap below total supply should revert", + ); + + // Tighten cap to exactly the current supply. + b20.update_supply_cap(token, U256::from(INITIAL_SUPPLY)).await?; + assert_eq!(b20.supply_cap(token).await?, U256::from(INITIAL_SUPPLY)); + + // Minting past the cap reverts. + assert!( + !b20.try_send_call( + token, + IB20::mintCall { to: admin.address(), amount: U256::from(1) }, + "mint past supply cap", + ) + .await?, + "mint past supply cap should revert", + ); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_metadata_updates() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x15); + let params = B20PrecompileClient::token_params( + "Old Name", + "OLD", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + b20.send_call( + token, + IB20::grantRoleCall { role: B20TokenRole::Metadata.id(), account: admin.address() }, + "grant B-20 metadata role", + ) + .await?; + + b20.update_name(token, "New Name").await?; + b20.update_symbol(token, "NEW").await?; + b20.update_contract_uri(token, "ipfs://QmTest").await?; + + assert_eq!(b20.name(token).await?, "New Name"); + assert_eq!(b20.symbol(token).await?, "NEW"); + assert_eq!(b20.contract_uri(token).await?, "ipfs://QmTest"); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_pause_and_unpause() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + let recipient = ANVIL_ACCOUNT_6.address; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x16); + let params = B20PrecompileClient::token_params( + "Pausable Token", + "PAUS", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + // Transfer succeeds before pause. + b20.transfer(token, recipient, U256::from(PAUSE_TRANSFER_AMOUNT)).await?; + assert_eq!(b20.balance_of(token, recipient).await?, U256::from(PAUSE_TRANSFER_AMOUNT)); + + b20.send_call( + token, + IB20::grantRoleCall { role: B20TokenRole::Pause.id(), account: admin.address() }, + "grant B-20 pause role", + ) + .await?; + b20.send_call( + token, + IB20::grantRoleCall { role: B20TokenRole::Unpause.id(), account: admin.address() }, + "grant B-20 unpause role", + ) + .await?; + + b20.pause(token, U256::from(1)).await?; + assert_ne!(b20.paused(token).await?, U256::ZERO, "token should be paused"); + + // Transfer reverts while paused. + assert!( + !b20.try_send_call( + token, + IB20::transferCall { to: recipient, amount: U256::from(PAUSE_TRANSFER_AMOUNT) }, + "transfer while paused", + ) + .await?, + "transfer should revert while paused", + ); + assert_eq!(b20.balance_of(token, recipient).await?, U256::from(PAUSE_TRANSFER_AMOUNT)); + + b20.unpause(token).await?; + assert_eq!(b20.paused(token).await?, U256::ZERO, "token should be unpaused"); + + b20.transfer(token, recipient, U256::from(PAUSE_TRANSFER_AMOUNT)).await?; + assert_eq!(b20.balance_of(token, recipient).await?, U256::from(PAUSE_TRANSFER_AMOUNT * 2)); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_factory_predict_and_is_b20() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x17); + let params = B20PrecompileClient::token_params( + "Predict Token", + "PRD", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + + let local_prediction = b20.predict_token_address(B20Variant::B20, salt); + let rpc_prediction = + b20.predict_token_address_rpc(admin.address(), B20Variant::B20, salt).await?; + assert_eq!(local_prediction, rpc_prediction, "local and RPC predictions should match"); + + let token = b20.create_token(B20Variant::B20, params, salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + assert_eq!(token, rpc_prediction, "created token address should match prediction"); + + assert!(b20.is_b20(token).await?, "created token should be recognised as B-20"); + assert!(!b20.is_b20(B20FactoryStorage::ADDRESS).await?, "factory address is not a B-20 token",); + assert!( + !b20.is_b20(Address::repeat_byte(0xab)).await?, + "arbitrary address is not a B-20 token", + ); + + Ok(()) +} + +#[tokio::test] +async fn test_b20_create_token_duplicate_reverts() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse admin key")?; + common::wait_for_balance(&provider, admin.address()).await?; + + let b20 = activated_b20_client(&provider, &admin).await?; + let salt = B256::repeat_byte(0x18); + let params = B20PrecompileClient::token_params( + "Dup Token", + "DUP", + admin.address(), + U256::from(INITIAL_SUPPLY), + admin.address(), + ); + + let token = b20.create_token(B20Variant::B20, params.clone(), salt).await?; + b20.wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL).await?; + + let succeeded = b20 + .try_send_call( + B20FactoryStorage::ADDRESS, + IB20Factory::createB20Call { + variant: IB20Factory::B20Variant::DEFAULT, + salt, + params: params.create.abi_encode().into(), + initCalls: Vec::new(), + }, + "createB20 (duplicate salt)", + ) + .await?; + assert!(!succeeded, "creating a token with the same salt should revert on-chain"); + + Ok(()) +} diff --git a/devnet/tests/common/mod.rs b/devnet/tests/common/mod.rs new file mode 100644 index 0000000000..d4d3ebcfea --- /dev/null +++ b/devnet/tests/common/mod.rs @@ -0,0 +1,68 @@ +//! Shared helpers for devnet integration tests. + +use std::time::Duration; + +use alloy_primitives::{Address, U256}; +use alloy_provider::{Provider, RootProvider}; +use base_common_network::Base; +use devnet::{Devnet, DevnetBuilder}; +use eyre::{Result, WrapErr}; +use tokio::time::{sleep, timeout}; + +pub(crate) const L1_CHAIN_ID: u64 = 1337; +pub(crate) const L2_CHAIN_ID: u64 = 84538453; +pub(crate) const BASE_AZUL_ACTIVATION_BLOCK: u64 = 0; +pub(crate) const BASE_BERYL_ACTIVATION_BLOCK: u64 = 3; +pub(crate) const BLOCK_PRODUCTION_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const BLOCK_POLL_INTERVAL: Duration = Duration::from_millis(500); +pub(crate) const TX_RECEIPT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Starts a devnet with Beryl active at block 3 and waits for block 4. +/// +/// The returned [`Devnet`] must be kept alive for the duration of the test; +/// dropping it shuts down the underlying containers. +pub(crate) async fn start_beryl_devnet() -> Result<(Devnet, RootProvider)> { + let devnet = DevnetBuilder::new() + .with_l1_chain_id(L1_CHAIN_ID) + .with_l2_chain_id(L2_CHAIN_ID) + .with_base_azul_activation_block(BASE_AZUL_ACTIVATION_BLOCK) + .with_base_beryl_activation_block(BASE_BERYL_ACTIVATION_BLOCK) + .build() + .await?; + let provider = devnet.l2_builder_provider()?; + wait_for_block(&provider, BASE_BERYL_ACTIVATION_BLOCK + 1).await?; + Ok((devnet, provider)) +} + +/// Polls until the L2 block number reaches `min_block`. +pub(crate) async fn wait_for_block(provider: &RootProvider, min_block: u64) -> Result { + timeout(BLOCK_PRODUCTION_TIMEOUT, async { + loop { + let block = provider.get_block_number().await?; + if block >= min_block { + return Ok::<_, eyre::Error>(block); + } + sleep(BLOCK_POLL_INTERVAL).await; + } + }) + .await + .wrap_err("Block production timed out")? +} + +/// Polls until `address` has a non-zero ETH balance on the L2. +pub(crate) async fn wait_for_balance( + provider: &RootProvider, + address: Address, +) -> Result<()> { + timeout(Duration::from_secs(15), async { + loop { + let balance = provider.get_balance(address).await?; + if balance > U256::ZERO { + return Ok::<_, eyre::Error>(()); + } + sleep(BLOCK_POLL_INTERVAL).await; + } + }) + .await + .wrap_err("Timed out waiting for funded devnet account")? +} diff --git a/devnet/tests/policy_registry.rs b/devnet/tests/policy_registry.rs new file mode 100644 index 0000000000..4277d67395 --- /dev/null +++ b/devnet/tests/policy_registry.rs @@ -0,0 +1,35 @@ +//! End-to-end tests for the policy registry precompile over Base node RPC. + +mod common; + +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolCall; +use base_common_precompiles::{ActivationFeature, IPolicyRegistry, PolicyRegistryStorage}; +use devnet::{B20PrecompileClient, config::ANVIL_ACCOUNT_5}; +use eyre::{Result, WrapErr}; + +/// `policyExists(ALWAYS_ALLOW_ID)` returns `true` once the policy registry is active. +#[tokio::test] +async fn test_policy_registry_policy_exists() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + let caller = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key) + .wrap_err("Failed to parse devnet private key")?; + common::wait_for_balance(&provider, caller.address()).await?; + + let client = B20PrecompileClient::new(&provider, &caller, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + client.activate_feature(ActivationFeature::PolicyRegistry.id()).await?; + + let output = client + .call( + PolicyRegistryStorage::ADDRESS, + IPolicyRegistry::policyExistsCall { policyId: PolicyRegistryStorage::ALWAYS_ALLOW_ID }, + ) + .await?; + let result = IPolicyRegistry::policyExistsCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode policyExists")?; + + assert!(result, "policyExists(0) should return true after Beryl activation"); + + Ok(()) +} diff --git a/devnet/tests/policy_transfer.rs b/devnet/tests/policy_transfer.rs new file mode 100644 index 0000000000..a15a901039 --- /dev/null +++ b/devnet/tests/policy_transfer.rs @@ -0,0 +1,354 @@ +//! End-to-end tests for policy-gated B20 token transfers over Base node RPC. +//! +//! Each test: +//! - Creates a policy in the policy registry via RPC. +//! - Creates a B20 token and wires the policy to its `TRANSFER_SENDER_POLICY` +//! slot via `updatePolicy`. +//! - Exercises the full transfer-gate cycle: blocked → allowed (or vice versa). + +mod common; + +use alloy_primitives::{Address, B256, U256}; +use alloy_provider::RootProvider; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolCall; +use base_common_network::Base; +use base_common_precompiles::{ + ActivationFeature, B20PolicyType, B20Variant, IB20, IPolicyRegistry, PolicyRegistryStorage, +}; +use devnet::{ + B20PrecompileClient, + config::{ANVIL_ACCOUNT_5, ANVIL_ACCOUNT_6, ANVIL_ACCOUNT_7}, +}; +use eyre::{Result, WrapErr}; + +const INITIAL_SUPPLY: u64 = 1_000_000; +const TRANSFER_AMOUNT: u64 = 100_000; + +// Salts must not overlap with those used in b20_precompile.rs (0x10–0x18, 0x42). +const SALT_ALLOWLIST: B256 = B256::repeat_byte(0x50); +const SALT_BLOCKLIST: B256 = B256::repeat_byte(0x51); +const SALT_ALWAYS_BLOCK: B256 = B256::repeat_byte(0x52); + +/// Activates `B20_FACTORY`, `B20_TOKEN`, and `POLICY_REGISTRY` features, then +/// returns a [`B20PrecompileClient`] ready for precompile calls. +async fn activated_client<'a>( + provider: &'a RootProvider, + admin: &'a PrivateKeySigner, +) -> Result> { + let client = B20PrecompileClient::new(provider, admin, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + client.activate_feature(ActivationFeature::B20Factory.id()).await?; + client.activate_feature(ActivationFeature::B20Token.id()).await?; + client.activate_feature(ActivationFeature::PolicyRegistry.id()).await?; + Ok(client) +} + +/// Creates a policy and returns its assigned ID. +/// +/// Simulates the call first (`eth_call`) to obtain the ID the registry will +/// assign, then dispatches the real transaction. Because the devnet is +/// single-sender the counter cannot advance between the simulation and the +/// actual transaction. +async fn create_policy( + client: &B20PrecompileClient<'_>, + admin: Address, + policy_type: IPolicyRegistry::PolicyType, + label: &'static str, +) -> Result { + let call = IPolicyRegistry::createPolicyCall { admin, policyType: policy_type }; + let output = client.call(PolicyRegistryStorage::ADDRESS, call.clone()).await?; + let policy_id = IPolicyRegistry::createPolicyCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode createPolicy return")?; + client.send_call(PolicyRegistryStorage::ADDRESS, call, label).await?; + Ok(policy_id) +} + +/// Queries `isAuthorized(policy_id, account)` from the policy registry. +async fn is_authorized( + client: &B20PrecompileClient<'_>, + policy_id: u64, + account: Address, +) -> Result { + let output = client + .call( + PolicyRegistryStorage::ADDRESS, + IPolicyRegistry::isAuthorizedCall { policyId: policy_id, account }, + ) + .await?; + IPolicyRegistry::isAuthorizedCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode isAuthorized") +} + +/// Creates a B20 token with an initial supply minted to `admin`. +async fn create_token( + client: &B20PrecompileClient<'_>, + admin: Address, + salt: B256, + name: &str, + symbol: &str, +) -> Result
{ + let params = + B20PrecompileClient::token_params(name, symbol, admin, U256::from(INITIAL_SUPPLY), admin); + let token = client.create_token(B20Variant::B20, params, salt).await?; + client + .wait_for_token_code(token, common::TX_RECEIPT_TIMEOUT, common::BLOCK_POLL_INTERVAL) + .await?; + Ok(token) +} + +/// Wires a policy ID to the token's `TRANSFER_SENDER_POLICY` slot. +async fn set_transfer_sender_policy( + client: &B20PrecompileClient<'_>, + token: Address, + policy_id: u64, +) -> Result<()> { + client + .send_call( + token, + IB20::updatePolicyCall { + policyScope: B20PolicyType::TransferSender.id(), + newPolicyId: policy_id, + }, + "updatePolicy TRANSFER_SENDER_POLICY", + ) + .await +} + +/// `test_allowlist_gates_transfer` +/// +/// Full cycle: +/// 1. Create an ALLOWLIST policy. +/// 2. Wire it to the token's `TRANSFER_SENDER_POLICY` slot. +/// 3. Assert a non-member transfer reverts. +/// 4. Add the non-member to the allowlist. +/// 5. Assert the transfer now succeeds. +#[tokio::test] +async fn test_allowlist_gates_transfer() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key).wrap_err("admin key")?; + let non_member = + PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_7.private_key).wrap_err("non-member key")?; + let recipient = ANVIL_ACCOUNT_6.address; + + common::wait_for_balance(&provider, admin.address()).await?; + common::wait_for_balance(&provider, non_member.address()).await?; + + let client = activated_client(&provider, &admin).await?; + + // --- Create ALLOWLIST policy --- + let policy_id = create_policy( + &client, + admin.address(), + IPolicyRegistry::PolicyType::ALLOWLIST, + "createPolicy ALLOWLIST", + ) + .await?; + + // Non-member is not authorized under the allowlist. + assert!( + !is_authorized(&client, policy_id, non_member.address()).await?, + "non-member must not be authorized on a fresh ALLOWLIST policy", + ); + + // --- Create B20 token and wire the allowlist policy --- + let token = + create_token(&client, admin.address(), SALT_ALLOWLIST, "Allowlist Token", "ALT").await?; + set_transfer_sender_policy(&client, token, policy_id).await?; + + // Seed the non-member with tokens so they have a balance to transfer from. + client.transfer(token, non_member.address(), U256::from(TRANSFER_AMOUNT)).await?; + assert_eq!(client.balance_of(token, non_member.address()).await?, U256::from(TRANSFER_AMOUNT)); + + // Non-member is not on the allowlist: transfer must revert. + let non_member_client = B20PrecompileClient::new(&provider, &non_member, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + let blocked = non_member_client + .try_send_call( + token, + IB20::transferCall { to: recipient, amount: U256::from(TRANSFER_AMOUNT / 2) }, + "transfer from non-member (should revert)", + ) + .await?; + assert!(!blocked, "transfer from non-member must revert when ALLOWLIST policy is wired"); + + // --- Add non-member to the allowlist --- + client + .send_call( + PolicyRegistryStorage::ADDRESS, + IPolicyRegistry::updateAllowlistCall { + policyId: policy_id, + allowed: true, + accounts: vec![non_member.address()], + }, + "updateAllowlist add non-member", + ) + .await?; + + // Non-member is now authorized. + assert!( + is_authorized(&client, policy_id, non_member.address()).await?, + "non-member must be authorized after being added to the allowlist", + ); + + // Transfer from the now-allowlisted sender must succeed. + let allowed = non_member_client + .try_send_call( + token, + IB20::transferCall { to: recipient, amount: U256::from(TRANSFER_AMOUNT / 2) }, + "transfer from allowlisted sender", + ) + .await?; + assert!(allowed, "transfer from allowlisted sender must succeed"); + + Ok(()) +} + +/// `test_blocklist_gates_transfer` +/// +/// Full cycle: +/// 1. Create a BLOCKLIST policy. +/// 2. Wire it to the token's `TRANSFER_SENDER_POLICY` slot. +/// 3. Assert an unblocked sender can transfer. +/// 4. Block the sender. +/// 5. Assert their transfer now reverts. +#[tokio::test] +async fn test_blocklist_gates_transfer() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key).wrap_err("admin key")?; + let blocked_sender = + PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_7.private_key).wrap_err("blocked key")?; + let recipient = ANVIL_ACCOUNT_6.address; + + common::wait_for_balance(&provider, admin.address()).await?; + common::wait_for_balance(&provider, blocked_sender.address()).await?; + + let client = activated_client(&provider, &admin).await?; + + // --- Create BLOCKLIST policy --- + let policy_id = create_policy( + &client, + admin.address(), + IPolicyRegistry::PolicyType::BLOCKLIST, + "createPolicy BLOCKLIST", + ) + .await?; + + // The sender is not on the blocklist; they are authorized. + assert!( + is_authorized(&client, policy_id, blocked_sender.address()).await?, + "non-blocked account must be authorized on a fresh BLOCKLIST policy", + ); + + // --- Create B20 token and wire the blocklist policy --- + let token = + create_token(&client, admin.address(), SALT_BLOCKLIST, "Blocklist Token", "BLT").await?; + set_transfer_sender_policy(&client, token, policy_id).await?; + + // Seed the sender with tokens. + client.transfer(token, blocked_sender.address(), U256::from(TRANSFER_AMOUNT)).await?; + assert_eq!( + client.balance_of(token, blocked_sender.address()).await?, + U256::from(TRANSFER_AMOUNT), + ); + + // Transfer from the (not-yet-blocked) sender must succeed. + let sender_client = B20PrecompileClient::new(&provider, &blocked_sender, common::L2_CHAIN_ID) + .with_receipt_timeout(common::TX_RECEIPT_TIMEOUT); + let first_transfer = sender_client + .try_send_call( + token, + IB20::transferCall { to: recipient, amount: U256::from(TRANSFER_AMOUNT / 2) }, + "transfer from non-blocked sender", + ) + .await?; + assert!(first_transfer, "transfer from non-blocked sender must succeed"); + + // --- Block the sender --- + client + .send_call( + PolicyRegistryStorage::ADDRESS, + IPolicyRegistry::updateBlocklistCall { + policyId: policy_id, + blocked: true, + accounts: vec![blocked_sender.address()], + }, + "updateBlocklist add sender", + ) + .await?; + + // Sender is now on the blocklist and must not be authorized. + assert!( + !is_authorized(&client, policy_id, blocked_sender.address()).await?, + "blocked account must not be authorized after being added to the blocklist", + ); + + // Transfer from the blocked sender must revert. + let second_transfer = sender_client + .try_send_call( + token, + IB20::transferCall { to: recipient, amount: U256::from(TRANSFER_AMOUNT / 4) }, + "transfer from blocked sender (should revert)", + ) + .await?; + assert!(!second_transfer, "transfer from blocked sender must revert"); + + Ok(()) +} + +/// `test_always_block_policy_blocks_transfer` +/// +/// Verifies that the built-in `ALWAYS_BLOCK` policy denies every account via +/// `isAuthorized`, and that wiring it to a token's `TRANSFER_SENDER_POLICY` +/// slot makes ALL transfers revert unconditionally. +#[tokio::test] +async fn test_always_block_policy_blocks_transfer() -> Result<()> { + let (_devnet, provider) = common::start_beryl_devnet().await?; + + let admin = PrivateKeySigner::from_bytes(&ANVIL_ACCOUNT_5.private_key).wrap_err("admin key")?; + let anyone = ANVIL_ACCOUNT_6.address; + + common::wait_for_balance(&provider, admin.address()).await?; + + let client = activated_client(&provider, &admin).await?; + + // ALWAYS_BLOCK must deny every account unconditionally. + assert!( + !is_authorized(&client, PolicyRegistryStorage::ALWAYS_BLOCK_ID, admin.address()).await?, + "ALWAYS_BLOCK must deny the admin", + ); + assert!( + !is_authorized(&client, PolicyRegistryStorage::ALWAYS_BLOCK_ID, anyone).await?, + "ALWAYS_BLOCK must deny any arbitrary account", + ); + + // The ALWAYS_BLOCK policy exists as a built-in. + let output = client + .call( + PolicyRegistryStorage::ADDRESS, + IPolicyRegistry::policyExistsCall { policyId: PolicyRegistryStorage::ALWAYS_BLOCK_ID }, + ) + .await?; + let exists = IPolicyRegistry::policyExistsCall::abi_decode_returns(output.as_ref()) + .wrap_err("Failed to decode policyExists")?; + assert!(exists, "ALWAYS_BLOCK policy must exist"); + + // --- Create B20 token and wire ALWAYS_BLOCK to TRANSFER_SENDER_POLICY --- + let token = + create_token(&client, admin.address(), SALT_ALWAYS_BLOCK, "Blocked Token", "BLKD").await?; + set_transfer_sender_policy(&client, token, PolicyRegistryStorage::ALWAYS_BLOCK_ID).await?; + + // Transfer from admin must revert: ALWAYS_BLOCK denies every sender unconditionally. + let blocked = client + .try_send_call( + token, + IB20::transferCall { to: anyone, amount: U256::from(TRANSFER_AMOUNT) }, + "transfer under ALWAYS_BLOCK (should revert)", + ) + .await?; + assert!(!blocked, "transfer from admin must revert under ALWAYS_BLOCK policy"); + + Ok(()) +} diff --git a/devnet/tests/smoke.rs b/devnet/tests/smoke.rs index 3053087120..f2373186c8 100644 --- a/devnet/tests/smoke.rs +++ b/devnet/tests/smoke.rs @@ -21,8 +21,11 @@ const BLOCK_PRODUCTION_TIMEOUT: Duration = Duration::from_secs(30); const BLOCK_POLL_INTERVAL: Duration = Duration::from_millis(500); const TX_RECEIPT_TIMEOUT: Duration = Duration::from_secs(60); +static SMOKE_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[tokio::test] async fn smoke_test_devnet_block_production_and_transactions() -> Result<()> { + let _guard = SMOKE_TEST_LOCK.lock().await; let devnet = DevnetBuilder::new() .with_l1_chain_id(L1_CHAIN_ID) .with_l2_chain_id(L2_CHAIN_ID) @@ -150,6 +153,7 @@ async fn send_l2_transaction_via_client( #[tokio::test] async fn smoke_test_builder_and_client_block_sync() -> Result<()> { + let _guard = SMOKE_TEST_LOCK.lock().await; base_node_runner::test_utils::init_silenced_tracing(); let devnet = DevnetBuilder::new() .with_l1_chain_id(L1_CHAIN_ID) @@ -191,6 +195,7 @@ async fn smoke_test_builder_and_client_block_sync() -> Result<()> { #[tokio::test] async fn smoke_test_client_pending_state_via_flashblocks() -> Result<()> { + let _guard = SMOKE_TEST_LOCK.lock().await; let devnet = DevnetBuilder::new() .with_l1_chain_id(L1_CHAIN_ID) .with_l2_chain_id(L2_CHAIN_ID) diff --git a/docs/specs/bun.lock b/docs/specs/bun.lock deleted file mode 100644 index 5607ac41b8..0000000000 --- a/docs/specs/bun.lock +++ /dev/null @@ -1,1444 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "base-specification", - "devDependencies": { - "mermaid": "^11.12.3", - "rehype-katex": "^7.0.1", - "remark-math": "^6.0.0", - "vocs": "^1.4.1", - }, - }, - }, - "overrides": { - "@hono/node-server": "1.19.9", - "dompurify": "3.3.1", - "hono": "4.12.3", - "katex": "0.16.33", - "mlly": "1.8.0", - "node-releases": "2.0.27", - }, - "packages": { - "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - - "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], - - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.2", "", { "dependencies": { "@chevrotain/gast": "11.1.2", "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q=="], - - "@chevrotain/gast": ["@chevrotain/gast@11.1.2", "", { "dependencies": { "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.2", "", {}, "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw=="], - - "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], - - "@chevrotain/utils": ["@chevrotain/utils@11.1.2", "", {}, "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA=="], - - "@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="], - - "@clack/prompts": ["@clack/prompts@0.7.0", "", { "dependencies": { "@clack/core": "^0.3.3", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA=="], - - "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - - "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - - "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - - "@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.7.2", "", {}, "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA=="], - - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - - "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - - "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - - "@mdx-js/rollup": ["@mdx-js/rollup@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "@rollup/pluginutils": "^5.0.0", "source-map": "^0.7.0", "vfile": "^6.0.0" }, "peerDependencies": { "rollup": ">=2" } }, "sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw=="], - - "@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="], - - "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@radix-ui/colors": ["@radix-ui/colors@3.0.0", "", {}, "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg=="], - - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - - "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], - - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], - - "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], - - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - - "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], - - "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], - - "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], - - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], - - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], - - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], - - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], - - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], - - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], - - "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], - - "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], - - "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], - - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], - - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - - "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], - - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], - - "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], - - "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], - - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], - - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], - - "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], - - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], - - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], - - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], - - "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], - - "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], - - "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], - - "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], - - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], - - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], - - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - - "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], - - "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], - - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], - - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - - "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" } }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], - - "@shikijs/core": ["@shikijs/core@1.29.2", "", { "dependencies": { "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ=="], - - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "oniguruma-to-es": "^2.2.0" } }, "sha512-iNEZv4IrLYPv64Q6k7EPpOCE/nuvGiKl7zxdq0WFuRPF5PAE9PRo2JGq/d8crLusM59BRemJ4eOqrFrC4wiQ+A=="], - - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1" } }, "sha512-7iiOx3SG8+g1MnlzZVDYiaeHe7Ez2Kf2HrJzdmGwkRisT7r4rak0e655AcM/tF9JG/kg5fMNYlLLKglbN7gBqA=="], - - "@shikijs/langs": ["@shikijs/langs@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-FIBA7N3LZ+223U7cJDUYd5shmciFQlYkFXlkKVaHsCPgfVLiO+e12FmQE6Tf9vuyEsFe3dIl8qGWKXgEHL9wmQ=="], - - "@shikijs/rehype": ["@shikijs/rehype@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "1.29.2", "unified": "^11.0.5", "unist-util-visit": "^5.0.0" } }, "sha512-sxi53HZe5XDz0s2UqF+BVN/kgHPMS9l6dcacM4Ra3ZDzCJa5rDGJ+Ukpk4LxdD1+MITBM6hoLbPfGv9StV8a5Q=="], - - "@shikijs/themes": ["@shikijs/themes@1.29.2", "", { "dependencies": { "@shikijs/types": "1.29.2" } }, "sha512-i9TNZlsq4uoyqSbluIcZkmPL9Bfi3djVxRnofUHwvx/h6SRW3cwgBC5SML7vsDcWyukY0eCzVN980rqP6qNl9g=="], - - "@shikijs/transformers": ["@shikijs/transformers@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/types": "1.29.2" } }, "sha512-NHQuA+gM7zGuxGWP9/Ub4vpbwrYCrho9nQCLcCPfOe3Yc7LOYwmSuhElI688oiqIXk9dlZwDiyAG9vPBTuPJMA=="], - - "@shikijs/twoslash": ["@shikijs/twoslash@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/types": "1.29.2", "twoslash": "^0.2.12" } }, "sha512-2S04ppAEa477tiaLfGEn1QJWbZUmbk8UoPbAEw4PifsrxkBXtAtOflIZJNtuCwz8ptc/TPxy7CO7gW4Uoi6o/g=="], - - "@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], - - "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.1.15", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.15" } }, "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.15", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.15", "@tailwindcss/oxide-darwin-arm64": "4.1.15", "@tailwindcss/oxide-darwin-x64": "4.1.15", "@tailwindcss/oxide-freebsd-x64": "4.1.15", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.15", "@tailwindcss/oxide-linux-arm64-musl": "4.1.15", "@tailwindcss/oxide-linux-x64-gnu": "4.1.15", "@tailwindcss/oxide-linux-x64-musl": "4.1.15", "@tailwindcss/oxide-wasm32-wasi": "4.1.15", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.15", "@tailwindcss/oxide-win32-x64-msvc": "4.1.15" } }, "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.15", "", { "os": "android", "cpu": "arm64" }, "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.15", "", { "os": "linux", "cpu": "arm" }, "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.15", "", { "os": "linux", "cpu": "x64" }, "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.15", "", { "os": "linux", "cpu": "x64" }, "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.15", "", { "cpu": "none" }, "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ=="], - - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.15", "", { "os": "win32", "cpu": "x64" }, "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.1.15", "", { "dependencies": { "@tailwindcss/node": "4.1.15", "@tailwindcss/oxide": "4.1.15", "tailwindcss": "4.1.15" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-B6s60MZRTUil+xKoZoGe6i0Iar5VuW+pmcGlda2FX+guDuQ1G1sjiIy1W0frneVpeL/ZjZ4KEgWZHNrIm++2qA=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], - - "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], - - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], - - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], - - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], - - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], - - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], - - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], - - "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], - - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], - - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], - - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], - - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], - - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], - - "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], - - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - - "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], - - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - - "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], - - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - - "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - - "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], - - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - - "@vanilla-extract/babel-plugin-debug-ids": ["@vanilla-extract/babel-plugin-debug-ids@1.2.2", "", { "dependencies": { "@babel/core": "^7.23.9" } }, "sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw=="], - - "@vanilla-extract/compiler": ["@vanilla-extract/compiler@0.3.4", "", { "dependencies": { "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/integration": "^8.0.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vite-node": "^3.2.2" } }, "sha512-W9HXf9EAccpE1vEIATvSoBVj/bQnmHfYHfDJjUN8dcOHW6oMcnoGTqweDM9I66BHqlNH4d0IsaeZKSViOv7K4w=="], - - "@vanilla-extract/css": ["@vanilla-extract/css@1.18.0", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.2.3", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ=="], - - "@vanilla-extract/dynamic": ["@vanilla-extract/dynamic@2.1.5", "", { "dependencies": { "@vanilla-extract/private": "^1.0.9" } }, "sha512-QGIFGb1qyXQkbzx6X6i3+3LMc/iv/ZMBttMBL+Wm/DetQd36KsKsFg5CtH3qy+1hCA/5w93mEIIAiL4fkM8ycw=="], - - "@vanilla-extract/integration": ["@vanilla-extract/integration@8.0.7", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", "@vanilla-extract/babel-plugin-debug-ids": "^1.2.2", "@vanilla-extract/css": "^1.18.0", "dedent": "^1.5.3", "esbuild": "npm:esbuild@>=0.17.6 <0.28.0", "eval": "0.1.8", "find-up": "^5.0.0", "javascript-stringify": "^2.0.1", "mlly": "^1.4.2" } }, "sha512-ILob4F9cEHXpbWAVt3Y2iaQJpqYq/c/5TJC8Fz58C2XmX3QW2Y589krvViiyJhQfydCGK3EbwPQhVFjQaBeKfg=="], - - "@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="], - - "@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.1.4", "", { "dependencies": { "@vanilla-extract/compiler": "^0.3.4", "@vanilla-extract/integration": "^8.0.7" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-fTYNKUK3n4ApkUf2FEcO7mpqNKEHf9kDGg8DXlkqHtPxgwPhjuaajmDfQCSBsNgnA2SLI+CB5EO6kLQuKsw2Rw=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], - - "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - - "astring": ["astring@1.9.0", "", { "bin": "bin/astring" }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], - - "autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": "bin/autoprefixer" }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="], - - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": "dist/cli.cjs" }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], - - "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], - - "bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="], - - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="], - - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - - "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - - "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - - "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - - "chevrotain": ["chevrotain@11.1.2", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", "@chevrotain/regexp-to-ast": "11.1.2", "@chevrotain/types": "11.1.2", "@chevrotain/utils": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - - "chroma-js": ["chroma-js@3.2.0", "", {}, "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw=="], - - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], - - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - - "compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="], - - "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="], - - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - - "create-vocs": ["create-vocs@1.0.0", "", { "dependencies": { "@clack/prompts": "^0.7.0", "cac": "^6.7.14", "detect-package-manager": "^3.0.2", "fs-extra": "^11.3.0", "picocolors": "^1.1.1" }, "bin": "_lib/bin.js" }, "sha512-Lv1Bd3WZEgwG4nrogkM54m8viW+TWPlGivLyEi7aNb3cuKPsEfMDZ/kTbo87fzOGtsZ2yh7scO54ZmVhhgBgTw=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="], - - "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], - - "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], - - "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], - - "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], - - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], - - "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], - - "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], - - "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], - - "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], - - "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], - - "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], - - "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], - - "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], - - "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], - - "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], - - "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - - "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], - - "deep-object-diff": ["deep-object-diff@1.1.9", "", {}, "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - - "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - - "detect-package-manager": ["detect-package-manager@3.0.2", "", { "dependencies": { "execa": "^5.1.1" } }, "sha512-8JFjJHutStYrfWwzfretQoyNGoZVW1Fsrp4JO9spa7h/fBfwgTMEIy4/LBzRDGsxwVPHU0q+T9YvwLDJoOApLQ=="], - - "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - - "direction": ["direction@2.0.1", "", { "bin": "cli.js" }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="], - - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], - - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], - - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - - "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], - - "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": "bin/esbuild" }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], - - "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], - - "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - - "estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="], - - "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], - - "estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="], - - "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="], - - "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - - "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - - "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], - - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - - "fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="], - - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - - "hast-util-classnames": ["hast-util-classnames@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-tI3JjoGDEBVorMAWK4jNRsfLMYmih1BUOG3VV36pH36njs1IEl7xkNrVTD2mD2yYHmQCa5R/fj61a8IAF4bRaQ=="], - - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], - - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], - - "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], - - "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], - - "hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="], - - "hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="], - - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - - "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="], - - "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], - - "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], - - "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], - - "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], - - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], - - "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - - "hastscript": ["hastscript@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw=="], - - "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], - - "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - - "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - - "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - - "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - - "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - - "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], - - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], - - "jiti": ["jiti@2.6.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - - "katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": "cli.js" }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="], - - "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - - "langium": ["langium@4.2.1", "", { "dependencies": { "chevrotain": "~11.1.1", "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ=="], - - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], - - "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], - - "log-symbols": ["log-symbols@5.1.0", "", { "dependencies": { "chalk": "^5.0.0", "is-unicode-supported": "^1.1.0" } }, "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA=="], - - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="], - - "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], - - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - - "marked": ["marked@16.4.2", "", { "bin": "bin/marked.js" }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - - "mdast-util-directive": ["mdast-util-directive@3.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q=="], - - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], - - "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], - - "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], - - "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], - - "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], - - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], - - "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], - - "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - - "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], - - "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], - - "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], - - "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], - - "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], - - "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], - - "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], - - "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - - "media-query-parser": ["media-query-parser@2.0.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" } }, "sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w=="], - - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - - "mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="], - - "mermaid-isomorphic": ["mermaid-isomorphic@3.1.0", "", { "dependencies": { "@fortawesome/fontawesome-free": "^6.0.0", "katex": "^0.16.0", "mermaid": "^11.0.0" }, "peerDependencies": { "playwright": "1" } }, "sha512-mzrvfEVjnJIkJlEqxp3eMuR1wS0TeLCH1VK5E/T5yzWaBwI3JqjJuw70yUIThSCDJ5bRs6O3rgfp00oBAbvSeQ=="], - - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], - - "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - - "micromark-extension-directive": ["micromark-extension-directive@3.0.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "parse-entities": "^4.0.0" } }, "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA=="], - - "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], - - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], - - "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], - - "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], - - "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], - - "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], - - "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], - - "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - - "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], - - "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], - - "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], - - "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], - - "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], - - "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], - - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], - - "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], - - "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], - - "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], - - "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], - - "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], - - "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], - - "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], - - "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], - - "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], - - "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], - - "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], - - "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], - - "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], - - "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], - - "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], - - "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], - - "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], - - "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], - - "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], - - "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - - "mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": "cli.js" }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], - - "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], - - "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], - - "modern-ahocorasick": ["modern-ahocorasick@1.1.0", "", {}, "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ=="], - - "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], - - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - - "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - - "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - - "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], - - "ora": ["ora@7.0.1", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.9.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.3.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "string-width": "^6.1.0", "strip-ansi": "^7.1.0" } }, "sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw=="], - - "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - - "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": "cli.js" }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - - "playwright-core": ["playwright-core@1.58.2", "", { "bin": "cli.js" }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], - - "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], - - "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], - - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], - - "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - - "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], - - "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - - "react-intersection-observer": ["react-intersection-observer@9.16.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA=="], - - "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - - "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], - - "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - - "react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="], - - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], - - "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], - - "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], - - "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], - - "regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], - - "regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], - - "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - - "rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="], - - "rehype-class-names": ["rehype-class-names@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-classnames": "^3.0.0", "hast-util-select": "^6.0.0", "unified": "^11.0.4" } }, "sha512-jldCIiAEvXKdq8hqr5f5PzNdIDkvHC6zfKhwta9oRoMu7bn0W7qLES/JrrjBvr9rKz3nJ8x4vY1EWI+dhjHVZQ=="], - - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], - - "rehype-mermaid": ["rehype-mermaid@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "mermaid-isomorphic": "^3.0.0", "mini-svg-data-uri": "^1.0.0", "space-separated-tokens": "^2.0.0", "unified": "^11.0.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "playwright": "1" } }, "sha512-fxrD5E4Fa1WXUjmjNDvLOMT4XB1WaxcfycFIWiYU0yEMQhcTDElc9aDFnbDFRLxG1Cfo1I3mfD5kg4sjlWaB+Q=="], - - "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], - - "rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="], - - "remark-directive": ["remark-directive@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^3.0.0", "unified": "^11.0.0" } }, "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A=="], - - "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], - - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - - "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], - - "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], - - "remark-mdx-frontmatter": ["remark-mdx-frontmatter@5.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "estree-util-value-to-estree": "^3.0.0", "toml": "^3.0.0", "unified": "^11.0.0", "unist-util-mdx-define": "^1.0.0", "yaml": "^2.0.0" } }, "sha512-U/hjUYTkQqNjjMRYyilJgLXSPF65qbLPdoESOkXyrwz2tVyhAnm4GUKhfXqOOS9W34M3545xEMq+aMpHgVjEeQ=="], - - "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], - - "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], - - "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - - "require-like": ["require-like@0.1.2", "", {}, "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A=="], - - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - - "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], - - "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], - - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], - - "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], - - "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], - - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "shiki": ["shiki@1.29.2", "", { "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", "@shikijs/engine-oniguruma": "1.29.2", "@shikijs/langs": "1.29.2", "@shikijs/themes": "1.29.2", "@shikijs/types": "1.29.2", "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg=="], - - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "stdin-discarder": ["stdin-discarder@0.1.0", "", { "dependencies": { "bl": "^5.0.0" } }, "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ=="], - - "string-width": ["string-width@6.1.0", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^10.2.1", "strip-ansi": "^7.0.1" } }, "sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], - - "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], - - "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - - "tailwindcss": ["tailwindcss@4.1.15", "", {}, "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ=="], - - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], - - "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], - - "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "twoslash": ["twoslash@0.3.6", "", { "dependencies": { "@typescript/vfs": "^1.6.2", "twoslash-protocol": "0.3.6" }, "peerDependencies": { "typescript": "^5.5.0" } }, "sha512-VuI5OKl+MaUO9UIW3rXKoPgHI3X40ZgB/j12VY6h98Ae1mCBihjPvhOPeJWlxCYcmSbmeZt5ZKkK0dsVtp+6pA=="], - - "twoslash-protocol": ["twoslash-protocol@0.3.6", "", {}, "sha512-FHGsJ9Q+EsNr5bEbgG3hnbkvEBdW5STgPU824AHUjB4kw0Dn4p8tABT7Ncg1Ie6V0+mDg3Qpy41VafZXcQhWMA=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": "script/cli.js" }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], - - "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], - - "unist-util-mdx-define": ["unist-util-mdx-define@1.1.2", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-9ncH7i7TN5Xn7/tzX5bE3rXgz1X/u877gYVAUB3mLeTKYJmQHmqKTDBi6BTGXV7AeolBCI9ErcVsOt2qryoD0g=="], - - "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], - - "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], - - "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], - - "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - - "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - - "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "uuid": ["uuid@11.1.0", "", { "bin": "dist/esm/bin/uuid" }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], - - "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], - - "vfile-matter": ["vfile-matter@5.0.1", "", { "dependencies": { "vfile": "^6.0.0", "yaml": "^2.0.0" } }, "sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw=="], - - "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": "vite-node.mjs" }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - - "vocs": ["vocs@1.4.1", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@hono/node-server": "^1.19.5", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@mdx-js/rollup": "^3.1.1", "@noble/hashes": "^1.7.1", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-tabs": "^1.1.3", "@shikijs/rehype": "^1", "@shikijs/transformers": "^1", "@shikijs/twoslash": "^1", "@tailwindcss/vite": "4.1.15", "@vanilla-extract/css": "^1.17.4", "@vanilla-extract/dynamic": "^2.1.5", "@vanilla-extract/vite-plugin": "^5.1.1", "@vitejs/plugin-react": "^5.0.4", "autoprefixer": "^10.4.21", "cac": "^6.7.14", "chroma-js": "^3.1.2", "clsx": "^2.1.1", "compression": "^1.8.1", "create-vocs": "^1.0.0-alpha.5", "cross-spawn": "^7.0.6", "fs-extra": "^11.3.2", "hastscript": "^8.0.0", "hono": "^4.10.3", "mark.js": "^8.11.1", "mdast-util-directive": "^3.1.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm": "^3.1.0", "mdast-util-mdx": "^3.0.0", "mdast-util-mdx-jsx": "^3.2.0", "mdast-util-to-hast": "^13.2.0", "mdast-util-to-markdown": "^2.1.2", "minisearch": "^7.2.0", "nuqs": "^2.7.2", "ora": "^7.0.1", "p-limit": "^5.0.0", "picomatch": "^4.0.3", "playwright": "^1.52.0", "postcss": "^8.5.2", "radix-ui": "^1.1.3", "react-intersection-observer": "^9.15.1", "react-router": "^7.9.4", "rehype-autolink-headings": "^7.1.0", "rehype-class-names": "^2.0.0", "rehype-mermaid": "^3.0.0", "rehype-slug": "^6.0.0", "remark-directive": "^3.0.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-mdx": "^3.1.1", "remark-mdx-frontmatter": "^5.2.0", "remark-parse": "^11.0.0", "serve-static": "^1.16.2", "shiki": "^1", "toml": "^3.0.0", "twoslash": "~0.3.4", "ua-parser-js": "^1.0.40", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "vfile-matter": "^5.0.1", "vite": "^7.1.11", "yaml": "^2.8.1" }, "peerDependencies": { "react": "^19", "react-dom": "^19" }, "bin": "_lib/cli/index.js" }, "sha512-PwCODbht+/0f6wtAyz5czqdWaMX80KlxOc6Mkqfd0u6bboTZ+YcyBuZaiQwJ4lkDE6NvSrCosPVD5CxGyvtitg=="], - - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yaml": ["yaml@2.8.2", "", { "bin": "bin.mjs" }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], - - "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - - "@babel/core/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "@babel/traverse/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], - - "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - - "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - - "@shikijs/twoslash/twoslash": ["twoslash@0.2.12", "", { "dependencies": { "@typescript/vfs": "^1.6.0", "twoslash-protocol": "0.2.12" }, "peerDependencies": { "typescript": "*" } }, "sha512-tEHPASMqi7kqwfJbkk7hc/4EhlrKCSLcur+TcvYki3vhIfaRMXnXjaYFgXpoZRbT6GdprD4tGuVBEmTpUgLBsw=="], - - "@typescript/vfs/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], - - "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - - "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], - - "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - - "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hast-util-from-parse5/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "hast-util-select/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "hast-util-to-estree/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "hast-util-to-html/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "hast-util-to-jsx-runtime/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], - - "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "vite-node/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "@babel/core/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "@babel/traverse/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - - "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="], - - "@typescript/vfs/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], - - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], - - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - - "hast-util-from-dom/hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "vite-node/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - } -} diff --git a/docs/specs/components/BCPsList.tsx b/docs/specs/components/BCPsList.tsx deleted file mode 100644 index 48a4a993e5..0000000000 --- a/docs/specs/components/BCPsList.tsx +++ /dev/null @@ -1,59 +0,0 @@ -type BcpStatus = 'Draft' | 'Review' | 'Accepted' | 'Final' | 'Rejected' | 'Withdrawn' | 'Deprecated' - -type BcpEntry = { - id: string - title: string - description: string - status: BcpStatus - link: string -} - -const bcps: BcpEntry[] = [ - { - id: 'BCP-0000', - title: 'BCP Process', - description: 'Defines the Base Change Proposal process, format, and lifecycle.', - status: 'Final', - link: '/bcps/bcp-0000', - }, -] - -const statusColor: Record = { - Final: { color: 'var(--vocs-color_textGreen)', background: 'var(--vocs-color_backgroundGreenTint)' }, - Accepted: { color: 'var(--vocs-color_textGreen)', background: 'var(--vocs-color_backgroundGreenTint2)' }, - Review: { color: 'var(--vocs-color_textBlue)', background: 'var(--vocs-color_backgroundBlueTint)' }, - Draft: { color: 'var(--vocs-color_text3)', background: 'var(--vocs-color_background3)' }, - Rejected: { color: 'var(--vocs-color_textRed)', background: 'var(--vocs-color_backgroundRedTint)' }, - Withdrawn: { color: 'var(--vocs-color_text3)', background: 'var(--vocs-color_background3)' }, - Deprecated: { color: 'var(--vocs-color_text3)', background: 'var(--vocs-color_background3)' }, -} - -export function BCPsList() { - return ( - - ) -} diff --git a/docs/specs/components/Mermaid.tsx b/docs/specs/components/Mermaid.tsx deleted file mode 100644 index 7d81f7718b..0000000000 --- a/docs/specs/components/Mermaid.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useId, useRef, useState } from 'react' - -type MermaidProps = { - chart: string -} - -export function Mermaid({ chart }: MermaidProps) { - const containerRef = useRef(null) - const chartId = `mermaid-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}` - const [isDark, setIsDark] = useState(false) - const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') - - useEffect(() => { - if (typeof document === 'undefined') return - - const root = document.documentElement - const updateTheme = () => { - setIsDark(root.classList.contains('dark')) - } - - updateTheme() - - const observer = new MutationObserver(updateTheme) - observer.observe(root, { - attributeFilter: ['class'], - attributes: true, - }) - - return () => observer.disconnect() - }, []) - - useEffect(() => { - let cancelled = false - - const renderDiagram = async () => { - const container = containerRef.current - if (!container) return - - setStatus('loading') - container.innerHTML = '' - - try { - const mermaid = (await import('mermaid')).default - - mermaid.initialize({ - securityLevel: 'strict', - startOnLoad: false, - theme: isDark ? 'dark' : 'default', - }) - - const { bindFunctions, svg } = await mermaid.render(chartId, chart) - if (cancelled) return - - container.innerHTML = svg - bindFunctions?.(container) - setStatus('ready') - } catch (error) { - if (cancelled) return - - console.error('Failed to render Mermaid diagram.', error) - container.innerHTML = '' - setStatus('error') - } - } - - void renderDiagram() - - return () => { - cancelled = true - } - }, [chart, chartId, isDark]) - - return ( -
- {status === 'loading' ? ( -
Rendering diagram...
- ) : null} - {status === 'error' ? ( -
-          {chart}
-        
- ) : null} -
- ) -} diff --git a/docs/specs/layout.tsx b/docs/specs/layout.tsx deleted file mode 100644 index c254058ecd..0000000000 --- a/docs/specs/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ReactNode } from 'react' -import { MDXProvider } from 'vocs/mdx-react' - -import { Mermaid } from './components/Mermaid' - -const components = { - Mermaid, -} - -export default function Layout({ children }: { children: ReactNode }) { - return {children} -} diff --git a/docs/specs/lib/remarkMermaid.ts b/docs/specs/lib/remarkMermaid.ts deleted file mode 100644 index 8bdee51612..0000000000 --- a/docs/specs/lib/remarkMermaid.ts +++ /dev/null @@ -1,48 +0,0 @@ -type MarkdownNode = { - children?: MarkdownNode[] - lang?: string | null - type?: string - value?: string -} - -type MermaidNode = MarkdownNode & { - attributes: Array<{ - name: string - type: 'mdxJsxAttribute' - value: string - }> - children: [] - name: 'Mermaid' - type: 'mdxJsxFlowElement' -} - -export function remarkMermaid() { - return (tree: MarkdownNode) => { - transform(tree) - } -} - -function transform(node: MarkdownNode) { - if (!Array.isArray(node.children)) return - - for (let index = 0; index < node.children.length; index += 1) { - const child = node.children[index] - if (child?.type === 'code' && child.lang === 'mermaid') { - node.children[index] = { - type: 'mdxJsxFlowElement', - name: 'Mermaid', - attributes: [ - { - type: 'mdxJsxAttribute', - name: 'chart', - value: child.value ?? '', - }, - ], - children: [], - } satisfies MermaidNode - continue - } - - transform(child) - } -} diff --git a/docs/specs/package.json b/docs/specs/package.json deleted file mode 100644 index 0cf04fba1c..0000000000 --- a/docs/specs/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "base-specification", - "private": true, - "type": "module", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "vocs dev", - "build": "vocs build", - "preview": "vocs preview" - }, - "devDependencies": { - "mermaid": "^11.12.3", - "rehype-katex": "^7.0.1", - "remark-math": "^6.0.0", - "vocs": "^1.4.1" - }, - "overrides": { - "@hono/node-server": "1.19.9", - "dompurify": "3.3.1", - "hono": "4.12.3", - "katex": "0.16.33", - "mlly": "1.8.0", - "node-releases": "2.0.27" - } -} diff --git a/docs/specs/pages/bcps/bcp-0000.md b/docs/specs/pages/bcps/bcp-0000.md deleted file mode 100644 index 308b606023..0000000000 --- a/docs/specs/pages/bcps/bcp-0000.md +++ /dev/null @@ -1,100 +0,0 @@ -# BCP-0000: BCP Process - -## Abstract - -BCP-0000 defines the Base Change Proposal (BCP) process — the mechanism by which changes to the Base -Chain protocol are proposed, reviewed, and accepted. A BCP is a design document providing a -complete specification of a proposed change, serving as the source of truth for implementation. - -## Motivation - -Base Chain needs a transparent, structured process for evolving its protocol. Without a formal -process, changes risk being underdocumented, inconsistently reviewed, or difficult to track across -stakeholders. BCPs provide a single canonical artifact per change: a self-contained specification -that captures the what, why, and how, from initial proposal through final acceptance. - ---- - -# Specification - -## BCP Types - -There are two types of BCPs: - -- **Standards Track** — describes a change to the Base Chain protocol itself: execution rules, - consensus, bridging, fault proofs, or other on-chain behavior. Most BCPs are Standards Track. -- **Meta** — describes a change to the BCP process or introduces governance around how the - protocol is evolved. BCP-0000 is a Meta BCP. - -## BCP Statuses - -A BCP moves through the following statuses over its lifetime: - -``` -Draft → Review → Accepted → Final - └→ Rejected - └→ Withdrawn -``` - -| Status | Description | -| ------ | ----------- | -| **Draft** | The BCP is being authored and is not yet ready for formal review. | -| **Review** | The BCP is complete and open for community and core team feedback. | -| **Accepted** | The BCP has been approved and is scheduled for implementation. | -| **Final** | The BCP has been implemented and deployed to mainnet. | -| **Rejected** | The BCP was reviewed and not accepted. | -| **Withdrawn** | The author(s) withdrew the BCP before a decision was reached. | -| **Deprecated** | A previously Final BCP has been superseded by a later BCP. | - -## BCP Numbering - -BCPs are assigned a number at the time of their first Draft commit. Numbers are assigned -sequentially starting from 1 (BCP-0001). The number is permanent and is never reused, even if the -BCP is rejected or withdrawn. BCP-0000 is reserved for this process document. - -## BCP Format - -Each BCP is a Markdown file stored at `docs/specs/pages/bcps/bcp-{NNNN}.md` in the -[base repository](https://github.com/base/base). It must begin with the title as an H1 -heading followed by the body sections below. - -### Required Sections - -**Abstract** — A 2–4 sentence high-level summary of the proposed change. - -**Motivation** — An explanation of the problem this BCP solves and why the proposed approach was -chosen over alternatives. Include links to prerequisite specs or relevant context. - -**Specification** — A complete description of the change: state transitions, data structures, -encodings, interface definitions, and any invariants that must hold. The specification must be -precise enough for an independent engineer to implement and test without inferring details. - -**Invariants** (if applicable) — Explicit invariants that must always hold after the change is -applied, and critical cases the test suite must cover. - -### Optional Sections - -Additional sections (e.g. **Security Considerations**, **Backwards Compatibility**, -**Reference Implementation**) may be added as needed. - -## Process - -1. **Draft** — An author opens a PR to the base repository adding a new BCP file. The PR - description should link to any relevant prior discussion. -2. **Review** — The PR is marked ready for review. Core team members and community stakeholders - review the specification for correctness, completeness, and alignment with Base's design goals. -3. **Accepted / Rejected** — The core team makes a final decision. If accepted, the BCP is merged - and assigned a Final status once the implementation ships to mainnet. If rejected, the BCP is - merged with Rejected status and a brief rationale added to the BCP. -4. **Final** — The BCP is updated to Final status when its implementation is deployed to mainnet. - -## BCP Index - -The [BCPs index page](./index.mdx) lists all BCPs with their current status. Authors are -responsible for keeping their BCP's status up to date as it progresses. - -# Invariants - -- Every BCP has a unique, permanent number. -- Every BCP that reaches Final status has a corresponding implementation deployed to mainnet. -- A Deprecated BCP must reference the superseding BCP. diff --git a/docs/specs/pages/bcps/index.mdx b/docs/specs/pages/bcps/index.mdx deleted file mode 100644 index c4b188e85b..0000000000 --- a/docs/specs/pages/bcps/index.mdx +++ /dev/null @@ -1,10 +0,0 @@ -import { BCPsList } from '../../components/BCPsList' - -# Base Change Proposals (BCPs) - -BCPs are design documents that describe changes to the Base Chain protocol. Each BCP provides a -complete specification that serves as the source of truth for implementation. - ---- - - diff --git a/docs/specs/pages/index.md b/docs/specs/pages/index.md deleted file mode 100644 index 1346e2cbb5..0000000000 --- a/docs/specs/pages/index.md +++ /dev/null @@ -1,24 +0,0 @@ -# Base Specification - -This specification defines the Base Chain protocol: how nodes derive and execute blocks, how -transactions are propagated, and how state transitions are verified. It covers core protocol rules, -execution behavior, and proving. - -## Design Goals - -Our aim is to design a protocol specification that is: - -- **Opinionated:** Simplicity through deliberate design choices. We identify the best solution and - commit to it. -- **Maximally Simple:** By focusing on just what Base needs, we radically simplify the stack. The - protocol spec and codebase should be understandable by a single developer. -- **Fast Cycles:** We ship upgrades frequently rather than batching risk into infrequent large ones. - We target six smaller, tightly scoped hard forks per year on a regular cadence, with fortnightly - releases. -- **Ethereum Aligned:** Base wins when Ethereum wins. We accelerate deployment of high-impact - changes ahead of L1 to provide data that informs the Ethereum roadmap. - -## Lineage - -Base Chain inherits Ethereum's EVM semantics, transaction rules, and L1-anchored security. After -the Jovian Hardfork, Base Chain follows this specification as the source of truth. diff --git a/docs/specs/pages/protocol/batcher.md b/docs/specs/pages/protocol/batcher.md deleted file mode 100644 index f96b848239..0000000000 --- a/docs/specs/pages/protocol/batcher.md +++ /dev/null @@ -1,73 +0,0 @@ -# Batcher - -[derivation spec]: consensus/derivation.md - -## Overview - -The batcher, also referred to as the batch submitter, is the entity responsible for posting L2 sequencer data to L1, making it available to the derivation pipeline operated by verifiers. The format of batcher transactions — channels, frames, and batches within them — is defined in the [derivation spec]: the data is constructed from L2 blocks in the reverse order from which it is derived back into L2 blocks. Only data that conforms to those rules will be accepted as valid from the verifier's perspective. - -The batcher observes the gap between the unsafe L2 head (the latest sequenced block) and the safe L2 head (the latest block confirmed on L1 through derivation). Any unsafe L2 blocks that have not yet been confirmed must be encoded and submitted. The batcher encodes L2 blocks into channels, fragments channels into frames, and posts frames as L1 transactions. The derivation pipeline then reads those frames, reassembles channels, decodes batches, and reconstructs the original L2 blocks. - -The timing and transaction signing are implementation-specific: data can be submitted at any time, but only data that matches the [derivation spec] rules will be valid from the verifier perspective. The L2 view of safe and unsafe does not update instantly after data is submitted or confirmed on L1, so a batcher implementation must take care not to duplicate data submissions. - -## Channel Lifecycle - -A channel is the unit of encoding used by the batcher. It is an ordered, compressed sequence of RLP-encoded L2 block batches. A channel is opened when there are L2 blocks awaiting submission and no channel is currently open. At most one channel may be open at any time; a new channel must not be opened until the previous one has been fully closed and all its frames have been submitted to L1. - -A channel accumulates L2 block batches in strictly increasing block number order until one of the following closure conditions is met. A channel must close when adding the next batch would cause the compressed output size to exceed the maximum blob data capacity, ensuring that no frame will carry a payload too large for its data availability target. A channel must also close when continued accumulation would cause the total uncompressed RLP byte length of its batches to exceed `max_rlp_bytes_per_channel`, a protocol limit that protects verifiers against decompression amplification. In both cases, the batch that would have caused the overflow is withheld from the current channel; the channel is closed, and that batch becomes the first entry of the next channel. - -A channel must additionally close on timeout: if the L1 chain advances more than `max_channel_duration` L1 blocks beyond the block at which the channel was opened, the channel must be closed and its frames posted immediately. This prevents channels from staying open indefinitely and ensures that verifiers — who drop any channel not completed within the `channel_timeout` window — do not discard the data. - -When a channel closes, its compressed data is partitioned into fixed-size frames. Each frame carries at most `max_frame_size` bytes of compressed payload plus per-frame header overhead. The resulting frames are queued for submission to L1 in order. The channel's block range — the contiguous interval of L2 block numbers it covers — is fixed upon closing and must not change. - -## Frame Production and Ordering - -Each frame carries a header identifying the channel it belongs to via a 16-byte channel ID, its position within the channel as a monotonically increasing 16-bit frame number beginning at zero, the length of its compressed payload, and a boolean flag indicating whether it is the last frame in the channel. The first frame of each channel additionally carries a single version byte identifying the compression codec; all subsequent frames consist entirely of compressed payload with no such prefix. - -Frames within a channel must be submitted to L1 in sequential order. Frame `N` must appear on L1 no later than frame `N+1`. The derivation pipeline may tolerate out-of-order frame delivery in some configurations, but from the Holocene hardfork onward it drops any non-first frame whose frame number is not exactly one greater than the previous frame received for that channel, and drops any new first frame whose predecessor channel has not yet been closed. After Holocene activation, strict in-order delivery is required for correctness. - -The `is_last` flag must be set to true on exactly the final frame of a channel and false on all preceding frames. A verifier considers a channel complete only when a frame with `is_last` set is received. Any channel that never receives its final frame within the `channel_timeout` window is discarded by the verifier. - -## Data Availability - -The batcher posts frames to L1 as batcher transactions addressed to the batcher inbox address, which is a designated EOA rather than a contract. Each batcher transaction must be signed by the batcher's signing key, and the recovered sender address must match the `batcherAddress` recorded in the L2 system configuration at the time of the L1 transaction's inclusion. The derivation pipeline authenticates batcher transactions by this address; transactions from any other sender are ignored regardless of their content. - -As of the Cancun L1 upgrade, the primary data availability mechanism is EIP-4844 blob transactions. Each blob carries one frame of compressed channel data. The maximum usable payload per blob is 130,044 bytes, which defines the effective `max_frame_size`. The batcher must not produce frames whose compressed payload exceeds this limit. - -All frames for a given channel must land on L1 within `channel_timeout` L1 blocks of the block in which the channel's first frame was included. If the channel is not completed within this window, the derivation pipeline discards all buffered frames for that channel, and the affected L2 blocks must be resubmitted in a new channel. The batcher must size channels and manage submission throughput to ensure frames are posted within this deadline. - -## Block Continuity - -The batcher encodes L2 blocks in strictly increasing order by block number. Each block added to the open channel must be the direct child of the previously encoded block: its parent hash must equal the hash of the most recently encoded block. This invariant ensures the channel represents a contiguous, unambiguous segment of the canonical L2 chain. - -If the L2 chain reorganizes — manifesting as a block whose parent hash does not match the previously seen tip, or as an explicit reorg signal from the block source — the batcher must discard all pending encoding state. This includes the currently open channel, any channels queued for submission but not yet fully confirmed, and all in-flight submission tracking. After a reorg, the batcher restarts from the new canonical chain tip. L1 transactions already in flight at the time of the reorg are abandoned; if they are eventually included on L1, the derivation pipeline ignores them as they are incoherent with the new chain. - -Each channel covers a contiguous, non-overlapping range of L2 block numbers. The block range of a subsequent channel must begin exactly where the block range of the preceding channel ends. No L2 block may appear in more than one channel, and no blocks may be skipped between consecutive channels. - -## Sequencer Drift and Throttling - -The derivation spec constrains how far the L2 timestamp may advance ahead of the L1 timestamp of its origin block. An L2 block's timestamp must not exceed the L1 origin timestamp plus `max_sequencer_drift`. Prior to the Fjord hardfork, `max_sequencer_drift` is a per-chain configuration parameter. From Fjord onward it is fixed at 1800 seconds. When this limit is exceeded, the derivation pipeline will only accept a batch if its transaction list is empty (a deposit-only block). The batcher must therefore not include user transactions in blocks whose timestamp would exceed the drift limit, and must coordinate with the sequencer accordingly. - -To prevent the sequencer from outpacing the batcher's L1 submission capacity, the batcher measures its data availability backlog — the total encoded size of L2 blocks that have been sequenced but whose data has not yet been confirmed on L1. When the backlog exceeds a configured threshold, the batcher signals the sequencer to reduce its block production rate. The throttle can be graduated: a modest backlog may request a modest slowdown, while a large backlog may pause block production entirely until the batcher catches up. This feedback mechanism is transparent to the derivation pipeline and is not reflected in any on-chain data. - -## Compression - -Channel data is compressed before being partitioned into frames. Prior to the Fjord hardfork, channels use zlib compression (RFC 1950, no dictionary) and carry no version prefix; the zlib magic bytes in the stream allow the decompressor to identify the format. From Fjord onward, channels use Brotli compression (RFC 7932), and the first frame of each channel carries a version byte of `0x01` immediately before the compressed payload to identify the codec. The lower nibble of the version byte must not be `0x08` or `0x0f`, as those values would collide with zlib magic header bytes and confuse earlier decompressors. - -Because compression ratios vary with input content, the batcher must estimate the compressed output size prospectively as it encodes batches into a channel. The channel must be closed before the compressed output would exceed `max_frame_size`, rather than after. A common approach is to maintain a shadow compressor in parallel with the real compressor and treat the shadow's output size as an upper bound; the channel is closed when the shadow output reaches the limit. This ensures the batcher never produces a frame too large to fit within a blob. - -The maximum uncompressed RLP size per channel, `max_rlp_bytes_per_channel`, is enforced separately from the compressed size limit. This limit protects verifiers from decompression amplification: a small compressed payload that expands to an unboundedly large uncompressed stream could exhaust memory. A verifier decoding a channel stops processing once the uncompressed output reaches this limit; any remaining batches are discarded. The batcher must ensure the uncompressed size of its batches does not exceed this bound, both to guarantee all batches are seen by verifiers and to stay within the protocol's defined limits. - -## Confirmation and Block Pruning - -The batcher tracks each submitted frame until it is included in an L1 block. A frame is confirmed when the batcher observes an L1 block containing the L1 transaction that carries the frame. A channel is fully confirmed when every one of its frames has been confirmed on L1. - -L2 blocks must not be discarded from the batcher's pending set until the channel containing them is fully confirmed. Until confirmation, those blocks must be retained so that any lost frames — for example due to an L1 reorg removing the transaction's inclusion — can be reconstructed and resubmitted. Only after a channel is fully confirmed may the batcher release the L2 blocks it covers. - -If a submitted frame's L1 transaction fails to be included, the batcher must resubmit that frame and all subsequent frames in the same channel. Resubmitted frames must be byte-identical to the originals: the derivation pipeline identifies frames by their channel ID and frame number, and a resubmitted frame with different content would be treated as corrupted data rather than as a retry. - -## Hardfork Rules - -The Fjord hardfork changes the channel encoding format. Channels opened after Fjord activation must use Brotli compression and prefix the first frame's payload with version byte `0x01`. The protocol limit `max_rlp_bytes_per_channel` increases substantially at Fjord activation, relaxing the channel size constraint. Channels opened before Fjord activation must use the pre-Fjord format for all their frames, regardless of when those frames are posted. - -The Holocene hardfork imposes strict ordering requirements at both the frame and batch layers. At the frame layer, frames for a given channel must be delivered to the derivation pipeline contiguously and in order; a non-first frame that is not the immediate successor of the previously seen frame for that channel is dropped immediately, and an incomplete channel is dropped if a new first frame for it arrives before its final frame has been seen. At the batch layer, batches within a channel must be strictly ordered by L2 timestamp with no repeated timestamps; any batch with a timestamp not strictly greater than the previous batch in the same channel causes the channel to be invalidated and all remaining batches in it to be dropped. These rules impose no new on-chain obligations, but they mean the batcher has zero tolerance for frame delivery gaps or reordering after Holocene activation. diff --git a/docs/specs/pages/protocol/bridging/bridges.md b/docs/specs/pages/protocol/bridging/bridges.md deleted file mode 100644 index 8faf4801ec..0000000000 --- a/docs/specs/pages/protocol/bridging/bridges.md +++ /dev/null @@ -1,44 +0,0 @@ -# Standard Bridges - -## Overview - -The standard bridges are responsible for allowing cross domain -ETH and ERC20 token transfers. They are built on top of the cross domain -messenger contracts and give a standard interface for depositing tokens. - -The bridge works for both L1 native tokens and L2 native tokens. The legacy API -is preserved to ensure that existing applications will not experience any -problems with the Bedrock `StandardBridge` contracts. - -The `L2StandardBridge` is a predeploy contract located at -`0x4200000000000000000000000000000000000010`. - -```solidity -interface StandardBridge { - event ERC20BridgeFinalized(address indexed localToken, address indexed remoteToken, address indexed from, address to, uint256 amount, bytes extraData); - event ERC20BridgeInitiated(address indexed localToken, address indexed remoteToken, address indexed from, address to, uint256 amount, bytes extraData); - event ETHBridgeFinalized(address indexed from, address indexed to, uint256 amount, bytes extraData); - event ETHBridgeInitiated(address indexed from, address indexed to, uint256 amount, bytes extraData); - - function bridgeERC20(address _localToken, address _remoteToken, uint256 _amount, uint32 _minGasLimit, bytes memory _extraData) external; - function bridgeERC20To(address _localToken, address _remoteToken, address _to, uint256 _amount, uint32 _minGasLimit, bytes memory _extraData) external; - function bridgeETH(uint32 _minGasLimit, bytes memory _extraData) payable external; - function bridgeETHTo(address _to, uint32 _minGasLimit, bytes memory _extraData) payable external; - function deposits(address, address) view external returns (uint256); - function finalizeBridgeERC20(address _localToken, address _remoteToken, address _from, address _to, uint256 _amount, bytes memory _extraData) external; - function finalizeBridgeETH(address _from, address _to, uint256 _amount, bytes memory _extraData) payable external; - function messenger() view external returns (address); - function OTHER_BRIDGE() view external returns (address); -} -``` - -## Token Depositing - -The `bridgeERC20` function is used to send a token from one domain to another -domain. An `OptimismMintableERC20` token contract must exist on the remote -domain to be able to deposit tokens to that domain. One of these tokens can be -deployed using the `OptimismMintableERC20Factory` contract. - -## Upgradability - -Both the L1 and L2 standard bridges should be behind upgradable proxies. diff --git a/docs/specs/pages/protocol/bridging/deposits.md b/docs/specs/pages/protocol/bridging/deposits.md deleted file mode 100644 index cf59402908..0000000000 --- a/docs/specs/pages/protocol/bridging/deposits.md +++ /dev/null @@ -1,487 +0,0 @@ -# Deposits - - - -[g-transaction-type]: ../../reference/glossary.md#transaction-type -[g-derivation]: ../../reference/glossary.md#L2-chain-derivation -[g-deposited]: ../../reference/glossary.md#deposited -[g-deposits]: ../../reference/glossary.md#deposits -[g-l1-attr-deposit]: ../../reference/glossary.md#l1-attributes-deposited-transaction -[g-user-deposited]: ../../reference/glossary.md#user-deposited-transaction -[g-eoa]: ../../reference/glossary.md#eoa -[g-exec-engine]: ../../reference/glossary.md#execution-engine - -## Overview - -[Deposited transactions][g-deposited], also known as [deposits][g-deposits] are transactions which -are initiated on L1, and executed on L2. This document outlines a new [transaction -type][g-transaction-type] for deposits. It also describes how deposits are initiated on L1, along -with the authorization and validation conditions on L2. - -**Vocabulary note**: _deposited transaction_ refers specifically to an L2 transaction, while -_deposit_ can refer to the transaction at various stages (for instance when it is deposited on L1). - -## The Deposited Transaction Type - -[deposited-tx-type]: #the-deposited-transaction-type - -[Deposited transactions][g-deposited] have the following notable distinctions from existing -transaction types: - -1. They are derived from Layer 1 blocks, and must be included as part of the protocol. -2. They do not include signature validation (see [User-Deposited Transactions][user-deposited] - for the rationale). -3. They buy their L2 gas on L1 and, as such, the L2 gas is not refundable. - -We define a new [EIP-2718] compatible transaction type with the prefix `0x7E` to represent a deposit transaction. - -A deposit has the following fields -(rlp encoded in the order they appear here): - -[EIP-2718]: https://eips.ethereum.org/EIPS/eip-2718 - -- `bytes32 sourceHash`: the source-hash, uniquely identifies the origin of the deposit. -- `address from`: The address of the sender account. -- `address to`: The address of the recipient account, or the null (zero-length) address if the - deposited transaction is a contract creation. -- `uint256 mint`: The ETH value to mint on L2. -- `uint256 value`: The ETH value to send to the recipient account. -- `uint64 gas`: The gas limit for the L2 transaction. -- `bool isSystemTx`: If true, the transaction does not interact with the L2 block gas pool. - - This value is disabled and MUST be `false`. -- `bytes data`: The calldata. - -In contrast to [EIP-155] transactions, this transaction type: - -- Does not include a `nonce`, since it is identified by the `sourceHash`. - API responses still include a `nonce` attribute, set to the `depositNonce` value - from the corresponding transaction receipt. -- Does not include signature information, and makes the `from` address explicit. - API responses contain zeroed signature `v`, `r`, `s` values for backwards compatibility. -- Includes new `sourceHash`, `from`, `mint`, and `isSystemTx` attributes. - API responses contain these as additional fields. - -[EIP-155]: https://eips.ethereum.org/EIPS/eip-155 - -We select `0x7E` because transaction type identifiers are currently allowed to go up to `0x7F`. -Picking a high identifier minimizes the risk that the identifier will be used by another -transaction type on the L1 chain in the future. We don't pick `0x7F` itself in case it becomes used -for a variable-length encoding scheme. - -### Source hash computation - -The `sourceHash` of a deposit transaction is computed based on the origin: - -- User-deposited: - `keccak256(bytes32(uint256(0)), keccak256(l1BlockHash, bytes32(uint256(l1LogIndex))))`. - Where the `l1BlockHash`, and `l1LogIndex` all refer to the inclusion of the deposit log event on L1. - `l1LogIndex` is the index of the deposit event log in the combined list of log events of the block. -- L1 attributes deposited: - `keccak256(bytes32(uint256(1)), keccak256(l1BlockHash, bytes32(uint256(seqNumber))))`. - Where `l1BlockHash` refers to the L1 block hash of which the info attributes are deposited. - And `seqNumber = l2BlockNum - l2EpochStartBlockNum`, - where `l2BlockNum` is the L2 block number of the inclusion of the deposit tx in L2, - and `l2EpochStartBlockNum` is the L2 block number of the first L2 block in the epoch. -- Upgrade-deposited: `keccak256(bytes32(uint256(2)), keccak256(intent))`. - Where `intent` is a UTF-8 byte string, identifying the upgrade intent. - -Without a `sourceHash` in a deposit, two different deposited transactions could have the same exact hash. - -The outer `keccak256` hashes the actual uniquely identifying information with a domain, -to avoid collisions between different types of sources. - -The [Interop derivation spec](../consensus/derivation.md) introduces two additional kinds of system deposits, -with domains `3` and `4`. - -We do not use the sender's nonce to ensure uniqueness because this would require an extra L2 EVM state read from the -[execution engine][g-exec-engine] during block-derivation. - -### Kinds of Deposited Transactions - -Although we define only one new transaction type, we can distinguish between two kinds of deposited -transactions, based on their positioning in the L2 block: - -1. The first transaction MUST be a [L1 attributes deposited transaction][l1-attr-deposit], followed by -2. an array of zero-or-more [user-deposited transactions][user-deposited] - submitted to the deposit feed contract on L1 (called `OptimismPortal`). - User-deposited transactions are only present in the first block of a L2 epoch. - -We only define a single new transaction type in order to minimize modifications to L1 client -software, and complexity in general. - -### Validation and Authorization of Deposited Transactions - -As noted above, the deposited transaction type does not include a signature for validation. Rather, -authorization is handled by the [L2 chain derivation][g-derivation] process, which when correctly -applied will only derive transactions with a `from` address attested to by the logs of the [L1 -deposit contract][deposit-contract]. - -### Execution - -In order to execute a deposited transaction: - -First, the balance of the `from` account MUST be increased by the amount of `mint`. -This is unconditional, and does not revert on deposit failure. - -Then, the execution environment for a deposited transaction is initialized based on the -transaction's attributes, in exactly the same manner as it would be for an EIP-155 transaction. - -The deposit transaction is processed exactly like a type-2 (EIP-1559) transaction, with the exception of: - -- No fee fields are verified: the deposit does not have any, as it pays for gas on L1. -- No `nonce` field is verified: the deposit does not have any, it's uniquely identified by its `sourceHash`. -- No access-list is processed: the deposit has no access-list, and it is thus processed as if the access-list is empty. -- No check if `from` is an Externally Owner Account (EOA): the deposit is ensured not to be an EOA through L1 address - masking, this may change in future L1 contract-deployments to e.g. enable an account-abstraction like mechanism. -- No gas is refunded as ETH. (either by not refunding or utilizing the fact the gas-price of the deposit is `0`) -- No transaction priority fee is charged. No payment is made to the block fee-recipient. -- No L1-cost fee is charged, as deposits are derived from L1 and do not have to be submitted as data back to it. -- No base fee is charged. The total base fee accounting does not change. - -Note that this includes contract-deployment behavior like with regular transactions, and gas -metering is the same (with the exception of fee related changes above), including metering of -intrinsic gas. - -Any non-EVM state-transition error emitted by the EVM execution is processed in a special way: - -- It is transformed into an EVM-error: - i.e. the deposit will always be included, but its receipt will indicate a failure - if it runs into a non-EVM state-transition error, e.g. failure to transfer the specified - `value` amount of ETH due to insufficient account-balance. -- The world state is rolled back to the start of the EVM processing, after the minting part of the deposit. -- The `nonce` of `from` in the world state is incremented by 1, making the error equivalent to a native EVM failure. - Note that a previous `nonce` increment may have happened during EVM processing, but this would be rolled back first. - -Finally, after the above processing, the execution post-processing runs the same: -i.e. the gas pool and receipt are processed identical to a regular transaction. -The receipt of deposit transactions is extended with an additional -`depositNonce` value, storing the `nonce` value of the `from` sender as registered _before_ the EVM processing. - -Note that the gas used as stated by the execution output is subtracted from the gas pool. - -Note for application developers: because `CALLER` and `ORIGIN` are set to `from`, the -semantics of using the `tx.origin == msg.sender` check will not work to determine whether -or not a caller is an EOA during a deposit transaction. Instead, the check could only be useful for -identifying the first call in the L2 deposit transaction. However this check does still satisfy -the common case in which developers are using this check to ensure that the `CALLER` is unable to -execute code before and after the call. - -#### Nonce Handling - -Despite the lack of signature validation, we still increment the nonce of the `from` account when a -deposit transaction is executed. In the context of a deposit-only roll up, this is not necessary -for transaction ordering or replay prevention, however it maintains consistency with the use of -nonces during [contract creation][create-nonce]. It may also simplify integration with downstream -tooling (such as wallets and block explorers). - -[create-nonce]: https://github.com/ethereum/execution-specs/blob/617903a8f8d7b50cf71bf1aa733c37897c8d75c1/src/ethereum/frontier/utils/address.py#L40 - -## Deposit Receipt - -Transaction receipts use standard typing as per [EIP-2718]. -The Deposit transaction receipt type is equal to a regular receipt, -but extended with an optional `depositNonce` field. - -The RLP-encoded consensus-enforced fields are: - -- `postStateOrStatus` (standard): this contains the transaction status, see [EIP-658]. -- `cumulativeGasUsed` (standard): gas used in the block thus far, including this transaction. - - The actual gas used is derived from the difference in `CumulativeGasUsed` with the previous transaction. - - This accounts for the actual gas usage by the deposit, like regular transactions. -- `bloom` (standard): bloom filter of the transaction logs. -- `logs` (standard): log events emitted by the EVM processing. -- `depositNonce` (unique extension): Optional field. The deposit transaction persists the nonce used during execution. -- `depositNonceVersion` (unique extension): Optional field. The value must be 1 if the field is present - - Before Canyon, these `depositNonce` & `depositNonceVersion` fields must always be omitted. - - With Canyon, these `depositNonce` & `depositNonceVersion` fields must always be included. - -The receipt API responses utilize the receipt changes for more accurate response data: - -- The `depositNonce` is included in the receipt JSON data in API responses -- For contract-deployments (when `to == null`), the `depositNonce` helps derive the correct `contractAddress` meta-data, - instead of assuming the nonce was zero. -- The `cumulativeGasUsed` accounts for the actual gas usage, as metered in the EVM processing. - -[EIP-658]: https://eips.ethereum.org/EIPS/eip-658 - -## L1 Attributes Deposited Transaction - -[l1-attr-deposit]: #l1-attributes-deposited-transaction - -An [L1 attributes deposited transaction][g-l1-attr-deposit] is a deposit transaction sent to the [L1 -attributes predeployed contract][predeploy]. - -This transaction MUST have the following values: - -1. `from` is `0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001` (the address of the - [L1 Attributes depositor account][depositor-account]) -2. `to` is `0x4200000000000000000000000000000000000015` (the address of the [L1 attributes predeployed - contract][predeploy]). -3. `mint` is `0` -4. `value` is `0` -5. `gasLimit` is set to `1,000,000`. -6. `isSystemTx` is set to `false`. -7. `data` is an encoded call to the [L1 attributes predeployed contract][predeploy] that - depends on the upgrades that are active (see below). - -This system-initiated transaction for L1 attributes is not charged any ETH for its allocated -`gasLimit`, as it is considered part of state-transition processing. - -### L1 Attributes Deposited Transaction Calldata - -#### L1 Attributes - Bedrock, Canyon, Delta - -The `data` field of the L1 attributes deposited transaction is an [ABI][ABI] encoded call to the -`setL1BlockValues()` function with correct values associated with the corresponding L1 block -(cf. [reference implementation][l1-attr-ref-implem]). - -## Special Accounts on L2 - -The L1 attributes deposit transaction involves two special purpose accounts: - -1. The L1 attributes depositor account -2. The L1 attributes predeployed contract - -### L1 Attributes Depositor Account - -[depositor-account]: #l1-attributes-depositor-account - -The depositor account is an [EOA][g-eoa] with no known private key. It has the address -`0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001`. Its value is returned by the `CALLER` and `ORIGIN` -opcodes during execution of the L1 attributes deposited transaction. - -### L1 Attributes Predeployed Contract - -[predeploy]: #l1-attributes-predeployed-contract - -A predeployed contract on L2 at address `0x4200000000000000000000000000000000000015`, which holds -certain block variables from the corresponding L1 block in storage, so that they may be accessed -during the execution of the subsequent deposited transactions. - -The predeploy stores the following values: - -- L1 block attributes: - - `number` (`uint64`) - - `timestamp` (`uint64`) - - `basefee` (`uint256`) - - `hash` (`bytes32`) -- `sequenceNumber` (`uint64`): This equals the L2 block number relative to the start of the epoch, - i.e. the L2 block distance to the L2 block height that the L1 attributes last changed, - and reset to 0 at the start of a new epoch. -- System configurables tied to the L1 block, see [System configuration specification](../consensus/derivation.md#system-configuration): - - `batcherHash` (`bytes32`): A versioned commitment to the batch-submitter(s) currently operating. - - `overhead` (`uint256`): The L1 fee overhead to apply to L1 cost computation of transactions in this L2 block. - - `scalar` (`uint256`): The L1 fee scalar to apply to L1 cost computation of transactions in this L2 block. - -The contract implements an authorization scheme, such that it only accepts state-changing calls from -the [depositor account][depositor-account]. - -The contract has the following solidity interface, and can be interacted with according to the -[contract ABI specification][ABI]. - -[ABI]: https://docs.soliditylang.org/en/v0.8.10/abi-spec.html - -#### L1 Attributes Predeployed Contract: Reference Implementation - -[l1-attr-ref-implem]: #l1-attributes-predeployed-contract-reference-implementation - -A reference implementation of the L1 Attributes predeploy contract can be found in [L1Block.sol]. - -[L1Block.sol]: https://github.com/ethereum-optimism/optimism/blob/d48b45954c381f75a13e61312da68d84e9b41418/packages/contracts-bedrock/src/L2/L1Block.sol - -## User-Deposited Transactions - -[user-deposited]: #user-deposited-transactions - -[User-deposited transactions][g-user-deposited] are [deposited transactions][deposited-tx-type] -generated by the [L2 Chain Derivation][g-derivation] process. The content of each user-deposited -transaction are determined by the corresponding `TransactionDeposited` event emitted by the -[deposit contract][deposit-contract] on L1. - -1. `from` is unchanged from the emitted value (though it may - have been transformed to an alias in `OptimismPortal`, the deposit feed contract). -2. `to` is any 20-byte address (including the zero address) - - In case of a contract creation (cf. `isCreation`), this address is set to `null`. -3. `mint` is set to the emitted value. -4. `value` is set to the emitted value. -5. `gaslimit` is unchanged from the emitted value. It must be at least 21000. -6. `isCreation` is set to `true` if the transaction is a contract creation, `false` otherwise. -7. `data` is unchanged from the emitted value. Depending on the value of `isCreation` it is handled - as either calldata or contract initialization code. -8. `isSystemTx` is set by the rollup node for certain transactions that have unmetered execution. - It is `false` for user deposited transactions - -### Deposit Contract - -[deposit-contract]: #deposit-contract - -The deposit contract is deployed to L1. Deposited transactions are derived from the values in -the `TransactionDeposited` event(s) emitted by the deposit contract. - -The deposit contract is responsible for maintaining the [guaranteed gas market](#guaranteed-gas-fee-market), -charging deposits for gas to be used on L2, and ensuring that the total amount of guaranteed -gas in a single L1 block does not exceed the L2 block gas limit. - -The deposit contract handles two special cases: - -1. A contract creation deposit, which is indicated by setting the `isCreation` flag to `true`. - In the event that the `to` address is non-zero, the contract will revert. -2. A call from a contract account, in which case the `from` value is transformed to its L2 - [alias][address-aliasing]. - -#### Address Aliasing - -[address-aliasing]: #address-aliasing - -If the caller is a contract, the address will be transformed by adding -`0x1111000000000000000000000000000000001111` to it. The math is `unchecked` and done on a -Solidity `uint160` so the value will overflow. This prevents attacks in which a -contract on L1 has the same address as a contract on L2 but doesn't have the same code. We can safely ignore this -for EOAs because they're guaranteed to have the same "code" (i.e. no code at all). This also makes -it possible for users to interact with contracts on L2 even when the Sequencer is down. - -#### Deposit Contract Implementation: Optimism Portal - -A reference implementation of the deposit contract can be found in [OptimismPortal.sol]. - -[OptimismPortal.sol]: https://github.com/ethereum-optimism/optimism/blob/d48b45954c381f75a13e61312da68d84e9b41418/packages/contracts-bedrock/src/L1/OptimismPortal.sol - -## Guaranteed Gas Fee Market - -[Deposited transactions][g-deposited] are transactions on L2 that are -initiated on L1. The gas that they use on L2 is bought on L1 via a gas burn (or a direct payment -in the future). We maintain a fee market and hard cap on the amount of gas provided to all deposits -in a single L1 block. - -The gas provided to deposited transactions is sometimes called "guaranteed gas". The gas provided to -deposited transactions is unique in the regard that it is not refundable. It cannot be refunded as -it is sometimes paid for with a gas burn and there may not be any ETH left to refund. - -The **guaranteed gas** is composed of a gas stipend, and of any guaranteed gas the user would like -to purchase (on L1) on top of that. - -Guaranteed gas on L2 is bought in the following manner. An L2 gas price is calculated via an -EIP-1559-style algorithm. The total amount of ETH required to buy that gas is then calculated as -(`guaranteed gas * L2 deposit base fee`). The contract then accepts that amount of ETH (in a future -upgrade) or (only method right now), burns an amount of L1 gas that corresponds to the L2 cost (`L2 -cost / L1 base fee`). The L2 gas price for guaranteed gas is not synchronized with the base fee on -L2 and will likely be different. - -### Gas Stipend - -To offset the gas spent on the deposit event, we credit `gas spent * L1 base fee` ETH to the cost -of the L2 gas, where `gas spent` is the amount of L1 gas spent processing the deposit. If the ETH -value of this credit is greater than the ETH value of the requested guaranteed gas (`requested -guaranteed gas * L2 gas price`), no L1 gas is burnt. - -### Default Values - -| Variable | Value | -| --------------------------------- | ---------------------------------------------- | -| `MAX_RESOURCE_LIMIT` | 20,000,000 | -| `ELASTICITY_MULTIPLIER` | 10 | -| `BASE_FEE_MAX_CHANGE_DENOMINATOR` | 8 | -| `MINIMUM_BASE_FEE` | 1 gwei | -| `MAXIMUM_BASE_FEE` | type(uint128).max | -| `SYSTEM_TX_MAX_GAS` | 1,000,000 | -| `TARGET_RESOURCE_LIMIT` | `MAX_RESOURCE_LIMIT` / `ELASTICITY_MULTIPLIER` | - -### Limiting Guaranteed Gas - -The total amount of guaranteed gas that can be bought in a single L1 block must be limited to -prevent a denial of service attack against L2 as well as ensure the total amount of guaranteed gas -stays below the L2 block gas limit. - -We set a guaranteed gas limit of `MAX_RESOURCE_LIMIT` gas per L1 block and a target of -`MAX_RESOURCE_LIMIT` / `ELASTICITY_MULTIPLIER` gas per L1 block. These numbers enabled -occasional large transactions while staying within our target and maximum gas usage on L2. - -Because the amount of guaranteed L2 gas that can be purchased in a single block is now limited, -we implement an EIP-1559-style fee market to reduce congestion on deposits. By setting the limit -at a multiple of the target, we enable deposits to temporarily use more L2 gas at a greater cost. - -```python -# Pseudocode to update the L2 deposit base fee and cap the amount of guaranteed gas -# bought in a block. Calling code must handle the gas burn and validity checks on -# the ability of the account to afford this gas. - -# prev_base fee is a u128, prev_bought_gas and prev_num are u64s -prev_base_fee, prev_bought_gas, prev_num = -now_num = block.number - -# Clamp the full base fee to a specific range. The minimum value in the range should be around 100-1000 -# to enable faster responses in the base fee. This replaces the `max` mechanism in the ethereum 1559 -# implementation (it also serves to enable the base fee to increase if it is very small). -def clamp(v: i256, min: u128, max: u128) -> u128: - if v < i256(min): - return min - elif v > i256(max): - return max - else: - return u128(v) - -# If this is a new block, update the base fee and reset the total gas -# If not, just update the total gas -if prev_num == now_num: - now_base_fee = prev_base_fee - now_bought_gas = prev_bought_gas + requested_gas -elif prev_num != now_num: - # Width extension and conversion to signed integer math - gas_used_delta = int128(prev_bought_gas) - int128(TARGET_RESOURCE_LIMIT) - # Use truncating (round to 0) division - solidity's default. - # Sign extend gas_used_delta & prev_base_fee to 256 bits to avoid overflows here. - base_fee_per_gas_delta = prev_base_fee * gas_used_delta / TARGET_RESOURCE_LIMIT / BASE_FEE_MAX_CHANGE_DENOMINATOR - now_base_fee_wide = prev_base_fee + base_fee_per_gas_delta - - now_base_fee = clamp(now_base_fee_wide, min=MINIMUM_BASE_FEE, max=UINT_128_MAX_VALUE) - now_bought_gas = requested_gas - - # If we skipped multiple blocks between the previous block and now update the base fee again. - # This is not exactly the same as iterating the above function, but quite close for reasonable - # gas target values. It is also constant time wrt the number of missed blocks which is important - # for keeping gas usage stable. - if prev_num + 1 < now_num: - n = now_num - prev_num - 1 - # Apply 7/8 reduction to prev_base_fee for the n empty blocks in a row. - now_base_fee_wide = now_base_fee * pow(1-(1/BASE_FEE_MAX_CHANGE_DENOMINATOR), n) - now_base_fee = clamp(now_base_fee_wide, min=MINIMUM_BASE_FEE, max=type(uint128).max) - -require(now_bought_gas < MAX_RESOURCE_LIMIT) - -store_values(now_base_fee, now_bought_gas, now_num) -``` - -### Rationale for burning L1 Gas - -There must be a sybil resistance mechanism for usage of the network. If it is very cheap to get -guaranteed gas on L2, then it would be possible to spam the network. Burning a dynamic amount -of gas on L1 acts as a sybil resistance mechanism as it becomes more expensive with more demand. - -If we collect ETH directly to pay for L2 gas, every (indirect) caller of the deposit function will need -to be marked with the payable selector. This won't be possible for many existing projects. Unfortunately -this is quite wasteful. As such, we will provide two options to buy L2 gas: - -1. Burn L1 Gas -2. Send ETH to the Optimism Portal (Not yet supported) - -The payable version (Option 2) will likely have discount applied to it (or conversely, #1 has a -premium applied to it). - -For the initial release of bedrock, only #1 is supported. - -### On Preventing Griefing Attacks - -The cost of purchasing all of the deposit gas in every block must be expensive -enough to prevent attackers from griefing all deposits to the network. -An attacker would observe a deposit in the mempool and frontrun it with a deposit -that purchases enough gas such that the other deposit reverts. -The smaller the max resource limit is, the easier this attack is to pull off. -This attack is mitigated by having a large resource limit as well as a large -elasticity multiplier. This means that the target resource usage is kept small, -giving a lot of room for the deposit base fee to rise when the max resource limit -is being purchased. - -This attack should be too expensive to pull off in practice, but if an extremely -wealthy adversary does decide to grief network deposits for an extended period -of time, efforts will be placed to ensure that deposits are able to be processed -on the network. diff --git a/docs/specs/pages/protocol/bridging/messengers.md b/docs/specs/pages/protocol/bridging/messengers.md deleted file mode 100644 index 58b1213e20..0000000000 --- a/docs/specs/pages/protocol/bridging/messengers.md +++ /dev/null @@ -1,118 +0,0 @@ -# Cross Domain Messengers - -## Overview - -The cross domain messengers are responsible for providing a higher level API for -developers who are interested in sending cross domain messages. They allow for -the ability to replay cross domain messages and sit directly on top of the lower -level system contracts responsible for cross domain messaging on L1 and L2. - -The `CrossDomainMessenger` is extended to create both an -`L1CrossDomainMessenger` as well as a `L2CrossDomainMessenger`. -These contracts are then extended with their legacy APIs to provide backwards -compatibility for applications that integrated before the Bedrock system -upgrade. - -The `L2CrossDomainMessenger` is a predeploy contract located at -`0x4200000000000000000000000000000000000007`. - -The base `CrossDomainMessenger` interface is: - -```solidity -interface CrossDomainMessenger { - event FailedRelayedMessage(bytes32 indexed msgHash); - event RelayedMessage(bytes32 indexed msgHash); - event SentMessage(address indexed target, address sender, bytes message, uint256 messageNonce, uint256 gasLimit); - event SentMessageExtension1(address indexed sender, uint256 value); - - function MESSAGE_VERSION() external view returns (uint16); - function MIN_GAS_CALLDATA_OVERHEAD() external view returns (uint64); - function MIN_GAS_CONSTANT_OVERHEAD() external view returns (uint64); - function MIN_GAS_DYNAMIC_OVERHEAD_DENOMINATOR() external view returns (uint64); - function MIN_GAS_DYNAMIC_OVERHEAD_NUMERATOR() external view returns (uint64); - function OTHER_MESSENGER() external view returns (address); - function baseGas(bytes memory _message, uint32 _minGasLimit) external pure returns (uint64); - function failedMessages(bytes32) external view returns (bool); - function messageNonce() external view returns (uint256); - function relayMessage( - uint256 _nonce, - address _sender, - address _target, - uint256 _value, - uint256 _minGasLimit, - bytes memory _message - ) external payable returns (bytes memory returnData_); - function sendMessage(address _target, bytes memory _message, uint32 _minGasLimit) external payable; - function successfulMessages(bytes32) external view returns (bool); - function xDomainMessageSender() external view returns (address); -} -``` - -## Message Passing - -The `sendMessage` function is used to send a cross domain message. To trigger -the execution on the other side, the `relayMessage` function is called. -Successful messages have their hash stored in the `successfulMessages` mapping -while unsuccessful messages have their hash stored in the `failedMessages` -mapping. - -The user experience when sending from L1 to L2 is a bit different than when -sending a transaction from L2 to L1. When going from L1 into L2, the user does -not need to call `relayMessage` on L2 themselves. The user pays for L2 gas on L1 -and the transaction is automatically pulled into L2 where it is executed on L2. -When going from L2 into L1, the user proves their withdrawal on OptimismPortal, -then waits for the finalization window to pass, and then finalizes the withdrawal -on the OptimismPortal, which calls `relayMessage` on the -`L1CrossDomainMessenger` to finalize the withdrawal. - -## Upgradability - -The L1 and L2 cross domain messengers should be deployed behind upgradable -proxies. This will allow for updating the message version. - -## Message Versioning - -Messages are versioned based on the first 2 bytes of their nonce. Depending on -the version, messages can have a different serialization and hashing scheme. -The first two bytes of the nonce are reserved for version metadata because -a version field was not originally included in the messages themselves, but -a `uint256` nonce is so large that we can very easily pack additional data -into that field. - -### Message Version 0 - -```solidity -abi.encodeWithSignature( - "relayMessage(address,address,bytes,uint256)", - _target, - _sender, - _message, - _messageNonce -); -``` - -### Message Version 1 - -```solidity -abi.encodeWithSignature( - "relayMessage(uint256,address,address,uint256,uint256,bytes)", - _nonce, - _sender, - _target, - _value, - _gasLimit, - _data -); -``` - -## Backwards Compatibility Notes - -An older version of the messenger contracts had the concept of blocked messages -in a `blockedMessages` mapping. This functionality was removed from the -messengers because a smart attacker could get around any message blocking -attempts. It also saves gas on finalizing withdrawals. - -The concept of a "relay id" and the `relayedMessages` mapping was removed. -It was built as a way to be able to fund third parties who relayed messages -on the behalf of users, but it was improperly implemented as it was impossible -to know if the relayed message actually succeeded. diff --git a/docs/specs/pages/protocol/bridging/withdrawals.md b/docs/specs/pages/protocol/bridging/withdrawals.md deleted file mode 100644 index bc12db8132..0000000000 --- a/docs/specs/pages/protocol/bridging/withdrawals.md +++ /dev/null @@ -1,208 +0,0 @@ -# Withdrawals - - - -[g-deposits]: ../../reference/glossary.md#deposits -[g-withdrawal]: ../../reference/glossary.md#withdrawal -[g-relayer]: ../../reference/glossary.md#withdrawals -[g-execution-engine]: ../../reference/glossary.md#execution-engine - -## Overview - -[Withdrawals][g-withdrawal] are cross domain transactions which are initiated on L2, and finalized by a transaction -executed on L1. Notably, withdrawals may be used by an L2 account to call an L1 contract, or to transfer ETH from -an L2 account to an L1 account. - -**Vocabulary note**: _withdrawal_ can refer to the transaction at various stages of the process, but we introduce -more specific terms to differentiate: - -- A _withdrawal initiating transaction_ refers specifically to a transaction on L2 sent to the Withdrawals predeploy. -- A _withdrawal proving transaction_ refers specifically to an L1 transaction - which proves the withdrawal is correct (that it has been included in a merkle - tree whose root is available on L1). -- A _withdrawal finalizing transaction_ refers specifically to an L1 transaction which finalizes and relays the - withdrawal. - -Withdrawals are initiated on L2 via a call to the Message Passer predeploy contract, which records the important -properties of the message in its storage. -Withdrawals are proven on L1 via a call to the `OptimismPortal`, which proves the inclusion of this withdrawal message. -Withdrawals are finalized on L1 via a call to the `OptimismPortal` contract, -which verifies that the fault challenge period has passed since the withdrawal message has been proved. - -In this way, withdrawals are different from [deposits][g-deposits] which make use of a special transaction type in the -[execution engine][g-execution-engine] client. Rather, withdrawals transaction must use smart contracts on L1 for -finalization. - -## Withdrawal Flow - -We first describe the end to end flow of initiating and finalizing a withdrawal: - -### On L2 - -An L2 account sends a withdrawal message (and possibly also ETH) to the `L2ToL1MessagePasser` predeploy contract. -This is a very simple contract that stores the hash of the withdrawal data. - -### On L1 - -1. A [relayer][g-relayer] submits a withdrawal proving transaction with the required inputs - to the `OptimismPortal` contract. - The relayer is not necessarily the same entity which initiated the withdrawal on L2. - These inputs include the withdrawal transaction data, inclusion proofs, and a block number. The block number - must be one for which an L2 output root exists, which commits to the withdrawal as registered on L2. -1. The `OptimismPortal` contract retrieves the output root for the given block number from the `L2OutputOracle`'s - `getL2Output()` function, and performs the remainder of the verification process internally. -1. If proof verification fails, the call reverts. Otherwise the hash is recorded to prevent it from being re-proven. - Note that the withdrawal can be proven more than once if the corresponding output root changes. -1. After the withdrawal is proven, it enters a 7 day challenge period, allowing time for other network participants - to challenge the integrity of the corresponding output root. -1. Once the challenge period has passed, a relayer submits a withdrawal finalizing transaction to the - `OptimismPortal` contract. - The relayer doesn't need to be the same entity that initiated the withdrawal on L2. -1. The `OptimismPortal` contract receives the withdrawal transaction data and verifies that the withdrawal has - both been proven and passed the challenge period. -1. If the requirements are not met, the call reverts. Otherwise the call is forwarded, and the hash is recorded to - prevent it from being replayed. - -## The L2ToL1MessagePasser Contract - -A withdrawal is initiated by calling the L2ToL1MessagePasser contract's `initiateWithdrawal` function. -The L2ToL1MessagePasser is a simple predeploy contract at `0x4200000000000000000000000000000000000016` -which stores messages to be withdrawn. - -```js -interface L2ToL1MessagePasser { - event MessagePassed( - uint256 indexed nonce, // this is a global nonce value for all withdrawal messages - address indexed sender, - address indexed target, - uint256 value, - uint256 gasLimit, - bytes data, - bytes32 withdrawalHash - ); - - event WithdrawerBalanceBurnt(uint256 indexed amount); - - function burn() external; - - function initiateWithdrawal(address _target, uint256 _gasLimit, bytes memory _data) payable external; - - function messageNonce() public view returns (uint256); - - function sentMessages(bytes32) view external returns (bool); -} - -``` - -The `MessagePassed` event includes all of the data that is hashed and -stored in the `sentMessages` mapping, as well as the hash itself. - -### Addresses are not Aliased on Withdrawals - -When a contract makes a deposit, the sender's address is [aliased](deposits.md#address-aliasing). The same is not true -of withdrawals, which do not modify the sender's address. The difference is that: - -- on L2, the deposit sender's address is returned by the `CALLER` opcode, meaning a contract cannot easily tell if the - call originated on L1 or L2, whereas -- on L1, the withdrawal sender's address is accessed by calling the `l2Sender()` function on the `OptimismPortal` - contract. - -Calling `l2Sender()` removes any ambiguity about which domain the call originated from. Still, developers will need to -recognize that having the same address does not imply that a contract on L2 will behave the same as a contract on L1. - -## The Optimism Portal Contract - -The Optimism Portal serves as both the entry and exit point to Base L2. It is a contract that inherits from -the [OptimismPortal](deposits.md#deposit-contract) contract, and in addition provides the following interface for -withdrawals: - -- [`WithdrawalTransaction` type] -- [`OutputRootProof` type] - -```js -interface OptimismPortal { - - event WithdrawalFinalized(bytes32 indexed withdrawalHash, bool success); - - - function l2Sender() returns(address) external; - - function proveWithdrawalTransaction( - Types.WithdrawalTransaction memory _tx, - uint256 _l2OutputIndex, - Types.OutputRootProof calldata _outputRootProof, - bytes[] calldata _withdrawalProof - ) external; - - function finalizeWithdrawalTransaction( - Types.WithdrawalTransaction memory _tx - ) external; -} -``` - -## Withdrawal Verification and Finalization - -The following inputs are required to prove and finalize a withdrawal: - -- Withdrawal transaction data: - - `nonce`: Nonce for the provided message. - - `sender`: Message sender address on L2. - - `target`: Target address on L1. - - `value`: ETH to send to the target. - - `data`: Data to send to the target. - - `gasLimit`: Gas to be forwarded to the target. -- Proof and verification data: - - `l2OutputIndex`: The index in the L2 outputs where the applicable output root may be found. - - `outputRootProof`: Four `bytes32` values which are used to derive the output root. - - `withdrawalProof`: An inclusion proof for the given withdrawal in the L2ToL1MessagePasser contract. - -These inputs must satisfy the following conditions: - -1. The `l2OutputIndex` must be the index in the L2 outputs that contains the applicable output root. -1. `L2OutputOracle.getL2Output(l2OutputIndex)` returns a non-zero `OutputProposal`. -1. The keccak256 hash of the `outputRootProof` values is equal to the `outputRoot`. -1. The `withdrawalProof` is a valid inclusion proof demonstrating that a hash of the Withdrawal transaction data - is contained in the storage of the L2ToL1MessagePasser contract on L2. - -## Security Considerations - -### Key Properties of Withdrawal Verification - -1. It should not be possible to 'double spend' a withdrawal, ie. to relay a withdrawal on L1 which does not - correspond to a message initiated on L2. For reference, see [this writeup][polygon-dbl-spend] of a vulnerability - of this type found on Polygon. - - [polygon-dbl-spend]: https://gerhard-wagner.medium.com/double-spending-bug-in-polygons-plasma-bridge-2e0954ccadf1 - -1. For each withdrawal initiated on L2 (i.e. with a unique `messageNonce()`), the following properties must hold: - 1. It should only be possible to prove the withdrawal once, unless the outputRoot for the withdrawal - has changed. - 1. It should only be possible to finalize the withdrawal once. - 1. It should not be possible to relay the message with any of its fields modified, ie. - 1. Modifying the `sender` field would enable a 'spoofing' attack. - 1. Modifying the `target`, `data`, or `value` fields would enable an attacker to dangerously change the - intended outcome of the withdrawal. - 1. Modifying the `gasLimit` could make the cost of relaying too high, or allow the relayer to cause execution - to fail (out of gas) in the `target`. - -### Handling Successfully Verified Messages That Fail When Relayed - -If the execution of the relayed call fails in the `target` contract, it is unfortunately not possible to determine -whether or not it was 'supposed' to fail, and whether or not it should be 'replayable'. For this reason, and to -minimize complexity, we have not provided any replay functionality, this may be implemented in external utility -contracts if desired. - -[`WithdrawalTransaction` type]: https://github.com/ethereum-optimism/optimism/blob/08daf8dbd38c9ffdbd18fc9a211c227606cdb0ad/packages/contracts-bedrock/src/libraries/Types.sol#L62-L69 -[`OutputRootProof` type]: https://github.com/ethereum-optimism/optimism/blob/08daf8dbd38c9ffdbd18fc9a211c227606cdb0ad/packages/contracts-bedrock/src/libraries/Types.sol#L25-L30 - -### OptimismPortal can send arbitrary messages on L1 - -The `L2ToL1MessagePasser` contract's `initiateWithdrawal` function accepts a `_target` address and `_data` bytes, -which is passed to a `CALL` opcode on L1 when `finalizeWithdrawalTransaction` is called after the challenge -period. This means that, by design, the `OptimismPortal` contract can be used to send arbitrary transactions on -the L1, with the `OptimismPortal` as the `msg.sender`. - -This means users of the `OptimismPortal` contract should be careful what permissions they grant to the portal. -For example, any ERC20 tokens mistakenly sent to the `OptimismPortal` contract are essentially lost, as they can -be claimed by anybody that pre-approves transfers of this token out of the portal, using the L2 to initiate the -approval and the L1 to prove and finalize the approval (after the challenge period). diff --git a/docs/specs/pages/protocol/consensus/derivation.md b/docs/specs/pages/protocol/consensus/derivation.md deleted file mode 100644 index 2fd8d5d7b1..0000000000 --- a/docs/specs/pages/protocol/consensus/derivation.md +++ /dev/null @@ -1,1065 +0,0 @@ -# Derivation - - - -[g-derivation]: ../../reference/glossary.md#l2-chain-derivation -[g-payload-attr]: ../../reference/glossary.md#payload-attributes -[g-block]: ../../reference/glossary.md#block -[g-exec-engine]: ../../reference/glossary.md#execution-engine -[g-reorg]: ../../reference/glossary.md#chain-re-organization -[g-receipts]: ../../reference/glossary.md#receipt -[g-deposit-contract]: ../../reference/glossary.md#deposit-contract -[g-deposited]: ../../reference/glossary.md#deposited-transaction -[g-l1-attr-deposit]: ../../reference/glossary.md#l1-attributes-deposited-transaction -[g-l1-origin]: ../../reference/glossary.md#l1-origin -[g-user-deposited]: ../../reference/glossary.md#user-deposited-transaction -[g-deposits]: ../../reference/glossary.md#deposits -[g-sequencing]: ../../reference/glossary.md#sequencing -[g-sequencer]: ../../reference/glossary.md#sequencer -[g-sequencing-epoch]: ../../reference/glossary.md#sequencing-epoch -[g-sequencing-window]: ../../reference/glossary.md#sequencing-window -[g-sequencer-batch]: ../../reference/glossary.md#sequencer-batch -[g-l2-genesis]: ../../reference/glossary.md#l2-genesis-block -[g-l2-chain-inception]: ../../reference/glossary.md#l2-chain-inception -[g-l2-genesis-block]: ../../reference/glossary.md#l2-genesis-block -[g-batcher-transaction]: ../../reference/glossary.md#batcher-transaction -[g-avail-provider]: ../../reference/glossary.md#data-availability-provider -[g-batcher]: ../../reference/glossary.md#batcher -[g-l2-output]: ../../reference/glossary.md#l2-output-root -[g-fault-proof]: ../../reference/glossary.md#fault-proof -[g-channel]: ../../reference/glossary.md#channel -[g-channel-frame]: ../../reference/glossary.md#channel-frame -[g-rollup-node]: ../../reference/glossary.md#rollup-node -[g-block-time]: ../../reference/glossary.md#block-time -[g-time-slot]: ../../reference/glossary.md#time-slot -[g-consolidation]: ../../reference/glossary.md#unsafe-block-consolidation -[g-safe-l2-head]: ../../reference/glossary.md#safe-l2-head -[g-safe-l2-block]: ../../reference/glossary.md#safe-l2-block -[g-unsafe-l2-head]: ../../reference/glossary.md#unsafe-l2-head -[g-unsafe-l2-block]: ../../reference/glossary.md#unsafe-l2-block -[g-unsafe-sync]: ../../reference/glossary.md#unsafe-sync -[g-deposit-tx-type]: ../../reference/glossary.md#deposited-transaction-type -[g-finalized-l2-head]: ../../reference/glossary.md#finalized-l2-head -[g-system-config]: ../../reference/glossary.md#system-configuration - -## Overview - -> **Note** the following assumes a single sequencer and batcher. In the future, the design will be adapted to -> accommodate multiple such entities. - -[L2 chain derivation][g-derivation] — deriving L2 [blocks][g-block] from L1 data — is one of the main responsibilities -of the [rollup node][g-rollup-node], both in validator mode, and in sequencer mode (where derivation acts as a sanity -check on sequencing, and enables detecting L1 chain [re-organizations][g-reorg]). - -The L2 chain is derived from the L1 chain. In particular, each L1 block following [L2 chain -inception][g-l2-chain-inception] is mapped to a [sequencing epoch][g-sequencing-epoch] comprising -at least one L2 block. Each L2 block belongs to exactly one epoch, and we call the corresponding L1 -block its [L1 origin][g-l1-origin]. The epoch's number equals that of its L1 origin block. - -To derive the L2 blocks of epoch number `E`, we need the following inputs: - -- L1 blocks in the range `[E, E + SWS)`, called the [sequencing window][g-sequencing-window] of the epoch, and `SWS` - the sequencing window size. (Note that sequencing windows overlap.) -- [Batcher transactions][g-batcher-transaction] from blocks in the sequencing window. - - These transactions allow us to reconstruct the epoch's [sequencer batches][g-sequencer-batch], each of - which will produce one L2 block. Note that: - - The L1 origin will never contain any data needed to construct sequencer batches since - each batch [must contain](#batch-format) the L1 origin hash. - - An epoch may have no sequencer batches. -- [Deposits][g-deposits] made in the L1 origin (in the form of events emitted by the [deposit - contract][g-deposit-contract]). -- L1 block attributes from the L1 origin (to derive the [L1 attributes deposited transaction][g-l1-attr-deposit]). -- The state of the L2 chain after the last L2 block of the previous epoch, or the [L2 genesis state][g-l2-genesis] - if `E` is the first epoch. - -To derive the whole L2 chain from scratch, we start with the [L2 genesis state][g-l2-genesis] and -the [L2 genesis block][g-l2-genesis-block] as the first L2 block. We then derive L2 blocks from each epoch in order, -starting at the first L1 block following [L2 chain inception][g-l2-chain-inception]. Refer to the -[Architecture section][architecture] for more information on how we implement this in practice. -The L2 chain may contain pre-Bedrock history, but the L2 genesis here refers to the Bedrock L2 -genesis block. - -Each L2 `block` with origin `l1_origin` is subject to the following constraints (whose values are -denominated in seconds): - -- `block.timestamp = prev_l2_timestamp + l2_block_time` - - - `prev_l2_timestamp` is the timestamp of the L2 block immediately preceding this one. If there - is no preceding block, then this is the genesis block, and its timestamp is explicitly - specified. - - `l2_block_time` is a configurable parameter of the time between L2 blocks (2s on Base). - -- `l1_origin.timestamp <= block.timestamp <= max_l2_timestamp`, where - - `max_l2_timestamp = max(l1_origin.timestamp + max_sequencer_drift, prev_l2_timestamp + l2_block_time)` - - `max_sequencer_drift` is a configurable parameter that bounds how far the sequencer can get ahead of - the L1. - -Finally, each epoch must have at least one L2 block. - -The first constraint means there must be an L2 block every `l2_block_time` seconds following L2 -chain inception. - -The second constraint ensures that an L2 block timestamp never precedes its L1 origin timestamp, -and is never more than `max_sequencer_drift` ahead of it, except only in the unusual case where it -might prohibit an L2 block from being produced every l2_block_time seconds. (Such cases might arise -for example under a proof-of-work L1 that sees a period of rapid L1 block production.) In either -case, the sequencer enforces `len(batch.transactions) == 0` while `max_sequencer_drift` is -exceeded. See [Batch Queue](#batch-queue) for more details. - -The final requirement that each epoch must have at least one L2 block ensures that all relevant -information from the L1 (e.g. deposits) is represented in the L2, even if it has no sequencer -batches. - -Post-merge, Ethereum has a fixed 12s [block time][g-block-time], though some slots can be -skipped. Under a 2s L2 block time, we thus expect each epoch to typically contain `12/2 = 6` L2 -blocks. The sequencer will however produce bigger epochs in order to maintain liveness in case of -either a skipped slot on the L1 or a temporary loss of connection to it. For the lost connection -case, smaller epochs might be produced after the connection was restored to keep L2 timestamps from -drifting further and further ahead. - -## Eager Block Derivation - -Deriving an L2 block requires that we have constructed its sequencer batch and derived all L2 -blocks and state updates prior to it. This means we can typically derive the L2 blocks of an epoch -_eagerly_ without waiting on the full sequencing window. The full sequencing window is required -before derivation only in the very worst case where some portion of the sequencer batch for the -first block of the epoch appears in the very last L1 block of the window. Note that this only -applies to _block_ derivation. Sequencer batches can still be derived and tentatively queued -without deriving blocks from them. - -## Protocol Parameters - -The following table gives an overview of some protocol parameters, and how they are affected by -protocol upgrades. - -| Parameter | Bedrock (default) value | Latest (default) value | Changes | Notes | -| --------- | ----------------------- | ---------------------- | ------- | ----- | -| `max_sequencer_drift` | 600 | 1800 | [Fjord](../../upgrades/fjord/derivation.md#constant-maximum-sequencer-drift) | Changed from a chain parameter to a constant with Fjord. | -| `MAX_RLP_BYTES_PER_CHANNEL` | 10,000,000 | 100,000,000 | [Fjord](../../upgrades/fjord/derivation.md#increasing-max_rlp_bytes_per_channel-and-max_channel_bank_size) | Constant increased with Fjord. | -| `MAX_CHANNEL_BANK_SIZE` | 100,000,000 | 1,000,000,000 | [Fjord](../../upgrades/fjord/derivation.md#increasing-max_rlp_bytes_per_channel-and-max_channel_bank_size) | Constant increased with Fjord. | -| `MAX_SPAN_BATCH_ELEMENT_COUNT` | 10,000,000 | 10,000,000 | Effectively introduced in [Fjord](../../upgrades/fjord/derivation.md#increasing-max_rlp_bytes_per_channel-and-max_channel_bank_size)| Number of elements | - -## System Configuration - -The `SystemConfig` is an L1 contract that emits rollup configuration changes as log events. -The derivation pipeline picks up these events and applies them to L2 state, ensuring every -node converges on the same configuration at the same L2 block height. `SystemConfig` is the -source of truth for configuration values within Base. - -### System Config Updates - -System config updates are signaled through the `ConfigUpdate(uint256,uint8,bytes)` event. The event -structure includes: - -- The first topic determines the version -- The second topic determines the type of update -- The remaining event data encodes the configuration update - -In version `0`, the following update types are supported: - -- Type `0`: `batcherHash` overwrite, as `bytes32` payload -- Type `1`: Pre-Ecotone, `overhead` and `scalar` overwrite, as two packed `uint256` entries. After - Ecotone upgrade, `overhead` is ignored and `scalar` is interpreted as a versioned encoding that - updates `baseFeeScalar` and `blobBaseFeeScalar` -- Type `2`: `gasLimit` overwrite, as `uint64` payload -- Type `3`: `unsafeBlockSigner` overwrite, as `address` payload -- Type `4`: `eip1559Params` overwrite, as `uint256` payload encoding denomination and elasticity -- Type `5`: `operatorFeeParams` overwrite, as `uint256` payload encoding scalar and constant -- Type `6`: `minBaseFee` overwrite, as `uint64` payload -- Type `7`: `daFootprintGasScalar` overwrite, as `uint16` payload - -If a System Config Update cannot be parsed for any reason, it is not applied and is instead skipped. - ---- - -# Batch Submission - -## Sequencing & Batch Submission Overview - -The [sequencer][g-sequencer] accepts L2 transactions from users. It is responsible for building blocks out of these. For -each such block, it also creates a corresponding [sequencer batch][g-sequencer-batch]. It is also responsible for -submitting each batch to a [data availability provider][g-avail-provider] (e.g. Ethereum calldata), which it does via -its [batcher][g-batcher] component. - -The difference between an L2 block and a batch is subtle but important: the block includes an L2 state root, whereas the -batch only commits to transactions at a given L2 timestamp (equivalently: L2 block number). A block also includes a -reference to the previous block (\*). - -(\*) This matters in some edge case where a L1 reorg would occur and a batch would be reposted to the L1 chain but not -the preceding batch, whereas the predecessor of an L2 block cannot possibly change. - -This means that even if the sequencer applies a state transition incorrectly, the transactions in the batch will still -be considered part of the canonical L2 chain. Batches are still subject to validity checks (i.e. they have to be encoded -correctly), and so are individual transactions within the batch (e.g. signatures have to be valid). Invalid batches and -invalid individual transactions within an otherwise valid batch are discarded by correct nodes. - -If the sequencer applies a state transition incorrectly and posts an [output root][g-l2-output], then this output root -will be incorrect. The incorrect output root will be challenged by a [proof][g-fault-proof], then replaced -by a correct output root **for the existing sequencer batches.** - -Refer to the [Batch Submission specification][batcher-spec] for more information. - -[batcher-spec]: ../batcher.md - -## Batch Submission Wire Format - -[wire-format]: #batch-submission-wire-format - -Batch submission is closely tied to L2 chain derivation because the derivation process must decode the batches that have -been encoded for the purpose of batch submission. - -The [batcher][g-batcher] submits [batcher transactions][g-batcher-transaction] to a [data availability -provider][g-avail-provider]. These transactions contain one or multiple [channel frames][g-channel-frame], which are -chunks of data belonging to a [channel][g-channel]. - -A [channel][g-channel] is a sequence of [sequencer batches][g-sequencer-batch] (for any L2 blocks) compressed -together. The reason to group multiple batches together is simply to obtain a better compression rate, hence reducing -data availability costs. - -Channels might be too large to fit in a single [batcher transaction][g-batcher-transaction], hence we need to split it -into chunks known as [channel frames][g-channel-frame]. A single batcher transaction can also carry multiple frames -(belonging to the same or to different channels). - -This design gives use the maximum flexibility in how we aggregate batches into channels, and split channels over batcher -transactions. It notably allows us to maximize data utilization in a batcher transaction: for instance it allows us to -pack the final (small) frame of one channel with one or more frames from the next channel. - -Also note that we use a streaming compression scheme, and we do not need to know how many batches a channel will end up -containing when we start a channel, or even as we send the first frames in the channel. - -And by splitting channels across multiple data transactions, the L2 can have larger block data than the -data-availability layer may support. - -All of this is illustrated in the following diagram. Explanations below. - -![batch derivation chain diagram](/static/assets/batch-deriv-chain.svg) - -The first line represents L1 blocks with their numbers. The boxes under the L1 blocks represent [batcher -transactions][g-batcher-transaction] included within the block. The squiggles under the L1 blocks represent -[deposits][g-deposits] (more specifically, events emitted by the [deposit contract][g-deposit-contract]). - -Each colored chunk within the boxes represents a [channel frame][g-channel-frame]. So `A` and `B` are -[channels][g-channel] whereas `A0`, `A1`, `B0`, `B1`, `B2` are frames. Notice that: - -- multiple channels are interleaved -- frames do not need to be transmitted in order -- a single batcher transaction can carry frames from multiple channels - -In the next line, the rounded boxes represent individual [sequencer batches][g-sequencer-batch] that were extracted from -the channels. The four blue/purple/pink were derived from channel `A` while the other were derived from channel `B`. -These batches are here represented in the order they were decoded from batches (in this case `B` is decoded first). - -> **Note** The caption here says "Channel B was seen first and will be decoded into batches first", but this is not a -> requirement. For instance, it would be equally acceptable for an implementation to peek into the channels and decode -> the one that contains the oldest batches first. - -The rest of the diagram is conceptually distinct from the first part and illustrates L2 chain derivation after the -channels have been reordered. - -The first line shows batcher transactions. Note that in this case, there exists an ordering of the batches that makes -all frames within the channels appear contiguously. This is not true in general. For instance, in the second -transaction, the position of `A1` and `B0` could have been inverted for exactly the same result — no changes needed in -the rest of the diagram. - -The second line shows the reconstructed channels in proper order. The third line shows the batches extracted from the -channel. Because the channels are ordered and the batches within a channel are sequential, this means the batches are -ordered too. The fourth line shows the [L2 block][g-block] derived from each batch. Note that we have a 1-1 batch to -block mapping here but, as we'll see later, empty blocks that do not map to batches can be inserted in cases where there -are "gaps" in the batches posted on L1. - -The fifth line shows the [L1 attributes deposited transaction][g-l1-attr-deposit] which, within each L2 block, records -information about the L1 block that matches the L2 block's epoch. The first number denotes the epoch/L1x number, while -the second number (the "sequence number") denotes the position within the epoch. - -Finally, the sixth line shows [user-deposited transactions][g-user-deposited] derived from the [deposit -contract][g-deposit-contract] event mentioned earlier. - -Note the `101-0` L1 attributes transaction on the bottom right of the diagram. Its presence there is only possible if -frame `B2` indicates that it is the last frame within the channel and (2) no empty blocks must be inserted. - -The diagram does not specify the sequencing window size in use, but from this we can infer that it must be at least 4 -blocks, because the last frame of channel `A` appears in block 102, but belong to epoch 99. - -As for the comment on "security types", it explains the classification of blocks as used on L1 and L2. - -- [Unsafe L2 blocks][g-unsafe-l2-block]: -- [Safe L2 blocks][g-safe-l2-block]: -- Finalized L2 blocks: refer to block that have been derived from [finalized][g-finalized-l2-head] L1 data. - -These security levels map to the `headBlockHash`, `safeBlockHash` and `finalizedBlockHash` values transmitted when -interacting with the [execution-engine API][exec-engine]. - -### Batcher Transaction Format - -Batcher transactions are encoded as `version_byte ++ rollup_payload` (where `++` denotes concatenation). - -| `version_byte` | `rollup_payload` | -| -------------- | ---------------------------------------------- | -| 0 | `frame ...` (one or more frames, concatenated) | -| 1 | `da_commitment` (experimental data-availability commitment format) | - -Unknown versions make the batcher transaction invalid (it must be ignored by the rollup node). -All frames in a batcher transaction must be parseable. If any one frame fails to parse, the all frames in the -transaction are rejected. - -Batch transactions are authenticated by verifying that the `to` address of the transaction matches the batch inbox -address, and the `from` address matches the batch-sender address in the [system configuration][g-system-config] at the -time of the L1 block that the transaction data is read from. - -### Frame Format - -A [channel frame][g-channel-frame] is encoded as: - -```text -frame = channel_id ++ frame_number ++ frame_data_length ++ frame_data ++ is_last - -channel_id = bytes16 -frame_number = uint16 -frame_data_length = uint32 -frame_data = bytes -is_last = bool -``` - -Where `uint32` and `uint16` are all big-endian unsigned integers. Type names should be interpreted to and -encoded according to [the Solidity ABI][solidity-abi]. - -[solidity-abi]: https://docs.soliditylang.org/en/v0.8.16/abi-spec.html - -All data in a frame is fixed-size, except the `frame_data`. The fixed overhead is `16 + 2 + 4 + 1 = 23 bytes`. -Fixed-size frame metadata avoids a circular dependency with the target total data length, -to simplify packing of frames with varying content length. - -where: - -- `channel_id` is an opaque identifier for the channel. It should not be reused and is suggested to be random; however, - outside of timeout rules, it is not checked for validity -- `frame_number` identifies the index of the frame within the channel -- `frame_data_length` is the length of `frame_data` in bytes. It is capped to 1,000,000 bytes. -- `frame_data` is a sequence of bytes belonging to the channel, logically after the bytes from the previous frames -- `is_last` is a single byte with a value of 1 if the frame is the last in the channel, 0 if there are frames in the - channel. Any other value makes the frame invalid (it must be ignored by the rollup node). - -### Channel Format - -[channel-format]: #channel-format - -A channel is encoded by applying a streaming compression algorithm to a list of batches: - -```text -encoded_batches = [] -for batch in batches: - encoded_batches ++ batch.encode() -rlp_batches = rlp_encode(encoded_batches) -``` - -where: - -- `batches` is the input, a sequence of batches each with a byte-encoder -function `.encode()` as per the next section ("Batch Encoding") -- `encoded_batches` is a byte array: the concatenation of the encoded batches -- `rlp_batches` is the rlp encoding of the concatenated encoded batches - -```text -channel_encoding = zlib_compress(rlp_batches) -``` - -where zlib_compress is the ZLIB algorithm (as specified in [RFC-1950][rfc1950]) with no dictionary. - -[rfc1950]: https://www.rfc-editor.org/rfc/rfc1950.html - -The Fjord upgrade introduces an additional [versioned channel encoding -format](../../upgrades/fjord/derivation.md#brotli-channel-compression) to support alternate compression -algorithms. - -When decompressing a channel, we limit the amount of decompressed data to `MAX_RLP_BYTES_PER_CHANNEL` (defined in the -[Protocol Parameters table](#protocol-parameters)), in order to avoid "zip-bomb" types of attack (where a small -compressed input decompresses to a humongous amount of data). -If the decompressed data exceeds the limit, things proceeds as though the channel contained -only the first `MAX_RLP_BYTES_PER_CHANNEL` decompressed bytes. The limit is set on RLP decoding, so all batches that -can be decoded in `MAX_RLP_BYTES_PER_CHANNEL` will be accepted even if the size of the channel is greater than -`MAX_RLP_BYTES_PER_CHANNEL`. The exact requirement is that `length(input) <= MAX_RLP_BYTES_PER_CHANNEL`. - -While the above pseudocode implies that all batches are known in advance, it is possible to perform streaming -compression and decompression of RLP-encoded batches. This means it is possible to start including channel frames in a -[batcher transaction][g-batcher-transaction] before we know how many batches (and how many frames) the channel will -contain. - -### Batch Format - -[batch-format]: #batch-format - -Recall that a batch contains a list of transactions to be included in a specific L2 block. - -A batch is encoded as `batch_version ++ content`, where `content` depends on the `batch_version`. -Prior to the Delta upgrade, batches all have batch_version 0 and are encoded as described below. - -| `batch_version` | `content` | -| --------------- | ---------------------------------------------------------------------------------- | -| 0 | `rlp_encode([parent_hash, epoch_number, epoch_hash, timestamp, transaction_list])` | - -where: - -- `batch_version` is a single byte, prefixed before the RLP contents, alike to transaction typing. -- `rlp_encode` is a function that encodes a batch according to the [RLP format], and `[x, y, z]` denotes a list - containing items `x`, `y` and `z` -- `parent_hash` is the block hash of the previous L2 block -- `epoch_number` and `epoch_hash` are the number and hash of the L1 block corresponding to the [sequencing - epoch][g-sequencing-epoch] of the L2 block -- `timestamp` is the timestamp of the L2 block -- `transaction_list` is an RLP-encoded list of [EIP-2718] encoded transactions. - -[RLP format]: https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/ -[EIP-2718]: https://eips.ethereum.org/EIPS/eip-2718 - -The Delta upgrade introduced an additional batch type, [span batches][span-batches]. - -[span-batches]: ../../upgrades/delta/span-batches.md - -Unknown versions make the batch invalid (it must be ignored by the rollup node), as do malformed contents. - -> **Note** if the batch version and contents can be RLP decoded correctly but extra content exists beyond the batch, -> the additional data may be ignored during parsing. Data _between_ RLP encoded batches may not be ignored -> (as they are seen as malformed batches), but if a batch can be fully described by the RLP decoding, -> extra content does not invalidate the decoded batch. - -The `epoch_number` and the `timestamp` must also respect the constraints listed in the [Batch Queue][batch-queue] -section, otherwise the batch is considered invalid and will be ignored. - ---- - -# Architecture - -[architecture]: #architecture - -The above primarily describes the general encodings used in L2 chain derivation, -primarily how batches are encoded within [batcher transactions][g-batcher-transaction]. - -This section describes how the L2 chain is produced from the L1 batches using a pipeline architecture. - -A verifier may implement this differently, but must be semantically equivalent to not diverge from the L2 chain. - -## L2 Chain Derivation Pipeline - -Our architecture decomposes the derivation process into a pipeline made up of the following stages: - -1. L1 Traversal -2. L1 Retrieval -3. Frame Queue -4. Channel Bank -5. Channel Reader (Batch Decoding) -6. Batch Queue -7. Payload Attributes Derivation -8. Engine Queue - -The data flows from the start (outer) of the pipeline towards the end (inner). -From the innermost stage the data is pulled from the outermost stage. - -However, data is _processed_ in reverse order. Meaning that if there is any data to be processed in the last stage, it -will be processed first. Processing proceeds in "steps" that can be taken at each stage. We try to take as many steps as -possible in the last (most inner) stage before taking any steps in its outer stage, etc. - -This ensures that we use the data we already have before pulling more data and minimizes the latency of data traversing -the derivation pipeline. - -Each stage can maintain its own inner state as necessary. In particular, each stage maintains a L1 block reference -(number + hash) to the latest L1 block such that all data originating from previous blocks has been fully processed, and -the data from that block is being or has been processed. This allows the innermost stage to account for finalization of -the L1 data-availability used to produce the L2 chain, to reflect in the L2 chain forkchoice when the L2 chain inputs -become irreversible. - -Let's briefly describe each stage of the pipeline. - -### L1 Traversal - -In the _L1 Traversal_ stage, we simply read the header of the next L1 block. In normal operations, these will be new -L1 blocks as they get created, though we can also read old blocks while syncing, or in case of an L1 [re-org][g-reorg]. - -Upon traversal of the L1 block, the [system configuration][g-system-config] copy used by the L1 retrieval stage is -updated, such that the batch-sender authentication is always accurate to the exact L1 block that is read by the stage. - -### L1 Retrieval - -In the _L1 Retrieval_ stage, we read the block we get from the outer stage (L1 traversal), and -extract data from its [batcher transactions][g-batcher-transaction]. A batcher -transaction is one with the following properties: - -- The [`to`] field is equal to the configured batcher inbox address. - -- The transaction type is one of `0`, `1`, `2`, `3`, or `0x7e` (L2 [Deposited transaction type][g-deposit-tx-type], to - support force-inclusion of batcher transactions on Base). - -- The sender, as recovered from the transaction signature (`v`, `r`, and `s`), is the batcher - address loaded from the system config matching the L1 block of the data. - -Each batcher transaction is versioned and contains a series of [channel frames][g-channel-frame] to -be read by the Frame Queue, see [Batch Submission Wire Format][wire-format]. Each batcher -transaction in the block is processed in the order they appear in the block by passing its calldata -on to the next phase. - -[`to`]: https://github.com/ethereum/execution-specs/blob/3fe6514f2d9d234e760d11af883a47c1263eff51/src/ethereum/frontier/fork_types.py#L52C31-L52C31 - -### Frame Queue - -The Frame Queue buffers one data-transaction at a time, -decoded into [channel frames][g-channel-frame], to be consumed by the next stage. -See [Batcher transaction format](#batcher-transaction-format) and [Frame format](#frame-format) specifications. - -### Channel Bank - -The _Channel Bank_ stage is responsible for managing buffering from the channel bank that was written to by the L1 -retrieval stage. A step in the channel bank stage tries to read data from channels that are "ready". - -Channels are currently fully buffered until read or dropped, -streaming channels may be supported in a future version of the ChannelBank. - -To bound resource usage, the Channel Bank prunes based on channel size, and times out old channels. - -Channels are recorded in FIFO order in a structure called the _channel queue_. A channel is added to the channel -queue the first time a frame belonging to the channel is seen. - -#### Pruning - -After successfully inserting a new frame, the ChannelBank is pruned: -channels are dropped in FIFO order, until `total_size <= MAX_CHANNEL_BANK_SIZE`, where: - -- `total_size` is the sum of the sizes of each channel, which is the sum of all buffered frame data of the channel, - with an additional frame-overhead of `200` bytes per frame. -- `MAX_CHANNEL_BANK_SIZE` is a protocol constant defined in the [Protocol Parameters table](#protocol-parameters). - -#### Timeouts - -The L1 origin that the channel was opened in is tracked with the channel as `channel.open_l1_block`, -and determines the maximum span of L1 blocks that the channel data is retained for, before being pruned. - -A channel is timed out if: `current_l1_block.number > channel.open_l1_block.number + CHANNEL_TIMEOUT`, where: - -- `current_l1_block` is the L1 origin that the stage is currently traversing. -- `CHANNEL_TIMEOUT` is a rollup-configurable, expressed in number of L1 blocks. - -New frames for timed-out channels are dropped instead of buffered. - -#### Reading - -Upon reading, while the first opened channel is timed-out, remove it from the channel-bank. - -Prior to the Canyon network upgrade, once the first opened channel, if any, is not timed-out and is ready, -then it is read and removed from the channel-bank. After the Canyon network upgrade, the entire channel bank -is scanned in FIFO order (by open time) & the first ready (i.e. not timed-out) channel will be returned. - -The canyon behavior will activate when frames from a L1 block whose timestamp is greater than or equal to the -canyon time first enter the channel queue. - -A channel is ready if: - -- The channel is closed -- The channel has a contiguous sequence of frames until the closing frame - -If no channel is ready, the next frame is read and ingested into the channel bank. - -#### Loading frames - -When a channel ID referenced by a frame is not already present in the Channel Bank, -a new channel is opened, tagged with the current L1 block, and appended to the channel-queue. - -Frame insertion conditions: - -- New frames matching timed-out channels that have not yet been pruned from the channel-bank are dropped. -- Duplicate frames (by frame number) for frames that have not been pruned from the channel-bank are dropped. -- Duplicate closes (new frame `is_last == 1`, but the channel has already seen a closing frame and has not yet been - pruned from the channel-bank) are dropped. - -If a frame is closing (`is_last == 1`) any existing higher-numbered frames are removed from the channel. - -Note that while this allows channel IDs to be reused once they have been pruned from the channel-bank, it is recommended -that batcher implementations use unique channel IDs. - -### Channel Reader (Batch Decoding) - -In this stage, we decompress the channel we pull from the last stage, and then parse -[batches][g-sequencer-batch] from the decompressed byte stream. - -See [Channel Format][channel-format] and [Batch Format][batch-format] for decompression and -decoding specification. - -### Batch Queue - -[batch-queue]: #batch-queue - -During the _Batch Buffering_ stage, we reorder batches by their timestamps. If batches are missing for some [time -slots][g-time-slot] and a valid batch with a higher timestamp exists, this stage also generates empty batches to fill -the gaps. - -Batches are pushed to the next stage whenever there is one sequential batch directly following the timestamp -of the current [safe L2 head][g-safe-l2-head] (the last block that can be derived from the canonical L1 chain). -The parent hash of the batch must also match the hash of the current safe L2 head. - -Note that the presence of any gaps in the batches derived from L1 means that this stage will need to buffer for a whole -[sequencing window][g-sequencing-window] before it can generate empty batches (because the missing batch(es) could have -data in the last L1 block of the window in the worst case). - -A batch can have 4 different forms of validity: - -- `drop`: the batch is invalid, and will always be in the future, unless we reorg. It can be removed from the buffer. -- `accept`: the batch is valid and should be processed. -- `undecided`: we are lacking L1 information until we can proceed batch filtering. -- `future`: the batch may be valid, but cannot be processed yet and should be checked again later. - -The batches are processed in order of the inclusion on L1: if multiple batches can be `accept`-ed the first is applied. -An implementation can defer `future` batches a later derivation step to reduce validation work. - -The batches validity is derived as follows: - -Definitions: - -- `batch` as defined in the [Batch format section][batch-format]. -- `epoch = safe_l2_head.l1_origin` a [L1 origin][g-l1-origin] coupled to the batch, with properties: - `number` (L1 block number), `hash` (L1 block hash), and `timestamp` (L1 block timestamp). -- `inclusion_block_number` is the L1 block number when `batch` was first _fully_ derived, - i.e. decoded and output by the previous stage. -- `next_timestamp = safe_l2_head.timestamp + block_time` is the expected L2 timestamp the next batch should have, - see [block time information][g-block-time]. -- `next_epoch` may not be known yet, but would be the L1 block after `epoch` if available. -- `batch_origin` is either `epoch` or `next_epoch`, depending on validation. - -Note that processing of a batch can be deferred until `batch.timestamp <= next_timestamp`, -since `future` batches will have to be retained anyway. - -Rules, in validation order: - -- `batch.timestamp > next_timestamp` -> `future`: i.e. the batch must be ready to process. -- `batch.timestamp < next_timestamp` -> `drop`: i.e. the batch must not be too old. -- `batch.parent_hash != safe_l2_head.hash` -> `drop`: i.e. the parent hash must be equal to the L2 safe head block hash. -- `batch.epoch_num + sequence_window_size < inclusion_block_number` -> `drop`: i.e. the batch must be included timely. -- `batch.epoch_num < epoch.number` -> `drop`: i.e. the batch origin is not older than that of the L2 safe head. -- `batch.epoch_num == epoch.number`: define `batch_origin` as `epoch`. -- `batch.epoch_num == epoch.number+1`: - - If `next_epoch` is not known -> `undecided`: - i.e. a batch that changes the L1 origin cannot be processed until we have the L1 origin data. - - If known, then define `batch_origin` as `next_epoch` -- `batch.epoch_num > epoch.number+1` -> `drop`: i.e. the L1 origin cannot change by more than one L1 block per L2 block. -- `batch.epoch_hash != batch_origin.hash` -> `drop`: i.e. a batch must reference a canonical L1 origin, - to prevent batches from being replayed onto unexpected L1 chains. -- `batch.timestamp < batch_origin.time` -> `drop`: enforce the min L2 timestamp rule. -- `batch.timestamp > batch_origin.time + max_sequencer_drift`: enforce the L2 timestamp drift rule, - but with exceptions to preserve above min L2 timestamp invariant: - - `len(batch.transactions) == 0`: - - `epoch.number == batch.epoch_num`: - this implies the batch does not already advance the L1 origin, and must thus be checked against `next_epoch`. - - If `next_epoch` is not known -> `undecided`: - without the next L1 origin we cannot yet determine if time invariant could have been kept. - - If `batch.timestamp >= next_epoch.time` -> `drop`: - the batch could have adopted the next L1 origin without breaking the `L2 time >= L1 time` invariant. - - `len(batch.transactions) > 0`: -> `drop`: - when exceeding the sequencer time drift, never allow the sequencer to include transactions. -- `batch.transactions`: `drop` if the `batch.transactions` list contains a transaction - that is invalid or derived by other means exclusively: - - any transaction that is empty (zero length byte string) - - any [deposited transactions][g-deposit-tx-type] (identified by the transaction type prefix byte) - - any transaction of a future type > 2 (note that - [Isthmus adds support](../../upgrades/isthmus/derivation.md#activation) - for `SetCode` transactions of type 4) - -If no batch can be `accept`-ed, and the stage has completed buffering of all batches that can fully be read from the L1 -block at height `epoch.number + sequence_window_size`, and the `next_epoch` is available, -then an empty batch can be derived with the following properties: - -- `parent_hash = safe_l2_head.hash` -- `timestamp = next_timestamp` -- `transactions` is empty, i.e. no sequencer transactions. Deposited transactions may be added in the next stage. -- If `next_timestamp < next_epoch.time`: the current L1 origin is repeated, to preserve the L2 time invariant. - - `epoch_num = epoch.number` - - `epoch_hash = epoch.hash` -- If the batch is the first batch of the epoch, that epoch is used instead of advancing the epoch to ensure that - there is at least one L2 block per epoch. - - `epoch_num = epoch.number` - - `epoch_hash = epoch.hash` -- Otherwise, - - `epoch_num = next_epoch.number` - - `epoch_hash = next_epoch.hash` - -### Payload Attributes Derivation - -In the _Payload Attributes Derivation_ stage, we convert the batches we get from the previous stage into instances of -the [`PayloadAttributes`][g-payload-attr] structure. Such a structure encodes the transactions that need to figure into -a block, as well as other block inputs (timestamp, fee recipient, etc). Payload attributes derivation is detailed in the -section [Deriving Payload Attributes section][deriving-payload-attr] below. - -This stage maintains its own copy of the [system configuration][g-system-config], independent of the L1 retrieval stage. -The system configuration is updated with L1 log events whenever the L1 epoch referenced by the batch input changes. - -### Engine Queue - -In the _Engine Queue_ stage, the previously derived `PayloadAttributes` structures are buffered and sent to the -[execution engine][g-exec-engine] to be executed and converted into a proper L2 block. - -The stage maintains references to three L2 blocks: - -- The [finalized L2 head][g-finalized-l2-head]: everything up to and including this block can be fully derived from the - [finalized][l1-finality] (i.e. canonical and forever irreversible) part of the L1 chain. -- The [safe L2 head][g-safe-l2-head]: everything up to and including this block can be fully derived from the - currently canonical L1 chain. -- The [unsafe L2 head][g-unsafe-l2-head]: blocks between the safe and unsafe heads are [unsafe - blocks][g-unsafe-l2-block] that have not been derived from L1. These blocks either come from sequencing (in sequencer - mode) or from [unsafe sync][g-unsafe-sync] to the sequencer (in validator mode). - This is also known as the "latest" head. - -Additionally, it buffers a short history of references to recently processed safe L2 blocks, along with references -from which L1 blocks each was derived. -This history does not have to be complete, but enables later L1 finality signals to be translated into L2 finality. - -#### Engine API usage - -To interact with the engine, the [execution engine API][exec-engine] is used, with the following JSON-RPC methods: - -[exec-engine]: ../execution/index.md - -##### Bedrock, Canyon, Delta: API Usage - -- [`engine_forkchoiceUpdatedV2`] — updates the forkchoice (i.e. the chain head) to `headBlockHash` if different, and - instructs the engine to start building an execution payload if the payload attributes parameter is not `null`. -- [`engine_getPayloadV2`] — retrieves a previously requested execution payload build. -- [`engine_newPayloadV2`] — executes an execution payload to create a block. - -##### Ecotone: API Usage - -- [`engine_forkchoiceUpdatedV3`] — updates the forkchoice (i.e. the chain head) to `headBlockHash` if different, and - instructs the engine to start building an execution payload if the payload attributes parameter is not `null`. -- [`engine_getPayloadV3`] — retrieves a previously requested execution payload build. -- `engine_newPayload` - - [`engine_newPayloadV2`] — executes a Bedrock/Canyon/Delta execution payload to create a block. - - [`engine_newPayloadV3`] — executes an Ecotone execution payload to create a block. - - [`engine_newPayloadV4`] - executes an Isthmus execution payload to create a block. - -The current version of `op-node` uses the `v4` Engine API RPC methods as well as `engine_newPayloadV3` and -`engine_newPayloadV2`, due to `engine_newPayloadV4` only supporting Isthmus execution payloads. Both -`engine_forkchoiceUpdatedV4` and `engine_getPayloadV4` are backwards compatible with Ecotone, Bedrock, -Canyon & Delta payloads. - -Prior versions of `op-node` used `v3`, `v2` and `v1` methods. - -[`engine_forkchoiceUpdatedV2`]: ../execution/index.md#engine_forkchoiceupdatedv2 -[`engine_forkchoiceUpdatedV3`]: ../execution/index.md#engine_forkchoiceupdatedv3 -[`engine_getPayloadV2`]: ../execution/index.md#engine_getpayloadv2 -[`engine_getPayloadV3`]: ../execution/index.md#engine_getpayloadv3 -[`engine_newPayloadV2`]: ../execution/index.md#engine_newpayloadv2 -[`engine_newPayloadV3`]: ../execution/index.md#engine_newpayloadv3 -[`engine_newPayloadV4`]: ../execution/index.md#engine_newpayloadv4 - -The execution payload is an object of type [`ExecutionPayloadV3`][eth-payload]. - -[eth-payload]: https://github.com/ethereum/execution-apis/blob/main/src/engine/cancun.md - -The `ExecutionPayload` has the following requirements: - -- Bedrock - - The withdrawals field MUST be nil - - The blob gas used field MUST be nil - - The blob gas limit field MUST be nil -- Canyon, Delta - - The withdrawals field MUST be non-nil - - The withdrawals field MUST be an empty list - - The blob gas used field MUST be nil - - The blob gas limit field MUST be nil -- Ecotone - - The withdrawals field MUST be non-nil - - The withdrawals field MUST be an empty list - - The blob gas used field MUST be 0 - - The blob gas limit field MUST be 0 - -#### Forkchoice synchronization - -If there are any forkchoice updates to be applied, before additional inputs are derived or processed, then these are -applied to the engine first. - -This synchronization may happen when: - -- A L1 finality signal finalizes one or more L2 blocks: updating the "finalized" L2 block. -- A successful consolidation of unsafe L2 blocks: updating the "safe" L2 block. -- The first thing after a derivation pipeline reset, to ensure a consistent execution engine forkchoice state. - -The new forkchoice state is applied by calling [fork choice updated](#engine-api-usage) on the engine API. -On forkchoice-state validity errors the derivation pipeline must be reset to recover to consistent state. - -#### L1-consolidation: payload attributes matching - -If the unsafe head is ahead of the safe head, then [consolidation][g-consolidation] is attempted, verifying that -existing unsafe L2 chain matches the derived L2 inputs as derived from the canonical L1 data. - -During consolidation, we consider the oldest unsafe L2 block, i.e. the unsafe L2 block directly after the safe head. If -the payload attributes match this oldest unsafe L2 block, then that block can be considered "safe" and becomes the new -safe head. - -The following fields of the derived L2 payload attributes are checked for equality with the L2 block: - -- Bedrock, Canyon, Delta, Ecotone Blocks - - `parent_hash` - - `timestamp` - - `randao` - - `fee_recipient` - - `transactions_list` (first length, then equality of each of the encoded transactions, including deposits) - - `gas_limit` -- Canyon, Delta, Ecotone Blocks - - `withdrawals` (first presence, then length, then equality of each of the encoded withdrawals) -- Ecotone Blocks - - `parent_beacon_block_root` - -If consolidation succeeds, the forkchoice change will synchronize as described in the section above. - -If consolidation fails, the L2 payload attributes will be processed immediately as described in the section below. -The payload attributes are chosen in favor of the previous unsafe L2 block, creating an L2 chain reorg on top of the -current safe block. Immediately processing the new alternative attributes enables execution engines like go-ethereum to -enact the change, as linear rewinds of the tip of the chain may not be supported. - -#### L1-sync: payload attributes processing - -[exec-engine-comm]: ../execution/index.md#engine-api - -If the safe and unsafe L2 heads are identical (whether because of failed consolidation or not), we send the L2 payload -attributes to the execution engine to be constructed into a proper L2 block. -This L2 block will then become both the new L2 safe and unsafe head. - -If a payload attributes created from a batch cannot be inserted into the chain because of a validation error (i.e. there -was an invalid transaction or state transition in the block) the batch should be dropped & the safe head should not be -advanced. The engine queue will attempt to use the next batch for that timestamp from the batch queue. If no valid batch -is found, the rollup node will create a deposit only batch which should always pass validation because deposits are -always valid. - -Interaction with the execution engine via the execution engine API is detailed in the [Communication with the Execution -Engine][exec-engine-comm] section. - -The payload attributes are then processed with a sequence of: - -- [Engine: Fork choice updated](#engine-api-usage) with current forkchoice state of the stage, and the attributes to - start block building. - - Non-deterministic sources, like the tx-pool, must be disabled to reconstruct the expected block. -- [Engine: Get Payload](#engine-api-usage) to retrieve the payload, by the payload-ID in the result of the previous - step. -- [Engine: New Payload](#engine-api-usage) to import the new payload into the execution engine. -- [Engine: Fork Choice Updated](#engine-api-usage) to make the new payload canonical, - now with a change of both `safe` and `unsafe` fields to refer to the payload, and no payload attributes. - -Engine API Error handling: - -- On RPC-type errors the payload attributes processing should be re-attempted in a future step. -- On payload processing errors the attributes must be dropped, and the forkchoice state must be left unchanged. - - Eventually the derivation pipeline will produce alternative payload attributes, with or without batches. - - If the payload attributes only contained deposits, then it is a critical derivation error if these are invalid. -- On forkchoice-state validity errors the derivation pipeline must be reset to recover to consistent state. - -#### Processing unsafe payload attributes - -If no forkchoice updates or L1 data remain to be processed, and if the next possible L2 block is already available -through an unsafe source such as the sequencer publishing it via the p2p network, then it is optimistically processed as -an "unsafe" block. This reduces later derivation work to just consolidation with L1 in the happy case, and enables the -user to see the head of the L2 chain faster than the L1 may confirm the L2 batches. - -To process unsafe payloads, the payload must: - -- Have a block number higher than the current safe L2 head. - - The safe L2 head may only be reorged out due to L1 reorgs. -- Have a parent blockhash that matches the current unsafe L2 head. - - This prevents the execution engine individually syncing a larger gap in the unsafe L2 chain. - - This prevents unsafe L2 blocks from reorging other previously validated L2 blocks. - - This check may change in the future versions to adopt e.g. the L1 snap-sync protocol. - -The payload is then processed with a sequence of: - -- Bedrock/Canyon/Delta Payloads - - `engine_newPayloadV2`: process the payload. It does not become canonical yet. - - `engine_forkchoiceUpdatedV2`: make the payload the canonical unsafe L2 head, and keep the safe/finalized L2 heads. -- Ecotone Payloads - - `engine_newPayloadV3`: process the payload. It does not become canonical yet. - - `engine_forkchoiceUpdatedV3`: make the payload the canonical unsafe L2 head, and keep the safe/finalized L2 heads. -- Isthmus Payloads - - `engine_newPayloadV4`: process the payload. It does not become canonical yet. - -Engine API Error handling: - -- On RPC-type errors the payload processing should be re-attempted in a future step. -- On payload processing errors the payload must be dropped, and not be marked as canonical. -- On forkchoice-state validity errors the derivation pipeline must be reset to recover to consistent state. - -### Resetting the Pipeline - -It is possible to reset the pipeline, for instance if we detect an L1 [reorg (reorganization)][g-reorg]. -**This enables the rollup node to handle L1 chain reorg events.** - -Resetting will recover the pipeline into a state that produces the same outputs as a full L2 derivation process, -but starting from an existing L2 chain that is traversed back just enough to reconcile with the current L1 chain. - -Note that this algorithm covers several important use-cases: - -- Initialize the pipeline without starting from 0, e.g. when the rollup node restarts with an existing engine instance. -- Recover the pipeline if it becomes inconsistent with the execution engine chain, e.g. when the engine syncs/changes. -- Recover the pipeline when the L1 chain reorganizes, e.g. a late L1 block is orphaned, or a larger attestation failure. -- Initialize the pipeline to derive a disputed L2 block with prior L1 and L2 history inside a proof program. - -Handling these cases also means a node can be configured to eagerly sync L1 data with 0 confirmations, -as it can undo the changes if the L1 later does recognize the data as canonical, enabling safe low-latency usage. - -The Engine Queue is first reset, to determine the L1 and L2 starting points to continue derivation from. -After this, the other stages are reset independent of each other. - -#### Finding the sync starting point - -To find the starting point, there are several steps, relative to the head of the chain traversing back: - -1. Find the current L2 forkchoice state - - If no `finalized` block can be found, start at the Bedrock genesis block. - - If no `safe` block can be found, fallback to the `finalized` block. - - The `unsafe` block should always be available and consistent with the above - (it may not be in rare engine-corruption recovery cases, this is being reviewed). -2. Find the first L2 block with plausible L1 reference to be the new `unsafe` starting point, - starting from previous `unsafe`, back to `finalized` and no further. - - Plausible iff: the L1 origin of the L2 block is known and canonical, or unknown and has a block-number ahead of L1. -3. Find the first L2 block with an L1 reference older than the sequencing window, to be the new `safe` starting point, - starting at the above plausible `unsafe` head, back to `finalized` and no further. - - If at any point the L1 origin is known but not canonical, the `unsafe` head is revised to parent of the current. - - The highest L2 block with known canonical L1 origin is remembered as `highest`. - - If at any point the L1 origin in the block is corrupt w.r.t. derivation rules, then error. Corruption includes: - - Inconsistent L1 origin block number or parent-hash with parent L1 origin - - Inconsistent L1 sequence number (always changes to `0` for a L1 origin change, or increments by `1` if not) - - If the L1 origin of the L2 block `n` is older than the L1 origin of `highest` by more than a sequence window, - and `n.sequence_number == 0`, then the parent L2 block of `n` will be the `safe` starting point. -4. The `finalized` L2 block persists as the `finalized` starting point. -5. Find the first L2 block with an L1 reference older than the channel-timeout - - The L1 origin referenced by this block which we call `l2base` will be the `base` for the L2 pipeline derivation: - By starting here, the stages can buffer any necessary data, while dropping incomplete derivation outputs until - L1 traversal has caught up with the actual L2 safe head. - -While traversing back the L2 chain, an implementation may sanity-check that the starting point is never set too far -back compared to the existing forkchoice state, to avoid an intensive reorg because of misconfiguration. - -Implementers note: step 1-4 are known as `FindL2Heads`. Step 5 is currently part of the Engine Queue reset. -This may change to isolate the starting-point search from the bare reset logic. - -#### Resetting derivation stages - -1. L1 Traversal: start at L1 `base` as first block to be pulled by next stage. -2. L1 Retrieval: empty previous data, and fetch the `base` L1 data, or defer the fetching work to a later pipeline step. -3. Frame Queue: empty the queue. -4. Channel Bank: empty the channel bank. -5. Channel Reader: reset any batch decoding state. -6. Batch Queue: empty the batch queue, use `base` as initial L1 point of reference. -7. Payload Attributes Derivation: empty any batch/attributes state. -8. Engine Queue: - - Initialize L2 forkchoice state with syncing start point state. (`finalized`/`safe`/`unsafe`) - - Initialize the L1 point of reference of the stage to `base`. - - Require a forkchoice update as first task - - Reset any finality data - -Where necessary, stages starting at `base` can initialize their system-config from data encoded in the `l2base` block. - -#### About reorgs Post-Merge - -Note that post-[merge], the depth of reorgs will be bounded by the [L1 finality delay][l1-finality] -(2 L1 beacon epochs, or approximately 13 minutes, unless more than 1/3 of the network consistently disagrees). -New L1 blocks may be finalized every L1 beacon epoch (approximately 6.4 minutes), and depending on these -finality-signals and batch-inclusion, the derived L2 chain will become irreversible as well. - -Note that this form of finalization only affects inputs, and nodes can then subjectively say the chain is irreversible, -by reproducing the chain from these irreversible inputs and the set protocol rules and parameters. - -This is however completely unrelated to the outputs posted on L1, which require a form of proof like a fault-proof or -zk-proof to finalize. Optimistic-rollup outputs like withdrawals on L1 are only labeled "finalized" after passing a week -without dispute (fault proof challenge window), a name-collision with the proof-of-stake finalization. - -[merge]: https://ethereum.org/en/upgrades/merge/ -[l1-finality]: https://ethereum.org/en/developers/docs/consensus-mechanisms/pos/#finality - ---- - -# Deriving Payload Attributes - -[deriving-payload-attr]: #deriving-payload-attributes - -For every L2 block derived from L1 data, we need to build [payload attributes][g-payload-attr], -represented by an [expanded version][expanded-payload] of the [`PayloadAttributesV2`][eth-payload] object, -which includes additional `transactions` and `noTxPool` fields. - -This process happens during the payloads-attributes queue ran by a verifier node, as well as during block-production -ran by a sequencer node (the sequencer may enable the tx-pool usage if the transactions are batch-submitted). - -[expanded-payload]: ../execution/index.md#extended-payloadattributesv1 - -## Deriving the Transaction List - -For each L2 block to be created by the sequencer, we start from a [sequencer batch][g-sequencer-batch] matching the -target L2 block number. This could potentially be an empty auto-generated batch, if the L1 chain did not include a batch -for the target L2 block number. [Remember][batch-format] that the batch includes a [sequencing -epoch][g-sequencing-epoch] number, an L2 timestamp, and a transaction list. - -This block is part of a [sequencing epoch][g-sequencing-epoch], -whose number matches that of an L1 block (its _[L1 origin][g-l1-origin]_). -This L1 block is used to derive L1 attributes and (for the first L2 block in the epoch) user deposits. - -Therefore, a [`PayloadAttributesV2`][expanded-payload] object must include the following transactions: - -- one or more [deposited transactions][g-deposited], of two kinds: - - a single _[L1 attributes deposited transaction][g-l1-attr-deposit]_, derived from the L1 origin. - - for the first L2 block in the epoch, zero or more _[user-deposited transactions][g-user-deposited]_, derived from - the [receipts][g-receipts] of the L1 origin. -- zero or more [network upgrade automation transactions]: special transactions to perform network upgrades. -- zero or more _[sequenced transactions][g-sequencing]_: regular transactions signed by L2 users, included in the - sequencer batch. - -Transactions **must** appear in this order in the payload attributes. - -The L1 attributes are read from the L1 block header, while deposits are read from the L1 block's [receipts][g-receipts]. -Refer to the [**deposit contract specification**][deposit-contract-spec] for details on how deposits are encoded as log -entries. - -[deposit-contract-spec]: ../bridging/deposits.md#deposit-contract - -Logs are derived from transactions following the future-proof best-effort process described in -[On Future Proof Transaction Log Derivation](#on-future-proof-transaction-log-derivation) - -### Network upgrade automation transactions - -[network upgrade automation transactions]: #network-upgrade-automation-transactions - -Some network upgrades require automated contract changes or deployments at specific blocks. -To automate these, without adding persistent changes to the execution-layer, -special transactions may be inserted as part of the derivation process. - -## Building Individual Payload Attributes - -After deriving the transactions list, the rollup node constructs a [`PayloadAttributesV2`][extended-attributes] as -follows: - -- `timestamp` is set to the batch's timestamp. -- `random` is set to the `prev_randao` L1 block attribute. -- `suggestedFeeRecipient` is set to the Sequencer Fee Vault address. See [Fee Vaults] specification. -- `transactions` is the array of the derived transactions: deposited transactions and sequenced transactions, all - encoded with [EIP-2718]. -- `noTxPool` is set to `true`, to use the exact above `transactions` list when constructing the block. -- `gasLimit` is set to the current `gasLimit` value in the [system configuration][g-system-config] of this payload. -- `withdrawals` is set to nil prior to Canyon and an empty array after Canyon - -[extended-attributes]: ../execution/index.md#extended-payloadattributesv1 -[Fee Vaults]: ../execution/index.md#fee-vaults - -## On Future-Proof Transaction Log Derivation - -As described in [L1 Retrieval](#l1-retrieval), batcher transactions' types are required to be from a fixed allow-list. - -However, we want to allow deposit transactions and `SystemConfig` update events to get derived even from receipts of -future transaction types, as long as the receipts can be decoded following a best-effort process: - -As long as a future transaction type follows the [EIP-2718](https://eips.ethereum.org/EIPS/eip-2718) specification, the -type can be decoded from the first byte of the transaction's (or its receipt's) binary encoding. We can then proceed as -follows to get the logs of such a future transaction, or discard the transaction's receipt as invalid. - -- If it's a known transaction type, that is, legacy (first byte of the encoding is in the range `[0xc0, 0xfe]`) or its -first byte is in the range `[0, 4]` or `0x7e` (_deposited_), then it's not a _future transaction_ and we know how to -decode the receipt and this process is irrelevant. -- If a transaction's first byte is in the range `[0x05, 0x7d]`, it is expected to be a _future_ EIP-2718 transaction, so -we can proceed to the receipt. Note that we excluded `0x7e` because that's the deposit transaction type, which is known. -- The _future_ receipt encoding's first byte must be the same byte as the transaction encoding's first byte, or it is -discarded as invalid, because we require it to be an EIP-2718-encoded receipt to continue. -- The receipt payload is decoded as if it is encoded as `rlp([status, cumulative_transaction_gas_used, logs_bloom, -logs])`, which is the encoding of the known non-legacy transaction types. - - If this decoding fails, the transaction's receipt is discarded as invalid. - - If this decoding succeeds, the `logs` have been obtained and can be processed as those of known transaction types. - -The intention of this best-effort decoding process is to future-proof the protocol for new L1 transaction types. diff --git a/docs/specs/pages/protocol/consensus/index.md b/docs/specs/pages/protocol/consensus/index.md deleted file mode 100644 index 8f960fa3d4..0000000000 --- a/docs/specs/pages/protocol/consensus/index.md +++ /dev/null @@ -1,63 +0,0 @@ -# Specification - - - -[g-rollup-node]: ../../reference/glossary.md#rollup-node -[g-derivation]: ../../reference/glossary.md#L2-chain-derivation -[g-payload-attr]: ../../reference/glossary.md#payload-attributes -[g-block]: ../../reference/glossary.md#block -[g-exec-engine]: ../../reference/glossary.md#execution-engine -[g-reorg]: ../../reference/glossary.md#re-organization -[g-rollup-driver]: ../../reference/glossary.md#rollup-driver -[g-receipts]: ../../reference/glossary.md#receipt - -## Overview - -The [rollup node][g-rollup-node] is the component responsible for [deriving the L2 chain][g-derivation] from L1 blocks -(and their associated [receipts][g-receipts]). - -The part of the rollup node that derives the L2 chain is called the [rollup driver][g-rollup-driver]. This document is -currently only concerned with the specification of the rollup driver. - -## Driver - -The task of the [driver][g-rollup-driver] in the [rollup node][g-rollup-node] -is to manage the [derivation][g-derivation] process: - -- Keep track of L1 head block -- Keep track of the L2 chain sync progress -- Iterate over the derivation steps as new inputs become available - -### Derivation - -This process happens in three steps: - -1. Select inputs from the L1 chain, on top of the last L2 block: - a list of blocks, with transactions and associated data and receipts. -2. Read L1 information, deposits, and sequencing batches in order to generate [payload attributes][g-payload-attr] - (essentially [a block without output properties][g-block]). -3. Pass the payload attributes to the [execution engine][g-exec-engine], so that the L2 block (including [output block - properties][g-block]) may be computed. - -While this process is conceptually a pure function from the L1 chain to the L2 chain, it is in practice incremental. The -L2 chain is extended whenever new L1 blocks are added to the L1 chain. Similarly, the L2 chain re-organizes whenever the -L1 chain [re-organizes][g-reorg]. - -For a complete specification of the L2 block derivation, refer to the [L2 block derivation document](derivation.md). - -The rollup node RPC surface is specified in the [RPC](rpc.md) document. - -## Protocol Version tracking - -The rollup-node should monitor the recommended and required protocol version by monitoring -the Protocol Versions contract on L1. - -This can be implemented through polling in the [Driver](#driver) loop. -After polling the Protocol Version, the rollup node SHOULD communicate it with the execution-engine through an -[`engine_signalSuperchainV1`](../execution/index.md#enginesignalsuperchainv1) call. - -The rollup node SHOULD warn the user when the recommended version is newer than -the current version supported by the rollup node. - -The rollup node SHOULD take safety precautions if it does not meet the required protocol version. -This may include halting the engine, with consent of the rollup node operator. diff --git a/docs/specs/pages/protocol/consensus/p2p.md b/docs/specs/pages/protocol/consensus/p2p.md deleted file mode 100644 index cf5d421d27..0000000000 --- a/docs/specs/pages/protocol/consensus/p2p.md +++ /dev/null @@ -1,429 +0,0 @@ -# P2P - -## Overview - -The [rollup node](index.md) has an optional peer-to-peer (P2P) network service to improve the latency between -the view of sequencers and the rest of the network by bypassing the L1 in the happy case, -without relying on a single centralized endpoint. - -This also enables faster historical sync to be bootstrapped by providing block headers to sync towards, -and only having to compare the L2 chain inputs to the L1 data as compared to processing everything one block at a time. - -The rollup node will _always_ prioritize L1 and reorganize to match the canonical chain. -The L2 data retrieved via the P2P interface is strictly a speculative extension, also known as the "unsafe" chain, -to improve the happy case performance. - -This also means that P2P behavior is a soft-rule: nodes keep each other in check with scoring and eventual banning -of malicious peers by identity or IP. Any behavior on the P2P layer does not affect the rollup security, at worst nodes -rely on higher-latency data from L1 to serve. - -In summary, the P2P stack looks like: - -- Discovery to find peers: [Discv5][discv5] -- Connections, peering, transport security, multiplexing, gossip: [LibP2P][libp2p] -- Application-layer publishing and validation of gossiped messages like L2 blocks. - -This document only specifies the composition and configuration of these network libraries. -These components have their own standards, implementations in Go/Rust/Java/Nim/JS/more, -and are adopted by several other blockchains, most notably the [L1 consensus layer (Eth2)][eth2-p2p]. - -## P2P configuration - -### Identification - -Nodes have a **separate** network- and consensus-identity. -The network identity is a `secp256k1` key, used for both discovery and active LibP2P connections. - -Common representations of network identity: - -- `PeerID`: a LibP2P specific ID derived from the pubkey (through protobuf encoding, typing and hashing) -- `NodeID`: a Discv5 specific ID derived from the pubkey (through hashing, used in the DHT) -- `Multi-address`: an unsigned address, containing: IP, TCP port, PeerID -- `ENR`: a signed record used for discovery, containing: IP, TCP port, UDP port, signature (pubkey can be derived) - and L2 network identification. Generally encoded in base64. - -### Discv5 - -#### Consensus Layer Structure - -The Ethereum Node Record (ENR) for a Base rollup node must contain the following values, identified by unique keys: - -- An IPv4 address (`ip` field) and/or IPv6 address (`ip6` field). -- A TCP port (`tcp` field) representing the local libp2p listening port. -- A UDP port (`udp` field) representing the local discv5 listening port. -- An `opstack` ENR field L2 network identifier. - -The `opstack` value is encoded as a single RLP `bytes` value, the concatenation of: - -- chain ID (`unsigned varint`) -- fork ID (`unsigned varint`) - -Note that DiscV5 is a shared DHT (Distributed Hash Table): the L1 consensus and execution nodes, -as well as testnet nodes and even external IoT nodes, all communicate records in this large common -DHT. -This makes it more difficult to censor the discovery of node records. - -The discovery process in Base is a pipeline of node records: - -1. Fill the table with `FINDNODES` if necessary (Performed by Discv5 library) -2. Pull additional records with searches to random Node IDs if necessary - (e.g. iterate [`RandomNodes()`][discv5-random-nodes] in Go implementation) -3. Pull records from the DiscV5 module when looking for peers -4. Check if the record contains the `opstack` entry, verify it matches the chain ID and current or future fork number -5. If not already connected, and not recently disconnected or put on deny-list, attempt to dial. - -### LibP2P - -#### Transport - -TCP transport. Additional transports are supported by LibP2P, but not required. - -#### Dialing - -Nodes should be publicly dialable, not rely on relay extensions, and able to dial both IPv4 and IPv6. - -#### NAT - -The listening endpoint must be publicly facing, but may be configured behind a NAT. -LibP2P will use PMP / UPNP based techniques to track the external IP of the node. -It is recommended to disable the above if the external IP is static and configured manually. - -#### Peer management - -The default is to maintain a peer count with a tide-system based on active peer count: - -- At "low tide" the node starts to actively search for additional peer connections. -- At "high tide" the node starts to prune active connections, - except those that are marked as trusted or have a grace period. - -Peers will have a grace period for a configurable amount of time after joining. -In an emergency, when memory runs low, the node should start pruning more aggressively. - -Peer records can be persisted to disk to quickly reconnect with known peers after restarting the rollup node. - -The discovery process feeds the peerstore with peer records to connect to, tagged with a time-to-live (TTL). -The current P2P processes do not require selective topic-specific peer connections, -other than filtering for the basic network participation requirement. - -Peers may be banned if their performance score is too low, or if an objectively malicious action was detected. - -Banned peers will be persisted to the same data-store as the peerstore records. - -TODO: the connection gater does currently not gate by IP address on the dial Accept-callback. - -#### Transport security - -[Libp2p-noise][libp2p-noise], `XX` handshake, with the `secp256k1` P2P identity, as popularized in Eth2. -The TLS option is available as well, but `noise` should be prioritized in negotiation. - -#### Protocol negotiation - -[Multistream-select 1.0][multistream-select] (`/multistream/1.0.0`) is an interactive protocol -used to negotiate sub-protocols supported in LibP2P peers. Multistream-select 2.0 may be used in the future. - -#### Identify - -LibP2P offers a minimal identification module to share client version and programming language. -This is optional and can be disabled for enhanced privacy. -It also includes the same protocol negotiation information, which can speed up initial connections. - -#### Ping - -LibP2P includes a simple ping protocol to track latency between connections. -This should be enabled to help provide insight into the network health. - -#### Multiplexing - -For async communication over different channels over the same connection, multiplexing is used. -[mplex][mplex] (`/mplex/6.7.0`) is required, and [yamux][yamux] (`/yamux/1.0.0`) is recommended but optional - -#### GossipSub - -[GossipSub 1.1][gossipsub] (`/meshsub/1.1.0`, i.e. with peer-scoring extension) is a pubsub protocol for mesh-networks, -deployed on L1 consensus (Eth2) and other protocols such as Filecoin, offering lots of customization options. - -##### Content-based message identification - -Messages are deduplicated, and filtered through application-layer signature verification. -Thus origin-stamping is disabled and published messages must only contain application data, -enforced through a [`StrictNoSign` Signature Policy][signature-policy] - -This provides greater privacy, and allows sequencers (consensus identity) to maintain -multiple network identities for redundancy. - -##### Message compression and limits - -The application contents are compressed with [snappy][snappy] single-block-compression -(as opposed to frame-compression), and constrained to 10 MiB. - -##### Message ID computation - -[Same as L1][l1-message-id], with recognition of compression and topic binding. -Let `topic_len` be the 8-byte little-endian length of `message.topic`. - -- If `message.data` has a valid snappy decompression, set `message-id` to the first 20 bytes of the `SHA256` hash of - the concatenation of `MESSAGE_DOMAIN_VALID_SNAPPY`, `topic_len`, `message.topic`, and the snappy decompressed message data, - i.e. `SHA256(MESSAGE_DOMAIN_VALID_SNAPPY + topic_len + message.topic + snappy_decompress(message.data))[:20]`. -- Otherwise, set `message-id` to the first 20 bytes of the `SHA256` hash of - the concatenation of `MESSAGE_DOMAIN_INVALID_SNAPPY`, `topic_len`, `message.topic`, and the raw message data, - i.e. `SHA256(MESSAGE_DOMAIN_INVALID_SNAPPY + topic_len + message.topic + message.data)[:20]`. - -#### Heartbeat and parameters - -GossipSub [parameters][gossip-parameters]: - -- `D` (topic stable mesh target count): 8 -- `D_low` (topic stable mesh low watermark): 6 -- `D_high` (topic stable mesh high watermark): 12 -- `D_lazy` (gossip target): 6 -- `heartbeat_interval` (interval of heartbeat, in seconds): 0.5 -- `fanout_ttl` (ttl for fanout maps for topics we are not subscribed to but have published to, in seconds): 24 -- `mcache_len` (number of windows to retain full messages in cache for `IWANT` responses): 12 -- `mcache_gossip` (number of windows to gossip about): 3 -- `seen_ttl` (number of heartbeat intervals to retain message IDs): 130 (= 65 seconds) - -Notable differences from L1 consensus (Eth2): - -- `seen_ttl` does not need to cover a full L1 epoch (6.4 minutes), but rather just a small window covering latest blocks -- `fanout_ttl`: adjusted to lower than `seen_ttl` -- `mcache_len`: a larger number of heartbeats can be retained since the gossip is much less noisy. -- `heartbeat_interval`: faster interval to reduce latency, bandwidth should still be reasonable since - there are far fewer messages to gossip about each interval than on L1 which uses an interval of 0.7 seconds. - -#### Topic configuration - -Topics have string identifiers and are communicated with messages and subscriptions. -`/optimism/chain_id/hardfork_version/Name` - -- `chain_id`: replace with decimal representation of chain ID -- `hardfork_version`: replace with decimal representation of hardfork, starting at `0` -- `Name`: topic application-name - -Note that the topic encoding depends on the topic, unlike L1, -since there are less topics, and all are snappy-compressed. - -#### Topic validation - -To ensure only valid messages are relayed, and malicious peers get scored based on application behavior, -an [extended validator][extended-validator] checks the message before it is relayed or processed. -The extended validator emits one of the following validation signals: - -- `ACCEPT` valid, relayed to other peers and passed to local topic subscriber -- `IGNORE` scored like inactivity, message is dropped and not processed -- `REJECT` score penalties, message is dropped - -## Gossip Topics - -Listed below are the topics for distributing blocks to other nodes faster than proxying through L1 would. These are: - -### `blocksv1` - -Pre-Canyon/Shanghai blocks are broadcast on `/optimism//0/blocks`. - -### `blocksv2` - -Canyon/Delta blocks are broadcast on `/optimism//1/blocks`. - -### `blocksv3` - -Ecotone blocks are broadcast on `/optimism//2/blocks`. - -### `blocksv4` - -Isthmus blocks are broadcast on `/optimism//3/blocks`. - -### Block encoding - -A block is structured as the concatenation of: - -- V1 and V2 topics - - `signature`: A `secp256k1` signature, always 65 bytes, `r (uint256), s (uint256), y_parity (uint8)` - - `payload`: A SSZ-encoded `ExecutionPayload`, always the remaining bytes. -- V3 topic - - `signature`: A `secp256k1` signature, always 65 bytes, `r (uint256), s (uint256), y_parity (uint8)` - - `parentBeaconBlockRoot`: L1 origin parent beacon block root, always 32 bytes - - `payload`: A SSZ-encoded `ExecutionPayload`, always the remaining bytes. -- V4 topic - - `signature`: A `secp256k1` signature, always 65 bytes, `r (uint256), s (uint256), y_parity (uint8)` - - `parentBeaconBlockRoot`: L1 origin parent beacon block root, always 32 bytes - - `payload`: A SSZ-encoded `ExecutionPayload`, always the remaining bytes. - - _Note_ - the `ExecutionPayload` is modified for the first time in Isthmus. See - ["Update to `ExecutionPayload`"](../../upgrades/isthmus/exec-engine.md#update-to-executionpayload) in the Isthmus spec. - -All topics use Snappy block-compression (i.e. no snappy frames): -the above needs to be compressed after encoding, and decompressed before decoding. - -### Block signatures - -The `signature` is a `secp256k1` signature, and signs over a message: -`keccak256(domain ++ chain_id ++ payload_hash)`, where: - -- `domain` is 32 bytes, reserved for message types and versioning info. All zero for this signature. -- `chain_id` is a big-endian encoded `uint256`. -- `payload_hash` is `keccak256(payload)`, where `payload` is: - - the `payload` in V1 and V2, - - `parentBeaconBlockRoot ++ payload` in V3 + V4 (_NOTE_: In V4, `payload` is extended to include the - `withdrawalsRoot`). - -The `secp256k1` signature must have `y_parity = 1 or 0`, the `chain_id` is already signed over. - -### Block validation - -An [extended-validator] checks the incoming messages as follows, in order of operation: - -- `[REJECT]` if the compression is not valid -- `[REJECT]` if the block encoding is not valid -- `[REJECT]` if the `payload.timestamp` is older than 60 seconds in the past - (graceful boundary for worst-case propagation and clock skew) -- `[REJECT]` if the `payload.timestamp` is more than 5 seconds into the future -- `[REJECT]` if the `block_hash` in the `payload` is not valid -- `[REJECT]` if the block is on the V1 topic and has withdrawals -- `[REJECT]` if the block is on the V1 topic and has a withdrawals list -- `[REJECT]` if the block is on a `topic >= V2` and does not have an empty withdrawals list -- `[REJECT]` if the block is on a `topic <= V2` and has a blob gas-used value set -- `[REJECT]` if the block is on a `topic <= V2` and has an excess blob gas value set -- `[REJECT]` if the block is on a `topic <= V2` and the parent beacon block root is not nil -- `[REJECT]` if the block is on a `topic >= V3` and has a blob gas-used value that is not zero -- `[REJECT]` if the block is on a `topic >= V3` and has an excess blob gas value that is not zero -- `[REJECT]` if the block is on a `topic >= V3` and the parent beacon block root is nil -- `[REJECT]` if the block is on a `topic <= V3` and the l2 withdrawals root is not nil -- `[REJECT]` if the block is on a `topic >= V4` and the l2 withdrawals root is nil -- `[REJECT]` if more than 5 different blocks have been seen with the same block height -- `[IGNORE]` if the block has already been seen -- `[REJECT]` if the signature by the sequencer is not valid -- Mark the block as seen for the given block height - -The block is signed by the corresponding sequencer, to filter malicious messages. -The sequencer model is singular but may change to multiple sequencers in the future. -A default sequencer pubkey is distributed with rollup nodes and should be configurable. - -Note that blocks that a block may still be propagated even if the L1 already confirmed a different block. -The local L1 view of the node may be wrong, and the time and signature validation will prevent spam. -Hence, calling into the execution engine with a block lookup every propagation step is not worth the added delay. - -#### Block processing - -A node may apply the block to their local engine ahead of L1 availability, if it ensures that: - -- The application of the block is reversible, in case of a conflict with delayed L1 information -- The subsequent forkchoice-update ensures this block is recognized as "unsafe" - (see [fork choice updated](derivation.md#engine-api-usage)) - -#### Branch selection - -Nodes expect that the sequencer will not equivocate, and therefore the fork choice rule for unsafe blocks -is a "first block wins" model, where the unsafe chain will not change once it has been extended, unless -invalidated by safe data published to the L1. - -Nodes who see a different initial unsafe block will not reach consensus until the L1 is published, -which resolves the disagreement. Because the L1 published data depends on the batcher's view of the data, -the safe head will be based on whatever the batcher's source's unsafe head is. - -#### Block topic scoring parameters - -TODO: GossipSub per-topic scoring to fine-tune incentives for ideal propagation delay and bandwidth usage. - -## Req-Resp - -The op-node implements a similar request-response encoding for its sync protocols as the L1 ethereum Beacon-Chain. -See [L1 P2P-interface req-resp specification][eth2-p2p-reqresp] and [Altair P2P update][eth2-p2p-altair-reqresp]. - -However, the protocol is simplified, to avoid several issues seen in L1: - -- Error strings in responses, if there is any alternative response, - should not need to be compressed or have an artificial global length limit. -- Payload lengths should be fixed-length: byte-by-byte uvarint reading from the underlying stream is undesired. -- `` are relaxed to encode a `uint32`, rather than a beacon-chain `ForkDigest`. -- Payload-encoding may change per hardfork, so is not part of the protocol-ID. -- Usage of response-chunks is specific to the req-resp method: most basic req-resp does not need chunked responses. -- Compression is encouraged to be part of the payload-encoding, specific to the req-resp method, where necessary: - pings and such do not need streaming frame compression etc. - -And the protocol ID format follows the same scheme as L1, -except the trailing encoding schema part, which is now message-specific: - -```text -/ProtocolPrefix/MessageName/SchemaVersion/ -``` - -The req-resp protocols served by the op-node all have `/ProtocolPrefix` set to `/opstack/req`. - -Individual methods may include the chain ID as part of the `/MessageName` segment, -so it's immediately clear which chain the method applies to, if the communication is chain-specific. -Other methods may include chain-information in the request and/or response data, -such as the `ForkDigest` `` in L1 beacon chain req-resp protocols. - -Each segment starts with a `/`, and may contain multiple `/`, and the final protocol ID is suffixed with a `/`. - -### `payload_by_number` - -This is an optional chain syncing method, to request/serve execution payloads by number. -This serves as a method to fill gaps upon missed gossip, and sync short to medium ranges of unsafe L2 blocks. - -Protocol ID: `/opstack/req/payload_by_number//0/` - -- `/MessageName` is `/block_by_number/` where `` is set to the op-node L2 chain ID. -- `/SchemaVersion` is `/0` - -Request format: ``: a little-endian `uint64` - the block number to request. - -Response format: ` = ` - -- `` is a byte code describing the result. - - `0` on success, `` should follow. - - `1` if valid request, but unavailable payload. - - `2` if invalid request - - `3+` if other error - - The `>= 128` range is reserved for future use. -- `` is a little-endian `uint32`, identifying the response type (fork-specific) -- `` is an encoded block, read till stream EOF. - -The input of `` should be limited, as well as any generated decompressed output, -to avoid unexpected resource usage or zip-bomb type attacks. -A 10 MB limit is recommended, to ensure all blocks may be synced. -Implementations may opt for a different limit, since this sync method is optional. - -`` list: - -- `0`: SSZ-encoded `ExecutionPayload`, with Snappy framing compression, - matching the `ExecutionPayload` SSZ definition of the L1 Merge, L2 Bedrock, and L2 Canyon versions. -- `1`: SSZ-encoded `ExecutionPayloadEnvelope` with Snappy framing compression, - matching the `ExecutionPayloadEnvelope` SSZ definition of the L2 Ecotone version. -- `2`: SSZ-encoded `ExecutionPayload` with Snappy framing compression, - matching the `ExecutionPayload` SSZ definition of the L2 Isthmus version. - -The request is by block-number, enabling parallel fetching of a chain across many peers. - -A `res = 0` response should be verified to: - -- Have a block-number matching the requested block number. -- Have a consistent `blockhash` w.r.t. the other block contents. -- Build towards a known canonical block. - - This can be verified by checking if the parent-hash of a previous trusted canonical block matches - that of the verified hash of the retrieved block. - - For unsafe blocks this may be relaxed to verification against the parent-hash of any previously trusted block: - - The gossip validation process limits the amount of blocks that may be trusted to sync towards. - - The unsafe blocks should be queued for processing, the latest received L2 unsafe blocks should always - override any previous chain, until the final L2 chain can be reproduced from L1 data. - -A `res > 0` response code should not be accepted. The result code is helpful for debugging, -but the client should regard any error like any other unanswered request, as the responding peer cannot be trusted. - ---- - -[libp2p]: https://libp2p.io/ -[discv5]: https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md -[discv5-random-nodes]: https://pkg.go.dev/github.com/ethereum/go-ethereum@v1.10.12/p2p/discover#UDPv5.RandomNodes -[eth2-p2p]: https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md -[eth2-p2p-reqresp]: https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md#the-reqresp-domain -[eth2-p2p-altair-reqresp]: https://github.com/ethereum/consensus-specs/blob/master/specs/altair/p2p-interface.md#the-reqresp-domain -[libp2p-noise]: https://github.com/libp2p/specs/tree/master/noise -[multistream-select]: https://github.com/multiformats/multistream-select/ -[mplex]: https://github.com/libp2p/specs/tree/master/mplex -[yamux]: https://github.com/hashicorp/yamux/blob/master/spec.md -[gossipsub]: https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.1.md -[signature-policy]: https://github.com/libp2p/specs/blob/master/pubsub/README.md#signature-policy-options -[snappy]: https://github.com/google/snappy -[l1-message-id]: https://github.com/ethereum/consensus-specs/blob/master/specs/phase0/p2p-interface.md#topics-and-messages -[gossip-parameters]: https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.0.md#parameters -[extended-validator]: https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.1.md#extended-validators diff --git a/docs/specs/pages/protocol/consensus/rpc.md b/docs/specs/pages/protocol/consensus/rpc.md deleted file mode 100644 index 977bb54436..0000000000 --- a/docs/specs/pages/protocol/consensus/rpc.md +++ /dev/null @@ -1,64 +0,0 @@ -# RPC - -## L2 Output RPC method - -The Rollup node has its own RPC method, `optimism_outputAtBlock` which returns a 32 -byte hash corresponding to the [L2 output root](../fault-proof/proposer.md#l2-output-commitment-construction). - -### Structures - -These define the types used by rollup node API methods. -The types defined here are extended from the [engine API specs][engine-structures]. - -#### BlockID - -- `hash`: `DATA`, 32 Bytes -- `number`: `QUANTITY`, 64 Bits - -#### L1BlockRef - -- `hash`: `DATA`, 32 Bytes -- `number`: `QUANTITY`, 64 Bits -- `parentHash`: `DATA`, 32 Bytes -- `timestamp`: `QUANTITY`, 64 Bits - -#### L2BlockRef - -- `hash`: `DATA`, 32 Bytes -- `number`: `QUANTITY`, 64 Bits -- `parentHash`: `DATA`, 32 Bytes -- `timestamp`: `QUANTITY`, 64 Bits -- `l1origin`: `BlockID` -- `sequenceNumber`: `QUANTITY`, 64 Bits - distance to first block of epoch - -#### SyncStatus - -Represents a snapshot of the rollup driver. - -- `current_l1`: `Object` - instance of [`L1BlockRef`](#l1blockref). -- `current_l1_finalized`: `Object` - instance of [`L1BlockRef`](#l1blockref). -- `head_l1`: `Object` - instance of [`L1BlockRef`](#l1blockref). -- `safe_l1`: `Object` - instance of [`L1BlockRef`](#l1blockref). -- `finalized_l1`: `Object` - instance of [`L1BlockRef`](#l1blockref). -- `unsafe_l2`: `Object` - instance of [`L2BlockRef`](#l2blockref). -- `safe_l2`: `Object` - instance of [`L2BlockRef`](#l2blockref). -- `finalized_l2`: `Object` - instance of [`L2BlockRef`](#l2blockref). -- `pending_safe_l2`: `Object` - instance of [`L2BlockRef`](#l2blockref). -- `queued_unsafe_l2`: `Object` - instance of [`L2BlockRef`](#l2blockref). - -### Output Method API - -The input and return types here are as defined by the [engine API specs][engine-structures]. - -[engine-structures]: https://github.com/ethereum/execution-apis/blob/main/src/engine/paris.md#structures - -- method: `optimism_outputAtBlock` -- params: - 1. `blockNumber`: `QUANTITY`, 64 bits - L2 integer block number. -- returns: - 1. `version`: `DATA`, 32 Bytes - the output root version number, beginning with 0. - 1. `outputRoot`: `DATA`, 32 Bytes - the output root. - 1. `blockRef`: `Object` - instance of [`L2BlockRef`](#l2blockref). - 1. `withdrawalStorageRoot`: 32 bytes - storage root of the `L2toL1MessagePasser` contract. - 1. `stateRoot`: `DATA`: 32 bytes - the state root. - 1. `syncStatus`: `Object` - instance of [`SyncStatus`](#syncstatus). diff --git a/docs/specs/pages/protocol/execution/evm/precompiles.md b/docs/specs/pages/protocol/execution/evm/precompiles.md deleted file mode 100644 index 6dd52e5eb6..0000000000 --- a/docs/specs/pages/protocol/execution/evm/precompiles.md +++ /dev/null @@ -1,27 +0,0 @@ -# Precompiles - -## Overview - -[Precompiled contracts](../../../reference/glossary.md#precompiled-contract-precompile) exist on Base at -predefined addresses. They are similar to predeploys but are implemented as native code in the EVM as opposed to -bytecode. Precompiles are used for computationally expensive operations, that would be cost prohibitive to implement -in Solidity. Where possible predeploys are preferred, as precompiles must be implemented in every execution client. - -Base contains the [standard Ethereum precompiles](https://www.evm.codes/precompiled) as well as a small -number of additional precompiles. The following table lists each of the additional precompiles. The system version -indicates when the precompile was introduced. - -| Name | Address | Introduced | -|------------| ------------------------------------------ |------------| -| P256VERIFY | 0x0000000000000000000000000000000000000100 | Fjord | - -## P256VERIFY - -The `P256VERIFY` precompile performs signature verification for the secp256r1 elliptic curve. This curve has widespread -adoption. It's used by Passkeys, Apple Secure Enclave and many other systems. - -It is specified as part of [RIP-7212](https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md) and was added to -the Base protocol in the Fjord release. The op-geth implementation is -[here](https://github.com/ethereum-optimism/op-geth/blob/optimism/core/vm/contracts.go#L1161-L1193). - -Address: `0x0000000000000000000000000000000000000100` diff --git a/docs/specs/pages/protocol/execution/evm/predeploys.md b/docs/specs/pages/protocol/execution/evm/predeploys.md deleted file mode 100644 index 15d707c87c..0000000000 --- a/docs/specs/pages/protocol/execution/evm/predeploys.md +++ /dev/null @@ -1,336 +0,0 @@ -# Predeploys - -## Overview - -[Predeployed smart contracts](../../../reference/glossary.md#predeployed-contract-predeploy) exist on Base -at predetermined addresses in the genesis state. They are similar to precompiles but instead run -directly in the EVM instead of running native code outside of the EVM. - -Predeploys are used instead of precompiles to make it easier for multiclient -implementations as well as allowing for more integration with hardhat/foundry -network forking. - -Predeploy addresses exist in a prefixed namespace `0x4200000000000000000000000000000000000xxx`. -Proxies are set at the first 2048 addresses in the namespace, except for the address reserved for the -`WETH` predeploy. - -The `LegacyERC20ETH` predeploy lives at a special address `0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000` -and there is no proxy deployed at that account. - -The following table includes each of the predeploys. The system version -indicates when the predeploy was introduced. The possible values are `Legacy` -or `Bedrock` or `Canyon`. Deprecated contracts should not be used. - -| Name | Address | Introduced | Deprecated | Proxied | -|-------------------------------|--------------------------------------------|------------| ---------- |---------| -| LegacyMessagePasser | 0x4200000000000000000000000000000000000000 | Legacy | Yes | Yes | -| DeployerWhitelist | 0x4200000000000000000000000000000000000002 | Legacy | Yes | Yes | -| LegacyERC20ETH | 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000 | Legacy | Yes | No | -| WETH9 | 0x4200000000000000000000000000000000000006 | Legacy | No | No | -| L2CrossDomainMessenger | 0x4200000000000000000000000000000000000007 | Legacy | No | Yes | -| L2StandardBridge | 0x4200000000000000000000000000000000000010 | Legacy | No | Yes | -| SequencerFeeVault | 0x4200000000000000000000000000000000000011 | Legacy | No | Yes | -| OptimismMintableERC20Factory | 0x4200000000000000000000000000000000000012 | Legacy | No | Yes | -| L1BlockNumber | 0x4200000000000000000000000000000000000013 | Legacy | Yes | Yes | -| GasPriceOracle | 0x420000000000000000000000000000000000000F | Legacy | No | Yes | -| L1Block | 0x4200000000000000000000000000000000000015 | Bedrock | No | Yes | -| L2ToL1MessagePasser | 0x4200000000000000000000000000000000000016 | Bedrock | No | Yes | -| L2ERC721Bridge | 0x4200000000000000000000000000000000000014 | Legacy | No | Yes | -| OptimismMintableERC721Factory | 0x4200000000000000000000000000000000000017 | Bedrock | No | Yes | -| ProxyAdmin | 0x4200000000000000000000000000000000000018 | Bedrock | No | Yes | -| BaseFeeVault | 0x4200000000000000000000000000000000000019 | Bedrock | No | Yes | -| L1FeeVault | 0x420000000000000000000000000000000000001a | Bedrock | No | Yes | -| SchemaRegistry | 0x4200000000000000000000000000000000000020 | Bedrock | No | Yes | -| EAS | 0x4200000000000000000000000000000000000021 | Bedrock | No | Yes | -| BeaconBlockRoot | 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02 | Ecotone | No | No | -| OperatorFeeVault | 0x420000000000000000000000000000000000001B | Isthmus | No | Yes | - -## LegacyMessagePasser - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/legacy/LegacyMessagePasser.sol) - -Address: `0x4200000000000000000000000000000000000000` - -The `LegacyMessagePasser` contract stores commitments to withdrawal -transactions before the Bedrock upgrade. A merkle proof to a particular -storage slot that commits to the withdrawal transaction is used as part -of the withdrawing transaction on L1. The expected account that includes -the storage slot is hardcoded into the L1 logic. After the bedrock upgrade, -the `L2ToL1MessagePasser` is used instead. Finalizing withdrawals from this -contract will no longer be supported after the Bedrock and is only left -to allow for alternative bridges that may depend on it. This contract does -not forward calls to the `L2ToL1MessagePasser` and calling it is considered -a no-op in context of doing withdrawals through the `CrossDomainMessenger` -system. - -Any pending withdrawals that have not been finalized are migrated to the -`L2ToL1MessagePasser` as part of the upgrade so that they can still be -finalized. - -## L2ToL1MessagePasser - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L2ToL1MessagePasser.sol) - -Address: `0x4200000000000000000000000000000000000016` - -The `L2ToL1MessagePasser` stores commitments to withdrawal transactions. -When a user is submitting the withdrawing transaction on L1, they provide a -proof that the transaction that they withdrew on L2 is in the `sentMessages` -mapping of this contract. - -Any withdrawn ETH accumulates into this contract on L2 and can be -permissionlessly removed from the L2 supply by calling the `burn()` function. - -## DeployerWhitelist - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/legacy/DeployerWhitelist.sol) - -Address: `0x4200000000000000000000000000000000000002` - -The `DeployerWhitelist` is a predeploy that was used to provide additional safety -during the initial phases of the legacy rollup. -It previously defined the accounts that are allowed to deploy contracts to the network. - -Arbitrary contract deployment was subsequently enabled and it is not possible to turn -off. In the legacy system, this contract was hooked into `CREATE` and -`CREATE2` to ensure that the deployer was allowlisted. - -In the Bedrock system, this contract will no longer be used as part of the -`CREATE` codepath. - -This contract is deprecated and its usage should be avoided. - -## LegacyERC20ETH - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/a4524ac152b4c9e8eb80beadc9cd772b96243aa2/packages/contracts-bedrock/src/legacy/LegacyERC20ETH.sol) - -Address: `0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000` - -The `LegacyERC20ETH` predeploy represents all ether in the system before the -Bedrock upgrade. All ETH was represented as an ERC20 token and users could opt -into the ERC20 interface or the native ETH interface. - -The upgrade to Bedrock migrates all ether out of this contract and moves it to -its native representation. All of the stateful methods in this contract will -revert after the Bedrock upgrade. - -This contract is deprecated and its usage should be avoided. - -## WETH9 - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/2b1c99b39744579cc226077d356ae9e5f162db4a/packages/contracts-bedrock/src/vendor/WETH9.sol) - -Address: `0x4200000000000000000000000000000000000006` - -`WETH9` is the standard implementation of Wrapped Ether on Base. It is a -commonly used contract and is placed as a predeploy so that it is at a -deterministic address across Base networks. - -## L2CrossDomainMessenger - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L2CrossDomainMessenger.sol) - -Address: `0x4200000000000000000000000000000000000007` - -The `L2CrossDomainMessenger` gives a higher level API for sending cross domain -messages compared to directly calling the `L2ToL1MessagePasser`. -It maintains a mapping of L1 messages that have been relayed to L2 -to prevent replay attacks and also allows for replayability if the L1 to L2 -transaction reverts on L2. - -Any calls to the `L1CrossDomainMessenger` on L1 are serialized such that they -go through the `L2CrossDomainMessenger` on L2. - -The `relayMessage` function executes a transaction from the remote domain while -the `sendMessage` function sends a transaction to be executed on the remote -domain through the remote domain's `relayMessage` function. - -## L2StandardBridge - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L2StandardBridge.sol) - -Address: `0x4200000000000000000000000000000000000010` - -The `L2StandardBridge` is a higher level API built on top of the -`L2CrossDomainMessenger` that gives a standard interface for sending ETH or -ERC20 tokens across domains. - -To deposit a token from L1 to L2, the `L1StandardBridge` locks the token and -sends a cross domain message to the `L2StandardBridge` which then mints the -token to the specified account. - -To withdraw a token from L2 to L1, the user will burn the token on L2 and the -`L2StandardBridge` will send a message to the `L1StandardBridge` which will -unlock the underlying token and transfer it to the specified account. - -The `OptimismMintableERC20Factory` can be used to create an ERC20 token contract -on a remote domain that maps to an ERC20 token contract on the local domain -where tokens can be deposited to the remote domain. It deploys an -`OptimismMintableERC20` which has the interface that works with the -`StandardBridge`. - -This contract can also be deployed on L1 to allow for L2 native tokens to be -withdrawn to L1. - -## L1BlockNumber - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/legacy/L1BlockNumber.sol) - -Address: `0x4200000000000000000000000000000000000013` - -The `L1BlockNumber` returns the last known L1 block number. This contract was -introduced in the legacy system and should be backwards compatible by calling -out to the `L1Block` contract under the hood. - -It is recommended to use the `L1Block` contract for getting information about -L1 on L2. - -## GasPriceOracle - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/GasPriceOracle.sol) - -Address: `0x420000000000000000000000000000000000000F` - -In the legacy system, the `GasPriceOracle` was a permissioned contract -that was pushed the L1 base fee and the L2 gas price by an offchain actor. -The offchain actor observes the L1 blockheaders to get the -L1 base fee as well as the gas usage on L2 to compute what the L2 gas price -should be based on a congestion control algorithm. - -After Bedrock, the `GasPriceOracle` is no longer a permissioned contract -and only exists to preserve the API for offchain gas estimation. The -function `getL1Fee(bytes)` accepts an unsigned RLP transaction and will return -the L1 portion of the fee. This fee pays for using L1 as a data availability -layer and should be added to the L2 portion of the fee, which pays for -execution, to compute the total transaction fee. - -The values used to compute the L1 portion of the fee prior to the Ecotone upgrade are: - -- scalar -- overhead -- decimals - -After the Bedrock upgrade, these values are instead managed by the -`SystemConfig` contract on L1. The `scalar` and `overhead` values -are sent to the `L1Block` contract each block and the `decimals` value -has been hardcoded to 6. - -Following the Ecotone upgrade, the values used for L1 fee computation are: - -- baseFeeScalar -- blobBaseFeeScalar -- decimals - -[ecotone-scalars]: ../../../reference/glossary.md#post-ecotone-parameters - -These new scalar values are managed by the `SystemConfig` contract on the L1 by introducing a -backwards compatible [versioned encoding scheme][ecotone-scalars] of its `scalars` storage -slot. The `decimals` remains hardcoded to 6, and the `overhead` value is ignored. - -## L1Block - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L1Block.sol) - -Address: `0x4200000000000000000000000000000000000015` - -[l1-block-predeploy]: ../../../reference/glossary.md#l1-attributes-predeployed-contract - -The [L1Block][l1-block-predeploy] was introduced in Bedrock and is responsible for -maintaining L1 context in L2. This allows for L1 state to be accessed in L2. - -## ProxyAdmin - -[ProxyAdmin](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/universal/ProxyAdmin.sol) -Address: `0x4200000000000000000000000000000000000018` - -The `ProxyAdmin` is the owner of all of the proxy contracts set at the -predeploys. It is itself behind a proxy. The owner of the `ProxyAdmin` will -have the ability to upgrade any of the other predeploy contracts. - -## SequencerFeeVault - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/SequencerFeeVault.sol) - -Address: `0x4200000000000000000000000000000000000011` - -The `SequencerFeeVault` accumulates any transaction priority fee and is the value of -`block.coinbase`. -When enough fees accumulate in this account, they can be withdrawn to an immutable L1 address. - -To change the L1 address that fees are withdrawn to, the contract must be -upgraded by changing its proxy's implementation key. - -## OptimismMintableERC20Factory - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/universal/OptimismMintableERC20Factory.sol) - -Address: `0x4200000000000000000000000000000000000012` - -The `OptimismMintableERC20Factory` is responsible for creating ERC20 contracts on L2 that can be -used for depositing native L1 tokens into. These ERC20 contracts can be created permissionlessly -and implement the interface required by the `StandardBridge` to just work with deposits and withdrawals. - -Each ERC20 contract that is created by the `OptimismMintableERC20Factory` allows for the `L2StandardBridge` to mint -and burn tokens, depending on if the user is depositing from L1 to L2 or withdrawing from L2 to L1. - -## OptimismMintableERC721Factory - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/OptimismMintableERC721Factory.sol) - -Address: `0x4200000000000000000000000000000000000017` - -The `OptimismMintableERC721Factory` is responsible for creating ERC721 contracts on L2 that can be used for -depositing native L1 NFTs into. - -## BaseFeeVault - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/BaseFeeVault.sol) - -Address: `0x4200000000000000000000000000000000000019` - -The `BaseFeeVault` predeploy receives the base fees on L2. The base fee is not -burnt on L2 like it is on L1. Once the contract has received a certain amount -of fees, the ETH can be withdrawn to an immutable address on -L1. - -## L1FeeVault - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/L1FeeVault.sol) - -Address: `0x420000000000000000000000000000000000001a` - -The `L1FeeVault` predeploy receives the L1 portion of the transaction fees. -Once the contract has received a certain amount of fees, the ETH can be -withdrawn to an immutable address on L1. - -## SchemaRegistry - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/vendor/eas/SchemaRegistry.sol) - -Address: `0x4200000000000000000000000000000000000020` - -The `SchemaRegistry` predeploy implements the global attestation schemas for the `Ethereum Attestation Service` -protocol. - -## EAS - -[Implementation](https://github.com/ethereum-optimism/optimism/tree/develop/packages/contracts-bedrock/src/vendor/eas) - -Address: `0x4200000000000000000000000000000000000021` - -The `EAS` predeploy implements the `Ethereum Attestation Service` protocol. - -## Beacon Block Root - -Address: `0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02` - -The `BeaconBlockRoot` predeploy provides access to the L1 beacon block roots. This was added during the -Ecotone network upgrade and is specified in [EIP-4788](https://eips.ethereum.org/EIPS/eip-4788). - -## Operator Fee Vault - -[Implementation](https://github.com/ethereum-optimism/optimism/blob/develop/packages/contracts-bedrock/src/L2/OperatorFeeVault.sol) - -Address: `0x420000000000000000000000000000000000001B` - -See [Operator Fee Vault](https://specs.base.org/upgrades/isthmus/predeploys#operatorfeevault) spec. diff --git a/docs/specs/pages/protocol/execution/evm/preinstalls.md b/docs/specs/pages/protocol/execution/evm/preinstalls.md deleted file mode 100644 index f91bd76780..0000000000 --- a/docs/specs/pages/protocol/execution/evm/preinstalls.md +++ /dev/null @@ -1,213 +0,0 @@ -# Preinstalls - -## Overview - -[Preinstalled smart contracts](../../../reference/glossary.md#preinstalled-contract-preinstall) exist on Base -at predetermined addresses in the genesis state. They are similar to precompiles but instead run -directly in the EVM instead of running native code outside of the EVM and are developed by third -parties unaffiliated with Base. - -These preinstalls are commonly deployed smart contracts that are being placed at genesis for convenience. -It's important to note that these contracts do not have the same security guarantees -as [Predeployed smart contracts](../../../reference/glossary.md#predeployed-contract-predeploy). - -The following table includes each of the preinstalls. - -| Name | Address | -| ----------------------------------------- | ------------------------------------------ | -| Safe | 0x69f4D1788e39c87893C980c06EdF4b7f686e2938 | -| SafeL2 | 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA | -| MultiSend | 0x998739BFdAAdde7C933B942a68053933098f9EDa | -| MultiSendCallOnly | 0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B | -| SafeSingletonFactory | 0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7 | -| Multicall3 | 0xcA11bde05977b3631167028862bE2a173976CA11 | -| Create2Deployer | 0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2 | -| CreateX | 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed | -| Arachnid's Deterministic Deployment Proxy | 0x4e59b44847b379578588920cA78FbF26c0B4956C | -| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 | -| ERC-4337 v0.6.0 EntryPoint | 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 | -| ERC-4337 v0.6.0 SenderCreator | 0x7fc98430eaedbb6070b35b39d798725049088348 | -| ERC-4337 v0.7.0 EntryPoint | 0x0000000071727De22E5E9d8BAf0edAc6f37da032 | -| ERC-4337 v0.7.0 SenderCreator | 0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C | - -## Safe - -[Implementation](https://github.com/safe-global/safe-contracts/blob/v1.3.0/contracts/GnosisSafe.sol) - -Address: `0x69f4D1788e39c87893C980c06EdF4b7f686e2938` - -A multisignature wallet with support for confirmations using signed messages based on ERC191. -Differs from [SafeL2](#safel2) by not emitting events to save gas. - -## SafeL2 - -[Implementation](https://github.com/safe-global/safe-contracts/blob/v1.3.0/contracts/GnosisSafeL2.sol) - -Address: `0xfb1bffC9d739B8D520DaF37dF666da4C687191EA` - -A multisignature wallet with support for confirmations using signed messages based on ERC191. -Differs from [Safe](#safe) by emitting events. - -## MultiSend - -[Implementation](https://github.com/safe-global/safe-contracts/blob/v1.3.0/contracts/libraries/MultiSend.sol) - -Address: `0x998739BFdAAdde7C933B942a68053933098f9EDa` - -Allows to batch multiple transactions into one. - -## MultiSendCallOnly - -[Implementation](https://github.com/safe-global/safe-contracts/blob/v1.3.0/contracts/libraries/MultiSendCallOnly.sol) - -Address: `0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B` - -Allows to batch multiple transactions into one, but only calls. - -## SafeSingletonFactory - -[Implementation](https://github.com/safe-global/safe-singleton-factory/blob/v1.0.17/source/deterministic-deployment-proxy.yul) - -Address: `0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7` - -Singleton factory used by Safe-related contracts based on -[Arachnid's Deterministic Deployment Proxy](#arachnids-deterministic-deployment-proxy). - -The original library used a pre-signed transaction without a chain ID to allow deployment on different chains. -Some chains do not allow such transactions to be submitted; therefore, this contract will provide the same factory -that can be deployed via a pre-signed transaction that includes the chain ID. The key that is used to sign is -controlled by the Safe team. - -## Multicall3 - -[Implementation](https://github.com/mds1/multicall/blob/v3.1.0/src/Multicall3.sol) - -Address: `0xcA11bde05977b3631167028862bE2a173976CA11` - -`Multicall3` has two main use cases: - -- Aggregate results from multiple contract reads into a single JSON-RPC request. -- Execute multiple state-changing calls in a single transaction. - -## Create2Deployer - -[Implementation](https://github.com/mdehoog/create2deployer/blob/69b9a8e112b15f9257ce8c62b70a09914e7be29c/contracts/Create2Deployer.sol) - -The `create2Deployer` is a nice Solidity wrapper around the CREATE2 opcode. It provides the following ABI. - -```solidity - /** - * @dev Deploys a contract using `CREATE2`. The address where the - * contract will be deployed can be known in advance via {computeAddress}. - * - * The bytecode for a contract can be obtained from Solidity with - * `type(contractName).creationCode`. - * - * Requirements: - * - `bytecode` must not be empty. - * - `salt` must have not been used for `bytecode` already. - * - the factory must have a balance of at least `value`. - * - if `value` is non-zero, `bytecode` must have a `payable` constructor. - */ - function deploy(uint256 value, bytes32 salt, bytes memory code) public; - /** - * @dev Deployment of the {ERC1820Implementer}. - * Further information: https://eips.ethereum.org/EIPS/eip-1820 - */ - function deployERC1820Implementer(uint256 value, bytes32 salt); - /** - * @dev Returns the address where a contract will be stored if deployed via {deploy}. - * Any change in the `bytecodeHash` or `salt` will result in a new destination address. - */ - function computeAddress(bytes32 salt, bytes32 codeHash) public view returns (address); - /** - * @dev Returns the address where a contract will be stored if deployed via {deploy} from a - * contract located at `deployer`. If `deployer` is this contract's address, returns the - * same value as {computeAddress}. - */ - function computeAddressWithDeployer( - bytes32 salt, - bytes32 codeHash, - address deployer - ) public pure returns (address); -``` - -Address: `0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2` - -When Canyon activates, the contract code at `0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2` is set to -`0x6080604052600436106100435760003560e01c8063076c37b21461004f578063481286e61461007157806356299481146100ba57806366cfa057146100da57600080fd5b3661004a57005b600080fd5b34801561005b57600080fd5b5061006f61006a366004610327565b6100fa565b005b34801561007d57600080fd5b5061009161008c366004610327565b61014a565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b3480156100c657600080fd5b506100916100d5366004610349565b61015d565b3480156100e657600080fd5b5061006f6100f53660046103ca565b610172565b61014582826040518060200161010f9061031a565b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe082820381018352601f90910116604052610183565b505050565b600061015683836102e7565b9392505050565b600061016a8484846102f0565b949350505050565b61017d838383610183565b50505050565b6000834710156101f4576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601d60248201527f437265617465323a20696e73756666696369656e742062616c616e636500000060448201526064015b60405180910390fd5b815160000361025f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820181905260248201527f437265617465323a2062797465636f6465206c656e677468206973207a65726f60448201526064016101eb565b8282516020840186f5905073ffffffffffffffffffffffffffffffffffffffff8116610156576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601960248201527f437265617465323a204661696c6564206f6e206465706c6f790000000000000060448201526064016101eb565b60006101568383305b6000604051836040820152846020820152828152600b8101905060ff815360559020949350505050565b61014e806104ad83390190565b6000806040838503121561033a57600080fd5b50508035926020909101359150565b60008060006060848603121561035e57600080fd5b8335925060208401359150604084013573ffffffffffffffffffffffffffffffffffffffff8116811461039057600080fd5b809150509250925092565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6000806000606084860312156103df57600080fd5b8335925060208401359150604084013567ffffffffffffffff8082111561040557600080fd5b818601915086601f83011261041957600080fd5b81358181111561042b5761042b61039b565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f011681019083821181831017156104715761047161039b565b8160405282815289602084870101111561048a57600080fd5b826020860160208301376000602084830101528095505050505050925092509256fe608060405234801561001057600080fd5b5061012e806100206000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c8063249cb3fa14602d575b600080fd5b603c603836600460b1565b604e565b60405190815260200160405180910390f35b60008281526020818152604080832073ffffffffffffffffffffffffffffffffffffffff8516845290915281205460ff16608857600060aa565b7fa2ef4600d742022d532d4747cb3547474667d6f13804902513b2ec01c848f4b45b9392505050565b6000806040838503121560c357600080fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811460ed57600080fd5b80915050925092905056fea26469706673582212205ffd4e6cede7d06a5daf93d48d0541fc68189eeb16608c1999a82063b666eb1164736f6c63430008130033a2646970667358221220fdc4a0fe96e3b21c108ca155438d37c9143fb01278a3c1d274948bad89c564ba64736f6c63430008130033`. - -## CreateX - -[Implementation](https://github.com/pcaversaccio/createx/blob/main/src/CreateX.sol) - -Address: `0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed` - -CreateX introduces additional logic for deploying contracts using `CREATE`, `CREATE2` and `CREATE3`. -It adds [salt protection](https://github.com/pcaversaccio/createx#special-features) for sender and chainID -and includes a set of helper functions. - -The `keccak256` of the CreateX bytecode is `0xbd8a7ea8cfca7b4e5f5041d7d4b17bc317c5ce42cfbc42066a00cf26b43eb53f`. - -## Arachnid's Deterministic Deployment Proxy - -[Implementation](https://github.com/Arachnid/deterministic-deployment-proxy/blob/v1.0.0/source/deterministic-deployment-proxy.yul) - -Address: `0x4e59b44847b379578588920cA78FbF26c0B4956C` - -This contract can deploy other contracts with a deterministic address on any chain using `CREATE2`. The `CREATE2` -call will deploy a contract (like `CREATE` opcode) but instead of the address being -`keccak256(rlp([deployer_address, nonce]))` it instead uses the hash of the contract's bytecode and a salt. -This means that a given deployer address will deploy the -same code to the same address no matter when or where they issue the deployment. The deployer is deployed -with a one-time-use account, so no matter what chain the deployer is on, its address will always be the same. This -means the only variables in determining the address of your contract are its bytecode hash and the provided salt. - -Between the use of `CREATE2` opcode and the one-time-use account for the deployer, this contracts ensures -that a given contract will exist at the exact same address on every chain, but without having to use the -same gas pricing or limits every time. - -## Permit2 - -[Implementation](https://github.com/Uniswap/permit2/blob/0x000000000022D473030F116dDEE9F6B43aC78BA3/src/Permit2.sol) - -Address: `0x000000000022D473030F116dDEE9F6B43aC78BA3` - -Permit2 introduces a low-overhead, next-generation token approval/meta-tx system to make token approvals easier, -more secure, and more consistent across applications. - -## ERC-4337 v0.6.0 EntryPoint - -[Implementation](https://github.com/eth-infinitism/account-abstraction/blob/v0.6.0/contracts/core/EntryPoint.sol) - -Address: `0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789` - -This contract verifies and executes the bundles of ERC-4337 v0.6.0 -[UserOperations](https://www.erc4337.io/docs/understanding-ERC-4337/user-operation) sent to it. - -## ERC-4337 v0.6.0 SenderCreator - -[Implementation](https://github.com/eth-infinitism/account-abstraction/blob/v0.6.0/contracts/core/SenderCreator.sol) - -Address: `0x7fc98430eaedbb6070b35b39d798725049088348` - -Helper contract for [EntryPoint](#erc-4337-v060-entrypoint) v0.6.0, to call `userOp.initCode` from a "neutral" address, -which is explicitly not `EntryPoint` itself. - -## ERC-4337 v0.7.0 EntryPoint - -[Implementation](https://github.com/eth-infinitism/account-abstraction/blob/v0.7.0/contracts/core/EntryPoint.sol) - -Address: `0x0000000071727De22E5E9d8BAf0edAc6f37da032` - -This contract verifies and executes the bundles of ERC-4337 v0.7.0 -[UserOperations](https://www.erc4337.io/docs/understanding-ERC-4337/user-operation) sent to it. - -## ERC-4337 v0.7.0 SenderCreator - -[Implementation](https://github.com/eth-infinitism/account-abstraction/blob/v0.7.0/contracts/core/SenderCreator.sol) - -Address: `0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C` - -Helper contract for [EntryPoint](#erc-4337-v070-entrypoint) v0.7.0, to call `userOp.initCode` from a "neutral" address, -which is explicitly not `EntryPoint` itself. diff --git a/docs/specs/pages/protocol/execution/evm/rpc.md b/docs/specs/pages/protocol/execution/evm/rpc.md deleted file mode 100644 index 688879b250..0000000000 --- a/docs/specs/pages/protocol/execution/evm/rpc.md +++ /dev/null @@ -1,193 +0,0 @@ -# RPC - -This document specifies the JSON-RPC methods implemented by the Flashblocks RPC provider. - -## Type Definitions - -All types used in these RPC methods are identical to the standard Base RPC types. No modifications have been made to the existing type definitions. - -## Modified Ethereum JSON-RPC Methods - -The following standard Ethereum JSON-RPC methods are enhanced to support the `pending` tag for querying preconfirmed state. - -### `eth_getBlockByNumber` - -Returns block information for the specified block number. - -### Parameters -- `blockNumber`: `String` - Block number or tag (`"pending"` for preconfirmed state) -- `fullTransactions`: `Boolean` - If true, returns full transaction objects; if false, returns transaction hashes - -### Returns -`Object` - Block object - -### Example -```json -// Request -{ - "method": "eth_getBlockByNumber", - "params": ["pending", false], - "id": 1, - "jsonrpc": "2.0" -} - -// Response -{ - "id": 1, - "jsonrpc": "2.0", - "result": { - "hash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "parentHash": "0x...", - "stateRoot": "0x...", - "transactionsRoot": "0x...", - "receiptsRoot": "0x...", - "number": "0x123", - "gasUsed": "0x5208", - "gasLimit": "0x1c9c380", - "timestamp": "0x...", - "extraData": "0x", - "mixHash": "0x...", - "nonce": "0x0", - "transactions": ["0x..."] - } -} -``` - -### Fields -- `hash`: Block hash calculated from the current flashblock header -- `parentHash`: Hash of the parent block -- `stateRoot`: Current state root from latest flashblock -- `transactionsRoot`: Transactions trie root -- `receiptsRoot`: Receipts trie root -- `number`: Block number being built -- `gasUsed`: Cumulative gas used by all transactions -- `gasLimit`: Block gas limit -- `timestamp`: Block timestamp -- `extraData`: Extra data bytes -- `mixHash`: Mix hash value -- `nonce`: Block nonce value -- `transactions`: Array of transaction hashes or objects - -### `eth_getTransactionReceipt` - -Returns the receipt for a transaction. - -**Parameters:** -- `transactionHash`: `String` - Hash of the transaction - -**Returns:** `Object` - Transaction receipt or `null` - -**Example:** -```json -// Request -{ - "method": "eth_getTransactionReceipt", - "params": ["0x..."], - "id": 1, - "jsonrpc": "2.0" -} - -// Response -{ - "id": 1, - "jsonrpc": "2.0", - "result": { - "transactionHash": "0x...", - "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", - "blockNumber": "0x123", - "transactionIndex": "0x0", - "from": "0x...", - "to": "0x...", - "gasUsed": "0x5208", - "cumulativeGasUsed": "0x5208", - "effectiveGasPrice": "0x...", - "status": "0x1", - "contractAddress": null, - "logs": [], - "logsBloom": "0x..." - } -} -``` - -**Fields:** -- `transactionHash`: Hash of the transaction -- `blockHash`: zero hash (`0x000...000`) for preconfirmed transactions -- `blockNumber`: Block number containing the transaction -- `transactionIndex`: Index of transaction in block -- `from`: Sender address -- `to`: Recipient address -- `gasUsed`: Gas used by this transaction -- `cumulativeGasUsed`: Total gas used up to this transaction -- `effectiveGasPrice`: Effective gas price paid -- `status`: `0x1` for success, `0x0` for failure -- `contractAddress`: Address of created contract (for contract creation) -- `logs`: Array of log objects -- `logsBloom`: Bloom filter for logs - -### `eth_getBalance` - -Returns the balance of an account. - -**Parameters:** -- `address`: `String` - Address to query -- `blockNumber`: `String` - Block number or tag (`"pending"` for preconfirmed state) - -**Returns:** `String` - Account balance in wei (hex-encoded) - -**Example:** -```json -// Request -{ - "method": "eth_getBalance", - "params": ["0x...", "pending"], - "id": 1, - "jsonrpc": "2.0" -} - -// Response -{ - "id": 1, - "jsonrpc": "2.0", - "result": "0x1bc16d674ec80000" -} -``` - -### `eth_getTransactionCount` - -Returns the number of transactions sent from an address (nonce). - -**Parameters:** -- `address`: `String` - Address to query -- `blockNumber`: `String` - Block number or tag (`"pending"` for preconfirmed state) - -**Returns:** `String` - Transaction count (hex-encoded) - -**Example:** -```json -// Request -{ - "method": "eth_getTransactionCount", - "params": ["0x...", "pending"], - "id": 1, - "jsonrpc": "2.0" -} - -// Response -{ - "id": 1, - "jsonrpc": "2.0", - "result": "0x5" -} -``` - -## Behavior Notes - -### Pending Tag Usage -- When `"pending"` is used, the method queries preconfirmed state from the flashblocks cache -- If no preconfirmed data is available, falls back to latest confirmed state -- For non-pending queries, behaves identically to standard Ethereum JSON-RPC - -### Error Handling -- Returns standard JSON-RPC error responses for invalid requests -- Returns `null` for non-existent transactions or blocks -- Falls back to standard behavior when flashblocks are disabled or unavailable diff --git a/docs/specs/pages/protocol/execution/index.md b/docs/specs/pages/protocol/execution/index.md deleted file mode 100644 index e981aa7dc2..0000000000 --- a/docs/specs/pages/protocol/execution/index.md +++ /dev/null @@ -1,490 +0,0 @@ -# L2 Execution Engine - -This document outlines the modifications, configuration and usage of a L1 execution engine for L2. - -## 1559 Parameters - -The execution engine must be able to take a per chain configuration which specifies the EIP-1559 Denominator -and EIP-1559 elasticity. After Canyon it should also take a new value `EIP1559DenominatorCanyon` and use that as -the denominator in the 1559 formula rather than the prior denominator. - -The formula for EIP-1559 is otherwise not modified. - -Starting with Holocene, the EIP-1559 parameters become [dynamically configurable](../../upgrades/holocene/exec-engine.md#dynamic-eip-1559-parameters). - -Starting with Jovian, a [configurable minimum base fee](../../upgrades/jovian/exec-engine.md#minimum-base-fee) is introduced. - -## Extra Data - -Before Holocene, the genesis block may contain an arbitrary `extraData` value whereas all normal -blocks must have an **empty** `extraData` field. - -With Holocene, the `extraData` field [encodes the EIP-1559 parameters](../../upgrades/holocene/exec-engine.md#dynamic-eip-1559-parameters). - -With Jovian, the `extraData` encoding is extended to [include `minBaseFee`](../../upgrades/jovian/exec-engine.md#minimum-base-fee). - -## Deposited transaction processing - -The Engine interfaces abstract away transaction types with [EIP-2718][eip-2718]. - -To support rollup functionality, processing of a new Deposit [`TransactionType`][eip-2718-transactions] -is implemented by the engine, see the [deposits specification][deposit-spec]. - -This type of transaction can mint L2 ETH, run EVM, -and introduce L1 information to enshrined contracts in the execution state. - -[deposit-spec]: ../bridging/deposits.md - -### Deposited transaction boundaries - -Transactions cannot be blindly trusted, trust is established through authentication. -Unlike other transaction types deposits are not authenticated by a signature: -the rollup node authenticates them, outside of the engine. - -To process deposited transactions safely, the deposits MUST be authenticated first: - -- Ingest directly through trusted Engine API -- Part of sync towards a trusted block hash (trusted through previous Engine API instruction) - -Deposited transactions MUST never be consumed from the transaction pool. -_The transaction pool can be disabled in a deposits-only rollup_ - -## Fees - -Sequenced transactions (i.e. not applicable to deposits) are charged with 3 types of fees: -priority fees, base fees, and L1-cost fees. - -### Fee Vaults - -The three types of fees are collected in 3 distinct L2 fee-vault deployments for accounting purposes: -fee payments are not registered as internal EVM calls, and thus distinguished better this way. - -These are hardcoded addresses, pointing at pre-deployed proxy contracts. -The proxies are backed by vault contract deployments, based on `FeeVault`, to route vault funds to L1 securely. - -| Vault Name | Predeploy | -| ------------------- | ------------------------------------------------------ | -| Sequencer Fee Vault | [`SequencerFeeVault`](evm/predeploys.md#sequencerfeevault) | -| Base Fee Vault | [`BaseFeeVault`](evm/predeploys.md#basefeevault) | -| L1 Fee Vault | [`L1FeeVault`](evm/predeploys.md#l1feevault) | - -### Priority fees (Sequencer Fee Vault) - -Priority fees follow the [eip-1559] specification, and are collected by the fee-recipient of the L2 block. -The block fee-recipient (a.k.a. coinbase address) is set to the Sequencer Fee Vault address. - -### Base fees (Base Fee Vault) - -Base fees largely follow the [eip-1559] specification, with the exception that base fees are not burned, -but add up to the Base Fee Vault ETH account balance. - -### L1-Cost fees (L1 Fee Vault) - -The protocol funds batch-submission of sequenced L2 transactions by charging L2 users an additional fee -based on the estimated batch-submission costs. -This fee is charged from the L2 transaction-sender ETH balance, and collected into the L1 Fee Vault. - -The exact L1 cost function to determine the L1-cost fee component of a L2 transaction depends on -the upgrades that are active. - -#### Pre-Ecotone - -Before Ecotone activation, L1 cost is calculated as: -`(rollupDataGas + l1FeeOverhead) * l1BaseFee * l1FeeScalar / 1e6` (big-int computation, result -in Wei and `uint256` range) -Where: - -- `rollupDataGas` is determined from the _full_ encoded transaction - (standard EIP-2718 transaction encoding, including signature fields): - - `rollupDataGas = zeroes * 4 + ones * 16` -- `l1FeeOverhead` is the Gas Price Oracle `overhead` value. -- `l1FeeScalar` is the Gas Price Oracle `scalar` value. -- `l1BaseFee` is the L1 base fee of the latest L1 origin registered in the L2 chain. - -Note that the `rollupDataGas` uses the same byte cost accounting as defined in [eip-2028], -except the full L2 transaction now counts towards the bytes charged in the L1 calldata. -This behavior matches pre-Bedrock L1-cost estimation of L2 transactions. - -Compression, batching, and intrinsic gas costs of the batch transactions are accounted for by the protocol -with the Gas Price Oracle `overhead` and `scalar` parameters. - -The Gas Price Oracle `l1FeeOverhead` and `l1FeeScalar`, as well as the `l1BaseFee` of the L1 origin, -can be accessed in two interchangeable ways: - -- read from the deposited L1 attributes (`l1FeeOverhead`, `l1FeeScalar`, `basefee`) of the current L2 block -- read from the L1 Block Info contract (`0x4200000000000000000000000000000000000015`) - - using the respective solidity `uint256`-getter functions (`l1FeeOverhead`, `l1FeeScalar`, `basefee`) - - using direct storage-reads: - - L1 basefee as big-endian `uint256` in slot `1` - - Overhead as big-endian `uint256` in slot `5` - - Scalar as big-endian `uint256` in slot `6` - -#### Ecotone L1-Cost fee changes (EIP-4844 DA) - -Ecotone allows posting batches via Blobs which are subject to a new fee market. To account for this feature, -L1 cost is computed as: - -`(zeroes*4 + ones*16) * (16*l1BaseFee*l1BaseFeeScalar + l1BlobBaseFee*l1BlobBaseFeeScalar) / 16e6` - -Where: - -- the computation is an unlimited precision integer computation, with the result in Wei and having - `uint256` range. - -- zeroes and ones are the count of zero and non-zero bytes respectively in the _full_ encoded - signed transaction. - -- `l1BaseFee` is the L1 base fee of the latest L1 origin registered in the L2 chain. - -- `l1BlobBaseFee` is the blob gas price, computed as described in [EIP-4844][4844-gas] from the - header of the latest registered L1 origin block. - -Conceptually what the above function captures is the formula below, where `compressedTxSize = -(zeroes*4 + ones*16) / 16` can be thought of as a rough approximation of how many bytes the -transaction occupies in a compressed batch. - -`(compressedTxSize) * (16*l1BaseFee*lBaseFeeScalar + l1BlobBaseFee*l1BlobBaseFeeScalar) / 1e6` - -The precise cost function used by Ecotone at the top of this section preserves precision under -integer arithmetic by postponing the inner division by 16 until the very end. - -[4844-gas]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-4844.md#gas-accounting - -The two base fee values and their respective scalars can be accessed in two interchangeable ways: - -- read from the deposited L1 attributes (`l1BaseFeeScalar`, `l1BlobBaseFeeScalar`, `basefee`, - `blobBaseFee`) of the current L2 block -- read from the L1 Block Info contract (`0x4200000000000000000000000000000000000015`) - - using the respective solidity getter functions - - using direct storage-reads: - - basefee `uint256` in slot `1` - - blobBaseFee `uint256` in slot `7` - - l1BaseFeeScalar big-endian `uint32` slot `3` at offset `12` - - l1BlobBaseFeeScalar big-endian `uint32` in slot `3` at offset `8` - -## Engine API - -### `engine_forkchoiceUpdatedV2` - -This updates which L2 blocks the engine considers to be canonical (`forkchoiceState` argument), -and optionally initiates block production (`payloadAttributes` argument). - -Within the rollup, the types of forkchoice updates translate as: - -- `headBlockHash`: block hash of the head of the canonical chain. Labeled `"unsafe"` in user JSON-RPC. - Nodes may apply L2 blocks out of band ahead of time, and then reorg when L1 data conflicts. -- `safeBlockHash`: block hash of the canonical chain, derived from L1 data, unlikely to reorg. -- `finalizedBlockHash`: irreversible block hash, matches lower boundary of the dispute period. - -To support rollup functionality, one backwards-compatible change is introduced -to [`engine_forkchoiceUpdatedV2`][engine_forkchoiceUpdatedV2]: the extended `PayloadAttributesV2` - -#### Extended PayloadAttributesV2 - -[`PayloadAttributesV2`][PayloadAttributesV2] is extended to: - -```js -PayloadAttributesV2: { - timestamp: QUANTITY - prevRandao: DATA (32 bytes) - suggestedFeeRecipient: DATA (20 bytes) - withdrawals: array of WithdrawalV1 - transactions: array of DATA - noTxPool: bool - gasLimit: QUANTITY or null -} -``` - -The type notation used here refers to the [HEX value encoding] used by the [Ethereum JSON-RPC API -specification][JSON-RPC-API], as this structure will need to be sent over JSON-RPC. `array` refers -to a JSON array. - -Each item of the `transactions` array is a byte list encoding a transaction: `TransactionType || -TransactionPayload` or `LegacyTransaction`, as defined in [EIP-2718][eip-2718]. -This is equivalent to the `transactions` field in [`ExecutionPayloadV2`][ExecutionPayloadV2] - -The `transactions` field is optional: - -- If empty or missing: no changes to engine behavior. The sequencers will (if enabled) build a block - by consuming transactions from the transaction pool. -- If present and non-empty: the payload MUST be produced starting with this exact list of transactions. - The [rollup driver][rollup-driver] determines the transaction list based on deterministic L1 inputs. - -The `noTxPool` is optional as well, and extends the `transactions` meaning: - -- If `false`, the execution engine is free to pack additional transactions from external sources like the tx pool - into the payload, after any of the `transactions`. This is the default behavior a L1 node implements. -- If `true`, the execution engine must not change anything about the given list of `transactions`. - -If the `transactions` field is present, the engine must execute the transactions in order and return `STATUS_INVALID` -if there is an error processing the transactions. It must return `STATUS_VALID` if all of the transactions could -be executed without error. **Note**: The state transition rules have been modified such that deposits will never fail -so if `engine_forkchoiceUpdatedV2` returns `STATUS_INVALID` it is because a batched transaction is invalid. - -The `gasLimit` is optional w.r.t. compatibility with L1, but required when used as rollup. -This field overrides the gas limit used during block-building. -If not specified as rollup, a `STATUS_INVALID` is returned. - -[rollup-driver]: ../consensus/index.md - -### `engine_forkchoiceUpdatedV3` - -See [`engine_forkchoiceUpdatedV2`](#engine_forkchoiceupdatedv2) for a description of the forkchoice updated method. -`engine_forkchoiceUpdatedV3` **must only be called with Ecotone payload.** - -To support rollup functionality, one backwards-compatible change is introduced -to [`engine_forkchoiceUpdatedV3`][engine_forkchoiceUpdatedV3]: the extended `PayloadAttributesV3` - -#### Extended PayloadAttributesV3 - -[`PayloadAttributesV3`][PayloadAttributesV3] is extended to: - -```js -PayloadAttributesV3: { - timestamp: QUANTITY - prevRandao: DATA (32 bytes) - suggestedFeeRecipient: DATA (20 bytes) - withdrawals: array of WithdrawalV1 - parentBeaconBlockRoot: DATA (32 bytes) - transactions: array of DATA - noTxPool: bool - gasLimit: QUANTITY or null - eip1559Params: DATA (8 bytes) or null - minBaseFee: QUANTITY or null -} -``` - -The requirements of this object are the same as extended [`PayloadAttributesV2`](#extended-payloadattributesv2) with -the addition of `parentBeaconBlockRoot` which is the parent beacon block root from the L1 origin block of the L2 block. - -Starting at Ecotone, the `parentBeaconBlockRoot` must be set to the L1 origin `parentBeaconBlockRoot`, -or a zero `bytes32` if the Dencun functionality with `parentBeaconBlockRoot` is not active on L1. - -Starting with Holocene, the `eip1559Params` field must encode the EIP1559 parameters. It must be `null` before. -See [Dynamic EIP-1559 Parameters](../../upgrades/holocene/exec-engine.md#dynamic-eip-1559-parameters) for details. - -Starting with Jovian, the `minBaseFee` field is added. It must be `null` before Jovian. -See [Jovian Minimum Base Fee](../../upgrades/jovian/exec-engine.md#minimum-base-fee) for details. - -### `engine_newPayloadV2` - -No modifications to [`engine_newPayloadV2`][engine_newPayloadV2]. -Applies a L2 block to the engine state. - -### `engine_newPayloadV3` - -[`engine_newPayloadV3`][engine_newPayloadV3] applies an Ecotone L2 block to the engine state. There are no -modifications to this API. -`engine_newPayloadV3` **must only be called with Ecotone payload.** - -The additional parameters should be set as follows: - -- `expectedBlobVersionedHashes` MUST be an empty array. -- `parentBeaconBlockRoot` MUST be the parent beacon block root from the L1 origin block of the L2 block. - -### `engine_newPayloadV4` - -[`engine_newPayloadV4`][engine_newPayloadV4] applies an Isthmus L2 block to the engine state. -The `ExecutionPayload` parameter will contain an extra field, `withdrawalsRoot`, after the Isthmus hardfork. - -`engine_newPayloadV4` **must only be called with Isthmus payload.** - -The additional parameters should be set as follows: - -- `executionRequests` MUST be an empty array. - -### `engine_getPayloadV2` - -No modifications to [`engine_getPayloadV2`][engine_getPayloadV2]. -Retrieves a payload by ID, prepared by `engine_forkchoiceUpdatedV2` when called with `payloadAttributes`. - -### `engine_getPayloadV3` - -[`engine_getPayloadV3`][engine_getPayloadV3] retrieves a payload by ID, prepared by `engine_forkchoiceUpdatedV3` -when called with `payloadAttributes`. -`engine_getPayloadV3` **must only be called with Ecotone payload.** - -#### Extended Response - -The [response][GetPayloadV3Response] is extended to: - -```js -{ - executionPayload: ExecutionPayload - blockValue: QUANTITY - blobsBundle: BlobsBundle - shouldOverrideBuilder: BOOLEAN - parentBeaconBlockRoot: DATA (32 bytes) -} -``` - -[GetPayloadV3Response]: https://github.com/ethereum/execution-apis/blob/main/src/engine/cancun.md#response-2 - -In Ecotone it MUST be set to the parentBeaconBlockRoot from the L1 Origin block of the L2 block. - -### `engine_getPayloadV4` - -[`engine_getPayloadV4`][engine_getPayloadV4] retrieves a payload by ID, prepared by `engine_forkchoiceUpdatedV3` -when called with `payloadAttributes`. -`engine_getPayloadV4` **must only be called with Isthmus payload.** - -### `engine_signalSuperchainV1` - -Optional extension to the Engine API. Signals superchain information to the Engine: -V1 signals which protocol version is recommended and required. - -Types: - -```javascript -SuperchainSignal: { - recommended: ProtocolVersion; - required: ProtocolVersion; -} -``` - -`ProtocolVersion`: encoded for RPC as defined in the protocol version format specification. - -Parameters: - -- `signal`: `SuperchainSignal`, the signaled superchain information. - -Returns: - -- `ProtocolVersion`: the latest supported Base protocol version of the execution engine. - -The execution engine SHOULD warn the user when the recommended version is newer than -the current version supported by the execution engine. - -The execution engine SHOULD take safety precautions if it does not meet the required protocol version. -This may include halting the engine, with consent of the execution engine operator. - -## Networking - -The execution engine can acquire all data through the rollup node, as derived from L1: -_P2P networking is strictly optional._ - -However, to not bottleneck on L1 data retrieval speed, the P2P network functionality SHOULD be enabled, serving: - -- Peer discovery ([Disc v5][discv5]) -- [`eth/66`][eth66]: - - Transaction pool (consumed by sequencer nodes) - - State sync (happy-path for fast trustless db replication) - - Historical block header and body retrieval - - _New blocks are acquired through the consensus layer instead (rollup node)_ - -No modifications to L1 network functionality are required, except configuration: - -- [`networkID`][network-id]: Distinguishes the L2 network from L1 and testnets. - Equal to the [`chainID`][chain-id] of the rollup network. -- Activate Merge fork: Enables Engine API and disables propagation of blocks, - as block headers cannot be authenticated without consensus layer. -- Bootnode list: DiscV5 is a shared network, - [bootstrap][discv5-rationale] is faster through connecting with L2 nodes first. - -[discv5]: https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md -[eth66]: https://github.com/ethereum/devp2p/blob/master/caps/eth.md -[network-id]: https://github.com/ethereum/devp2p/blob/master/caps/eth.md#status-0x00 -[chain-id]: https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md -[discv5-rationale]: https://github.com/ethereum/devp2p/blob/master/discv5/discv5-rationale.md - -## Sync - -The execution engine can operate sync in different ways: - -- Happy-path: rollup node informs engine of the desired chain head as determined by L1, completes through engine P2P. -- Worst-case: rollup node detects stalled engine, completes sync purely from L1 data, no peers required. - -The happy-path is more suitable to bring new nodes online quickly, -as the engine implementation can sync state faster through methods like [snap-sync][snap-sync]. - -[snap-sync]: https://github.com/ethereum/devp2p/blob/master/caps/snap.md - -### Happy-path sync - -1. The rollup node informs the engine of the L2 chain head, unconditionally (part of regular node operation): - - Bedrock / Canyon / Delta Payloads - - [`engine_newPayloadV2`][engine_newPayloadV2] is called with latest L2 block received from P2P. - - [`engine_forkchoiceUpdatedV2`][engine_forkchoiceUpdatedV2] is called with the current - `unsafe`/`safe`/`finalized` L2 block hashes. - - Ecotone Payloads - - [`engine_newPayloadV3`][engine_newPayloadV3] is called with latest L2 block received from P2P. - - [`engine_forkchoiceUpdatedV3`][engine_forkchoiceUpdatedV3] is called with the current - `unsafe`/`safe`/`finalized` L2 block hashes. -2. The engine requests headers from peers, in reverse till the parent hash matches the local chain -3. The engine catches up: - a) A form of state sync is activated towards the finalized or head block hash - b) A form of block sync pulls block bodies and processes towards head block hash - -The exact P2P based sync is out of scope for the L2 specification: -the operation within the engine is the exact same as with L1 (although with an EVM that supports deposits). - -### Worst-case sync - -1. Engine is out of sync, not peered and/or stalled due other reasons. -2. The rollup node maintains latest head from engine (poll `eth_getBlockByNumber` and/or maintain a header subscription) -3. The rollup node activates sync if the engine is out of sync but not syncing through P2P (`eth_syncing`) -4. The rollup node inserts blocks, derived from L1, one by one, potentially adapting to L1 reorg(s), - as outlined in the [rollup node spec]. - -[rollup node spec]: ../consensus/index.md - -## Ecotone: disable Blob-transactions - -[EIP-4844] introduces Blob transactions: featuring all the functionality of an [EIP-1559] transaction, -plus a list of "blobs": "Binary Large Object", i.e. a dedicated data type for serving Data-Availability as base-layer. - -With the Ecotone upgrade, all Cancun L1 execution features are enabled, with [EIP-4844] as exception: -as an L2, Base does not serve blobs, and thus disables this new transaction type. - -EIP-4844 is disabled as following: - -- Transaction network-layer announcements, announcing blob-type transactions, are ignored. -- Transactions of the blob-type, through the RPC or otherwise, are not allowed into the transaction pool. -- Block-building code does not select EIP-4844 transactions. -- An L2 block state-transition with EIP-4844 transactions is invalid. - -The [BLOBBASEFEE opcode](https://eips.ethereum.org/EIPS/eip-7516) is present but its semantics are -altered because there are no blobs processed by L2. The opcode will always push a value of 1 onto -the stack. - -## Ecotone: Beacon Block Root - -[EIP-4788] introduces a "beacon block root" into the execution-layer block-header and EVM. -This block root is an [SSZ hash-tree-root] of the consensus-layer contents of the previous consensus block. - -With the adoption of [EIP-4399] in the Bedrock upgrade the Base already includes the `PREVRANDAO` of L1. -And thus with [EIP-4788] the L1 beacon block root is made available. - -For the Ecotone upgrade, this entails that: - -- The `parent_beacon_block_root` of the L1 origin is now embedded in the L2 block header. -- The "Beacon roots contract" is deployed at Ecotone upgrade-time, or embedded at genesis if activated at genesis. -- The block state-transition process now includes the same special beacon-block-root EVM processing as L1 ethereum. - -[SSZ hash-tree-root]: https://github.com/ethereum/consensus-specs/blob/master/ssz/simple-serialize.md#merkleization -[EIP-4399]: https://eips.ethereum.org/EIPS/eip-4399 -[EIP-4788]: https://eips.ethereum.org/EIPS/eip-4788 -[EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 -[eip-1559]: https://eips.ethereum.org/EIPS/eip-1559 -[eip-2028]: https://eips.ethereum.org/EIPS/eip-2028 -[eip-2718]: https://eips.ethereum.org/EIPS/eip-2718 -[eip-2718-transactions]: https://eips.ethereum.org/EIPS/eip-2718#transactions -[PayloadAttributesV3]: https://github.com/ethereum/execution-apis/blob/cea7eeb642052f4c2e03449dc48296def4aafc24/src/engine/cancun.md#payloadattributesv3 -[PayloadAttributesV2]: https://github.com/ethereum/execution-apis/blob/584905270d8ad665718058060267061ecfd79ca5/src/engine/shanghai.md#PayloadAttributesV2 -[ExecutionPayloadV2]: https://github.com/ethereum/execution-apis/blob/main/src/engine/shanghai.md#executionpayloadv2 -[engine_forkchoiceUpdatedV3]: https://github.com/ethereum/execution-apis/blob/cea7eeb642052f4c2e03449dc48296def4aafc24/src/engine/cancun.md#engine_forkchoiceupdatedv3 -[engine_forkchoiceUpdatedV2]: https://github.com/ethereum/execution-apis/blob/584905270d8ad665718058060267061ecfd79ca5/src/engine/shanghai.md#engine_forkchoiceupdatedv2 -[engine_newPayloadV2]: https://github.com/ethereum/execution-apis/blob/584905270d8ad665718058060267061ecfd79ca5/src/engine/shanghai.md#engine_newpayloadv2 -[engine_newPayloadV3]: https://github.com/ethereum/execution-apis/blob/cea7eeb642052f4c2e03449dc48296def4aafc24/src/engine/cancun.md#engine_newpayloadv3 -[engine_newPayloadV4]: https://github.com/ethereum/execution-apis/blob/869b7f062830ba51a7fd8a51dfa4678c6d36b6ec/src/engine/prague.md#engine_newpayloadv4 -[engine_getPayloadV2]: https://github.com/ethereum/execution-apis/blob/584905270d8ad665718058060267061ecfd79ca5/src/engine/shanghai.md#engine_getpayloadv2 -[engine_getPayloadV3]: https://github.com/ethereum/execution-apis/blob/a0d03086564ab1838b462befbc083f873dcf0c0f/src/engine/cancun.md#engine_getpayloadv3 -[engine_getPayloadV4]: https://github.com/ethereum/execution-apis/blob/869b7f062830ba51a7fd8a51dfa4678c6d36b6ec/src/engine/prague.md#engine_getpayloadv4 -[HEX value encoding]: https://ethereum.org/en/developers/docs/apis/json-rpc/#hex-encoding -[JSON-RPC-API]: https://github.com/ethereum/execution-apis - -## P2P Modifications - -The Ethereum Node Record (ENR) for a Base execution node must contain an `opel` key-value pair where the key is -`opel` and the value is a [EIP-2124](https://eips.ethereum.org/EIPS/eip-2124) fork id. -The EL uses a different key from the CL in order to stop EL and CL nodes from connecting to each other. diff --git a/docs/specs/pages/protocol/fault-proof/cannon-fault-proof-vm.md b/docs/specs/pages/protocol/fault-proof/cannon-fault-proof-vm.md deleted file mode 100644 index 1bf8e57f37..0000000000 --- a/docs/specs/pages/protocol/fault-proof/cannon-fault-proof-vm.md +++ /dev/null @@ -1,560 +0,0 @@ -# Multithreaded Cannon Fault Proof Virtual Machine - -## Overview - -This is a description of the second iteration of the Cannon Fault Proof Virtual Machine (FPVM). -When necessary to distinguish this version from the initial implementation, -it can be referred to as Multithreaded Cannon (MTCannon). Similarly, -the original Cannon implementation can be referred to as Singlethreaded Cannon (STCannon) where necessary for clarity. - -The MTCannon FPVM emulates a minimal uniprocessor Linux-based system running on big-endian 64-bit MIPS64 architecture. -A lot of its behaviors are copied from Linux/MIPS with a few tweaks made for fault proofs. -For the rest of this doc, we refer to the MTCannon FPVM as simply the FPVM. - -Operationally, the FPVM is a state transition function. This state transition is referred to as a _Step_, -that executes a single instruction. We say the VM is a function $f$, given an input state $S_{pre}$, steps on a -single instruction encoded in the state to produce a new state $S_{post}$. -$$f(S_{pre}) \rightarrow S_{post}$$ - -Thus, the trace of a program executed by the FPVM is an ordered set of VM states. - -### Definitions - -#### Concepts - -##### Natural Alignment - -A memory address is said to be "naturally aligned" in the context of some data type -if it is a multiple of that data type's byte size. -For example, the address of a 32-bit (4-byte) value is naturally aligned if it is a multiple of 4 (e.g. `0x1000`, `0x1004`). -Similarly, the address of a 64-bit (8-byte) value is naturally aligned if it is a multiple of 8 (e.g. `0x1000`, `0x1008`). - -A non-aligned address can be naturally aligned by dropping the least significant bits of the address: -`aligned = unaligned & ^(byteSize - 1)`. -For example, to align the address `0x1002` targeting a 32-bit value: -`aligned = 0x1002 & ^(0x3) = 0x1000`. - -#### Data types - -- `Boolean` - An 8-bit boolean value equal to 0 (false) or 1 (true). -- `Hash` - A 256-bit fixed-size value produced by the Keccak-256 cryptographic hash function. -- `UInt8` - An 8-bit unsigned integer value. -- `UInt64` - A 64-bit unsigned integer value. -- `Word` - A 64-bit value. - -#### Constants - -- `EBADF` - A Linux error number indicating a bad file descriptor: `0x9`. -- `MaxWord` - A `Word` with all bits set to 1: `0xFFFFFFFFFFFFFFFF`. -When interpreted as a signed value, this is equivalent to -1. -- `ProgramBreakAddress` - The fixed memory address for the program break: `Word(0x0000_4000_0000_0000)`. -- `WordSize` - The number of bytes in a `Word` (8). - -### New Features - -#### Multithreading - -MTCannon adds support for [multithreading](https://en.wikipedia.org/wiki/Thread_(computing)). -Thread management and scheduling are typically handled by the -[operating system (OS) kernel](https://en.wikipedia.org/wiki/Kernel_%28operating_system%29): -programs make thread-related requests to the OS kernel via [syscalls](https://en.wikipedia.org/wiki/System_call). -As such, this implementation includes a few new Linux-specific thread-related [syscalls](#syscalls). -Additionally, the [FPVM state](#fpvm-state) has been modified in order to track the set of active threads -and thread-related global state. - -#### 64-bit Architecture - -MTCannon emulates a MIPS64 machine whereas STCannon emulates a MIPS32 machine. The transition from MIPS32 to MIPS64 -means the address space goes from 32-bit to 64-bit, greatly expanding addressable memory. - -#### Robustness - -In the initial implementation of Cannon, unrecognized syscalls were treated as -noops (see ["Noop Syscalls"](#noop-syscalls)). To ensure no unexpected behaviors are triggered, -MTCannon will now raise an exception if unrecognized syscalls are encountered during program execution. - -## Multithreading - -The MTCannon FPVM rotates between threads to provide -[multitasking](https://en.wikipedia.org/wiki/Computer_multitasking) rather than -true [parallel processing](https://en.wikipedia.org/wiki/Parallel_computing). -The VM state holds an ordered set of thread state objects representing all executing threads. - -On any given step, there is one active thread that will be processed. - -### Thread Management - -The FPVM state contains two thread stacks that are used to represent the set of all threads: `leftThreadStack` and -`rightThreadStack`. An additional boolean value (`traverseRight`) determines which stack contains the currently active -thread and how threads are rearranged when the active thread is preempted (see ["Thread Preemption"](#thread-preemption) -for details). - -When traversing right, the thread on the top of the right stack is the active thread, the right stack is referred to as -the "active" stack, -and the left the "inactive" stack. Conversely, when traversing left, the active thread is on top of the left stack, -the left stack is "active", and the right is "inactive". - -Representing the set of threads as two stacks allows for a succinct commitment to the contents of all threads. -For details, see [“Thread Stack Hashing”](#thread-stack-hashing). - -### Thread Traversal Mechanics - -Threads are traversed deterministically by moving from the first thread to the last thread, -then from the last thread to the first thread repeatedly. For example, given the set of threads: {0,1,2,3}, -the FPVM would traverse to each as follows: 0, 1, 2, 3, 3, 2, 1, 0, 0, 1, 2, 3, 3, 2, …. - -#### Thread Preemption - -Threads are traversed via "preemption": the currently active thread is popped from the active stack and pushed to the -inactive stack. If the active stack is empty, the FPVM state's `traverseRight` field is flipped ensuring that -there is always an active thread. - -### Exited Threads - -When the VM encounters an active thread that has exited, it is popped from the active thread stack, removing it from -the VM state. - -### Futex Operations - -The VM supports [futex syscall](https://www.man7.org/linux/man-pages/man2/futex.2.html) operations -`FUTEX_WAIT_PRIVATE` and `FUTEX_WAKE_PRIVATE`. - -Futexes are commonly used to implement locks in user space. -In this scenario, a shared 32-bit value (the "futex value") represents the state of a lock. -If a thread cannot acquire the lock, it calls a futex wait, which puts the thread to sleep. -To release the lock, the owning thread updates the futex value and then calls a futex wake -to notify any other waiting threads. - -Because wake-ups may be spurious or could be triggered by unrelated operations on the same memory, -waiting threads must always re-check the futex value after waking up to decide if they can proceed. - -#### Wait - -When a futex wait is successfully executed, the current thread is simply [preempted](#thread-preemption). -This gives other threads a chance to run and potentially change the shared futex value (for example, by releasing a lock). -When the thread is eventually scheduled again, if the futex value has not changed the wakeup will be considered spurious -and the thread will simply call futex wait again. - -#### Wake - -When a futex wake is executed, the current thread is [preempted](#thread-preemption). This allows the scheduler to move -on to other threads which may potentially be ready to run (for example, because a shared lock was released). - -### Voluntary Preemption - -In addition to the [futex syscall](#futex-operations), there are a few other syscalls that -will cause a thread to be "voluntarily" preempted: `sched_yield`, `nanosleep`. - -### Forced Preemption - -To avoid thread starvation (for example where a thread hogs resources by never executing a sleep, yield, wait, etc.), -the FPVM will force a context switch if the active thread has been executing too long. - -For each step executed on a particular thread, the state field `stepsSinceLastContextSwitch` is incremented. -When a thread is preempted, `StepsSinceLastContextSwitch` is reset to 0. -If `StepsSinceLastContextSwitch` reaches a maximum value (`SchedQuantum` = 100_000), -the FPVM preempts the active thread. - -## Stateful Instructions - -### Load Linked / Store Conditional Word - -The Load Linked Word (`ll`) and Store Conditional Word (`sc`) instructions provide the low-level -primitives used to implement atomic read-modify-write (RMW) operations. A typical RMW sequence might play out as -follows: - -- `ll` places a "reservation" targeting a 32-bit value in memory and returns the current value at this location. -- Subsequent instructions take this value and perform some operation on it: - - For example, maybe a counter variable is loaded and then incremented. -- `sc` is called and the modified value overwrites the original value in memory -only if the memory reservation is still intact. - -This RMW sequence ensures that if another thread or process modifies a reserved value while -an atomic update is being performed, the reservation will be invalidated and the atomic update will fail. - -Prior to MTCannon, we could be assured that no intervening process would modify such a reserved value because -STCannon is singlethreaded. With the introduction of multithreading, additional fields need to be stored in the -FPVM state to track memory reservations initiated by `ll` operations. - -When an `ll` instruction is executed: - -- `llReservationStatus` is set to `1`. -- `llAddress` is set to the virtual memory address specified by `ll`. -- `llOwnerThread` is set to the `threadID` of the active thread. - -Only a single memory reservation can be active at a given time - a new reservation will clear any previous reservation. - -When the VM writes any data to memory, these `ll`-related fields are checked and any existing memory reservation -is cleared if a memory write touches the naturally-aligned `Word` that contains `llAddress`. - -When an `sc` instruction is executed, the operation will only succeed if: - -- The `llReservationStatus` field is equal to `1`. -- The active thread's `threadID` matches `llOwnerThread`. -- The virtual address specified by `sc` matches `llAddress`. - -On success, `sc` stores a value to the specified address after it is naturally aligned, -clears the memory reservation by zeroing out `llReservationStatus`, `llOwnerThread`, and `llAddress` -and returns `1`. - -On failure, `sc` returns `0`. - -### Load Linked / Store Conditional Doubleword - -With the transition to MIPS64, Load Linked Doubleword (`lld`), and Store Conditional Doubleword (`scd`) instructions -are also now supported. -These instructions are similar to `ll` and `sc`, but they operate on 64-bit rather than 32-bit values. - -The `lld` instruction functions similarly to `ll`, but the `llReservationStatus` is set to `2`. -The `scd` instruction functions similarly to `sc`, but the `llReservationStatus` must be equal to `2` -for the operation to succeed. In other words, an `scd` instruction must be preceded by a matching `lld` instruction -just as the `sc` instruction must be preceded by a matching `ll` instruction if the store operation is to succeed. - -## FPVM State - -### State - -The FPVM is a state transition function that operates on a state object consisting of the following fields: - -1. `memRoot` - \[`Hash`\] A value representing the merkle root of VM memory. -1. `preimageKey` - \[`Hash`\] The value of the last requested pre-image key. -1. `preimageOffset` - \[`Word`\] The value of the last requested pre-image offset. -1. `heap` - \[`Word`\] The base address of the most recent memory allocation via mmap. -1. `llReservationStatus` - \[`UInt8`\] The current memory reservation status where: `0` means there is no - reservation, `1` means an `ll`/`sc`-compatible reservation is active, - and `2` means an `lld`/`scd`-compatible reservation is active. - Memory is reserved via Load Linked Word (`ll`) and Load Linked Doubleword (`lld`) instructions. -1. `llAddress` - \[`Word`\] If a memory reservation is active, the value of - the address specified by the last `ll` or `lld` instruction. - Otherwise, set to `0`. -1. `llOwnerThread` - \[`Word`\] The id of the thread that initiated the current memory reservation - or `0` if there is no active reservation. -1. `exitCode` - \[`UInt8`\] The exit code value. -1. `exited` - \[`Boolean`\] Indicates whether the VM has exited. -1. `step` - \[`UInt64`\] A step counter. -1. `stepsSinceLastContextSwitch` - \[`UInt64`\] A step counter that tracks the number of steps executed on the current - thread since the last [preemption](#thread-preemption). -1. `traverseRight` - \[`Boolean`\] Indicates whether the currently active thread is on the left or right thread - stack, as well as some details on thread traversal mechanics. - See ["Thread Traversal Mechanics"](#thread-traversal-mechanics) for details. -1. `leftThreadStack` - \[`Hash`\] A hash of the contents of the left thread stack. - For details, see the [“Thread Stack Hashing” section.](#thread-stack-hashing) -1. `rightThreadStack` - \[`Hash`\] A hash of the contents of the right thread stack. - For details, see the [“Thread Stack Hashing” section.](#thread-stack-hashing) -1. `nextThreadID` - \[`Word`\] The value defining the id to assign to the next thread that is created. - -The state is represented by packing the above fields, in order, into a 188-byte buffer. - -### State Hash - -The state hash is computed by hashing the 188-byte state buffer with the Keccak256 hash function -and then setting the high-order byte to the respective VM status. - -The VM status can be derived from the state's `exited` and `exitCode` fields. - -```rs -enum VmStatus { - Valid = 0, - Invalid = 1, - Panic = 2, - Unfinished = 3, -} - -fn vm_status(exit_code: u8, exited: bool) -> u8 { - if exited { - match exit_code { - 0 => VmStatus::Valid, - 1 => VmStatus::Invalid, - _ => VmStatus::Panic, - } - } else { - VmStatus::Unfinished - } -} -``` - -### Thread State - -The state of a single thread is tracked and represented by a thread state object consisting of the following fields: - -1. `threadID` - \[`Word`\] A unique thread identifier. -1. `exitCode` - \[`UInt8`\] The exit code value. -1. `exited` - \[`Boolean`\] Indicates whether the thread has exited. -1. `pc` - \[`Word`\] The program counter. -1. `nextPC` - \[`Word`\] The next program counter. Note that this value may not always be $pc+4$ - when executing a branch/jump delay slot. -1. `lo` - \[`Word`\] The MIPS LO special register. -1. `hi` - \[`Word`\] The MIPS HI special register. -1. `registers` - 32 general-purpose MIPS registers numbered 0 - 31. Each register contains a `Word` value. - -A thread is represented by packing the above fields, in order, into a 298-byte buffer. - -### Thread Hash - -A thread hash is computed by hashing the 298-byte thread state buffer with the Keccak256 hash function. - -### Thread Stack Hashing - -> **Note:** The `++` operation represents concatenation of 2 byte string arguments - -Each thread stack is represented in the FPVM state by a "hash onion" construction using the Keccak256 hash -function. This construction provides a succinct commitment to the contents of a thread stack using a single `bytes32` -value: - -- An empty stack is represented by the value: - - `c0 = hash(bytes32(0) ++ bytes32(0))` -- To push a thread to the stack, hash the concatenation of the current stack commitment with the thread hash: - - `push(c0, el0) => c1 = hash(c0 ++ hash(el0))`. -- To push another thread: - - `push(c1, el1) => c2 = hash(c1 ++ hash(el1))`. -- To pop an element from the stack, peel back the last hash (push) operation: - - `pop(c2) => c3 = c1` -- To prove the top value `elTop` on the stack, given some commitment `c`, you just need to reveal the `bytes32` - commitment `c'` for the stack without `elTop` and verify: - - `c = hash(c' ++ hash(elTop))` - -## Memory - -Memory is represented as a binary merkle tree. -The tree has a fixed-depth of 59 levels, with leaf values of 32 bytes each. -This spans the full 64-bit address space, where each leaf contains the memory at that part of the tree. -The state `memRoot` represents the merkle root of the tree, reflecting the effects of memory writes. -As a result of this memory representation, all memory operations are `WordSize`-byte aligned. -Memory access doesn't require any privileges. An instruction step can access any memory -location as the entire address space is unprotected. - -### Heap - -FPVM state contains a `heap` that tracks the base address of the most recent memory allocation. -Heap pages are bump allocated at the page boundary, per `mmap` syscall. -mmap-ing is purely to satisfy program runtimes that need the memory-pointer -result of the syscall to locate free memory. The page size is 4096. - -The FPVM has a fixed program break at `ProgramBreakAddress`. However, the FPVM is permitted to extend the -heap beyond this limit via mmap syscalls. -For simplicity, there are no memory protections against "heap overruns" against other memory segments. -Such VM steps are still considered valid state transitions. - -Specification of memory mappings is outside the scope of this document as it is irrelevant to -the VM state. FPVM implementers may refer to the Linux/MIPS kernel for inspiration. - -#### mmap hints - -When a process issues an mmap(2) syscall with a non-NULL addr parameter, the FPVM honors this hint as a strict requirement -rather than a suggestion. The VM unconditionally maps memory at exactly the requested address, -creating the mapping without performing address validity checks. - -The VM does not validate whether the specified address range overlaps with existing mappings. -As this is a single-process execution environment, collision detection is delegated to userspace. -The calling process must track its own page mappings to avoid mapping conflicts, as the usual -kernel protections against overlapping mappings are not implemented. - -## Delay Slots - -The post-state of a step updates the `nextPC`, indicating the instruction following the `pc`. -However, in the case of where a branch instruction is being stepped, the `nextPC` post-state is -set to the branch target. And the `pc` post-state set to the branch delay slot as usual. - -A VM state transition is invalid whenever the current instruction is a delay slot that is filled -with jump or branch type instruction. -That is, where $nextPC \neq pc + 4$ while stepping on a jump/branch instruction. -Otherwise, there would be two consecutive delay slots. While this is considered "undefined" -behavior in typical MIPS implementations, FPVM must raise an exception when stepping on such states. - -## Syscalls - -Syscalls work similar to [Linux/MIPS](https://www.linux-mips.org/wiki/Syscall), including the -syscall calling conventions and general syscall handling behavior. -However, the FPVM supports a subset of Linux/MIPS syscalls with slightly different behaviors. -These syscalls have identical syscall numbers and ABIs as Linux/MIPS. - -For all of the following syscalls, an error is indicated by setting the return -register (`$v0`) to `MaxWord` and `errno` (`$a3`) is set accordingly. -The VM must not modify any register other than `$v0` and `$a3` during syscall handling. - -The following tables summarize supported syscalls and their behaviors. -If an unsupported syscall is encountered, the VM will raise an exception. - -### Supported Syscalls - - -| \$v0 | system call | \$a0 | \$a1 | \$a2 | \$a3 | Effect | -|------|---------------|-----------------|------------------|--------------|------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| 5009 | mmap | uint64 addr | uint64 len | 🚫 | 🚫 | Allocates a page from the heap. See [heap](#heap) for details. | -| 5012 | brk | 🚫 | 🚫 | 🚫 | 🚫 | Returns a fixed address for the program break at `ProgramBreakAddress` | -| 5205 | exit_group | uint8 exit_code | 🚫 | 🚫 | 🚫 | Sets the exited and exitCode state fields to `true` and `$a0` respectively. | -| 5000 | read | uint64 fd | char \*buf | uint64 count | 🚫 | Similar behavior as Linux/MIPS with support for unaligned reads. See [I/O](#io) for more details. | -| 5001 | write | uint64 fd | char \*buf | uint64 count | 🚫 | Similar behavior as Linux/MIPS with support for unaligned writes. See [I/O](#io) for more details. | -| 5070 | fcntl | uint64 fd | int64 cmd | 🚫 | 🚫 | Similar behavior as Linux/MIPS. Only the `F_GETFD`(1) and `F_GETFL` (3) cmds are supported. Sets errno to `0x16` for all other commands. | -| 5055 | clone | uint64 flags | uint64 stack_ptr | 🚫 | 🚫 | Creates a new thread based on the currently active thread's state. Supports a `flags` argument equal to `0x00050f00`, other values cause the VM to exit with exit_code `VmStatus.PANIC`. | -| 5058 | exit | uint8 exit_code | 🚫 | 🚫 | 🚫 | Sets the active thread's exited and exitCode state fields to `true` and `$a0` respectively. | -| 5023 | sched_yield | 🚫 | 🚫 | 🚫 | 🚫 | Preempts the active thread and returns 0. | -| 5178 | gettid | 🚫 | 🚫 | 🚫 | 🚫 | Returns the active thread's threadID field. | -| 5194 | futex | uint64 addr | uint64 futex_op | uint64 val | uint64 \*timeout | Supports `futex_op`'s `FUTEX_WAIT_PRIVATE` (128) and `FUTEX_WAKE_PRIVATE` (129). Other operations set errno to `0x16`. | -| 5002 | open | 🚫 | 🚫 | 🚫 | 🚫 | Sets errno to `EBADF`. | -| 5034 | nanosleep | 🚫 | 🚫 | 🚫 | 🚫 | Preempts the active thread and returns 0. | -| 5222 | clock_gettime | uint64 clock_id | uint64 addr | 🚫 | 🚫 | Supports `clock_id`'s `REALTIME`(0) and `MONOTONIC`(1). For other `clock_id`'s, sets errno to `0x16`. Calculates a deterministic time value based on the state's `step` field and a constant `HZ` (10,000,000) where `HZ` represents the approximate clock rate (steps / second) of the FPVM:

`seconds = step/HZ`
`nsecs = (step % HZ) * 10^9/HZ`

Seconds are set at memory address `addr` and nsecs are set at `addr + WordSize`. | -| 5038 | getpid | 🚫 | 🚫 | 🚫 | 🚫 | Returns 0. | -| 5313 | getrandom | char \*buf | uint64 buflen | 🚫 | 🚫 | Generates pseudorandom bytes and writes them to the buffer at `buf`. Uses splitmix64 seeded with the current step count. Returns the number of bytes written, which is at most `buflen` and limited by alignment boundaries. | -| 5284 | eventfd2 | uint64 initval | int64 flags | 🚫 | 🚫 | Creates an eventfd file descriptor. Only non-blocking mode is supported: if `flags` does not include `EFD_NONBLOCK` (0x80), sets errno to `0x16`. On success, returns file descriptor 100. | - - -### Noop Syscalls - - -For the following noop syscalls, the VM must do nothing except to zero out the syscall return (`$v0`) -and errno (`$a3`) registers. - -| \$v0 | system call | -|------|--------------------| -| 5011 | munmap | -| 5010 | mprotect | -| 5196 | sched_get_affinity | -| 5027 | madvise | -| 5014 | rt_sigprocmask | -| 5129 | sigaltstack | -| 5013 | rt_sigaction | -| 5297 | prlimit64 | -| 5003 | close | -| 5016 | pread64 | -| 5004 | stat | -| 5005 | fstat | -| 5247 | openat | -| 5087 | readlink | -| 5257 | readlinkat | -| 5015 | ioctl | -| 5285 | epoll_create1 | -| 5287 | pipe2 | -| 5208 | epoll_ctl | -| 5272 | epoll_pwait | -| 5061 | uname | -| 5100 | getuid | -| 5102 | getgid | -| 5026 | mincore | -| 5225 | tgkill | -| 5095 | getrlimit | -| 5008 | lseek | -| 5036 | setitimer | -| 5216 | timer_create | -| 5217 | timer_settime | -| 5220 | timer_delete | - - -## I/O - -The VM does not support Linux open(2). However, the VM can read from and write to a predefined set of file descriptors. - -| Name | File descriptor | Description | -| ------------------ | --------------- | ---------------------------------------------------------------------------- | -| stdin | 0 | read-only standard input stream. | -| stdout | 1 | write-only standard output stream. | -| stderr | 2 | write-only standard error stream. | -| hint response | 3 | read-only. Used to read the status of [pre-image hinting](index.md#hinting). | -| hint request | 4 | write-only. Used to provide [pre-image hints](index.md#hinting) | -| pre-image response | 5 | read-only. Used to [read pre-images](index.md#pre-image-communication). | -| pre-image request | 6 | write-only. Used to [request pre-images](index.md#pre-image-communication). | -| eventfd | 100 | read-write. Created by `eventfd2` syscall. Reads return `EAGAIN`. Writes return `EAGAIN`. | - -Syscalls referencing unknown file descriptors fail with an `EBADF` errno as done on Linux. - -Writing to and reading from standard output, input and error streams have no effect on the FPVM state. -FPVM implementations may use them for debugging purposes as long as I/O is stateless. - -All I/O operations are restricted to a maximum of `WordSize` bytes per operation. -Any read or write syscall request exceeding this limit will be truncated to `WordSize` bytes. -Consequently, the return value of read/write syscalls is at most `WordSize` bytes, -indicating the actual number of bytes read/written. - -### Standard Streams - -Writing to stderr/stdout standard stream always succeeds with the write count input returned, -effectively continuing execution without writing work. -Reading from stdin has no effect other than to return zero and errno set to 0, signalling that there is no input. - -### Hint Communication - -Hint requests and responses have no effect on the VM state other than setting the `$v0` return -register to the requested read/write count. -VM implementations may utilize hints to setup subsequent pre-image requests. - -### Pre-image Communication - -The `preimageKey` and `preimageOffset` state are updated via read/write syscalls to the pre-image -read and write file descriptors (see [I/O](#io)). -The `preimageKey` buffers the stream of bytes written to the pre-image write fd. -The `preimageKey` buffer is shifted to accommodate new bytes written to the end of it. -A write also resets the `preimageOffset` to 0, indicating the intent to read a new pre-image. - -When handling pre-image reads, the `preimageKey` is used to lookup the pre-image data from an Oracle. -A max `WordSize`-byte chunk of the pre-image at the `preimageOffset` is read to the specified address. -Each read operation increases the `preimageOffset` by the number of bytes requested -(truncated to `WordSize` bytes and subject to alignment constraints). - -#### Pre-image I/O Alignment - -As mentioned earlier in [memory](#memory), all memory operations are `WordSize`-byte aligned. -Since pre-image I/O occurs on memory, all pre-image I/O operations must strictly adhere to alignment boundaries. -This means the start and end of a read/write operation must fall within the same alignment boundary. -If an operation were to violate this, the input `count` of the read/write syscall must be -truncated such that the effective address of the last byte read/written matches the input effective address. - -The VM must read/write the maximum amount of bytes possible without crossing the input address alignment boundary. -For example, the effect of a write request for a 3-byte aligned buffer must be exactly 3 bytes. -If the buffer is misaligned, then the VM may write less than 3 bytes depending on the size of the misalignment. - -## Exceptions - -The FPVM may raise an exception rather than output a post-state to signal an invalid state -transition. Nominally, the FPVM must raise an exception in at least the following cases: - -- Invalid instruction (either via an invalid opcode or an instruction referencing registers - outside the general purpose registers). -- Unsupported syscall. -- Pre-image read at an offset larger than the size of the pre-image. -- Delay slot contains branch/jump instruction types. -- Invalid thread state: the active thread stack is empty. - -VM implementations may raise an exception in other cases that is specific to the implementation. -For example, an on-chain FPVM that relies on pre-supplied merkle proofs for memory access may -raise an exception if the supplied merkle proof does not match the pre-state `memRoot`. - -## Security Model - -### Compiler Correctness - -MTCannon is designed to prove the correctness of a particular state transition that emulates a MIPS64 machine. -MTCannon does not guarantee that the MIPS64 instructions correctly implement the program that the user intends to prove. -As a result, MTCannon's use as a Fault Proof system inherently depends to some extent on the correctness of the compiler -used to generate the MIPS64 instructions over which MTCannon operates. - -To illustrate this concept, suppose that a user intends to prove simple program `input + 1 = output`. -Suppose then that the user's compiler for this program contains a bug and errantly generates the MIPS instructions for a -slightly different program `input + 2 = output`. Although MTCannon would correctly prove the operation of this compiled program, -the result proven would differ from the user's intent. MTCannon proves the MIPS state transition but makes no assertion about -the correctness of the translation between the user's high-level code and the resulting MIPS program. - -As a consequence of the above, it is the responsibility of a program developer to develop tests that demonstrate that MTCannon -is capable of proving their intended program correctly over a large number of possible inputs. Such tests defend against -bugs in the user's compiler as well as ways in which the compiler may inadvertently break one of MTCannon's -[Compiler Assumptions](#compiler-assumptions). Users of Fault Proof systems are strongly encouraged to utilize multiple -proof systems and/or compilers to mitigate the impact of errant behavior in any one toolchain. - -### Compiler Assumptions - -MTCannon makes the simplifying assumption that users are utilizing compilers that do not rely on MIPS exception states for -standard program behavior. In other words, MTCannon generally assumes that the user's compiler generates spec-compliant -instructions that would not trigger an exception. Refer to [Exceptions](#exceptions) for a list of conditions that are -explicitly handled. - -Certain cases that would typically be asserted by a strict implementation of the MIPS64 specification are not handled by -MTCannon as follows: - -- `add`, `addi`, and `sub` do not trigger an exception on signed integer overflow. -- Instruction encoding validation does not trigger an exception for fields that should be zero. -- Memory instructions do not trigger an exception when addresses are not naturally aligned. - -Many compilers, including the Golang compiler, will not generate code that would trigger these conditions under bug-free -operation. Given the inherent reliance on [Compiler Correctness](#compiler-correctness) in applications using MTCannon, the -tests and defense mechanisms that must necessarily be employed by MTCannon users to protect their particular programs -against compiler bugs should also suffice to surface bugs that would break these compiler assumptions. Stated simply, MTCannon -can rely on specific compiler behaviors because users inherently must employ safety nets to guard against compiler bugs. diff --git a/docs/specs/pages/protocol/fault-proof/index.md b/docs/specs/pages/protocol/fault-proof/index.md deleted file mode 100644 index 6987df52a2..0000000000 --- a/docs/specs/pages/protocol/fault-proof/index.md +++ /dev/null @@ -1,535 +0,0 @@ -# Fault Proof - - - - -## Overview - -A fault proof, also known as fraud proof or interactive game, consists of 3 components: - -- [Program]: given a commitment to all rollup inputs (L1 data) and the dispute, verify the dispute statelessly. -- [VM]: given a stateless program and its inputs, trace any instruction step, and prove it on L1. -- [Interactive Dispute Game]: bisect a dispute down to a single instruction, and resolve the base-case using the VM. - -Each of these 3 components may have different implementations, which can be combined into different proof stacks, -and contribute to proof diversity when resolving a dispute. - -"Stateless execution" of the program, and its individual instructions, refers to reproducing -the exact same computation by authenticating the inputs with a [Pre-image Oracle][oracle]. - -![Diagram of Program and VM architecture](/static/assets/fault-proof.svg) - -## Pre-image Oracle - -[oracle]: #pre-image-oracle - -The pre-image oracle is the only form of communication between -the [Program] (in the Client role) and the [VM] (in the Server role). - -The program uses the pre-image oracle to query any input data that is understood to be available to the user: - -- The initial inputs to bootstrap the program. See [Bootstrapping](#bootstrapping). -- External data not already part of the program code. See [Pre-image hinting routes](#pre-image-hinting-routes). - -The communication happens over a simple request-response wire protocol, -see [Pre-image communication](#pre-image-communication). - -### Pre-image key types - -Pre-images are identified by a `bytes32` type-prefixed key: - -- The first byte identifies the type of request. -- The remaining 31 bytes identify the pre-image key. - -#### Type `0`: Zero key - -The zero prefix is illegal. This ensures all pre-image keys are non-zero, -enabling storage lookup optimizations and avoiding easy mistakes with invalid zeroed keys in the EVM. - -#### Type `1`: Local key - -Information specific to the dispute: the remainder of the key may be an index, a string, a hash, etc. -Only the contract(s) managing this dispute instance may serve the value for this key: -it is localized and context-dependent. - -This type of key is used for program bootstrapping, to identify the initial input arguments by index or name. - -#### Type `2`: Global keccak256 key - -This type of key uses a global pre-image store contract, and is fully context-independent and permissionless. -I.e. every key must have a single unique value, regardless of chain history or time. - -Using a global store reduces duplicate pre-image registration work, -and avoids unnecessary contract redeployments per dispute. - -This global store contract should be non-upgradeable. - -Since `keccak256` is a safe 32-byte hash input, the first byte is overwritten with a `2` to derive the key, -while keeping the rest of the key "readable" (matching the original hash). - -#### Type `3`: Global generic key - -Reserved. This scheme allows for unlimited application-layer pre-image types without fault-proof VM redeployments. - -This is a generic version of a global key store: `key = 0x03 ++ keccak256(x, sender)[1:]`, where: - -- `x` is a `bytes32`, which can be a hash of an arbitrary-length type of cryptographically secure commitment. -- `sender` is a `bytes32` identifying the pre-image inserter address (left-padded to 32 bytes) - -This global store contract should be non-upgradeable. - -The global contract is permissionless: users can standardize around external contracts that verify pre-images -(i.e. a specific `sender` will always be trusted for a specific kind of pre-image). -The external contract verifies the pre-image before inserting it into the global store for usage by all -fault proof VMs without requiring the VM or global store contract to be changed. - -Users may standardize around upgradeable external pre-image contracts, -in case the implementation of the verification of the pre-image is expected to change. - -The store update function is `update(x bytes32, offset uint64, span uint8, value bytes32)`: - -- `x` is the `bytes32` `x` that the pre-image `key` is computed with. -- Only part of the pre-image, starting at `offset`, and up to (incl.) 32 bytes `span` can be inserted at a time. -- Pre-images may have an undefined length (e.g. a stream), we only need to know how many bytes of `value` are usable. -- The key and offset will be hashed together to uniquely store the `value` and `span`, for later pre-image serving. - -This enables fault proof programs to adopt any new pre-image schemes without VM update or contract redeployment. - -It is up to the user to index the special pre-image values by this key scheme, -as there is no way to revert it to the original commitment without knowing said commitment or value. - -#### Type `4`: Global SHA2-256 key - -A SHA-256 pre-image. - -Key: the SHA-256 hash, with the first byte overwritten with the type byte: `4 ++ sha256(data)[1:]`. - -#### Type `5`: Global EIP-4844 Point-evaluation key - -An EIP-4844 point-evaluation. -In an EIP-4844 blob, 4096 field elements represent the blob data. - -It verifies `p(z) = y` given `commitment` that corresponds to the polynomial `p(x)` and a KZG proof. -The value `y` is the pre-image. -The value `z` is part of the key; the index of the point within the blob. -The `commitment` is part of the key. - -Each element is proven with a point-evaluation. - -Key: `5 ++ keccak256(commitment ++ z)[1:]`, where: - -- `5` is the type byte -- `++` is concatenation -- `commitment` is a bytes48, representing the KZG commitment. -- `z` is a big-endian `uint256` - -#### Type `6`: Global Precompile key - -A precompile result. It maps directly to precompiles on Ethereum. - -This preimage key can be used to avoid running expensive precompile operations in the program. - -Key: `6 ++ keccak256(precompile ++ input)[1:]`, where: - -- `6` is the type byte -- `++` is concatenation -- `precompile` is the 20-byte address of the precompile contract -- `input` is the input to the precompile contract - -The result is identical to that of a call to the precompile contract, prefixed with a revert indicator: - -- `reverted ++ precompile_result`. - -`reverted` is a 1-byte indicator with a `0` value if the precompile reverts for the given input, otherwise it's `1`. - -#### Type `7-128`: reserved range - -Range start and end both inclusive. - -This range of key types is reserved for future usage by the core protocol. -E.g. version changes, contract migrations, chain-data, additional core features, etc. - -`128` specifically (`1000 0000` in binary) is reserved for key-type length-extension -(reducing the content part to `30` or less key bytes), if the need arises. - -#### Type `129-255`: application usage - -This range of key types may be used by forks or customized versions of the fault proof protocol. - -### Bootstrapping - -Initial inputs are deterministic, but not necessarily singular or global: -there may be multiple different disputes at the same time, each with its own disputed claims and L1 context. - -To bootstrap, the program requests the initial inputs from the VM, using pre-image key type `1`. - -The VM is aware of the external context, and maps requested pre-image keys based on their type, i.e. -a local lookup for type `1`, or global one for `2`, and optionally support other key-types. - -### Hinting - -There is one more form of optional communication between client and server: pre-image hinting. -Hinting is optional, and _is a no-op_ in a L1 VM implementation. - -The hint itself comes at very low cost onchain: the hint can be a single `write` sys-call, -which is instant as the memory to write as hint does not actually need to be loaded as part of the onchain proof. - -Hinting allows the program, when generating a proof offchain, -to instruct the VM what data it is interested in. - -The VM can choose to execute the requested hint at any time: either locally (for standard requests), -or in a modular form by redirecting the hint to tooling that may come with the VM program. - -Hints do not have to be executed directly: they may first just be logged to show the intents of the program, -and the latest hint may be buffered for lazy execution, or dropped entirely when in read-only mode (like onchain). - -When the pre-image oracle serves a request, and the request cannot be served from an existing collection of pre-images -(e.g. a local pre-image cache) then the VM can execute the hint to retrieve the missing pre-image(s). -It is the responsibility of the program to provide sufficient hinting for every pre-image request. -Some hints may have to be repeated: the VM only has to execute the last hint when handling a missing pre-image. - -Note that hints may produce multiple pre-images: -e.g. a hint for an ethereum block with transaction list may prepare pre-images for the header, -each of the transactions, and the intermediate merkle-nodes that form the transactions-list Merkle Patricia Trie. - -Hinting is implemented with a request-acknowledgement wire-protocol over a blocking two-way stream: - -```text - := - - := - - := big-endian uint32 # length of - := byte sequence - := 1-byte zero value -``` - -The ack informs the client that the hint has been processed. Servers may respond to hints and pre-image (see below) -requests asynchronously as they are on separate streams. To avoid requesting pre-images that are not yet fetched, -clients should request the pre-image only after it has observed the hint acknowledgement. - -### Pre-image communication - -Pre-images are communicated with a minimal wire-protocol over a blocking two-way stream. -This protocol can be implemented with blocking read/write syscalls. - -```text - := # the type-prefixed pre-image key - - := - - := big-endian uint64 # length of , note: uint64 -``` - -The `` here may be arbitrarily high: -the client can stop reading at any time if the required part of the pre-image has been read. - -After the client writes new `` bytes, the server should be prepared to respond with -the pre-image starting from `offset == 0` upon `read` calls. - -The server may limit `read` results artificially to only a small amount of bytes at a time, -even though the full pre-image is ready: this is expected regular IO protocol, -and the client will just have to continue to read the small results at a time, -until 0 bytes are read, indicating EOF. -This enables the server to serve e.g. at most 32 bytes at a time or align reads with VM memory structure, -to limit the amount of VM state that changes per syscall instruction, -and thus keep the proof size per instruction bounded. - -## Fault Proof Program - -[Program]: #fault-proof-program - -The Fault Proof Program defines the verification of claims of the state-transition outputs -of the L2 rollup as a pure function of L1 data. - -The `op-program` is the reference implementation of the program, based on `op-node` and `op-geth` implementations. - -The program consists of: - -- Prologue: load the inputs, given minimal bootstrapping, with possible test-overrides. -- Main content: process the L2 state-transition, i.e. derive the state changes from the L1 inputs. -- Epilogue: inspect the state changes to verify the claim. - -### Prologue - -The program is bootstrapped with two primary inputs: - -- `l1_head`: the L1 block hash that will be perceived as the tip of the L1 chain, - authenticating all prior L1 history. -- `dispute`: identity of the claim to verify. - -Bootstrapping happens through special input requests to the host of the program. - -Additionally, there are _implied_ inputs, which are _derived from the above primary inputs_, -but can be overridden for testing purposes: - -- `l2_head`: the L2 block hash that will be perceived as the previously agreed upon tip of the L2 chain, - authenticating all prior L2 history. -- Chain configurations: chain configuration may be baked into the program, - or determined from attributes of the identified `dispute` on L1. - - `l1_chain_config`: The chain-configuration of the L1 chain (also known as `l1_genesis.json`) - - `l2_chain_config`: The chain-configuration of the L2 chain (also known as `l2_genesis.json`) - - `rollup_config`: The rollup configuration used by the rollup-node (also known as `rollup.json`) - -The implied inputs rely on L1-introspection to load attributes of the `dispute` through the -[dispute game interface](stage-one/dispute-game-interface.md), in the L1 history up and till the specified `l1_head`. -The `dispute` may be the claim itself, or a pointer to specific prior claimed data in L1, -depending on the dispute game interface. - -Implied inputs are loaded in a "prologue" before the actual core state-transition function executes. -During testing a simplified prologue that loads the overrides may be used. - -> Note: only the test-prologues are currently supported, since the dispute game interface is actively changing. - -### Main content - -To verify a claim about L2 state, the program first reproduces -the L2 state by applying L1 data to prior agreed L2 history. - -This process is also known as the [L2 derivation process](../consensus/derivation.md), -and matches the processing in the [rollup node](../consensus/index.md) and -[execution-engine](../execution/index.md). - -The difference is that rather than retrieving inputs from an RPC and applying state changes to disk, -the inputs are loaded through the [pre-image oracle][oracle] and the changes accumulate in memory. - -The derivation executes with two data-sources: - -- Interface to read-only L1 chain, backed by the pre-image oracle: - - The `l1_head` determines the view over the available L1 data: no later L1 data is available. - - The implementation of the chain traverses the header-chain from the `l1_head` down to serve by-number queries. - - The `l1_head` is the L1 unsafe head, safe head, and finalized head. -- Interface to L2 engine API - - Prior L2 chain history is backed by the pre-image oracle, similar to the L1 chain: - - The initial `l2_head` determines the view over the initial available L2 history: no later L2 data is available. - - The implementation of the chain traverses the header-chain from the `l2_head` down to serve by-number queries. - - The `l2_head` is the initial L2 unsafe head, safe head, and finalized head. - - New L2 chain history accumulates in memory. - - Although the pre-image oracle can be used to retrieve data by hash if memory is limited, - the program should prefer to keep the newly created chain data in memory, to minimize pre-image oracle access. - - The L2 unsafe head, safe head, and finalized L2 head will potentially change as derivation progresses. - - L2 state consists of the diff of changes in memory, - and any unchanged state nodes accessible through the read-only L2 history view. - -See [Pre-image routes](#pre-image-hinting-routes) for specifications of the pre-image oracle backing of these data sources. - -Using these data-sources, the derivation pipeline is processed till we hit one of two conditions: - -- `EOF`: when we run out of L1 data, the L2 chain will not change further, and the epilogue can start. -- Eager epilogue condition: depending on the type of claim to verify, - if the L2 result is irreversible (i.e. no later L1 inputs can override it), - the processing may end early when the result is ready. - E.g. when asserting state at a specific L2 block, rather than the very tip of the L2 chain. - -### Epilogue - -While the main-content produces the disputed L2 state already, -the epilogue concludes what this means for the disputed claim. - -The program produces a binary output to verify the claim, using a standard single-byte Unix exit-code: - -- a `0` for success, i.e. the claim is correct. -- a non-zero code for failure, i.e. the claim is incorrect. - - `1` should be preferred for identifying an incorrect claim. - - Other non-zero exit codes may indicate runtime failure, - e.g. a bug in the program code may resolve in a kind of `panic` or unexpected error. - Safety should be preferred over liveness in this case, and the `claim` will fail. - -To assert the disputed claim, the epilogue, like the main content, -can introspect L1 and L2 chain data and post-process it further, -to then make a statement about the claim with the final exit code. - -A disputed output-root may be disproven by first producing the output-root, and then comparing it: - -1. Retrieve the output attributes from the L2 chain view: the state-root, block-hash, withdrawals storage-root. -2. Compute the output-root, as the - [proposer should compute it](proposer.md#l2-output-commitment-construction). -3. If the output-root matches the `claim`, exit with code 0. Otherwise, exit with code 1. - -> Note: the dispute game interface is actively changing, and may require additional claim assertions. -> the output-root epilogue may be replaced or extended for general L2 message proving. - -### Pre-image hinting routes - -The fault proof program implements hint handling for the VM to use, -as well as any program testing outside of VM environment. -This can be exposed via a CLI, or alternative inter-process API. - -Every instance of `` in the below routes is `0x`-prefixed, lowercase, hex-encoded. - -#### `l1-block-header ` - -Requests the host to prepare the L1 block header RLP pre-image of the block ``. - -#### `l1-transactions ` - -Requests the host to prepare the list of transactions of the L1 block with ``: -prepare the RLP pre-images of each of them, including transactions-list MPT nodes. - -#### `l1-receipts ` - -Requests the host to prepare the list of receipts of the L1 block with ``: -prepare the RLP pre-images of each of them, including receipts-list MPT nodes. - -#### `l1-blob ` - -Requests the host to prepare EIP-4844 blob data for fault proof verification. - -The hint data consists of 48 bytes concatenated together: - -- Bytes 0-31: Blob version hash (32 bytes) - the keccak256 hash of the KZG commitment with version byte prefix -- Bytes 32-39: Blob index within the block (8-byte big-endian uint64) -- Bytes 40-47: L1 block timestamp (8-byte big-endian uint64) - -The host will: - -1. Fetch the blob from the L1 beacon chain using the timestamp and blob hash -2. Compute the KZG commitment and prepare it as a [SHA256 preimage](#type-3-global-generic-key) -3. Prepare all 4096 field elements of the blob as [Blob-type preimages](#type-5-global-eip-4844-point-evaluation-key), - keyed by `keccak256(commitment || rootOfUnity[i])` for evaluation at the standard roots of unity - -This hint is required for verifying transactions that use EIP-4844 blob data (post-Ecotone). - -#### `l1-precompile-v2
` - -Requests the host to prepare the result of an L1 precompile call with gas validation. - -The hint data format: - -- Bytes 0-19: Precompile address (20 bytes) -- Bytes 20-27: Required gas (8-byte big-endian uint64) -- Bytes 28+: Input bytes - -The host validates the precompile address against an allowlist of accelerated precompiles and -prepares a [precompile-type preimage](#type-6-global-precompile-key) of the execution result. -The `requiredGas` parameter allows the preimage oracle to enforce complete precompile execution. - -This supersedes the earlier `l1-precompile ` format which did not include gas validation. - -#### `l2-block-header ?` - -Requests the host to prepare the L2 block header RLP pre-image of the block ``. - -The `` is optionally concatenated after the `` as a big endian uint64 value to specify which L2 -chain to retrieve data from. `` must be specified when the interop hard fork is active. - -#### `l2-transactions ?` - -Requests the host to prepare the list of transactions of the L2 block with ``: -prepare the RLP pre-images of each of them, including transactions-list MPT nodes. - -The `` is optionally concatenated after the `` as a big endian uint64 value to specify which L2 -chain to retrieve data from. `` must be specified when the interop hard fork is active. - -#### `l2-receipts ` - -Requests the host to prepare the list of receipts of the L2 block with `` for the specified ``: -prepare the RLP pre-images of each of them, including receipts-list MPT nodes. - -This hint is used only when the interop hard fork is active. - -#### `l2-code ?` - -Requests the host to prepare the L2 smart-contract code with the given ``. - -The `` is optionally concatenated after the `` as a big endian uint64 value to specify which L2 -chain to retrieve data from. `` must be specified when the interop hard fork is active. - -#### `l2-state-node ?` - -Requests the host to prepare the L2 MPT node preimage with the given ``. - -The `` is optionally concatenated after the `` as a big endian uint64 value to specify which L2 -chain to retrieve data from. `` must be specified when the interop hard fork is active. - -#### `l2-output ?` - -Requests the host to prepare the L2 Output at the l2 output root ``. -The L2 Output is the preimage of a -[computed output root](proposer.md#l2-output-commitment-construction). - -The `` is optionally concatenated after the `` as a big endian uint64 value to specify which L2 -chain to retrieve data from. `` must be specified when the interop hard fork is active. - -#### `l2-payload-witness ` - -Requests the host to prepare all preimages used in the building of the payload specified by ``. -`` is a JSON object with the fields `parentBlockHash`, `payloadAttributes` and optionally `chainID`. -The `chainID` must be specific when the interop hard fork is active. - -#### `l2-account-proof ` - -Requests the host send account proof for a certain block hash and address. `` is hex -encoded: 32-byte block hash + 20-byte address + 8 byte big endian chain ID. - -`l2-payload-witness` and `l2-account-proof` hints are preferred over the more granular `l2-code` and `l2-state-node`, -and they should be sent before the more granular hints to ensure proper handling. - -#### `l2-block-data ` - -Requests the host to prepare all preimages used in the building of the block specified by ``. -`` is a hex encoded concatenation of the following: - -- 32-byte parent block hash -- 32-byte block hash of the block to be prepared -- 8-byte big-endian chain ID - -This hint is used only when the interop hard fork is active. - -### Precompile Accelerators - -Precompiles that are too expensive to be executed in a fault-proof VM can be executed -more efficiently using the pre-image oracle. -This approach ensures that the fault proof program can complete a state transition in a reasonable -amount of time. - -During program execution, the precompiles are substituted with interactions with pre-image oracle. -The program hints the host for a precompile input. Which it the subsequently retrieves the result of the precompile -operation using the [type 6 global precompile key](#type-6-global-precompile-key). -All accelerated precompiles must be functionally equivalent to their EVM equivalent. - -## Fault Proof VM - -[VM]: #fault-proof-vm - -A fault proof VM implements: - -- a smart-contract to verify a single execution-trace step, e.g. a single MIPS instruction. -- a CLI command to generate a proof of a single execution-trace step. -- a CLI command to compute a VM state-root at step N - -A fault proof VM relies on a fault proof program to provide an interface -for fetching any missing pre-images based on hints. - -The VM emulates the program, as prepared for the VM target architecture, -and generates the state-root or instruction proof data as requested through the VM CLI. - -Refer to the documentation of the fault proof VM for further usage information. - -Fault Proof VMs: - -- [Cannon]: big-endian 64-bit MIPS64 architecture, by OP Labs, in active development. -- [cannon-rs]: Rust implementation of `Cannon`, by `@clabby`, deprecated. -- [Asterisc]: little-endian 64-bit RISC-V architecture, by `@protolambda`, in active development. - -[Cannon]: https://github.com/ethereum-optimism/cannon -[cannon-rs]: https://github.com/anton-rs/cannon-rs -[Asterisc]: https://github.com/protolambda/asterisc - -## Fault Proof Interactive Dispute Game - -[Interactive Dispute Game]: #fault-proof-interactive-dispute-game - -The interactive dispute game allows actors to resolve a dispute with an onchain challenge-response game -that bisects to a disagreed block $n \rightarrow n + 1$ state transition, and then over the execution trace of the VM -which models this state transition, bounded with a base-case that proves a single VM trace step. - -The game is multi-player: different non-aligned actors may participate when bonded. - -Response time is allocated based on the remaining time in the branch of the tree and alignment with the claim. -The allocated response time is limited by the dispute-game window, -and any additional time necessary based on L1 fee changes when bonds are insufficient. - -> Note: the timed, bonded, bisection dispute game is in development. -> Also see [fault dispute-game specs](stage-one/fault-dispute-game.md) for fault dispute game system specifications, -> And [dispute-game-interface specs](stage-one/dispute-game-interface.md) diff --git a/docs/specs/pages/protocol/fault-proof/proposer.md b/docs/specs/pages/protocol/fault-proof/proposer.md deleted file mode 100644 index 98df56236b..0000000000 --- a/docs/specs/pages/protocol/fault-proof/proposer.md +++ /dev/null @@ -1,167 +0,0 @@ -# Proposer - - - -[g-rollup-node]: ../../reference/glossary.md#rollup-node -[g-mpt]: ../../reference/glossary.md#merkle-patricia-trie -[header-withdrawals-root]: ../../upgrades/isthmus/exec-engine.md#l2tol1messagepasser-storage-root-in-header - -## Overview - -After processing one or more blocks the outputs will need to be synchronized with the settlement layer (L1) -for trustless execution of L2-to-L1 messaging, such as withdrawals. -These output proposals act as the bridge's view into the L2 state. -Actors called "Proposers" submit the output roots to the settlement layer (L1) and can be contested with a proof, -with a bond at stake if the proof is wrong. The proposer service is one such implementation. - -[cannon]: https://github.com/ethereum-optimism/cannon - -## Proposing L2 Output Commitments - -The proposer's role is to construct and submit output roots, which are commitments to the L2's state, -to the `L2OutputOracle` contract on L1 (the settlement layer). To do this, the proposer periodically -queries the [rollup node](../consensus/index.md) for the latest output root derived from the latest -[finalized](../consensus/index.md#finalization-guarantees) L1 block. It then takes the output root and -submits it to the `L2OutputOracle` contract on the settlement layer (L1). - -### L2OutputOracle v1.0.0 - -The submission of output proposals is permissioned to a single account. It is expected that this -account will continue to submit output proposals over time to ensure that user withdrawals do not halt. - -The L2 output proposer is expected to submit output roots on a deterministic interval based on the -configured `SUBMISSION_INTERVAL` in the `L2OutputOracle`. The larger the `SUBMISSION_INTERVAL`, the -less often L1 transactions need to be sent to the `L2OutputOracle` contract, but L2 users will need -to wait a bit longer for an output root to be included in L1 (the settlement layer) that includes -their intention to withdraw from the system. - -The honest proposer algorithm assumes a connection to the `L2OutputOracle` contract to know -the L2 block number that corresponds to the next output proposal that must be submitted. It also -assumes a connection to a Base consensus node to query sync status. - -```python -import time - -while True: - next_checkpoint_block = L2OutputOracle.nextBlockNumber() - rollup_status = consensus_node_client.sync_status() - if rollup_status.finalized_l2.number >= next_checkpoint_block: - output = consensus_node_client.output_at_block(next_checkpoint_block) - tx = send_transaction(output) - time.sleep(poll_interval) -``` - -A `CHALLENGER` account can delete multiple output roots by calling the `deleteL2Outputs()` function -and specifying the index of the first output to delete, this will also delete all subsequent outputs. - -## L2 Output Commitment Construction - -The `output_root` is a 32 byte string, which is derived based on the a versioned scheme: - -```pseudocode -output_root = keccak256(version_byte || payload) -``` - -where: - -1. `version_byte` (`bytes32`) a simple version string which increments anytime the construction of the output root - is changed. - -2. `payload` (`bytes`) is a byte string of arbitrary length. - -In the initial version of the output commitment construction, the version is `bytes32(0)`, and the payload is defined -as: - -```pseudocode -payload = state_root || withdrawal_storage_root || latest_block_hash -``` - -where: - -1. The `latest_block_hash` (`bytes32`) is the block hash for the latest L2 block. - -1. The `state_root` (`bytes32`) is the Merkle-Patricia-Trie ([MPT][g-mpt]) root of all execution-layer accounts. - This value is frequently used and thus elevated closer to the L2 output root, which removes the need to prove its - inclusion in the pre-image of the `latest_block_hash`. This reduces the merkle proof depth and cost of accessing the - L2 state root on L1. - -1. The `withdrawal_storage_root` (`bytes32`) elevates the Merkle-Patricia-Trie ([MPT][g-mpt]) root of the [Message - Passer contract](../bridging/withdrawals.md#the-l2tol1messagepasser-contract) storage. Instead of making an MPT proof for a - withdrawal against the state root (proving first the storage root of the L2toL1MessagePasser against the state root, - then the withdrawal against that storage root), we can prove against the L2toL1MessagePasser's storage root directly, - thus reducing the verification cost of withdrawals on L1. - - After Isthmus hard fork, the `withdrawal_storage_root` is present in the - [block header as `withdrawalsRoot`][header-withdrawals-root] and can be used directly, instead of computing - the storage root of the L2toL1MessagePasser contract. - - Similarly, if Isthmus hard fork is active at the genesis block, the `withdrawal_storage_root` is present - in the [block header as `withdrawalsRoot`][header-withdrawals-root]. - -## L2 Output Oracle Smart Contract - -L2 blocks are produced at a constant rate of `L2_BLOCK_TIME` (2 seconds). -A new L2 output MUST be appended to the chain once per `SUBMISSION_INTERVAL` which is based on a number of blocks. -The exact number is yet to be determined, and will depend on the design of the fault proving game. - -The L2 Output Oracle contract implements the following interface: - -```solidity -/** - * @notice The number of the first L2 block recorded in this contract. - */ -uint256 public startingBlockNumber; - -/** - * @notice The timestamp of the first L2 block recorded in this contract. - */ -uint256 public startingTimestamp; - -/** - * @notice Accepts an L2 outputRoot and the timestamp of the corresponding L2 block. The - * timestamp must be equal to the current value returned by `nextTimestamp()` in order to be - * accepted. - * This function may only be called by the Proposer. - * - * @param _l2Output The L2 output of the checkpoint block. - * @param _l2BlockNumber The L2 block number that resulted in _l2Output. - * @param _l1Blockhash A block hash which must be included in the current chain. - * @param _l1BlockNumber The block number with the specified block hash. -*/ - function proposeL2Output( - bytes32 _l2Output, - uint256 _l2BlockNumber, - bytes32 _l1Blockhash, - uint256 _l1BlockNumber - ) - -/** - * @notice Deletes all output proposals after and including the proposal that corresponds to - * the given output index. Only the challenger address can delete outputs. - * - * @param _l2OutputIndex Index of the first L2 output to be deleted. All outputs after this - * output will also be deleted. - */ -function deleteL2Outputs(uint256 _l2OutputIndex) external - -/** - * @notice Computes the block number of the next L2 block that needs to be checkpointed. - */ -function nextBlockNumber() public view returns (uint256) -``` - -### Configuration - -The `startingBlockNumber` must be at least the number of the first Bedrock block. -The `startingTimestamp` MUST be the same as the timestamp of the start block. - -The first `outputRoot` proposed will thus be at height `startingBlockNumber + SUBMISSION_INTERVAL` - -## Security Considerations - -### L1 Reorgs - -If the L1 has a reorg after an output has been generated and submitted, the L2 state and correct output may change -leading to a faulty proposal. This is mitigated against by allowing the proposer to submit an -L1 block number and hash to the Output Oracle when appending a new output; in the event of a reorg, the block hash -will not match that of the block with that number and the call will revert. diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/anchor-state-registry.md b/docs/specs/pages/protocol/fault-proof/stage-one/anchor-state-registry.md deleted file mode 100644 index bad39f5c98..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/anchor-state-registry.md +++ /dev/null @@ -1,470 +0,0 @@ -# AnchorStateRegistry - -## Overview - -The `AnchorStateRegistry` was designed as a registry where `DisputeGame` contracts could store and -register their results so that these results could be used as the starting states for new -`DisputeGame` instances. These starting states, called "anchor states", allow new `DisputeGame` -contracts to use a newer starting state to bound the size of the execution trace for any given -game. - -We are generally aiming to shift the `AnchorStateRegistry` to act as a unified source of truth for -the validity of `DisputeGame` contracts and their corresponding root claims. This specification -corresponds to the first iteration of the `AnchorStateRegistry` that will move us in this -direction. - -## Definitions - -### Dispute Game - -> See [Fault Dispute Game](fault-dispute-game.md) - -A Dispute Game is a smart contract that makes a determination about the validity of some claim. In -the context of Base, the claim is generally assumed to be a claim about the value of an -output root at a given L2 block height. We assume that all Dispute Game contracts using the same -AnchorStateRegistry contract are arguing over the same underlying state/claim structure. - -### Respected Game Type - -The `AnchorStateRegistry` contract defines a **Respected Game Type** which is the Dispute Game type -that is considered to be the correct by the `AnchorStateRegistry` and, by extension, other -contracts that may rely on the assertions made within the `AnchorStateRegistry`. The Respected Game -Type is, in a more general sense, a game type that the system believes will resolve correctly. For -now, the `AnchorStateRegistry` only allows a single Respected Game Type. - -### Dispute Game Finality Delay (Airgap) - -The **Dispute Game Finality Delay** or **Airgap** is the amount of time that must elapse after a -game resolves before the game's result is considered "final". - -### Registered Game - -A Dispute Game is considered to be a **Registered Game** if the game contract was created by the -system's `DisputeGameFactory` contract. - -### Respected Game - -A Dispute Game is considered to be a **Respected Game** if the game contract's game type **was** -the Respected Game Type defined by the `AnchorStateRegistry` contract at the time of the game's -creation. Games that are not Respected Games cannot be used as an Anchor Game. See -[Respected Game Type](#respected-game-type) for more information. - -### Blacklisted Game - -A Dispute Game is considered to be a **Blacklisted Game** if the game contract's address is marked -as blacklisted inside of the `AnchorStateRegistry` contract. - -### Retirement Timestamp - -The **Retirement Timestamp** is a timestamp value maintained within the `AnchorStateRegistry` that -can be used to invalidate games. Games with a creation timestamp less than or equal to the -Retirement Timestamp are automatically considered to be invalid. - -The RetirementTimestamp has the effect of retiring all games created before the specific -transaction in which the retirement timestamp was set. This includes all games created in the same -block as the transaction that set the Retirement Timestamp. We acknowledge the edge-case that games -created in the same block *after* the Retirement Timestamp was set will be considered Retired Games -even though they were technically created "after" the Retirement Timestamp was set. - -### Retired Game - -A Dispute Game is considered to be a **Retired Game** if the game contract was created with a -timestamp less than or equal to the [Retirement Timestamp](#retirement-timestamp). - -### Proper Game - -A Dispute Game is considered to be a **Proper Game** if it has not been invalidated through any of -the mechanisms defined by the `AnchorStateRegistry` contract. A Proper Game is, in a sense, a -"clean" game that exists in the set of games that are playing out correctly in a bug-free manner. A -Dispute Game can be a Proper Game even if it has not yet resolved or resolves in favor of the -Challenger. - -A Dispute Game that is **NOT** a Proper Game can also be referred to as an **Improper Game** for -brevity. A Dispute Game can go from being a Proper Game to later *not* being an **Improper Game** -if it is invalidated by being [blacklisted](#blacklisted-game) or [retired](#retired-game). - -**ALL** Dispute Games **TEMPORARILY** become Improper Games while the -Pause Mechanism is active. However, this is -a *temporary* condition such that Registered Games that are not invalidated by -[blacklisting](#blacklisted-game) or [retirement](#retired-game) will become Proper Games again -once the pause is lifted. The Pause Mechanism is therefore a way to *temporarily* prevent Dispute -Games from being used by consumers like the `OptimismPortal` while relevant parties coordinate the -use of some other invalidation mechanism. - -A Game is considered to be a Proper Game if all of the following are true: - -- The game is a [Registered Game](#registered-game) -- The game is **NOT** a [Blacklisted Game](#blacklisted-game) -- The game is **NOT** a [Retired Game](#retired-game) -- The Pause Mechanism is not active - -### Resolved Game - -A Dispute Game is considered to be a **Resolved Game** if the game has resolved a result in favor -of either the Challenger or the Defender. - -### Finalized Game - -A Dispute Game is considered to be a **Finalized Game** if all of the following are true: - -- The game is a [Resolved Game](#resolved-game) -- The game resolved a result more than - [Dispute Game Finality Delay](#dispute-game-finality-delay-airgap) seconds ago as defined by the - `disputeGameFinalityDelaySeconds` variable in the `AnchorStateRegistry` contract. - -### Valid Claim - -A Dispute Game is considered to have a **Valid Claim** if all of the following are true: - -- The game is a [Proper Game](#proper-game) -- The game is a [Respected Game](#respected-game) -- The game is a [Finalized Game](#finalized-game) -- The game resolved in favor of the root claim (i.e., in favor of the Defender) - -### Truly Valid Claim - -A Truly Valid Claim is a claim that accurately represents the correct root for the L2 block height -on the L2 system as would be reported by a perfect oracle for the L2 system state. - -### Starting Anchor State - -The Starting Anchor State is the anchor state (root and L2 block height) that is used as the -starting state for new Dispute Game instances when there is no current Anchor Game. The Starting -Anchor State is set during the initialization of the `AnchorStateRegistry` contract. - -### Anchor Game - -The Anchor Game is a game whose claim is used as the starting state for new Dispute Game instances. -A Game can become the Anchor Game if it has a Valid Claim and the claim's L2 block height is -greater than the claim of the current Anchor Game. If there is no current Anchor Game, a Game can -become the Anchor Game if it has a Valid Claim and the claim's L2 block height is greater than the -current Starting Anchor State's L2 block height. - -After a Game becomes the Anchor Game, it will remain the Anchor Game until it is replaced by some -other Game. A Game that is retired after becoming the Anchor Game will remain the Anchor Game. - -### Anchor Root - -The Anchor Root is the root and L2 block height that is used as the starting state for new Dispute -Game instances. The value of the Anchor Root is the Starting Anchor State if no Anchor Game has -been set. Otherwise, the value of the Anchor Root is the root and L2 block height of the current -Anchor Game. - -## Assumptions - -> **NOTE:** Assumptions are utilized by specific invariants and do not apply globally. Invariants -> typically only rely on a subset of the following assumptions. Different invariants may rely on -> different assumptions. Refer to individual invariants for their dependencies. - -### aASR-001: Dispute Game contracts properly report important properties - -We assume that the `FaultDisputeGame` and `PermissionedDisputeGame` contracts properly and -faithfully report the following properties: - -- Game type -- L2 block number -- Root claim value -- Game extra data -- Creation timestamp -- Resolution timestamp -- Resolution result -- Whether the game was the respected game type at creation - -We also specifically assume that the game creation timestamp and the resolution timestamp are not -set to values in the future. - -#### Mitigations - -- Existing audit on the `FaultDisputeGame` contract -- Integration testing - -### aASR-002: DisputeGameFactory properly reports its created games - -We assume that the `DisputeGameFactory` contract properly and faithfully reports the games it has -created. - -#### Mitigations - -- Existing audit on the `DisputeGameFactory` contract -- Integration testing - -### aASR-003: Incorrectly resolving games will be invalidated before they have Valid Claims - -We assume that any games that are resolved incorrectly will be invalidated either by -[blacklisting](#blacklisted-game) or by [retirement](#retired-game) BEFORE they are considered to -have [Valid Claims](#valid-claim). - -Proper Games that resolve in favor the Defender will be considered to have Valid Claims after the -[Dispute Game Finality Delay](#dispute-game-finality-delay-airgap) has elapsed UNLESS the -Pause Mechanism is active. Therefore, in the absence of the Pause Mechanism, parties responsible -for game invalidation have exactly the Dispute Game Finality Delay to invalidate a withdrawal after -it resolves incorrectly. If the Pause Mechanism is active, then any incorrectly resolving games -must be invalidated before the pause is deactivated. - -#### Mitigations - -- Stakeholder incentives / processes -- Incident response plan -- Monitoring - -## Invariants - -### iASR-001: Games are represented as Proper Games accurately - -When asked if a game is a Proper Game, the `AnchorStateRegistry` must serve a response that is -identical to the response that would be given by a perfect oracle for this query. - -#### Impact - -**Severity: High** - -If this invariant is broken, the Anchor Game could be set to an incorrect value, which would cause -future Dispute Game instances to use an incorrect starting state. This would lead games to resolve -incorrectly. Additionally, this could cause a `FaultDisputeGame` to incorrectly choose the wrong -bond refunding mode. - -#### Dependencies - -- [aASR-001](#aasr-001-dispute-game-contracts-properly-report-important-properties) -- [aASR-002](#aasr-002-disputegamefactory-properly-reports-its-created-games) -- [aASR-003](#aasr-003-incorrectly-resolving-games-will-be-invalidated-before-they-have-valid-claims) - -### iASR-002: All Valid Claims are Truly Valid Claims - -When asked if a game has a Valid Claim, the `AnchorStateRegistry` must serve a response that is -identical to the response that would be given by a perfect oracle for this query. However, it is -important to note that we do NOT say that all Truly Valid Claims are Valid Claims. It is possible -that a game has a Truly Valid Claim but the `AnchorStateRegistry` reports that the claim is not -a Valid Claim. This permits the `AnchorStateRegistry` and system-wide safety net actions to err on -the side of caution. - -In a nutshell, the set of Valid Claims is a subset of the set of Truly Valid Claims. - -#### Impact - -**Severity: Critical** - -If this invariant is broken, then any component that relies on the correctness of this function may -allow actions to occur based on invalid dispute games. - -Some examples of strong negative impact are: - -- Invalid Dispute Game could be used as the Anchor Game, which would cause future Dispute Game - instances to use an incorrect starting state. This would lead these games to resolve incorrectly. - **(HIGH)** -- Invalid Dispute Game could be used to prove or finalize withdrawals within the `OptimismPortal` - contract. This would lead to a critical vulnerability in the bridging system. **(CRITICAL)** - -#### Dependencies - -- [aASR-001](#aasr-001-dispute-game-contracts-properly-report-important-properties) -- [aASR-002](#aasr-002-disputegamefactory-properly-reports-its-created-games) -- [aASR-003](#aasr-003-incorrectly-resolving-games-will-be-invalidated-before-they-have-valid-claims) - -### iASR-003: The Anchor Game is a Truly Valid Claim - -We require that the Anchor Game is a Truly Valid Claim. This makes it possible to use the Anchor -Game as the starting state for new Dispute Game instances. Notably, given the allowance that not -all Truly Valid Claims are Valid Claims, this invariant does not imply that the Anchor Game is a -Valid Claim. - -We allow retired games to be used as the Anchor Game because the retirement mechanism is broad in a -way that commonly causes Truly Valid Claims to no longer be considered Valid Claims. We allow both -blacklisted games and retired games to remain the Anchor Game if they are already the Anchor Game. -This is because we assume games that become the Anchor Game would be invalidated *before* becoming -the Anchor Game. After the game becomes the Anchor Game, it would be possible to use that game to -execute withdrawals from the system, which would already be a critical bug in the system. - -#### Impact - -**Severity: High** - -If this invariant is broken, an invalid Anchor Game could be used as the starting state for new -Dispute Game instances. This would lead games to resolve incorrectly. - -#### Dependencies - -- [aASR-001](#aasr-001-dispute-game-contracts-properly-report-important-properties) -- [aASR-002](#aasr-002-disputegamefactory-properly-reports-its-created-games) -- [aASR-003](#aasr-003-incorrectly-resolving-games-will-be-invalidated-before-they-have-valid-claims) - -### iASR-004: Invalidation functions operate correctly - -We require that the blacklisting and retirement functions operate correctly. Games that are -blacklisted must not be used as the Anchor Game, must not be considered Valid Games, and must not -be usable to prove or finalize withdrawals. Any game created before a transaction that updates the -retirement timestamp must not be set as the Anchor Game, must not be considered Valid Games, and -must not be usable to prove or finalize withdrawals. - -#### Impact - -**Severity: High/Critical** - -If this invariant is broken, the Anchor Game could be set to an incorrect value, which would cause -future Dispute Game instances to use an incorrect starting state. This would lead games to resolve -incorrectly and would be considered a High Severity issue. Issues that would allow users to -finalize withdrawals with invalidated games would be considered Critical Severity. - -#### Dependencies - -- [aASR-003](#aasr-003-incorrectly-resolving-games-will-be-invalidated-before-they-have-valid-claims) - -### iASR-005: The Anchor Game is recent enough to be fault provable - -We require that the Anchor Game corresponds to an L2 block with an L1 origin timestamp that is no -older than 6 months from the current timestamp. This time constraint is necessary because the fault -proof VM must walk backwards through L1 blocks to verify derivation, and processing 7 months worth -of L1 blocks approaches the maximum time available to challengers in the dispute game process. - -#### Impact - -**Severity: High** - -If this invariant is broken, challengers will be unable to participate in fault proofs within the -allotted response time, and resolution would require intervention from the Proxy Admin Owner. - -## Function Specification - -### constructor - -- MUST set the value of the [Dispute Game Finality Delay](#dispute-game-finality-delay-airgap). - -### initialize - -- MUST only be callable by the ProxyAdmin or its owner. -- MUST only be triggerable once. -- MUST set the value of the `SystemConfig` contract that stores the address of the Guardian. -- MUST set the value of the `DisputeGameFactory` contract that creates Dispute Game instances. -- MUST set the value of the [Starting Anchor State](#starting-anchor-state). -- MUST set the value of the initial [Respected Game Type](#respected-game-type). -- MUST set the value of the [Retirement Timestamp](#retirement-timestamp) to the current block - timestamp. NOTE that this is a safety mechanism that invalidates all existing Dispute Game - contracts to support the safe transition away from the `OptimismPortal` as the source of truth - for game validity. In this way, the `AnchorStateRegistry` does not need to consider the state of - the legacy blacklisting/retirement mechanisms within the `OptimismPortal` and starts from a clean - slate. - -### paused - -Returns the value of `paused()` from the `SystemConfig` contract. - -### respectedGameType - -Returns the value of the currently [Respected Game Type](#respected-game-type). - -### retirementTimestamp - -Returns the value of the current [Retirement Timestamp](#retirement-timestamp). - -### disputeGameFinalityDelaySeconds - -Returns the value of the [Dispute Game Finality Delay](#dispute-game-finality-delay-airgap). - -### setRespectedGameType - -Permits the Guardian role to set the [Respected Game Type](#respected-game-type). - -- MUST revert if called by any address other than the Guardian. -- MUST update the respected game type with the provided type. -- MUST emit an event showing that the game type was updated. - -### updateRetirementTimestamp - -Permits the Guardian role to update the [Retirement Timestamp](#retirement-timestamp). - -- MUST revert if called by any address other than the Guardian. -- MUST set the retirement timestamp to the current block timestamp. -- MUST emit an event showing that the retirement timestamp was updated. - -### blacklistDisputeGame - -Permits the Guardian role to [blacklist](#blacklisted-game) a Dispute Game. - -- MUST revert if called by any address other than the Guardian. -- MUST mark the game as blacklisted. -- MUST emit an event showing that the game was blacklisted. - -### isGameRegistered - -Determines if a game is a Registered Game. - -- MUST return `true` if and only if the game was created by the system's `DisputeGameFactory` - contract AND the game's `AnchorStateRegistry` address matches the address of this contract. - -### isGameRespected - -Determines if a game is a Respected Game. - -- MUST return `true` if and only if the game's game type was the respected game type defined by the - `AnchorStateRegistry` contract at the time of the game's creation as per a call to - `AnchorStateRegistry.respectedGameType()`. - -### isGameBlacklisted - -Determines if a game is a Blacklisted Game. - -- MUST return `true` if and only if the game's address is marked as blacklisted inside of the - `AnchorStateRegistry` contract. - -### isGameRetired - -Determines if a game is a Retired Game. - -- MUST return `true` if and only if the game was created before or at the retirement timestamp - defined by the `AnchorStateRegistry` contract as per a call to - `AnchorStateRegistry.retirementTimestamp()`. We check for less than or equal to the current - retirement timestamp to prevent games from being created in the same block but before the - transaction in which the retirement timestamp was set. Note that this has the side effect of also - invalidating any games created in the same block *after* the retirement timestamp was set but - this is an acceptable tradeoff. - -### isGameProper - -Determines if a game is a Proper Game. - -- MUST return `true` if and only if `isGameRegistered(game)` is `true`, `isGameBlacklisted(game)` - and `isGameRetired(game)` are both `false`, and `paused()` is `false`. - -### isGameResolved - -Determines if a game is a Resolved Game. - -- MUST return `true` if and only if the game has resolved a result in favor of either the - Challenger or the Defender as determined by the `FaultDisputeGame.status()` function. - -### isGameFinalized - -Determines if a game is a Finalized Game. - -- MUST return `true` if and only if `isGameResolved(game)` and the game has resolved a result more - than the airgap delay seconds ago as defined by the `disputeGameFinalityDelaySeconds` variable in - the `AnchorStateRegistry` contract. - -### isGameClaimValid - -Determines if a game has a Valid Claim. - -- MUST return `true` if and only if `isGameProper(game)` is `true`, `isGameRespected(game)` is - `true`, `isGameFinalized(game)` is `true`, and the game resolved in favor of the root claim - (i.e., in favor of the Defender). - -### getAnchorRoot - -Retrieves the current anchor root. - -- MUST return the root hash and L2 block height of the current anchor state. - -### anchors - -Legacy function. Accepts a game type as a parameter but does not use it. - -- MUST return the current value of `getAnchorRoot()`. - -### setAnchorState - -Allows any address to attempt to update the Anchor Game with a new Game as input. - -- MUST revert if the provided game does not have a Valid Claim for any reason. -- MUST revert if the provided game corresponds to an L2 block height that is less than or equal - to the current anchor state's L2 block height. -- MUST otherwise update the anchor state to match the game's result. diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/bond-incentives.md b/docs/specs/pages/protocol/fault-proof/stage-one/bond-incentives.md deleted file mode 100644 index a241d39bb0..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/bond-incentives.md +++ /dev/null @@ -1,243 +0,0 @@ -# Bond Incentives - -## Overview - -Bonds is an add-on to the core [Fault Dispute Game](fault-dispute-game.md). The core game mechanics are -designed to ensure honesty as the best response to winning subgames. By introducing financial incentives, -Bonds makes it worthwhile for honest challengers to participate. -Without the bond reward incentive, the FDG will be too costly for honest players to participate in given the -cost of verifying and making claims. - -Implementations may allow the FDG to directly receive bonds, or delegate this responsibility to another entity. -Regardless, there must be a way for the FDG to query and distribute bonds linked to a claim. - -Bonds are integrated into the FDG in two areas: - -- Moves -- Subgame Resolution - -## Moves - -Moves must be adequately bonded to be added to the FDG. This document does not specify a -scheme for determining the minimum bond requirement. FDG implementations should define a function -computing the minimum bond requirement with the following signature: - -```solidity -function getRequiredBond(Position _movePosition) public pure returns (uint256 requiredBond_) -``` - -As such, attacking or defending requires a check for the `getRequiredBond()` amount against the bond -attached to the move. To incentivize participation, the minimum bond should cover the cost of a possible -counter to the move being added. Thus, the minimum bond depends only on the position of the move that's added. - -## Subgame Resolution - -If a subgame root resolves incorrectly, then its bond is distributed to the **leftmost claimant** that countered -it. This creates an incentive to identify the earliest point of disagreement in an execution trace. -The subgame root claimant gets back its bond iff it resolves correctly. - -At maximum game depths, where a claimant counters a bonded claim via `step`, the bond is instead distributed -to the account that successfully called `step`. - -### Leftmost Claim Incentives - -There exists defensive positions that cannot be countered, even if they hold invalid claims. These positions -are located on the same level as honest claims, but situated to its right (i.e. its gindex > honest claim's). - -An honest challenger can always successfully dispute any sibling claims not positioned to the right of an honest claim. -The leftmost payoff rule encourages such disputes, ensuring only one claim is leftmost at correct depths. -This claim will be the honest one, and thus bond rewards will be directed exclusively to honest claims. - -## Fault Proof Mainnet Incentives - -This section describes the specific bond incentives to be used for the Fault Proof Mainnet launch of the Base fault -proof system. - -### Authenticated Roles - -| Name | Description | -| ------------ | ----------------------------------------------------------------------------------------------------- | -| Guardian | Role responsible for blacklisting dispute game contracts and changing the respected dispute game type | -| System Owner | Role that owns the `ProxyAdmin` contract that in turn owns most `Proxy` contracts within Base | - -### Base Fee Assumption - -FPM bonds are to assume a fixed 200 Gwei base fee. -Future iterations of the fault proof may include a dynamic base fee calculation. -For the moment, we suppose that the `Guardian` address may account for increased average base fees by updating the -`OptimismPortal` contract to a new respected game type with a higher assumed base fee. - -### Bond Scaling - -FPM bonds are priced in the amount of gas that they are intended to cover. -Bonds start at the very first depth of the game at a baseline of `400_000` gas. -The `400_000` value is chosen as a deterrence amount that is approximately double the cost to respond at the top level. -Bonds scale up to a value of `300_000_000` gas, a value chosen to cover approximately double the cost of a max-size -Large Preimage Proposal. - -We use a multiplicative scaling mechanism to guarantee that the ratio between bonds remains constant. -We determine the multiplier based on the proposed `MAX_DEPTH` of 73. -We can use the formula `x = (300_000_000 / 400_000) ** (1 / 73)` to determine that `x = 1.09493`. -At each depth `N`, the amount of gas charged is therefore `400_000 * (1.09493 ** N)` - -Below is a diagram demonstrating this curve for a max depth of 73. - -![bond scaling curve](https://github.com/ethereum-optimism/specs/assets/14298799/b381037b-193d-42c5-9a9c-9cc5f43b255f) - -### Required Bond Formula - -Applying the [Base Fee Assumption](#base-fee-assumption) and [Bond Scaling](#bond-scaling) specifications, we have a -`getRequiredBond` function: - -```python -def get_required_bond(position): - assumed_gas_price = 200 gwei - base_gas_charged = 400_000 - gas_charged = 400_000 * (1.09493 ** position.depth) - return gas_charged * assumed_gas_price -``` - -### Other Incentives - -There are other costs associated with participating in the game, including operating a challenger agent and the -opportunity cost of locking up capital in the dispute game. While we do not explicitly create incentives to cover -these costs, we assume that the current bond rewards, based on this specification, are enough as a whole to cover -all other costs of participation. - -## Game Finalization - -After the game is resolved, claimants must wait for the [AnchorStateRegistry's -`isGameFinalized()`](anchor-state-registry.md#isgamefinalized) to return `true` before they can claim their bonds. This -implies a wait period of at least the `disputeGameFinalityDelaySeconds` variable from the `OptimismPortal` contract. -After the game is finalized, bonds can be distributed. - -### Bond Distribution Mode - -The FDG will in most cases distribute bonds to the winners of the game after it is resolved and finalized, but in -special cases will refund the bonds to the original depositor. - -#### Normal Mode - -In normal mode, the FDG will distribute bonds to the winners of the game after it is resolved and finalized. - -#### Refund Mode - -In refund mode, the FDG will refund the bonds to the original depositor. - -### Game Closure - -The `FaultDisputeGame` contract can be closed after finalization via the `closeGame()` function. - -`closeGame` must do the following: - -1. Verify the game is resolved and finalized according to the Anchor State Registry -2. Attempt to set this game as the new anchor game. -3. Determine the bond distribution mode based on whether the [AnchorStateRegistry's - `isGameProper()`](anchor-state-registry.md#isgameproper) returns `true`. -4. Emit a `GameClosed` event with the chosen distribution mode. - -### Claiming Credit - -There is a 2-step process to claim credit. First, `claimCredit(address claimant)` should be called to unlock the credit -from the [DelayedWETH](#delayedweth) contract. After DelayedWETH's [delay period](#delay-period) has passed, -`claimCredit` should be called again to withdraw the credit. - -The `claimCredit(address claimant)` function must do the following: - -- Call `closeGame()` to determine the distribution mode if not already closed. - - In NORMAL mode: Distribute credit from the standard `normalModeCredit` mapping. - - In REFUND mode: Distribute credit from the `refundModeCredit` mapping. -- If the claimant has not yet unlocked their credit, unlock it by calling `DelayedWETH.unlock(claimant, credit)`. - - Claimant must not be able to unlock this credit again. -- If the claimant has already unlocked their credit, call `DelayedWETH.withdraw(claimant, credit)` (implying a - [delay period](#delay-period)) to withdraw the credit, and set claimant's `credit` balances to 0. - -### DelayedWETH - -`DelayedWETH` is designed to hold the bonded ETH for each -[Fault Dispute Game](fault-dispute-game.md). -`DelayedWETH` is an extended version of the standard `WETH` contract that introduces a delayed unwrap mechanism that -allows an owner address to function as a backstop in the case that a Fault Dispute Game would -incorrectly distribute bonds. - -`DelayedWETH` is modified from `WETH` as follows: - -- `DelayedWETH` is an upgradeable proxy contract. -- `DelayedWETH` has an `owner()` address. We typically expect this to be set to the `System Owner` address. -- `DelayedWETH` has a `delay()` function that returns a period of time that withdrawals will be delayed. -- `DelayedWETH` has an `unlock(guy,wad)` function that modifies a mapping called `withdrawals` keyed as - `withdrawals[msg.sender][guy] => WithdrawalRequest` where `WithdrawalRequest` is - `struct Withdrawal Request { uint256 amount, uint256 timestamp }`. When `unlock` is called, the timestamp for - `withdrawals[msg.sender][guy]` is set to the current timestamp and the amount is increased by the given amount. -- `DelayedWETH` modifies the `WETH.withdraw` function such that an address _must_ provide a "sub-account" to withdraw - from. The function signature becomes `withdraw(guy,wad)`. The function retrieves `withdrawals[msg.sender][guy]` and - checks that the current `block.timestamp` is greater than the timestamp on the withdrawal request plus the `delay()` - seconds and reverts if not. It also confirms that the amount being withdrawn is less than the amount in the withdrawal - request. Before completing the withdrawal, it reduces the amount contained within the withdrawal request. The original - `withdraw(wad)` function becomes an alias for `withdraw(msg.sender, wad)`. - `withdraw(guy,wad)` will not be callable when `SuperchainConfig.paused()` is `true`. -- `DelayedWETH` has a `hold(guy,wad)` function that allows the `owner()` address to, for any holder, give itself an - allowance and immediately `transferFrom` that allowance amount to itself. -- `DelayedWETH` has a `hold(guy)` function that allows the `owner()` address to, for any holder, give itself a full - allowance of the holder's balance and immediately `transferFrom` that amount to itself. -- `DelayedWETH` has a `recover()` function that allows the `owner()` address to recover any amount of ETH from the - contract. - -#### Sub-Account Model - -This specification requires that withdrawal requests specify "sub-accounts" that these requests correspond to. This -takes the form of requiring that `unlock` and `withdraw` both take an `address guy` parameter as input. By requiring -this extra input, withdrawals are separated between accounts and it is always possible to see how much WETH a specific -end-user of the `FaultDisputeGame` can withdraw at any given time. It is therefore possible for the `DelayedWETH` -contract to account for all bug cases within the `FaultDisputeGame` as long as the `FaultDisputeGame` always passes the -correct address into `withdraw`. - -#### Delay Period - -We propose a delay period of 7 days for Base. 7 days provides sufficient time for the `owner()` of the -`DelayedWETH` contract to act even if that owner is a large multisig that requires action from many different members -over multiple timezones. - -#### Integration - -`DelayedWETH` is expected to be integrated into the Fault Dispute Game as follows: - -- When `FaultDisputeGame.initialize` is triggered, `DelayedWETH.deposit{value: msg.value}()` is called. -- When `FaultDisputeGame.move` is triggered, `DelayedWETH.deposit{value: msg.value}()` is called. -- When `FaultDisputeGame.resolveClaim` is triggered, the game will add to the claimant's internal credit balance. -- When `FaultDisputeGame.claimCredit` is triggered, `DelayedWETH.withdraw(recipient, credit)` is called. - -```mermaid -sequenceDiagram - participant U as User - participant FDG as FaultDisputeGame - participant DW as DelayedWETH - - U->>FDG: initialize() - FDG->>DW: deposit{value: msg.value}() - Note over DW: FDG gains balance in DW - - loop move by Users - U->>FDG: move() - FDG->>DW: deposit{value: msg.value}() - Note over DW: Increases FDG balance in DW - end - - loop resolveClaim by Users - U->>FDG: resolveClaim() - FDG->>FDG: Add to claimant credit - end - - loop Initial claimCredit call by Users - U->>FDG: claimCredit() - FDG->>DW: unlock(recipient, bond) - end - - loop Subsequent claimCredit call by Users - U->>FDG: claimCredit() - FDG->>DW: withdraw(recipient, credit) - Note over DW: Checks timer/amount for recipient - DW->>FDG: Transfer claim to FDG - FDG->>U: Transfer claim to User - end -``` diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/bridge-integration.md b/docs/specs/pages/protocol/fault-proof/stage-one/bridge-integration.md deleted file mode 100644 index c31720210c..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/bridge-integration.md +++ /dev/null @@ -1,269 +0,0 @@ -# Bridge Integration - - - -[g-l2-proposal]: ../../../reference/glossary.md#l2-output-root-proposals - - - -[fdg]: fault-dispute-game.md - -## Overview - -With fault proofs, the withdrawal path changes such that withdrawals submitted to the `OptimismPortal` are proven -against [output proposals][g-l2-proposal] submitted as a [`FaultDisputeGame`][fdg] prior to being finalized. Output -proposals are now finalized whenever a dispute game resolves in their favor. - -## Legacy Semantics - -The `OptimismPortal` uses the `L2OutputOracle` in the withdrawal path of the rollup to allow users to prove the -presence of their withdrawal inside of the `L2ToL1MessagePasser` account storage root, which can be retrieved by -providing a preimage to an output root in the oracle. The oracle currently holds a list of all L2 outputs proposed to -L1 by a permissioned PROPOSER key. The list in the contract has the following properties: - -- It must always be sorted by the L2 Block Number that the output proposal is claiming it corresponds to. -- All outputs in the list that are > `FINALIZATION_PERIOD_SECONDS` old are considered "finalized." The separator - between unfinalized/finalized outputs moves forwards implicitly as time passes. - -![legacy-l2oo-list](/static/assets/legacy-l2oo-list.png) - -Currently, if there is a faulty output proposed by the permissioned `PROPOSER` key, a separate permissioned -`CHALLENGER` key may intervene. Note that the `CHALLENGER` role effectively has god-mode privileges, and can currently -act without proving that the outputs they're deleting are indeed incorrect. By deleting an output proposal, the -challenger also deletes all output proposals in front of it. - -With the upgrade to the Fault Proof Alpha Chad system, output proposals are no longer sent to the `L2OutputOracle`, but -to the `DisputeGameFactory` in order to be fault proven. In contrast to the L2OO, an incorrect output proposal is not -deleted, but proven to be incorrect. The semantics of finalization timelines and the definition of a "finalized" output -proposal also change. Since the DisputeGameFactory fulfills the same role as the L2OutputOracle in a post fault proofs -world by tracking proposed outputs, and the L2OO's semantics are incompatible with the new system, the L2OO is no -longer required. - -## FPAC `OptimismPortal` Mods Specification - -### Roles - `OptimismPortal` - -- `Guardian`: Permissioned actor able to pause the portal, blacklist dispute games, and change the - `RESPECTED_GAME_TYPE`. - -### New `DeployConfig` Variables - -| Name | Description | -| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DISPUTE_GAME_FINALITY_DELAY_SECONDS` | The amount of time given to the `Guardian` role to blacklist a resolved dispute game before any withdrawals proven against it can be finalized, in case of system failure. | -| `PROOF_MATURITY_DELAY_SECONDS` | Formerly `FINALIZATION_PERIOD_SECONDS` in the `L2OutputOracle`, defines the duration that must pass between proving and finalizing a withdrawal. | -| `RESPECTED_GAME_TYPE` | The dispute game type that the portal uses for the withdrawal path. | - -### Data Structures - -Withdrawals are now proven against dispute games, which have immutable "root claims" representing the output root -being proposed. The `ProvenWithdrawal` struct is now defined as: - -```solidity -/// @notice Represents a proven withdrawal. -/// @custom:field disputeGameProxy The address of the dispute game proxy that the withdrawal was proven against. -/// @custom:field timestamp Timestamp at which the withdrawal was proven. -struct ProvenWithdrawal { - IDisputeGame disputeGameProxy; - uint64 timestamp; -} -``` - -### State Layout - -#### Legacy Spacers - -Spacers should be added at the following storage slots in the `OptimismPortal` so that they may not be reused: - -| Slot | Description | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `52` | Legacy `provenWithdrawals` mapping. Withdrawals proven against the `L2OutputOracle`'s output proposals will be deleted upon the upgrade. | -| `54` | Legacy `L2OutputOracle` address. | - -#### New State Variables - -**`DisputeGameFactory` address** - -```solidity -/// @notice Address of the DisputeGameFactory. -/// @custom:network-specific -DisputeGameFactory public disputeGameFactory; -``` - -**Respected Game Type** - -```solidity -/// @notice The respected game type of the `OptimismPortal`. -/// Can be changed by Guardian. -GameType public respectedGameType; -``` - -**Respected Game Type Updated Timestamp** - -```solidity -/// @notice The timestamp at which the respected game type was last updated. -uint64 public respectedGameTypeUpdatedAt; -``` - -**New `ProvenWithdrawals` mapping** - -```solidity -/// @notice A mapping of withdrawal hashes to `ProvenWithdrawal` data. -mapping(bytes32 => ProvenWithdrawal) public provenWithdrawals; -``` - -**Blacklisted `DisputeGame` mapping** - -```solidity -/// @notice A mapping of dispute game addresses to whether or not they are blacklisted. -mapping(IDisputeGame => bool) public disputeGameBlacklist; -``` - -### `proveWithdrawalTransaction` modifications - -Proving a withdrawal transaction now proves against an output root in a dispute game, rather than one in the -`L2OutputOracle`. - -#### Interface - -The type signature of the function does not change, but the purpose of the second argument transitions from providing -an index within the `L2OutputOracle`'s `l2Outputs` array to an index within the `DisputeGameFactory`'s list of created -games. - -```solidity -/// @notice Proves a withdrawal transaction. -/// @param _tx Withdrawal transaction to finalize. -/// @param _disputeGameIndex Index of the dispute game to prove the withdrawal against. -/// @param _outputRootProof Inclusion proof of the L2ToL1MessagePasser contract's storage root. -/// @param _withdrawalProof Inclusion proof of the withdrawal in L2ToL1MessagePasser contract. -function proveWithdrawalTransaction( - Types.WithdrawalTransaction memory _tx, - uint256 _disputeGameIndex, - Types.OutputRootProof calldata _outputRootProof, - bytes[] calldata _withdrawalProof -) external whenNotPaused; -``` - -#### New Invariants - `proveWithdrawalTransaction` - -**Trusted `GameType`** -The `DisputeGameFactory` can create many different types of dispute games, delineated by their `GameType`. The game -type of the dispute game fetched from the factory's list at `_disputeGameIndex` must be of type `RESPECTED_GAME_TYPE`. -The call should revert on all other game types it encounters. - -#### Changed Invariants - `proveWithdrawalTransaction` - -**Re-proving withdrawals** -Users being able to re-prove withdrawals, in special cases, is still necessary to prevent user withdrawals from being -bricked. It is kept to protect honest users when they prove their withdrawal inside of a malicious proposal. The -timestamp of re-proven withdrawals is still reset. - -1. **Old:** Re-proving is allowed if the output root at the proven withdrawal's `l2OutputIndex` changed in the - `L2OutputOracle`. -2. **New:** Re-proving is allowed at any time by the user. When a withdrawal is re-proven, its proof maturity delay is - reset. - -### `finalizeWithdrawalTransaction` modifications - -Finalizing a withdrawal transaction now references a `DisputeGame` to determine the status of the output proposal that -the withdrawal was proven against. - -#### New Invariants - `finalizeWithdrawalTransaction` - -**Trusted `GameType`** -The `DisputeGameFactory` can create many different types of dispute games, delineated by their `GameType`. The game -type of the dispute game fetched from the factory's list at `_disputeGameIndex` must be of type `RESPECTED_GAME_TYPE`. -The call should revert on all other game types it encounters. - -**Respected Game Type Updated** -A withdrawal may never be finalized if the dispute game was created before the respected game type was last updated. - -**Dispute Game Blacklist** -The `Guardian` role can blacklist certain `DisputeGame` addresses in the event of a system failure. If the address of -the dispute game that the withdrawal was proven against is present in the `disputeGameBlacklist` mapping, the call -should always revert. - -**Dispute Game Maturity** -See ["Air-gap"](#air-gap) - -#### Changed Invariants - `finalizeWithdrawalTransaction` - -**Output Proposal Validity** -Instead of checking if the proven withdrawal's output proposal has existed for longer than the legacy finalization period, -we check if the dispute game has resolved in the root claim's favor. A `FaultDisputeGame` must never be considered to -have resolved in the `rootClaim`'s favor unless its `status()` is equal to `DEFENDER_WINS`. - -### Air-gap - -Given it's own section due to it's importance, the air gap is an enforced period of time between a dispute game's -resolution and users being able to finalize withdrawals that were proven against its root claim. When the `DisputeGame` -resolves globally, it stores the timestamp. The portal's `finalizeWithdrawalTransaction` function asserts that -`DISPUTE_GAME_FINALITY_DELAY_SECONDS` have passed since the resolution timestamp before allowing any withdrawals proven -against the dispute game to be finalized. Because the `FaultDisputeGame` is a trusted implementation set by the owner -of the `DisputeGameFactory`, it is safe to trust that this value is honestly set. - -#### Blacklisting `DisputeGame`s - -A new method is added to assign `DisputeGame`s in the `disputeGameBlacklist` mapping mentioned in -["State Layout"](#state-layout), in the event that a dispute game is detected to have resolved incorrectly. The only -actor who may call this function is the `Guardian` role. - -Blacklisting a dispute game means that no withdrawals proven against it will be allowed to finalize -(per the "Dispute Game Blacklist" invariant), and they must re-prove against a new dispute game that resolves correctly. -The Portal's guardian role is obligated to blacklist any dispute games that it deems to have resolved incorrectly. -Withdrawals proven against a blacklisted dispute game are not prevented from re-proving or being finalized in the -future. - -#### Blacklisting a full `GameType` - -In the event of a catastrophic failure, we can upgrade the `OptimismPortal` proxy to an implementation with a -different `RESPECTED_GAME_TYPE`. All pending withdrawals that reference a different game type will not be allowed to -finalize and must re-prove, due to the "Trusted `GameType`" invariant. This should generally be avoided, but allows -for a blanket blacklist of pending withdrawals corresponding to the current `RESPECTED_GAME_TYPE`. Depending on if -we're okay with the tradeoffs, this also may be the most efficient way to upgrade the dispute game in the future. - -### Proxy Upgrade - -Upgrading the `OptimismPortal` proxy to an implementation that follows this specification will invalidate all pending -withdrawals. This means that all users with pending withdrawals will need to re-prove their withdrawals against an -output proposal submitted in the form of a `DisputeGame`. - -## Permissioned `FaultDisputeGame` - -As a fallback to permissioned proposals, a child contract of the `FaultDisputeGame` will be created that has 2 new -roles: the `PROPOSER` and a `CHALLENGER` (or set of challengers). Each interaction -(`move` \[`attack` / `defend`\], `step`, `resolve` / `resolveClaim`, `addLocalData`, etc.) will be permissioned to the -`CHALLENGER` key, and the `initialize` function will be permissioned to the `PROPOSER` key. - -In the event that we'd like to switch back to permissioned proposals, we can change the `RESPECTED_GAME_TYPE` in the -`OptimismPortal` to a deployment of the `PermissionedFaultDisputeGame`. - -### Roles - `PermissionedDisputeGame` - -- `PROPOSER` - Actor that can create a `PermissionedFaultDisputeGame` and participate in the games they've created. -- `CHALLENGER` - Actor(s) that can participate in a `PermissionedFaultDisputeGame`. - -### Modifications - -**State Layout** - -2 new immutables: - -```solidity -/// @notice The `PROPOSER` role. -address public immutable PROPOSER; - -/// @notice The `CHALLENGER` role. -address public immutable CHALLENGER; -``` - -**Functions** - -Every function that can mutate state should be overridden to add a check that either: - -1. The `msg.sender` has the `CHALLENGER` role. -2. The `msg.sender` has the `PROPOSER` role. - -If the `msg.sender` does not have either role, the function must revert. - -The exception is the `initialize` function, which may only be called if the `tx.origin` is the `PROPOSER` role. diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/dispute-game-interface.md b/docs/specs/pages/protocol/fault-proof/stage-one/dispute-game-interface.md deleted file mode 100644 index afec269d7c..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/dispute-game-interface.md +++ /dev/null @@ -1,345 +0,0 @@ -# Dispute Game Interface - -## Overview - -A dispute game is played between multiple parties when contesting the truthiness -of a claim. In the context of an optimistic rollup, claims are made about the -state of the layer two network to enable withdrawals to the layer one. A proposer -makes a claim about the layer two state such that they can withdraw and a -challenger can dispute the validity of the claim. The security of the layer two -comes from the ability of fraudulent withdrawals being able to be disputed. - -A dispute game interface is defined to allow for multiple implementations of -dispute games to exist. If multiple dispute games run in production, it gives -a similar security model as having multiple protocol clients, as a bug in a -single dispute game will not result in the bug becoming consensus. - -## Types - -For added context, we define a few types that are used in the following snippets. - -```solidity -/// @notice A `Claim` type represents a 32 byte hash or other unique identifier for a claim about -/// a certain piece of information. -type Claim is bytes32; - -/// @notice A custom type for a generic hash. -type Hash is bytes32; - -/// @notice A dedicated timestamp type. -type Timestamp is uint64; - -/// @notice A `GameType` represents the type of game being played. -type GameType is uint32; - -/// @notice A `GameId` represents a packed 4 byte game type, a 8 byte timestamp, and a 20 byte address. -/// @dev The packed layout of this type is as follows: -/// ┌───────────┬───────────┐ -/// │ Bits │ Value │ -/// ├───────────┼───────────┤ -/// │ [0, 32) │ Game Type │ -/// │ [32, 96) │ Timestamp │ -/// │ [96, 256) │ Address │ -/// └───────────┴───────────┘ -type GameId is bytes32; - -/// @title GameTypes -/// @notice A library that defines the IDs of games that can be played. -library GameTypes { - /// @dev A dispute game type the uses the cannon vm. - GameType internal constant CANNON = GameType.wrap(0); - - /// @dev A dispute game type that performs output bisection and then uses the cannon vm. - GameType internal constant OUTPUT_CANNON = GameType.wrap(1); - - /// @notice A dispute game type that performs output bisection and then uses an alphabet vm. - /// Not intended for production use. - GameType internal constant OUTPUT_ALPHABET = GameType.wrap(254); - - /// @notice A dispute game type that uses an alphabet vm. - /// Not intended for production use. - GameType internal constant ALPHABET = GameType.wrap(255); -} - -/// @notice The current status of the dispute game. -enum GameStatus { - /// @dev The game is currently in progress, and has not been resolved. - IN_PROGRESS, - /// @dev The game has concluded, and the `rootClaim` was challenged successfully. - CHALLENGER_WINS, - /// @dev The game has concluded, and the `rootClaim` could not be contested. - DEFENDER_WINS -} -``` - -## `DisputeGameFactory` Interface - -The dispute game factory is responsible for creating new `DisputeGame` contracts -given a `GameType` and a root `Claim`. Challenger agents listen to the `DisputeGameCreated` events in order to -keep up with on-going disputes in the protocol and participate accordingly. - -A [`clones-with-immutable-args`](https://github.com/Vectorized/solady/blob/main/src/utils/LibClone.sol) factory -(originally by @wighawag, but forked and improved by @Vectorized) is used to create Clones. Each `GameType` has -a corresponding implementation within the factory, and when a new game is created, the factory creates a -clone of the `GameType`'s pre-deployed implementation contract. - -The `rootClaim` of created dispute games can either be a claim that the creator agrees or disagrees with. -This is an implementation detail that is left up to the `IDisputeGame` to handle within its `resolve` function. - -When the `DisputeGameFactory` creates a new `DisputeGame` contract, it calls `initialize()` on the clone to -set up the game. The factory passes immutable arguments to the clone using the CWIA (Clone With Immutable Args) -pattern. There are two CWIA layouts depending on whether the game type has implementation args configured: - -**Standard CWIA Layout** (when `gameArgs[_gameType]` is empty): - -| Bytes | Description | -|-------|-------------| -| [0, 20) | Game creator address | -| [20, 52) | Root claim | -| [52, 84) | Parent block hash at creation time | -| [84, 84 + n) | Extra data (opaque) | - -**Extended CWIA Layout** (when `gameArgs[_gameType]` is non-empty): - -| Bytes | Description | -|-------|-------------| -| [0, 20) | Game creator address | -| [20, 52) | Root claim | -| [52, 84) | Parent block hash at creation time | -| [84, 88) | Game type | -| [88, 88 + n) | Extra data (opaque) | -| [88 + n, 88 + n + m) | Implementation args (opaque) | - -The implementation args allow chain-specific configuration to be passed to the game implementation at clone -creation time, enabling a single implementation contract to be reused across different chain configurations. - -```solidity -/// @title IDisputeGameFactory -/// @notice The interface for a DisputeGameFactory contract. -interface IDisputeGameFactory { - /// @notice Emitted when a new dispute game is created - /// @param disputeProxy The address of the dispute game proxy - /// @param gameType The type of the dispute game proxy's implementation - /// @param rootClaim The root claim of the dispute game - event DisputeGameCreated(address indexed disputeProxy, GameType indexed gameType, Claim indexed rootClaim); - - /// @notice Emitted when a new game implementation added to the factory - /// @param impl The implementation contract for the given `GameType`. - /// @param gameType The type of the DisputeGame. - event ImplementationSet(address indexed impl, GameType indexed gameType); - - /// @notice Emitted when a game type's implementation args are set - /// @param gameType The type of the DisputeGame. - /// @param args The constructor args for the game type. - event ImplementationArgsSet(GameType indexed gameType, bytes args); - - /// @notice Emitted when a game type's initialization bond is updated - /// @param gameType The type of the DisputeGame. - /// @param newBond The new bond (in wei) for initializing the game type. - event InitBondUpdated(GameType indexed gameType, uint256 indexed newBond); - - /// @notice Information about a dispute game found in a `findLatestGames` search. - struct GameSearchResult { - uint256 index; - GameId metadata; - Timestamp timestamp; - Claim rootClaim; - bytes extraData; - } - - /// @notice The total number of dispute games created by this factory. - /// @return gameCount_ The total number of dispute games created by this factory. - function gameCount() external view returns (uint256 gameCount_); - - /// @notice `games` queries an internal mapping that maps the hash of - /// `gameType ++ rootClaim ++ extraData` to the deployed `DisputeGame` clone. - /// @dev `++` equates to concatenation. - /// @param _gameType The type of the DisputeGame - used to decide the proxy implementation - /// @param _rootClaim The root claim of the DisputeGame. - /// @param _extraData Any extra data that should be provided to the created dispute game. - /// @return proxy_ The clone of the `DisputeGame` created with the given parameters. - /// Returns `address(0)` if nonexistent. - /// @return timestamp_ The timestamp of the creation of the dispute game. - function games( - GameType _gameType, - Claim _rootClaim, - bytes calldata _extraData - ) - external - view - returns (IDisputeGame proxy_, Timestamp timestamp_); - - /// @notice `gameAtIndex` returns the dispute game contract address and its creation timestamp - /// at the given index. Each created dispute game increments the underlying index. - /// @param _index The index of the dispute game. - /// @return gameType_ The type of the DisputeGame - used to decide the proxy implementation. - /// @return timestamp_ The timestamp of the creation of the dispute game. - /// @return proxy_ The clone of the `DisputeGame` created with the given parameters. - /// Returns `address(0)` if nonexistent. - function gameAtIndex(uint256 _index) - external - view - returns (GameType gameType_, Timestamp timestamp_, IDisputeGame proxy_); - - /// @notice `gameImpls` is a mapping that maps `GameType`s to their respective - /// `IDisputeGame` implementations. - /// @param _gameType The type of the dispute game. - /// @return impl_ The address of the implementation of the game type. - /// Will be cloned on creation of a new dispute game with the given `gameType`. - function gameImpls(GameType _gameType) external view returns (IDisputeGame impl_); - - /// @notice Returns the required bonds for initializing a dispute game of the given type. - /// @param _gameType The type of the dispute game. - /// @return bond_ The required bond for initializing a dispute game of the given type. - function initBonds(GameType _gameType) external view returns (uint256 bond_); - - /// @notice Returns the chain-specific configuration arguments for a given game type's implementation. - /// @dev These arguments are typically passed to the game implementation during proxy creation using CWIA. - /// @param _gameType The type of the dispute game. - /// @return args_ The chain-specific configuration arguments. - function gameArgs(GameType _gameType) external view returns (bytes memory args_); - - /// @notice Creates a new DisputeGame proxy contract. - /// @param _gameType The type of the DisputeGame - used to decide the proxy implementation. - /// @param _rootClaim The root claim of the DisputeGame. - /// @param _extraData Any extra data that should be provided to the created dispute game. - /// @return proxy_ The address of the created DisputeGame proxy. - function create( - GameType _gameType, - Claim _rootClaim, - bytes calldata _extraData - ) - external - payable - returns (IDisputeGame proxy_); - - /// @notice Sets the implementation contract for a specific `GameType`. - /// @dev May only be called by the `owner`. - /// @param _gameType The type of the DisputeGame. - /// @param _impl The implementation contract for the given `GameType`. - /// @param _args The chain-specific configuration arguments for this game type's implementation. - function setImplementation(GameType _gameType, IDisputeGame _impl, bytes calldata _args) external; - - /// @notice Sets the bond (in wei) for initializing a game type. - /// @dev May only be called by the `owner`. - /// @param _gameType The type of the DisputeGame. - /// @param _initBond The bond (in wei) for initializing a game type. - function setInitBond(GameType _gameType, uint256 _initBond) external; - - /// @notice Returns a unique identifier for the given dispute game parameters. - /// @dev Hashes the concatenation of `gameType . rootClaim . extraData` - /// without expanding memory. - /// @param _gameType The type of the DisputeGame. - /// @param _rootClaim The root claim of the DisputeGame. - /// @param _extraData Any extra data that should be provided to the created dispute game. - /// @return uuid_ The unique identifier for the given dispute game parameters. - function getGameUUID( - GameType _gameType, - Claim _rootClaim, - bytes memory _extraData - ) - external - pure - returns (Hash uuid_); - - /// @notice Finds the `_n` most recent `GameId`'s of type `_gameType` starting at `_start`. If there are less than - /// `_n` games of type `_gameType` starting at `_start`, then the returned array will be shorter than `_n`. - /// @param _gameType The type of game to find. - /// @param _start The index to start the reverse search from. - /// @param _n The number of games to find. - function findLatestGames( - GameType _gameType, - uint256 _start, - uint256 _n - ) - external - view - returns (GameSearchResult[] memory games_); -} -``` - -## `DisputeGame` Interface - -The dispute game interface defines a generic, black-box dispute. It exposes stateful information such as the status of -the dispute, when it was created, as well as the bootstrap data and dispute type. This interface exposes one state -mutating function, `resolve`, which when implemented should deterministically yield an opinion about the `rootClaim` -and reflect the opinion by updating the `status` to `CHALLENGER_WINS` or `DEFENDER_WINS`. - -Clones of the `IDisputeGame`'s `initialize` functions will be called by the `DisputeGameFactory` atomically upon -creation. - -```solidity -/// @title IDisputeGame -/// @notice The generic interface for a DisputeGame contract. -interface IDisputeGame is IInitializable { - /// @notice Emitted when the game is resolved. - /// @param status The status of the game after resolution. - event Resolved(GameStatus indexed status); - - /// @notice Returns the timestamp that the DisputeGame contract was created at. - /// @return createdAt_ The timestamp that the DisputeGame contract was created at. - function createdAt() external view returns (Timestamp createdAt_); - - /// @notice Returns the timestamp that the DisputeGame contract was resolved at. - /// @return resolvedAt_ The timestamp that the DisputeGame contract was resolved at. - function resolvedAt() external view returns (Timestamp resolvedAt_); - - /// @notice Returns the current status of the game. - /// @return status_ The current status of the game. - function status() external view returns (GameStatus status_); - - /// @notice Getter for the game type. - /// @dev The reference impl should be entirely different depending on the type (fault, validity) - /// i.e. The game type should indicate the security model. - /// @return gameType_ The type of proof system being used. - function gameType() external view returns (GameType gameType_); - - /// @notice Getter for the creator of the dispute game. - /// @dev `clones-with-immutable-args` argument #1 - /// @return creator_ The creator of the dispute game. - function gameCreator() external pure returns (address creator_); - - /// @notice Getter for the root claim. - /// @dev `clones-with-immutable-args` argument #2 - /// @return rootClaim_ The root claim of the DisputeGame. - function rootClaim() external pure returns (Claim rootClaim_); - - /// @notice Getter for the parent hash of the L1 block when the dispute game was created. - /// @dev `clones-with-immutable-args` argument #3 - /// @return l1Head_ The parent hash of the L1 block when the dispute game was created. - function l1Head() external pure returns (Hash l1Head_); - - /// @notice Getter for the L2 sequence number (typically the L2 block number). - /// @dev Extracted from the extra data supplied to the dispute game contract by the creator. - /// @return l2SequenceNumber_ The L2 sequence number for this dispute game. - function l2SequenceNumber() external pure returns (uint256 l2SequenceNumber_); - - /// @notice Getter for the extra data. - /// @dev `clones-with-immutable-args` argument #4 - /// @return extraData_ Any extra data supplied to the dispute game contract by the creator. - function extraData() external pure returns (bytes memory extraData_); - - /// @notice If all necessary information has been gathered, this function should mark the game - /// status as either `CHALLENGER_WINS` or `DEFENDER_WINS` and return the status of - /// the resolved game. It is at this stage that the bonds should be awarded to the - /// necessary parties. - /// @dev May only be called if the `status` is `IN_PROGRESS`. - /// @return status_ The status of the game after resolution. - function resolve() external returns (GameStatus status_); - - /// @notice A compliant implementation of this interface should return the components of the - /// game UUID's preimage provided in the cwia payload. The preimage of the UUID is - /// constructed as `keccak256(gameType . rootClaim . extraData)` where `.` denotes - /// concatenation. - /// @return gameType_ The type of proof system being used. - /// @return rootClaim_ The root claim of the DisputeGame. - /// @return extraData_ Any extra data supplied to the dispute game contract by the creator. - function gameData() external view returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_); - - /// @notice Returns whether the game type was respected when this game was created. - /// @dev Used as a withdrawal finality condition - games created when their type wasn't - /// respected cannot be used to finalize withdrawals. - /// @return wasRespected_ True if the game type was the respected game type when created. - function wasRespectedGameTypeWhenCreated() external view returns (bool wasRespected_); -} -``` diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/fault-dispute-game.md b/docs/specs/pages/protocol/fault-proof/stage-one/fault-dispute-game.md deleted file mode 100644 index 719bb10004..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/fault-dispute-game.md +++ /dev/null @@ -1,433 +0,0 @@ -# Fault Dispute Game - - - -[g-output-root]: ../../../reference/glossary.md#l2-output-root - -## Overview - -The Fault Dispute Game (FDG) is a specific type of [dispute game](dispute-game-interface.md) that verifies the -validity of a root claim by iteratively bisecting over [output roots][g-output-root] and execution traces of single -block state transitions down to a single instruction step. It relies on a Virtual Machine (VM) to falsify invalid -claims made at a single instruction step. - -Actors, i.e. Players, interact with the game by making claims that dispute other claims in the FDG. -Each claim made narrows the range over the entire historical state of L2, until the source of dispute is a single -state transition. Once a time limit is reached, the dispute game is _resolved_, based on claims made that are disputed -and which aren't, to determine the winners of the game. - -## Definitions - -### Virtual Machine (VM) - -This is a state transition function (STF) that takes a _pre-state_ and computes the post-state. -The VM may access data referenced during the STF and as such, it also accepts a _proof_ of this data. -Typically, the pre-state contains a commitment to the _proof_ to verify the integrity of the data referenced. - -Mathematically, we define the STF as $VM(S_i,P_i)$ where - -- $S_i$ is the pre-state -- $P_i$ is an optional proof needed for the transition from $S_i$ to $S_{i+1}$. - -### PreimageOracle - -This is a pre-image data store. It is often used by VMs to read external data during its STF. -Before successfully executing a VM STF, it may be necessary to preload the PreimageOracle with pertinent data. -The method for key-based retrieval of these pre-images varies according to the specific VM. - -### Execution Trace - -An execution trace $T$ is a sequence $(S_0,S_1,S_2,...,S_n)$ where each $S_i$ is a VM state and -for each $i$, $0 \le i \lt n$, $S_{i+1} = VM(S_i, P_i)$. -Every execution trace has a unique starting state, $S_0$, that's preset to a FDG implementation. -We refer to this state as the **ABSOLUTE_PRESTATE**. - -### Claims - -Claims assert an [output root][g-output-root] or the state of the FPVM at a given instruction. This is represented as -a `Hash` type, a `bytes32` representing either an [output root][g-output-root] or a commitment to the last VM state in a -trace. A FDG is initialized with an output root that corresponds to the state of L2 at a given L2 block number, and -execution trace subgames at `SPLIT_DEPTH + 1` are initialized with a claim that commits to the entire execution trace -between two consecutive output roots (a block `n -> n+1` state transition). As we'll see later, there can be multiple -claims, committing to different output roots and FPVM states in the FDG. - -### Anchor State - -An anchor state, or anchor output root, is a previous output root that is assumed to be valid. An -FDG is always initialized with an anchor state and execution is carried out between this anchor -state and the [claimed output root](#claims). FDG contracts pull their anchor state from the -[Anchor State Registry](#anchor-state-registry) contract. The initial anchor state for a FDG is the -genesis state of the L2. - -Clients must currently gather L1 data for the window between the anchor state and the claimed -state. In order to reduce this L1 data requirement, [claims](#claims) about the state of the L2 -become new anchor states when dispute games resolve in their favor. FDG contracts set their anchor -states at initialization time so that these updates do not impact active games. - -### Anchor State Registry - -The Anchor State Registry is a registry that the FDG uses to determine its [anchor state](#anchor-state). It also -determines if the game is [finalized](anchor-state-registry.md#finalized-game) and -["proper"](anchor-state-registry.md#proper-game) for purposes of [Bond -Distribution](bond-incentives.md#game-finalization). See [Anchor State Registry](anchor-state-registry.md) for more -details. - -### Respected Game Type - -A Fault Dispute Game must record whether its game type is respected at the time of its creation. See -[Respected Game Type](anchor-state-registry.md#respected-game-type) for more details. - -### DAG - -A Directed Acyclic Graph $G = (V,E)$ representing the relationship between claims, where: - -- $V$ is the set of nodes, each representing a claim. Formally, $V = \{C_1,C_2,...,C_n\}$, - where $C_i$ is a claim. -- $E$ is the set of _directed_ edges. An edge $(C_i,C_j)$ exists if $C_j$ is a direct dispute - against $C_i$ through either an "Attack" or "Defend" [move](#moves). - -### Subgame - -A sub-game is a DAG of depth 1, where the root of the DAG is a `Claim` and the children are `Claim`s that counter the -root. A good mental model around this structure is that it is a fundamental dispute between two parties over a single -piece of information. These subgames are chained together such that a child within a subgame is the root of its own -subgame, which is visualized in the [resolution](#resolution) section. There are two types of sub-games in the fault -dispute game: - -1. Output Roots -1. Execution Trace Commitments - -At and above the split depth, all subgame roots correspond to [output roots][g-output-root], or commitments to the full -state of L2 at a given L2 block number. Below the split depth, subgame roots correspond to commitments to the fault -proof VM's state at a given instruction step. - -### Game Tree - -The Game Tree is a binary tree of positions. Every claim in the DAG references a position in the Game Tree. -The Game Tree has a split depth and maximum depth, `SPLIT_DEPTH` and `MAX_GAME_DEPTH` respectively, that are both -preset to an FDG implementation. The split depth defines the maximum depth at which claims about -[output roots][g-output-root] can occur, and below it, execution trace bisection occurs. Thus, the Game Tree contains -$2^{d+1}-1$ positions, where $d$ is the `MAX_GAME_DEPTH` (unless $d=0$, in which case there's only 1 position). - -The full game tree, with a layer of the tree allocated to output bisection, and sub-trees after an arbitrary split -depth, looks like: - -![ob-tree](/static/assets/ob-tree.png) - -### Position - -A position represents the location of a claim in the Game Tree. This is represented by a -"generalized index" (or **gindex**) where the high-order bit is the level in the tree and the remaining -bits is a unique bit pattern, allowing a unique identifier for each node in the tree. - -The **gindex** of a position $n$ can be calculated as $2^{d(n)} + idx(n)$, where: - -- $d(n)$ is a function returning the depth of the position in the Game Tree -- $idx(n)$ is a function returning the index of the position at its depth (starting from the left). - -Positions at the deepest level of the game tree correspond to indices in the execution trace, whereas claims at the -split depth represent single L2 blocks' [output roots][g-output-root]. -Positions higher up the game tree also cover the deepest, right-most positions relative to the current position. -We refer to this coverage as the **trace index** of a Position. - -> This means claims commit to an execution trace that terminates at the same index as their Position's trace index. -> That is, for a given trace index $n$, its state witness hash corresponds to the $S_n$ th state in the trace. - -Note that there can be multiple positions covering the same _trace index_. - -### MAX_CLOCK_DURATION - -This is an immutable, preset to a FDG implementation, representing the maximum amount of time that may accumulate on a -team's [chess clock](#game-clock). - -### CLOCK_EXTENSION - -This is an immutable, preset to a FDG implementation, representing the flat credit that is given to a team's clock if -their clock has less than `CLOCK_EXTENSION` seconds remaining. - -### Freeloader Claims - -Due to the subgame resolution logic, there are certain moves which result in the correct final resolution of the game, -but do not pay out bonds to the correct parties. - -An example of this is as follows: - -1. Alice creates a dispute game with an honest root claim. -1. Bob counters the honest root with a correct claim at the implied L2 block number. -1. Alice performs a defense move against Bob's counter, as the divergence exists later in Bob's view of the chain state. -1. Bob attacks his own claim. - -Bob's attack against his own claim _is_ a counter to a bad claim, but with the incorrect pivot direction. If left -untouched, because it exists at a position further left than Alice's, he will reclaim his own bond upon resolution. -Because of this, the honest challenger must always counter freeloader claims for incentive compatibility to be -preserved. - -Critically, freeloader claims, if left untouched, do not influence incorrect resolution of the game globally. - -## Core Game Mechanics - -This section specifies the core game mechanics of the FDG. The full FDG mechanics includes a -[specification for Bonds](bond-incentives.md). Readers should understand basic game mechanics before -reading up on the Bond specification. - -### Actors - -The game involves two types of participants (or Players): **Challengers** and **Defenders**. -These players are grouped into separate teams, each employing distinct strategies to interact with the game. -Team members share a common goal regarding the game's outcome. Players interact with the game primarily through -_moves_. - -### Moves - -A Move is a challenge against an existing claim and must include an alternate claim asserting a different trace. -Moves can either be attacks or defenses and serve to update to DAG by adding nodes and edges targeting the disputed -claim. - -Moves within the fault dispute game can claim two separate values: [output roots][g-output-root] and execution trace -commitments. At and above the `SPLIT_DEPTH`, claims correspond to output roots, while below the split depth, they -correspond to execution trace commitments. - -Initially, claims added to the DAG are _uncontested_ (i.e. not **countered**). Once a move targets a claim, that claim -is considered countered. -The status of a claim — whether it's countered or not — helps determine its validity and, ultimately, the -game's winner. - -#### Attack - -A logical move made when a claim is disagreed with. -A claim at the relative attack position to a node, `n`, in the Game Tree commits to half -of the trace of the `n`’s claim. -The attack position relative to a node can be calculated by multiplying its gindex by 2. - -To illustrate this, here's a Game Tree highlighting an attack on a Claim positioned at 6. - -![Attacking node 6](/static/assets/attack.png) - -Attacking the node at 6 moves creates a new claim positioned at 12. - -#### Defend - -The logical move against a claim when you agree with both it and its parent. -A defense at the relative position to a node, `n`, in the Game Tree commits to the first half of n + 1’s trace range. - -![Defend at 4](/static/assets/defend.png) - -Note that because of this, some nodes may never exist within the Game Tree. -However, they're not necessary as these nodes have complimentary, valid positions -with the same trace index within the tree. For example, a Position with gindex 5 has the same -trace index as another Position with gindex 2. We can verify that all trace indices have valid moves within the game: - -![Game Tree Showing All Valid Move Positions](/static/assets/valid-moves.png) - -There may be multiple claims at the same position, so long as their state witness hashes are unique. - -Each move adds new claims to the Game Tree at strictly increasing depth. -Once a claim is at `MAX_GAME_DEPTH`, the only way to dispute such claims is to **step**. - -### L2 Block Number Challenge - -This is a special type of action, made by the Challenger, to counter a root claim. - -Given an output root preimage and its corresponding RLP-encoded L2 block header, the L2 block number can be verified. -This process ensures the integrity and authenticity of an L2 block number. -The procedure for this verification involves three steps: checking the output root preimage, validating the block hash preimage, -and extracting the block number from the RLP-encoded header. -By comparing the challenger-supplied preimages and the extracted block number against their claimed values, -the consistency of the L2 block number with the one in the provided header can be confirmed, detecting any discrepancies. - -Root claims made with an invalid L2 block number can be disputed through a special challenge. -This challenge is validated in the FDG contract using the aforementioned procedure. -However, it is crucial to note that this challenge can only be issued against the root claim, -as it's the only entity making explicit claims on the L2 block number. -A successful challenge effectively disputes the root claim once its subgame is resolved. - -### Step - -At `MAX_GAME_DEPTH`, the position of claims correspond to indices of an execution trace. -It's at this point that the FDG is able to query the VM to determine the validity of claims, -by checking the states they're committing to. -This is done by applying the VM's STF to the state a claim commits to. -If the STF post-state does not match the claimed state, the challenge succeeds. - -```solidity -/// @notice Perform an instruction step via an on-chain fault proof processor. -/// @dev This function should point to a fault proof processor in order to execute -/// a step in the fault proof program on-chain. The interface of the fault proof -/// processor contract should adhere to the `IBigStepper` interface. -/// @param _claimIndex The index of the challenged claim within `claimData`. -/// @param _isAttack Whether or not the step is an attack or a defense. -/// @param _stateData The stateData of the step is the preimage of the claim at the given -/// prestate, which is at `_stateIndex` if the move is an attack and `_claimIndex` if -/// the move is a defense. If the step is an attack on the first instruction, it is -/// the absolute prestate of the fault proof VM. -/// @param _proof Proof to access memory nodes in the VM's merkle state tree. -function step(uint256 _claimIndex, bool _isAttack, bytes calldata _stateData, bytes calldata _proof) external; -``` - -### Step Types - -Similar to moves, there are two ways to step on a claim; attack or defend. -These determine the pre-state input to the VM STF and the expected output. - -- **Attack Step** - Challenges a claim by providing a pre-state, proving an invalid state transition. - It uses the previous state in the execution trace as input and expects the disputed claim's state as output. - There must exist a claim in the DAG that commits to the input. -- **Defense Step** - Challenges a claim by proving it was an invalid attack, - thereby defending the disputed ancestor's claim. It uses the disputed claim's state as input and expects - the next state in the execution trace as output. There must exist a claim in the DAG that commits to the - expected output. - -The FDG step handles the inputs to the VM and asserts the expected output. -A step that successfully proves an invalid post-state (when attacking) or pre-state (when defending) is a -successful counter against the disputed claim. -Players interface with `step` by providing an indicator of attack and state data (including any proofs) -that corresponds to the expected pre/post state (depending on whether it's an attack or defend). -The FDG will assert that an existing claim commits to the state data provided by players. - -### PreimageOracle Interaction - -Certain steps (VM state transitions) require external data to be available by the `PreimageOracle`. -To ensure a successful state transition, players should provide this data in advance. -The FDG provides the following interface to manage data loaded to the `PreimageOracle`: - -```solidity -/// @notice Posts the requested local data to the VM's `PreimageOracle`. -/// @param _ident The local identifier of the data to post. -/// @param _execLeafIdx The index of the leaf claim in an execution subgame that requires the local data for a step. -/// @param _partOffset The offset of the data to post. -function addLocalData(uint256 _ident, uint256 _execLeafIdx, uint256 _partOffset) external; -``` - -The `addLocalData` function loads local data into the VM's `PreimageOracle`. This data consists of bootstrap data for -the program. There are multiple sets of local preimage keys that belong to the `FaultDisputeGame` contract due to the -ability for players to bisect to any block $n \rightarrow n + 1$ state transition since the configured anchor state, the -`_execLeafIdx` parameter enables a search for the starting / disputed outputs to be performed such that the contract -can write to and reference unique local keys in the `PreimageOracle` for each of these $n \rightarrow n + 1$ -transitions. - -| Identifier | Description | -| ---------- | ------------------------------------------------------ | -| `1` | Parent L1 head hash at the time of the proposal | -| `2` | Starting output root hash (commits to block # `n`) | -| `3` | Disputed output root hash (commits to block # `n + 1`) | -| `4` | Disputed L2 block number (block # `n + 1`) | -| `5` | L2 Chain ID | - -For global `keccak256` preimages, there are two routes for players to submit: - -1. Small preimages atomically. -2. Large preimages via streaming. - -Global `keccak256` preimages are non-context specific and can be submitted directly to the `PreimageOracle` via the -`loadKeccak256PreimagePart` function, which takes the part offset as well as the full preimage. In the event that the -preimage is too large to be submitted through calldata in a single block, challengers must resort to the streaming -option. - -**Large Preimage Proposals** - -Large preimage proposals allow for submitters to stream in a large preimage over multiple transactions, along-side -commitments to the intermediate state of the `keccak256` function after absorbing/permuting the $1088$ bit block. -This data is progressively merkleized on-chain as it is streamed in, with each leaf constructed as follows: - -```solidity -/// @notice Returns a leaf hash to add to a preimage proposal merkle tree. -/// @param input A single 136 byte chunk of the input. -/// @param blockIndex The index of the block that `input` corresponds to in the full preimage's absorption. -/// @param stateCommitment The hash of the full 5x5 state matrix *after* absorbing and permuting `input`. -function hashLeaf( - bytes memory input, - uint256 blockIndex, - bytes32 stateCommitment -) internal view returns (bytes32 leaf) { - require(input.length == 136, "input must be exactly the size of the keccak256 rate"); - - leaf = keccak256(abi.encodePacked(input, blockIndex, stateCommitment)); -} -``` - -Once the full preimage and all intermediate state commitments have been posted, the large preimage proposal enters a -challenge period. During this time, a challenger can reconstruct the merkle tree that was progressively built on-chain -locally by scanning the block bodies that contain the proposer's leaf preimages. If they detect that a commitment to -the intermediate state of the hash function is incorrect at any step, they may perform a single-step dispute for the -proposal in the `PreimageOracle`. This involves: - -1. Creating a merkle proof for the agreed upon prestate leaf (not necessary if the invalid leaf is the first one, the - setup state of the matrix is constant.) within the proposal's merkle root. -2. Creating a merkle proof for the disputed post state leaf within the proposal's merkle root. -3. Computing the state matrix at the agreed upon prestate (not necessary if the invalid leaf is the first one, the - setup state of the matrix is constant.) - -The challenger then submits this data to the `PreimageOracle`, where the post state leaf's claimed input is absorbed into -the pre state leaf's state matrix and the SHA3 permutation is executed on-chain. After that, the resulting state matrix -is hashed and compared with the proposer's claim in the post state leaf. If the hash does not match, the proposal -is marked as challenged, and it may not be finalized. If, after the challenge period is concluded, a proposal has no -challenges, it may be finalized and the preimage part may be placed into the authorized mappings for the FPVM to read. - -### Team Dynamics - -Challengers seek to dispute the root claim, while Defenders aim to support it. -Both types of actors will move accordingly to support their team. For Challengers, this means -attacking the root claim and disputing claims positioned at even depths in the Game Tree. -Defenders do the opposite by disputing claims positioned at odd depths. - -Players on either team are motivated to support the actions of their teammates. -This involves countering disputes against claims made by their team (assuming these claims are honest). -Uncontested claims are likely to result in a loss, as explained later under [Resolution](#resolution). - -### Game Clock - -Every claim in the game has a Clock. A claim inherits the clock of its grandparent claim in the -DAG (and so on). Akin to a chess clock, it keeps track of the total time each team takes to make -moves, preventing delays. Making a move resumes the clock for the disputed claim and pauses it for the newly added one. - -If a move is performed, where the potential grandchild's clock has less time than `CLOCK_EXTENSION` seconds remaining, -the potential grandchild's clock is granted exactly `CLOCK_EXTENSION` seconds remaining. This is to combat the situation -where a challenger must inherit a malicious party's clock when countering a [freeloader claim](#freeloader-claims), in -order to preserve incentive compatibility for the honest party. As the extension only applies to the potential -grandchild's clock, the max possible extension for the game is bounded, and scales with the `MAX_GAME_DEPTH`. - -If the potential grandchild is an execution trace bisection root claim and their clock has less than `CLOCK_EXTENSION` -seconds remaining, exactly `CLOCK_EXTENSION * 2` seconds are allocated for the potential grandchild. This extra time -is allotted to allow for completion of the off-chain FPVM run to generate the initial instruction trace. - -A move against a particular claim is no longer possible once the parent of the disputed claim's Clock -has accumulated `MAX_CLOCK_DURATION` seconds. By which point, the claim's clock has _expired_. - -### Resolution - -Resolving the FDG determines which team won the game. To do this, we use the internal sub game structure. -Each claim within the game is the root of its own sub game. These subgames are modeled as nested DAGs, each with a max -depth of 1. In order for a claim to be considered countered, only one of its children must be uncountered. Subgames -can also not be resolved until all of their children, which are subgames themselves, have been resolved and -the potential opponent's chess clock has run out. To determine if the potential opponent's chess clock has ran out, and -therefore no more moves against the subgame are possible, the duration elapsed on the subgame root's parent clock is -added to the difference between the current time and the timestamp of the subgame root's creation. Because each claim -is the root of its own sub-game, truth percolates upwards towards the root claim by resolving each individual sub-game -bottom-up. - -In a game like the one below, we can resolve up from the deepest subgames. Here, we'd resolve `b0` -to uncountered and `a0` to countered by walking up from their deepest children, and once all children of the -root game are recursively resolved, we can resolve the root to countered due to `b0` remaining uncountered. -![Subgame resolution example](https://github.com/ethereum-optimism/optimism/assets/8406232/d2b708a0-539e-439d-96bd-c2f66f3a45f8) - -Another example is this game, which has a slightly different structure. Here, the root claim will also -be countered due to `b0` remaining uncountered. -![Subgame resolution variant](https://github.com/ethereum-optimism/optimism/assets/8406232/9b20ba8d-0b64-47b3-9962-5533f7eb4ef7) - -Given these rules, players are motivated to move quickly to challenge all dishonest claims. -Each move bisects the historical state of L2 and eventually, `MAX_GAME_DEPTH` is reached where disputes -can be settled conclusively. Dishonest players are disincentivized to participate, via backwards induction, -as an invalid claim won't remain uncontested. Further incentives can be added to the game by requiring -claims to be bonded, while rewarding game winners using the bonds of dishonest claims. - -#### Resolving the L2 Block Number Challenge - -The resolution of an L2 block number challenge occurs in the same manner as subgame resolution, with one caveat; -the L2 block number challenger, if it exist, must be the winner of a root subgame. -Thus, no moves against the root, including uncontested ones, can win a root subgame that has an L2 block number challenge. - -### Finalization - -Once the game is resolved, it must wait for the `disputeGameFinalityDelaySeconds` on the `OptimismPortal` to pass before -it can be finalized, after which bonds can be distributed via the process outlined in [Bond Incentives: Game -Finalization](bond-incentives.md#game-finalization). diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/honest-challenger-fdg.md b/docs/specs/pages/protocol/fault-proof/stage-one/honest-challenger-fdg.md deleted file mode 100644 index 595e51edbf..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/honest-challenger-fdg.md +++ /dev/null @@ -1,97 +0,0 @@ -# Honest Challenger (Fault Dispute Game) - -## Overview - -The honest challenger is an agent interacting in the [Fault Dispute Game](fault-dispute-game.md) -that supports honest claims and disputes false claims. -An honest challenger strives to ensure a correct, truthful, game resolution. -The honest challenger is also _rational_ as any deviation from its behavior will result in -negative outcomes. -This document specifies the expected behavior of an honest challenger. - -The Honest Challenger has two primary duties: - -1. Support valid root claims in Fault Dispute Games. -2. Dispute invalid root claims in Fault Dispute Games. - -The honest challenger polls the `DisputeGameFactory` contract for new and on-going Fault -Dispute Games. -For verifying the legitimacy of claims, it relies on a synced, trusted rollup node -as well as a trace provider (ex: [Cannon](../cannon-fault-proof-vm.md)). -The trace provider must be configured with the [ABSOLUTE_PRESTATE](fault-dispute-game.md#execution-trace) -of the game being interacted with to generate the traces needed to make truthful claims. - -## Invariants - -To ensure an accurate and incentive compatible fault dispute system, the honest challenger behavior must preserve -three invariants for any game: - -1. The game resolves as `DefenderWins` if the root claim is correct and `ChallengerWins` if the root claim is incorrect -2. The honest challenger is refunded the bond for every claim it posts and paid the bond of the parent of that claim -3. The honest challenger never counters its own claim - -## Fault Dispute Game Responses - -The honest challenger determines which claims to counter by iterating through the claims in the order they are stored -in the contract. This ordering ensures that a claim's ancestors are processed prior to the claim itself. For each claim, -the honest challenger determines and tracks the set of honest responses to all claims, regardless of whether that -response already exists in the full game state. - -The root claim is considered to be an honest claim if and only if it has a -[state witness Hash](fault-dispute-game.md#claims) that agrees with the honest challenger's state witness hash for the -root claim. - -The honest challenger should counter a claim if and only if: - -1. The claim is a child of a claim in the set of honest responses -2. The set of honest responses, contains a sibling to the claim with a trace index greater than or equal to the - claim's trace index - -Note that this implies the honest challenger never counters its own claim, since there is at most one honest counter to -each claim, so an honest claim never has an honest sibling. - -### Moves - -To respond to a claim with a depth in the range of `[1, MAX_DEPTH]`, the honest challenger determines if the claim -has a valid commitment. If the state witness hash matches the honest challenger's at the same trace -index, then we disagree with the claim's stance by move to [defend](fault-dispute-game.md#defend). -Otherwise, the claim is [attacked](fault-dispute-game.md#attack). - -The claim that would be added as a result of the move is added to the set of honest moves being tracked. - -If the resulting claim does not already exist in the full game state, the challenger issue the move by calling -the `FaultDisputeGame` contract. - -### Steps - -At the max depth of the game, claims represent commitments to the state of the fault proof VM -at a single instruction step interval. -Because the game can no longer bisect further, when the honest challenger counters these claims, -the only option for an honest challenger is to execute a VM step on-chain to disprove the claim at `MAX_GAME_DEPTH`. - -If the `counteredBy` of the claim being countered is non-zero, the claim has already been countered and the honest -challenger does not perform any action. - -Otherwise, similar to the above section, the honest challenger will issue an -[attack step](fault-dispute-game.md#step-types) when in response to such claims with -invalid state witness commitments. Otherwise, it issues a _defense step_. - -### Timeliness - -The honest challenger responds to claims as soon as possible to avoid the clock of its -counter-claim from expiring. - -## Resolution - -When the [chess clock](fault-dispute-game.md#game-clock) of a -[subgame root](fault-dispute-game.md#resolution) has run out, the subgame can be resolved. -The honest challenger should resolve all subgames in bottom-up order, until the subgame -rooted at the game root is resolved. - -The honest challenger accomplishes this by calling the `resolveClaim` function on the -`FaultDisputeGame` contract. Once the root claim's subgame is resolved, -the challenger then finally calls the `resolve` function to resolve the entire game. - -The `FaultDisputeGame` does not put a time cap on resolution - because of the liveness -assumption on honest challengers and the bonds attached to the claims they’ve countered, -challengers are economically incentivized to resolve the game promptly to capture the bonds. diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/index.md b/docs/specs/pages/protocol/fault-proof/stage-one/index.md deleted file mode 100644 index 07418caa4c..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/index.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# Stage One Decentralization - -[g-l2-proposal]: ../../../reference/glossary.md#l2-output-root-proposals - -This section of the specification contains the system design for stage one decentralization, with a fault-proof system -for [output proposals][g-l2-proposal] and the integration with the `OptimismPortal` contract, which is the arbiter of -withdrawals on L1. diff --git a/docs/specs/pages/protocol/fault-proof/stage-one/optimism-portal.md b/docs/specs/pages/protocol/fault-proof/stage-one/optimism-portal.md deleted file mode 100644 index ec31c61d07..0000000000 --- a/docs/specs/pages/protocol/fault-proof/stage-one/optimism-portal.md +++ /dev/null @@ -1,459 +0,0 @@ -# OptimismPortal - -## Overview - -The `OptimismPortal` contract is the primary interface for deposits and withdrawals between the L1 -and L2 chains within Base. The `OptimismPortal` contract allows users to create -"deposit transactions" on the L1 chain that are automatically executed on the L2 chain within a -bounded amount of time. Additionally, the `OptimismPortal` contract allows users to execute -withdrawal transactions by proving that such a withdrawal was initiated on the L2 chain. The -`OptimismPortal` verifies the correctness of these withdrawal transactions against Output Roots -that have been declared valid by the L1 Fault Proof system. - -## Definitions - -### Proof Maturity Delay - -The **Proof Maturity Delay** is the minimum amount of time that a withdrawal must be a -[Proven Withdrawal](#proven-withdrawal) before it can be finalized. - -### Proven Withdrawal - -A **Proven Withdrawal** is a withdrawal transaction that has been proven against some Output Root -by a user. Users can prove withdrawals against any Dispute Game contract that meets the following -conditions: - -- The game is a [Registered Game](anchor-state-registry.md#registered-game) -- The game is not a [Retired Game](anchor-state-registry.md#retired-game) -- The game has a game type that matches the current - [Respected Game Type](anchor-state-registry.md#respected-game-type) -- The game has not resolved in favor of the Challenger - -Notably, the `OptimismPortal` allows users to prove withdrawals against games that are currently -in progress (games that are not [Resolved Games](anchor-state-registry.md#resolved-game)). - -Users may re-prove a withdrawal at any time. User withdrawals are stored on a per-user basis such -that re-proving a withdrawal cannot cause the timer for -[finalizing a withdrawal](#finalized-withdrawal) to be reset for another user. - -### Finalized Withdrawal - -A **Finalized Withdrawal** is a withdrawal transaction that was previously a Proven Withdrawal and -meets a number of additional conditions that allow the withdrawal to be executed. - -Users can finalize a withdrawal if they have previously proven the withdrawal and their withdrawal -meets the following conditions: - -- Withdrawal is a [Proven Withdrawal](#proven-withdrawal) -- Withdrawal was proven at least [Proof Maturity Delay](#proof-maturity-delay) seconds ago -- Withdrawal was proven against a game with a [Valid Claim](anchor-state-registry.md#valid-claim) -- Withdrawal was not previously finalized - -### Valid Withdrawal - -A **Valid Withdrawal** is a withdrawal transaction that was correctly executed on the L2 system as -would be reported by a perfect oracle for the query. - -### Invalid Withdrawal - -An **Invalid Withdrawal** is any withdrawal that is not a [Valid Withdrawal](#valid-withdrawal). - -### L2 Withdrawal Sender - -The **L2 Withdrawal Sender** is the address of the account that triggered a given withdrawal -transaction on L2. The `OptimismPortal` is expected to expose a variable that includes this value -when [finalizing](#finalized-withdrawal) a withdrawal. - -### Receive Default Gas Limit - -The receive default gas limit is the gas limit provided for simple ETH deposits that are triggered -when a user sends ETH to the `OptimismPortal` via the `receive` function. This gas limit is -currently set to a value of 100000 gas. - -### Minimum Gas Limit - -The minimum gas limit is the minimum amount of L2 gas that must be purchased when creating a -deposit transaction. This limit increases linearly based on the size of the calldata to prevent -users from creating L2 resource usage without paying for it. The minimum gas limit is calculated -as: calldata_byte_count * 40 + 21000. - -### Unsafe Target - -An **Unsafe Target** is a target address that is considered unsafe for withdrawal or deposit -transactions. Unsafe targets include the OptimismPortal contract itself and the ETHLockbox -contract. Targeting these addresses could potentially create attack vectors. - -### Block Output - -A **Block Output**, commonly called an **Output**, is a data structure that wraps the key hash -elements of a given L2 block. - -The structure of the Block Output is versioned (32 bytes). The current Block Output version is -`0x0000000000000000000000000000000000000000000000000000000000000000` (V0). A V0 Block Output has -the following structure: - -```solidity -struct BlockOutput { - bytes32 version; - bytes32 stateRoot; - bytes32 messagePasserStorageRoot; - bytes32 blockHash; -} -``` - -Where: - -- `version` is a version identifier that describes the structure of the Output Root -- `stateRoot` is the state root of the L2 block this Output Root corresponds to -- `messagePasserStorageRoot` is the storage root of the `L2ToL1MessagePasser` contract at the L2 - block this Output Root corresponds to -- `blockHash` is the block hash of the L2 block this Output Root corresponds to - -### Output Root - -An **Output Root** is a commitment to a [Block Output](#block-output). A detailed description of -this commitment can be found [on this page](../proposer.md#l2-output-commitment-construction). - -### Super Output - -A **Super Output** is a data structure that commits all of the [Block Outputs](#block-output) for -all chains within the Superchain Interop Set at a given timestamp. A Super Output can also commit -to a single Block Output to maintain compatibility with chains outside of the Interop Set. - -The structure of the Super Output is versioned (1 byte). The current version is `0x01` (V1). A V1 -Super Output has the following structure: - -```solidity -struct OutputRootWithChainId { - uint256 chainId; - bytes32 root; -} - -struct SuperOutput { - uint64 timestamp; - OutputRootWithChainid[] outputRoots; -} -``` - -The output root for each chain in the super root MUST be for the block with a timestamp where `Time_B` is strictly -greater than `Time_S - BlockTime` and less than or equal to `Time_S`, where `Time_S` is the super root timestamp, -`BlockTime` is the chain block time, and `Time_B` is the block timestamp. That is, the output root must be from the -last possible block at or before the super root timestamp. - -The output roots in the super root MUST be sorted by chain ID ascending. - -### Super Root - -A **Super Root** is a commitment to a [Super Output](#super-output), computed as: - -```solidity -keccak256(encodeSuperRoot(SuperRoot)) -``` - -Where `encodeSuperRoot` for the V1 Super Output is: - -```solidity -function encodeSuperRoot(SuperRoot memory root) returns (bytes) { - require(root.outputRoots.length > 0); // Super Root must have at least one Output Root. - return concat( - 0x01, // Super Root version byte - root.timestamp, - [ - concat(outputRoot.chainId, outputRoot.root) - for outputRoot - in root.outputRoots - ] - ); -} -``` - -## Assumptions - -### aOP-001: Dispute Game contracts properly report important properties - -We assume that the `FaultDisputeGame` and `PermissionedDisputeGame` contracts properly and -faithfully report the following properties: - -- Game type -- L2 block number -- Root claim value -- Game extra data -- Creation timestamp -- Resolution timestamp -- Resolution result -- Whether the game was the respected game type at creation - -We also specifically assume that the game creation timestamp and the resolution timestamp are not -set to values in the future. - -#### Mitigations - -- Existing audit on the `FaultDisputeGame` contract -- Integration testing - -### aOP-002: DisputeGameFactory properly reports its created games - -We assume that the `DisputeGameFactory` contract properly and faithfully reports the games it has -created. - -#### Mitigations - -- Existing audit on the `DisputeGameFactory` contract -- Integration testing - -### aOP-003: Incorrectly resolving games will be invalidated before they have Valid Claims - -We assume that any games that are resolved incorrectly will be invalidated either by -[blacklisting](anchor-state-registry.md#blacklisted-game) or by -[retirement](anchor-state-registry.md#retired-game) BEFORE they are considered to have -[Valid Claims](anchor-state-registry.md#valid-claim). - -Proper Games that resolve in favor the Defender will be considered to have Valid Claims after the -[Dispute Game Finality Delay](anchor-state-registry.md#dispute-game-finality-delay-airgap) has -elapsed UNLESS the Pause Mechanism is active. Therefore, in the absence of the Pause Mechanism, -parties responsible for game invalidation have exactly the Dispute Game Finality Delay to -invalidate a withdrawal after it resolves incorrectly. If the Pause Mechanism is active, then any -incorrectly resolving games must be invalidated before the pause is deactivated. - -#### Mitigations - -- Stakeholder incentives / processes -- Incident response plan -- Monitoring - -## Dependencies - -- [iASR-001](anchor-state-registry.md#iasr-001-games-are-represented-as-proper-games-accurately) -- [iASR-002](anchor-state-registry.md#iasr-002-all-valid-claims-are-truly-valid-claims) - -## Invariants - -### iOP-001: Invalid Withdrawals can never be finalized - -We require that [Invalid Withdrawals](#invalid-withdrawal) can never be -[finalized](#finalized-withdrawal) for any reason. - -#### Impact - -**Severity: Critical** - -If this invariant is broken, any number of arbitrarily bad outcomes could happen. Most obviously, -we would expect all bridge systems relying on the `OptimismPortal` to be immediately compromised. - -### iOP-002: Valid Withdrawals can always be finalized in bounded time - -We require that [Valid Withdrawals](#valid-withdrawal) can always be -[finalized](#finalized-withdrawal) within some reasonable, bounded amount of time. - -#### Impact - -**Severity: Critical** - -If this invariant is broken, we would expect that users are unable to withdraw bridged assets. We -see this as a critical system risk. - -## Function Specification - -### constructor - -- MUST set the value of the [Proof Maturity Delay](#proof-maturity-delay). - -### initialize - -- MUST only be callable by the ProxyAdmin or its owner. -- MUST set the value of the `SystemConfig` contract. -- MUST set the value of the `AnchorStateRegistry` contract. -- MUST assert that the ETHLockbox state is valid based on the feature flag. -- MUST set the value of the [L2 Withdrawal Sender](#l2-withdrawal-sender) variable to the default - value if the value is not set already. -- MUST initialize the resource metering configuration. - -### paused - -Returns the current state of the `SystemConfig.paused()` function. - -### guardian - -Returns the address of the Guardian as per `SystemConfig.guardian()`. - -### ethLockbox - -Returns the address of the ETHLockbox configured for this contract. If the contract has not been -configured for this OptimismPortal, this function will return `address(0)`. - -### proofMaturityDelaySeconds - -Returns the value of the [Proof Maturity Delay](#proof-maturity-delay). - -### disputeGameFactory - -Returns the DisputeGameFactory contract from the AnchorStateRegistry contract. - -### disputeGameFinalityDelaySeconds - -**Legacy Function** - -Returns the value of the -[Dispute Game Finality Delay](anchor-state-registry.md#dispute-game-finality-delay-airgap) as per -a call to `AnchorStateRegistry.disputeGameFinalityDelaySeconds()`. - -### respectedGameType - -**Legacy Function** - -Returns the value of the current -[Respected Game Type](anchor-state-registry.md#respected-game-type) as per a call to -`AnchorStateRegistry.respectedGameType`. - -### respectedGameTypeUpdatedAt - -**Legacy Function** - -Returns the value of the current -[Retirement Timestamp](anchor-state-registry.md#retirement-timestamp) as per a call to -`AnchorStateRegistry.retirementTimestamp. - -### l2Sender - -Returns the address of the [L2 Withdrawal Sender](#l2-withdrawal-sender). If the `OptimismPortal` -has not been initialized then this value will be `address(0)` and should not be used. If the -`OptimismPortal` is not currently executing an withdrawal transaction then this value will be -`0x000000000000000000000000000000000000dEaD` and should not be used. - -### proveWithdrawalTransaction - -Allows a user to [prove](#proven-withdrawal) a withdrawal transaction. - -- MUST revert if the system is paused. -- MUST revert if the withdrawal target is an [Unsafe Target](#unsafe-target). -- MUST revert if the withdrawal is being proven against a game that is not a - [Proper Game](anchor-state-registry.md#proper-game). -- MUST revert if the withdrawal is being proven against a game that is not a - [Respected Game](anchor-state-registry.md#respected-game). -- MUST revert if the withdrawal is being proven against a game that has resolved in favor of the - Challenger. -- MUST revert if the current timestamp is less than or equal to the dispute game's creation - timestamp. -- MUST revert if the proof provided by the user of the preimage of the Output Root that the dispute - game argues about is invalid. This proof is verified by hashing the user-provided preimage and - comparing them to the root claim of the referenced dispute game. -- MUST revert if the provided merkle trie proof that the withdrawal was included within the root - claim of the provided dispute game is invalid. -- MUST otherwise store a record of the withdrawal proof that includes the hash of the proven - withdrawal, the address of the game against which it was proven, and the block timestamp at which - the proof transaction was submitted. -- MUST add the proof submitter to the list of submitters for this withdrawal hash. -- MUST emit a `WithdrawalProven` event with the withdrawal hash, sender, and target. -- MUST emit a `WithdrawalProvenExtension1` event with the withdrawal hash and proof submitter address. - -### checkWithdrawal - -Checks that a withdrawal transaction can be [finalized](#finalized-withdrawal). - -- MUST revert if the withdrawal being finalized has already been finalized. -- MUST revert if the withdrawal being finalized has not been proven. -- MUST revert if the withdrawal was proven at a timestamp less than or equal to the creation - timestamp of the dispute game it was proven against, which would signal an unexpected proving - bug. Note that prevents withdrawals from being proven in the same block that a dispute game is - created. -- MUST revert if the withdrawal being finalized has been proven less than - [Proof Maturity Delay](#proof-maturity-delay) seconds ago. -- MUST revert if the withdrawal being finalized was proven against a game that does not have a - [Valid Claim](anchor-state-registry.md#valid-claim). - -### finalizeWithdrawalTransaction - -Allows a user to [finalize](#finalized-withdrawal) a withdrawal transaction. - -- MUST delegate to `finalizeWithdrawalTransactionExternalProof` with `msg.sender` as the proof - submitter. - -### donateETH - -Allows any address to donate ETH to the contract without triggering a deposit to L2. - -- MUST accept ETH payments via the payable modifier. -- MUST not perform any state-changing operations. -- MUST not trigger a deposit transaction to L2. - -### finalizeWithdrawalTransactionExternalProof - -Allows a user to [finalize](#finalized-withdrawal) a withdrawal transaction using a proof submitted -by another address. - -- MUST revert if the system is paused. -- MUST revert if the function is called while a previous withdrawal is being executed. -- MUST revert if the withdrawal target is an [Unsafe Target](#unsafe-target). -- MUST revert if the withdrawal being finalized does not pass `checkWithdrawal`. -- MUST mark the withdrawal as finalized. -- MUST unlock ETH from the ETHLockbox if the withdrawal includes an ETH value AND the OptimismPortal - has an ETHLockbox configured AND the ETHLockbox system feature is active. -- MUST set the L2 Withdrawal Sender variable correctly. -- MUST execute the withdrawal transaction by executing a contract call to the target address with - the data and ETH value specified within the withdrawal using AT LEAST the minimum amount of gas - specified by the withdrawal. -- MUST unset the L2 Withdrawal Sender after the withdrawal call. -- MUST emit a `WithdrawalFinalized` event with the withdrawal hash and success status. -- MUST lock any unused ETH back into the ETHLockbox if the call to the target address fails AND the - OptimismPortal has an ETHLockbox configured AND the ETHLockbox system feature is active. -- MUST revert if the withdrawal call fails and the transaction origin is the estimation address, to - help determine exact gas costs. - -### numProofSubmitters - -Returns the number of proof submitters for a given withdrawal hash. - -- MUST return the length of the proofSubmitters array for the specified withdrawal hash. -- MUST NOT change state. - -### receive - -Accepts ETH value and creates a deposit transaction to the sender's address on L2. - -- MUST be payable and accept ETH. -- MUST create a deposit transaction where the sender and target are the same address, refer to - [depositTransaction](#deposittransaction) for full specification of expected behavior. -- MUST use the [receive default gas limit](#receive-default-gas-limit) as the gas limit. -- MUST set contract creation flag to false. -- MUST use empty data for the deposit. -- MUST transform the sender address to its alias if the caller is a contract. -- MUST emit a TransactionDeposited event with the appropriate parameters. - -### minimumGasLimit - -Computes the minimum gas limit for a deposit transaction based on calldata size. - -- MUST calculate the minimum gas limit using the formula: calldata_byte_count * 40 + 21000. - -### superchainConfig - -Returns the `SuperchainConfig` contract address. - -- MUST return the address of the `SuperchainConfig` contract stored in the `SystemConfig` contract - that was set during initialization. - -### disputeGameBlacklist - -**Legacy Function** - -Checks if a dispute game is blacklisted. - -- MUST delegate to the blacklist of the `AnchorStateRegistry` contract that was set during initialization. -- MUST return whether the given dispute game is blacklisted. - -### depositTransaction - -Accepts deposits of ETH and data, and emits a TransactionDeposited event for use in -deriving deposit transactions. Note that if a deposit is made by a contract, its -address will be aliased when retrieved using `tx.origin` or `msg.sender`. Consider -using the CrossDomainMessenger contracts for a simpler developer experience. - -- MUST lock any ETH value (msg.value) in the ETHLockbox contract if the OptimismPortal has an - ETHLockbox configured AND the ETHLockbox system feature is active. -- MUST revert if the target address is not address(0) for contract creations. -- MUST revert if the gas limit provided is below the [minimum gas limit](#minimum-gas-limit). -- MUST revert if the calldata is too large (> 120,000 bytes). -- MUST transform the sender address to its alias if the caller is a contract. -- MUST apply resource metering to the gas limit parameter. -- MUST emit a TransactionDeposited event with the from address, to address, deposit version, and - opaque data. diff --git a/docs/specs/pages/protocol/overview.md b/docs/specs/pages/protocol/overview.md deleted file mode 100644 index 7b95651634..0000000000 --- a/docs/specs/pages/protocol/overview.md +++ /dev/null @@ -1,343 +0,0 @@ -# Overview - -Base is a rollup built on Ethereum. L2 transaction data is posted to Ethereum for data availability, -and proofs allow anyone to challenge invalid state transitions. This page gives a high-level tour of the -protocol components and the core user flows. - -## Network Participants - -There are three primary actors that interact with Base: users, sequencers, and validators. - -```mermaid -graph TD - EthereumL1(Ethereum L1) - - subgraph "L2 Participants" - Users(Users) - Sequencers(Sequencers) - Validators(Validators) - end - - Validators -.->|fetch transaction batches| EthereumL1 - Validators -.->|fetch deposit data| EthereumL1 - Validators -->|submit/validate/challenge output proposals| EthereumL1 - Validators -.->|fetch realtime P2P updates| Sequencers - - Users -->|submit deposits/withdrawals| EthereumL1 - Users -->|submit transactions| Sequencers - Users -->|query data| Validators - - Sequencers -->|submit transaction batches| EthereumL1 - Sequencers -.->|fetch deposit data| EthereumL1 - - classDef l1Contracts stroke:#bbf,stroke-width:2px; - classDef l2Components stroke:#333,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - - class EthereumL1 l1Contracts; - class Users,Sequencers,Validators l2Components; -``` - -### Users - -Users are the general class of network participants who: - -- Submit transactions through the sequencer or by interacting with contracts on Ethereum. -- Query transaction data from interfaces operated by validators. - -### Sequencers - -The sequencer fills the role of block producer on Base. Base currently operates with a single active sequencer. - -The Sequencer: - -- Accepts transactions directly from Users. -- Observes "deposit" transactions generated on Ethereum. -- Consolidates both transaction streams into ordered L2 blocks. -- Submits information to L1 that is sufficient to fully reproduce those L2 blocks. -- Provides real-time access to pending L2 blocks that have not yet been confirmed on L1. -- Produces Flashblocks every 200ms, committing to the ordering of transactions within the block as it is being built. - -The Sequencer serves an important role for the operation of an L2 chain but is not a trusted actor. The Sequencer is generally -responsible for improving the user experience by ordering transactions much more quickly and cheaply than would currently -be possible if users were to submit all transactions directly to L1. - -### Validators - -Validators execute the L2 state transition function independently of the Sequencer. Validators help to maintain -the integrity of the network and serve blockchain data to Users. - -Validators generally: - -- Sync rollup data from L1 and the Sequencer. -- Use rollup data to execute the L2 state transition function. -- Serve rollup data and computed L2 state information to Users. - -Validators can also act as Proposers and/or Challengers who: - -- Submit assertions about the state of the L2 to a smart contract on L1. -- Validate assertions made by other participants. -- Dispute invalid assertions made by other participants. - -## High-Level System Diagram - -The following diagram shows how the major protocol components interact across L1 and L2. - -```mermaid -graph LR - subgraph "Ethereum L1" - OptimismPortal(OptimismPortal) - BatchInbox(Batch Inbox Address) - DisputeGameFactory(DisputeGameFactory) - end - - subgraph "L2 Node" - RollupNode(Consensus) - ExecutionEngine(Execution Engine) - end - - Batcher(Batcher) - Proposers(Proposers) - Challengers(Challengers) - Users(Users) - - Users -->|deposits / withdrawals| OptimismPortal - Users -->|transactions| ExecutionEngine - - Batcher -->|post transaction batches| BatchInbox - Batcher -.->|fetch batch data| RollupNode - - RollupNode -.->|fetch batches| BatchInbox - RollupNode -.->|fetch deposit events| OptimismPortal - RollupNode -->|Engine API| ExecutionEngine - - Proposers -->|submit output proposals| DisputeGameFactory - Proposers -.->|fetch outputs| RollupNode - Challengers -->|verify / challenge games| DisputeGameFactory - OptimismPortal -.->|query state proposals| DisputeGameFactory - - classDef l1Contracts stroke:#bbf,stroke-width:2px; - classDef l2Components stroke:#333,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - - class OptimismPortal,BatchInbox,DisputeGameFactory l1Contracts; - class RollupNode,ExecutionEngine l2Components; - class Batcher,Proposers,Challengers,Users systemUser; -``` - -## Protocol Components - -### Consensus - -Consensus is responsible for deriving the canonical L2 chain from L1 data. It reads transaction batches -from the Batch Inbox and deposit events from OptimismPortal, constructs payload attributes, and drives the -execution engine via the Engine API. Unsafe (unconfirmed) blocks are gossiped to other nodes over a dedicated -P2P network to give validators low-latency access before batches land on L1. - -[Consensus →](./consensus/) - -```mermaid -graph LR - L1(Ethereum L1) - subgraph "Rollup Node" - BatchDecoding(Batch Decoding) - Derivation(Derivation Pipeline) - end - EngineAPI(Engine API) - EE(Execution Engine) - L2(L2 Blocks) - - L1 -->|batches + deposit events| BatchDecoding - BatchDecoding --> Derivation - Derivation -->|payload attributes| EngineAPI - EngineAPI --> EE - EE --> L2 - - classDef l1 stroke:#bbf,stroke-width:2px; - classDef l2 stroke:#333,stroke-width:2px; - class L1 l1; - class EE,L2 l2; -``` - -### Execution - -The execution engine is a Reth-based runtime. It exposes the standard Ethereum JSON-RPC API and -processes blocks produced by consensus. Predeploys (system contracts at fixed L2 addresses), precompiles, -and preinstalls extend the EVM for rollup-specific functionality such as fee distribution, L1 block attribute -injection, and cross-domain messaging. - -[Execution →](./execution/) - -### Bridging - -Deposits flow from the `OptimismPortal` contract on L1 into L2 as special deposit transactions included at the -start of each L2 block. Withdrawals flow in the opposite direction: a withdrawal transaction is initiated on L2, -a proposer submits an output root to `DisputeGameFactory`, and after the challenge period the user proves and -finalizes the withdrawal on L1 via `OptimismPortal`. - -[Bridging →](./bridging/deposits) - -```mermaid -graph LR - subgraph "Deposit Path" - User1(User) - OP1(OptimismPortal) - DepTx(Deposit Transaction on L2) - end - - subgraph "Withdrawal Path" - User2(User) - WdTx(Withdrawal Tx on L2) - DGF(DisputeGameFactory) - OP2(OptimismPortal) - end - - User1 -->|depositTransaction| OP1 - OP1 -->|TransactionDeposited event| DepTx - - User2 -->|initiates withdrawal| WdTx - WdTx -->|output root proposed| DGF - User2 -->|prove + finalize| OP2 - OP2 -.->|verify game| DGF - - classDef l1 stroke:#bbf,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - class OP1,OP2,DGF l1; - class User1,User2 systemUser; -``` - -### Batcher - -The batcher is a service run by the sequencer that compresses L2 transaction data into channel frames and posts -them as calldata (or blobs) to the Batch Inbox Address on L1. This is the data availability layer that allows -any validator to independently reconstruct the L2 chain from L1. - -[Batcher →](./batcher) - -```mermaid -graph LR - Sequencer(Sequencer) - Batcher(Batcher) - BatchInbox(Batch Inbox Address) - RollupNode(Rollup Node) - - Sequencer -->|L2 blocks| Batcher - Batcher -->|compressed channel frames| BatchInbox - BatchInbox -.->|fetch batches| RollupNode - - classDef l1 stroke:#bbf,stroke-width:2px; - classDef l2 stroke:#333,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - class BatchInbox l1; - class RollupNode l2; - class Batcher,Sequencer systemUser; -``` - -### Proofs - -Output proposals and proofs allow permissionless verification of the L2 state. Anyone can propose an -output root to the `DisputeGameFactory`, and anyone can challenge it. Disputes are resolved by the `FaultDisputeGame` -contract using the Cannon VM for on-chain execution tracing of disputed state transitions. Valid withdrawals can -only be finalized through `OptimismPortal` once the associated dispute game resolves in favor of the proposer. - -[Proofs →](./fault-proof/) - -```mermaid -graph LR - Proposer(Proposer) - DGF(DisputeGameFactory) - FDG(FaultDisputeGame) - Challengers(Challengers) - Portal(OptimismPortal) - - Proposer -->|submit output root| DGF - DGF -->|create game| FDG - Challengers -->|challenge / defend| FDG - FDG -->|resolved result| Portal - - classDef l1 stroke:#bbf,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - class DGF,FDG,Portal l1; - class Proposer,Challengers systemUser; -``` - -## Core User Flows - -### Depositing ETH to Base - -Users will often begin their L2 journey by depositing ETH from L1. -Once they have ETH to pay fees, they'll start sending transactions on L2. -The following diagram demonstrates this interaction and key Base protocol components. - -```mermaid -graph TD - subgraph "Ethereum L1" - OptimismPortal(OptimismPortal) - BatchInbox(Batch Inbox Address) - end - - Sequencer(Sequencer) - Users(Users) - - %% Interactions - Users -->|1. submit deposit| OptimismPortal - Sequencer -.->|2. fetch deposit events| OptimismPortal - Sequencer -->|3. generate deposit block| Sequencer - Users -->|4. send transactions| Sequencer - Sequencer -->|5. submit transaction batches| BatchInbox - - classDef l1Contracts stroke:#bbf,stroke-width:2px; - classDef l2Components stroke:#333,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - - class OptimismPortal,BatchInbox l1Contracts; - class Sequencer l2Components; - class Users systemUser; -``` - -### Sending Transactions on Base - -Sending transactions on Base works the same as on Ethereum. Users sign transactions and submit them via -`eth_sendRawTransaction` to any node's JSON-RPC endpoint. The sequencer picks them up from its mempool, -orders them into L2 blocks, and eventually posts the batch to L1. - -### Withdrawing from Base - -Users may also want to withdraw ETH or ERC20 tokens from Base back to Ethereum. Withdrawals are initiated -as standard transactions on L2 but are then completed using transactions on L1. Withdrawals must reference a valid -`FaultDisputeGame` contract that proposes the state of the L2 at a given point in time. - -```mermaid -graph LR - subgraph "Ethereum L1" - BatchInbox(Batch Inbox Address) - DisputeGameFactory(DisputeGameFactory) - FaultDisputeGame(FaultDisputeGame) - OptimismPortal(OptimismPortal) - ExternalContracts(External Contracts) - end - - Sequencer(Sequencer) - Proposers(Proposers) - Users(Users) - - %% Interactions - Users -->|1. send withdrawal initialization txn| Sequencer - Sequencer -->|2. submit transaction batch| BatchInbox - Proposers -->|3. submit output proposal| DisputeGameFactory - DisputeGameFactory -->|4. generate game| FaultDisputeGame - Users -->|5. submit withdrawal proof| OptimismPortal - Users -->|6. wait for finalization| FaultDisputeGame - Users -->|7. submit withdrawal finalization| OptimismPortal - OptimismPortal -->|8. check game validity| FaultDisputeGame - OptimismPortal -->|9. execute withdrawal transaction| ExternalContracts - - %% Styling - classDef l1Contracts stroke:#bbf,stroke-width:2px; - classDef l2Components stroke:#333,stroke-width:2px; - classDef systemUser stroke:#f9a,stroke-width:2px; - - class BatchInbox,DisputeGameFactory,FaultDisputeGame,OptimismPortal l1Contracts; - class Sequencer l2Components; - class Users,Proposers systemUser; -``` diff --git a/docs/specs/pages/protocol/proofs/challenger.md b/docs/specs/pages/protocol/proofs/challenger.md deleted file mode 100644 index 66b550b2ee..0000000000 --- a/docs/specs/pages/protocol/proofs/challenger.md +++ /dev/null @@ -1,265 +0,0 @@ -# Challenger - -The challenger is an offchain service that protects the proof system by independently checking -in-progress `AggregateVerifier` games against canonical L2 state. When it finds an invalid -checkpoint root, it obtains the proof material required by the game contract and submits a dispute -transaction on L1. - -The challenger is permissionless in the ZK path: any operator with access to canonical L1 and L2 -RPCs, a ZK proving service, and an L1 transaction signer can run it. Base may also run a challenger -with access to a TEE proof endpoint so invalid TEE-backed games can be nullified on a faster path -before falling back to ZK. - -## Responsibilities - -A conforming challenger performs the following work: - -1. Scan recent `DisputeGameFactory` games. -2. Select games that are still `IN_PROGRESS` and have proof state that may require action. -3. Recompute the relevant checkpoint output roots from an L2 node. -4. Identify the first invalid checkpoint root, or determine whether a ZK challenge targeted a valid - checkpoint. -5. Source a TEE or ZK proof for the checkpoint interval that must be proven. -6. Submit `nullify()` or `challenge()` to the game contract. -7. Track the resulting bond lifecycle when configured to claim bonds. - -The challenger does not decide canonical L2 state by trusting the game. It recomputes roots from -L2 headers and account proofs and treats the game as an input to be checked. - -## Game Selection - -The challenger reads the current `AnchorStateRegistry.anchorGame()`, locates that game in the -factory index array, and scans every later factory index. If the registry is still at the starting -anchor, or if the anchor game cannot be found in the factory, scanning starts at index `0`. Games -observed `IN_PROGRESS` remain tracked until they resolve or are fully nullified, so metrics reflect -the live post-anchor set. Each scan re-evaluates the full post-anchor range so games can move -between categories as new proofs, challenges, or nullifications are posted onchain. Individual game -query failures are logged and retried on the next scan; they do not abort the full scan. - -A game is selected only when `status() == IN_PROGRESS`. The challenger then reads: - -- `teeProver()` -- `zkProver()` -- `counteredByIntermediateRootIndexPlusOne()` -- `rootClaim()` -- `l2SequenceNumber()` -- `startingBlockNumber()` -- `l1Head()` -- `INTERMEDIATE_BLOCK_INTERVAL()` from the game implementation for the game type - -The `(teeProver, zkProver, countered index)` tuple determines the candidate category. - -| TEE prover | ZK prover | Countered index | Category | Challenger action | -| ---------- | --------- | --------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | -| non-zero | zero | `0` | Invalid TEE proposal | Validate all checkpoint roots. If invalid, prefer TEE nullification and fall back to ZK `challenge()`. | -| non-zero | non-zero | `> 0` | Fraudulent ZK challenge | Validate only the challenged checkpoint. If the challenged root is correct, submit ZK `nullify()`. | -| zero | non-zero | `0` | Invalid ZK proposal | Validate all checkpoint roots. If invalid, submit ZK `nullify()`. | -| non-zero | non-zero | `0` | Invalid dual proposal | Validate all checkpoint roots. If invalid, nullify the TEE proof first, then rescan to handle the remaining ZK proof. | - -Games with both prover addresses set to zero are already fully nullified and are skipped. TEE-only or -ZK-only games with a non-zero countered index are unexpected states and are skipped. - -## Output Root Validation - -For an unchallenged proposal, the challenger validates the submitted intermediate roots. For index -`i`, the checkpoint block is: - -```text -startingBlockNumber + INTERMEDIATE_BLOCK_INTERVAL * (i + 1) -``` - -The number of submitted roots must equal: - -```text -(l2SequenceNumber - startingBlockNumber) / INTERMEDIATE_BLOCK_INTERVAL -``` - -The interval must be non-zero, and the starting block must be lower than the proposed L2 sequence -number. Arithmetic overflow and checkpoint-count mismatches make validation fail for that scan tick. - -For each checkpoint block, the challenger computes the expected output root as follows: - -1. Fetch the L2 block header by block number. -2. Verify that the RPC-provided header hash equals the hash computed from the consensus header. -3. Fetch an `eth_getProof` account proof for `L2ToL1MessagePasser` at that block hash. -4. Verify the account proof against the header state root. -5. Build the output root from the L2 state root, `L2ToL1MessagePasser` storage root, and L2 block - hash. -6. Compare the computed root to the root stored in the game. - -Intermediate roots are validated concurrently, but results are consumed in checkpoint order. The -first mismatch determines the `intermediateRootIndex` and `intermediateRootToProve` used in the -dispute transaction. `intermediateRootToProve` is the locally computed correct root for the invalid -checkpoint. - -When the requested L2 block is not yet available, the challenger skips the game for that scan tick. -The game remains eligible and will be retried on the next scan. - -## Fraudulent ZK Challenge Validation - -When a TEE proposal has been challenged by a ZK proof, the game stores a 1-based countered index. -The challenger converts it to a 0-based checkpoint index and validates only that checkpoint. - -If the onchain root at the challenged index does not match the locally computed root, the ZK -challenge was legitimate and the challenger takes no action. If the onchain root matches the local -root, the ZK challenge targeted a correct checkpoint and is fraudulent. The challenger then obtains -a ZK proof for that checkpoint interval and submits `nullify()`. - -This validation is intentionally local to the challenged index. Earlier invalid roots do not make a -challenge against a later valid root legitimate. - -## Proof Sourcing - -The challenger proves only the interval that contains the invalid checkpoint. The trusted anchor is -the prior checkpoint root, or the game's `startingBlockNumber` state when the invalid checkpoint is -index `0`. - -For a ZK proof request: - -- `start_block_number` is the start of the invalid checkpoint interval. -- `number_of_blocks_to_prove` is `INTERMEDIATE_BLOCK_INTERVAL`. -- `proof_type` is Groth16 SNARK. -- `session_id` is deterministic from `(game address, invalid checkpoint index)`. -- `prover_address` is the L1 address that will submit the transaction. -- `l1_head` is the L1 head hash stored in the game at creation. - -The deterministic session ID makes proof requests idempotent across retries. - -When TEE proof sourcing is configured and the game has a TEE prover, the challenger tries the TEE -path first for invalid TEE and invalid dual proposals. The TEE request uses the game `l1Head`, the -corresponding L1 block number, the locally computed agreed L2 output at the start of the interval, -and the expected output root at the invalid checkpoint. The challenger accepts the TEE result only -if the enclave output root equals the locally computed expected root, then encodes the TEE dispute -proof bytes for `nullify()`. - -If the TEE request fails or times out, the challenger falls back to ZK. If a TEE proof is obtained -but the TEE `nullify()` transaction fails, the pending entry transitions to a ZK proof request -instead of retrying the same TEE transaction indefinitely. - -## Dispute Transactions - -The challenger submits one of two game calls: - -| Intent | Contract call | Used when | -| --------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| Nullify | `nullify(proofBytes, intermediateRootIndex, intermediateRootToProve)` | Removing an invalid TEE proof, removing an invalid ZK proof, or refuting a fraudulent ZK challenge. | -| Challenge | `challenge(proofBytes, intermediateRootIndex, intermediateRootToProve)` | Challenging an invalid TEE proposal with a ZK proof. | - -TEE proofs always target `nullify()`. ZK proofs can target either `challenge()` or `nullify()` -depending on the candidate category. - -Before submitting or retrying a failed proof, the challenger rechecks the game status and prover -slots. If the game has already resolved, has already been challenged, or the targeted prover slot -has already been zeroed, the pending proof is dropped. This prevents duplicate transactions when -another actor has already handled the game. - -## Pending Proof Lifecycle - -Each pending proof is keyed by game address and tracks: - -- proof kind: TEE or ZK -- invalid checkpoint index -- expected root for that checkpoint -- dispute intent -- retry count -- phase - -The phase machine is: - -```mermaid -flowchart TB - ZkStart([ZK job accepted]) --> AwaitingProof[AwaitingProof] - TeeStart([TEE proof ready]) --> ReadyToSubmit[ReadyToSubmit] - - AwaitingProof -->|ZK success| ReadyToSubmit - AwaitingProof -->|ZK failed| NeedsRetry[NeedsRetry] - - NeedsRetry -->|retry accepted| AwaitingProof - NeedsRetry -->|exhausted/no fallback| Dropped[Dropped] - - ReadyToSubmit -->|submitted/stale| Dropped - ReadyToSubmit -->|ZK fallback| AwaitingProof -``` - -ZK proofs are polled from the proving service until the job succeeds, fails, or remains pending. -Successful ZK receipts are prefixed with the ZK proof-type byte before submission. Failed proof jobs -are retried up to three times. A TEE proof enters `ReadyToSubmit` immediately after it is obtained; -if its transaction fails, the challenger immediately requests the pre-built ZK fallback proof when -one is available. If no fallback request exists, the entry is dropped; if the fallback `prove_block` -call fails, the entry remains in `NeedsRetry` until the next tick. A proof that remains pending, a -failed ZK transaction, or a failed `prove_block` retry leaves the proof in its current phase until -the next tick. A pending proof causes no contract reads for that game on that tick. - -## Bond Claiming - -Bond claiming is optional and is enabled by configuring claim addresses. When enabled, the challenger -tracks games whose `bondRecipient()` or pre-resolution `zkProver()` matches one of those addresses. -This allows a challenger to recover claimable games after restart and to discover games handled by -other actors. - -The bond lifecycle is: - -1. `NeedsResolve`: wait for `gameOver()`, then submit `resolve()`. -2. `NeedsUnlock`: submit the first `claimCredit()` to unlock the `DelayedWETH` credit. -3. `AwaitingDelay`: wait for the `DelayedWETH` delay. -4. `NeedsWithdraw`: submit the second `claimCredit()` to withdraw the credit. - -After resolution, the challenger re-reads `bondRecipient()` and stops tracking the game if the bond -is no longer claimable by a configured address. For games that resolve as `DEFENDER_WINS`, it also -attempts a best-effort `AnchorStateRegistry.setAnchorState(game)` update. The registry call is -permissionless and self-validating; premature or ineligible calls can revert and be retried. - -## Service Lifecycle - -At startup, the challenger: - -1. Creates L1 and L2 RPC clients. -2. Creates the L1 transaction manager from the configured signer. -3. Creates `DisputeGameFactory` and `AggregateVerifier` clients. -4. Creates the ZK proof client and optional TEE proof client. -5. Starts the health server. -6. Starts the driver loop. - -Each driver tick: - -1. Polls pending proof sessions and submits ready disputes. -2. Discovers claimable bonds and advances tracked bond claims. -3. Scans for in-progress candidate games. -4. Validates and initiates proofs for new candidates. - -The health endpoint reports ready only after the first successful driver step. Shutdown is driven by -a cancellation token so the driver and health server stop together. - -## Operator Inputs - -A challenger needs: - -- L1 RPC endpoint. -- L2 execution RPC endpoint. -- `DisputeGameFactory` address. -- `AnchorStateRegistry` address. -- ZK proof RPC endpoint. -- L1 transaction signer. -- Poll interval. - -Optional inputs: - -- TEE proof RPC endpoint and timeout, enabling TEE-first nullification for TEE-backed games. -- Bond claim addresses, bond discovery interval, and bond discovery lookback window, enabling - automatic bond recovery and claiming. -- Metrics and health server settings. - -## Safety Requirements - -A challenger implementation must preserve these safety properties: - -- Do not dispute a game from the game's own claimed roots alone; recompute roots from L2 headers and - verified `L2ToL1MessagePasser` account proofs. -- Use the game's stored L1 head when requesting dispute proofs, so proof journals match the game - context verified onchain. -- For fraudulent ZK challenges, validate the challenged checkpoint itself rather than the first - invalid checkpoint in the whole proposal. -- Recheck game state before submitting a ready proof, because another challenger or prover may have - already changed the game. -- Treat unavailable L2 blocks and transient RPC failures as retryable scan conditions rather than - final validation results. diff --git a/docs/specs/pages/protocol/proofs/contracts.md b/docs/specs/pages/protocol/proofs/contracts.md deleted file mode 100644 index 580b9a6ca3..0000000000 --- a/docs/specs/pages/protocol/proofs/contracts.md +++ /dev/null @@ -1,693 +0,0 @@ -# Contracts - -The proof contracts turn offchain proof material into onchain checkpoint games. A game claims an -L2 output root for a fixed block interval. The contracts verify the initial proof, accept an -optional second proof, allow invalid proof material to be challenged or nullified, resolve the game -after the applicable delay, move the anchor state forward, and release the initialization bond. - -This page specifies the contract behavior used by the proof system: - -- `AnchorStateRegistry` -- `DelayedWETH` -- `DisputeGameFactory` -- `AggregateVerifier` -- `ZKVerifier` -- `TEEVerifier` -- `TEEProverRegistry` -- `NitroEnclaveVerifier` - -## Contract Graph - -```mermaid -flowchart TB - Factory[DisputeGameFactory] -->|clones| Game[AggregateVerifier game] - Game -->|validates parent and finality| ASR[AnchorStateRegistry] - Game -->|escrows and releases bond| WETH[DelayedWETH] - Game -->|TEE proofs| TEEVerifier[TEEVerifier] - Game -->|ZK proofs| ZKVerifier[ZKVerifier] - TEEVerifier -->|signer and proposer checks| Registry[TEEProverRegistry] - Registry -->|attestation proof| Nitro[NitroEnclaveVerifier] - Registry -->|current TEE_IMAGE_HASH| Factory - ZKVerifier -->|SP1 proof| SP1[SP1 verifier gateway] - Nitro -->|RISC Zero or SP1 proof| Coprocessor[ZK verifier contract] -``` - -`DisputeGameFactory`, `AnchorStateRegistry`, and `DelayedWETH` are proxied system contracts. -`AggregateVerifier` is deployed as an implementation and cloned by the factory with immutable -arguments. `TEEVerifier`, `ZKVerifier`, `TEEProverRegistry`, and `NitroEnclaveVerifier` are -standalone verifier and registry contracts referenced by the game implementation. - -## Data Model - -The contracts share the same dispute-game types: - -| Type | Meaning | -| ------------ | ----------------------------------------------------------------------------------------- | -| `GameType` | A `uint32` identifier for a dispute game implementation. | -| `Claim` | A 32-byte root claim. In this proof system it is an L2 output root. | -| `Hash` | A 32-byte hash wrapper. | -| `Timestamp` | A `uint64` timestamp wrapper. | -| `Proposal` | `(root, l2SequenceNumber)`, where `l2SequenceNumber` is the L2 block number for the root. | -| `GameStatus` | `IN_PROGRESS`, `CHALLENGER_WINS`, or `DEFENDER_WINS`. | -| `ProofType` | `TEE` or `ZK` inside `AggregateVerifier`. | - -The `AggregateVerifier` game uses two block intervals: - -```text -BLOCK_INTERVAL -INTERMEDIATE_BLOCK_INTERVAL -``` - -`BLOCK_INTERVAL` is the distance between a parent output root and a proposed output root. -`INTERMEDIATE_BLOCK_INTERVAL` is the spacing between intermediate roots inside that range. -`BLOCK_INTERVAL` and `INTERMEDIATE_BLOCK_INTERVAL` must be non-zero, and `BLOCK_INTERVAL` must be -divisible by `INTERMEDIATE_BLOCK_INTERVAL`. - -The number of intermediate roots in every game is: - -```text -BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL -``` - -The final intermediate root must equal the game's `rootClaim`. - -## Game Lifecycle - -1. The factory owner configures a game type with an `AggregateVerifier` implementation and an - initialization bond. -2. TEE operators register enclave signer addresses in `TEEProverRegistry` using ZK-verified Nitro - attestation. -3. A proposer creates a game through `DisputeGameFactory.createWithInitData()`, paying the exact - initialization bond and providing an initial TEE or ZK proof. -4. The game validates its parent, L2 block number, intermediate roots, L1 origin, and proof - journal. The bond is deposited into `DelayedWETH`. -5. A second proof may be submitted through `verifyProposalProof()`. If the proposal is invalid, - challengers can call `challenge()` or `nullify()` with proof material for an intermediate root. -6. After the expected resolution time, anyone can call `resolve()`. The result is - `DEFENDER_WINS` for a valid unchallenged game and `CHALLENGER_WINS` for a successful challenge - or invalid parent. -7. After resolution and the registry finality delay, anyone can call `closeGame()` to make a - best-effort anchor update. -8. The bond recipient calls `claimCredit()` twice: once to unlock the `DelayedWETH` credit, then - again after the `DelayedWETH` delay to withdraw and receive ETH. - -## DisputeGameFactory - -`DisputeGameFactory` creates and indexes dispute-game clones. Each game is uniquely identified by: - -```text -keccak256(abi.encode(gameType, rootClaim, extraData)) -``` - -The factory stores that UUID in `_disputeGames` and also appends a packed `GameId` to -`_disputeGameList` for index-based discovery. Offchain services use `DisputeGameCreated`, -`gameAtIndex()`, and `findLatestGames()` to discover games. - -### Configuration - -Only the factory owner can: - -- set a game implementation with `setImplementation(gameType, impl)` -- set a game implementation plus opaque implementation args with - `setImplementation(gameType, impl, args)` -- set the exact required creation bond with `setInitBond(gameType, initBond)` - -Creation reverts if the implementation is unset, if the paid value differs from `initBonds`, or if -a game with the same UUID already exists. - -### Clone Arguments - -When no implementation args are configured, the clone-with-immutable-args payload is: - -| Bytes | Description | -| -------------- | ------------------------------------- | -| `[0, 20)` | Game creator address | -| `[20, 52)` | Root claim | -| `[52, 84)` | Parent L1 block hash at creation time | -| `[84, 84 + n)` | Opaque game `extraData` | - -When implementation args are configured, the payload is: - -| Bytes | Description | -| ---------------------- | ------------------------------------- | -| `[0, 20)` | Game creator address | -| `[20, 52)` | Root claim | -| `[52, 84)` | Parent L1 block hash at creation time | -| `[84, 88)` | Game type | -| `[88, 88 + n)` | Opaque game `extraData` | -| `[88 + n, 88 + n + m)` | Opaque implementation args | - -`AggregateVerifier` uses the standard layout. Its `extraData` is specified in the -`AggregateVerifier` section below. - -## AnchorStateRegistry - -`AnchorStateRegistry` is the source of truth for whether a dispute game can be trusted by the proof -system. It stores: - -- the `SystemConfig` -- the `DisputeGameFactory` -- the starting anchor root -- the current anchor game, if one has been accepted -- the current respected game type -- a game blacklist -- a retirement timestamp -- a dispute-game finality delay - -The initial retirement timestamp is set during first initialization. Games created at or before the -retirement timestamp are retired. - -### Game Predicates - -The registry exposes these predicates: - -| Predicate | True when | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `isGameRegistered(game)` | The factory maps the game's `(gameType, rootClaim, extraData)` back to the same address, and the game points at this registry. | -| `isGameRespected(game)` | The game reports that its game type was respected when it was created. | -| `isGameBlacklisted(game)` | The guardian has blacklisted the game address. | -| `isGameRetired(game)` | `game.createdAt() <= retirementTimestamp`. | -| `isGameResolved(game)` | The game has a non-zero `resolvedAt` and ended with `DEFENDER_WINS` or `CHALLENGER_WINS`. | -| `isGameProper(game)` | The game is registered, not blacklisted, not retired, and the system is not paused. | -| `isGameFinalized(game)` | The game is resolved and more than `disputeGameFinalityDelaySeconds` have elapsed since `resolvedAt`. | -| `isGameClaimValid(game)` | The game is proper, respected, finalized, and resolved with `DEFENDER_WINS`. | - -`isGameProper()` does not prove that the root claim is correct. It only means the game has not been -invalidated by registry-level controls. Consumers that need claim validity must use -`isGameClaimValid()`. - -### Guardian Controls - -The `SystemConfig.guardian()` can: - -- set the respected game type -- update the retirement timestamp to the current block timestamp -- blacklist individual games - -These controls are the onchain safety valves for invalidating games before they can become valid -claims. - -### Anchor Updates - -`getAnchorRoot()` returns the starting anchor root until an anchor game is accepted. After that, it -returns the root claim and L2 block number of `anchorGame`. - -`setAnchorState(game)` accepts a new anchor game only when: - -- `isGameClaimValid(game)` is true -- the game's L2 sequence number is greater than the current anchor root's sequence number - -The update is permissionless and self-validating. - -## DelayedWETH - -`DelayedWETH` is WETH with delayed withdrawals. It escrows game bonds and forces a two-step credit -claim: - -1. The game calls `unlock(subAccount, amount)` for the bond recipient. -2. After `delay()` seconds, the game calls `withdraw(subAccount, amount)` and sends ETH to the - recipient. - -Unlocks are keyed by: - -```text -withdrawals[msg.sender][subAccount] -``` - -For proof games, `msg.sender` is the `AggregateVerifier` game contract and `subAccount` is the -current `bondRecipient`. - -Withdrawals revert while the system is paused. The proxy admin owner also has emergency recovery -powers: - -- `recover(amount)` sends up to `amount` ETH from the contract to the owner. -- `hold(account)` or `hold(account, amount)` pulls WETH from an account into the owner address. - -## AggregateVerifier - -`AggregateVerifier` is the dispute-game implementation for checkpoint proofs. Every factory-created -game is a clone with immutable game data. The implementation owns no per-game storage except the -clone's storage. - -### Constructor Configuration - -An implementation fixes these values for all clones of that game type: - -| Value | Purpose | -| ----------------------------- | -------------------------------------------------------- | -| `GAME_TYPE` | The dispute-game type served by this implementation. | -| `ANCHOR_STATE_REGISTRY` | Parent validation, claim validity, and anchor updates. | -| `DISPUTE_GAME_FACTORY` | Read from the registry during construction. | -| `DELAYED_WETH` | Bond escrow. | -| `TEE_VERIFIER` | Verifier for TEE signatures. | -| `TEE_IMAGE_HASH` | Expected TEE image hash committed into TEE journals. | -| `ZK_VERIFIER` | Verifier for ZK proofs. | -| `ZK_RANGE_HASH` | Range-program hash committed into ZK journals. | -| `ZK_AGGREGATE_HASH` | Aggregate-program hash passed to the ZK verifier. | -| `CONFIG_HASH` | Rollup configuration hash committed into proof journals. | -| `L2_CHAIN_ID` | L2 chain the game argues about. | -| `BLOCK_INTERVAL` | Distance from parent block to proposed block. | -| `INTERMEDIATE_BLOCK_INTERVAL` | Distance between intermediate checkpoint roots. | -| `PROOF_THRESHOLD` | Number of proofs required to resolve, either `1` or `2`. | - -`PROOF_THRESHOLD` controls resolution, not proof submission. The game can store one TEE proof, one -ZK proof, or both. - -### Game Extra Data - -`AggregateVerifier.extraData()` is encoded as: - -| Bytes | Description | -| ------------------- | ---------------------------------------------------------------------- | -| `[0, 32)` | Proposed L2 block number. | -| `[32, 52)` | Parent address. The first game uses the `AnchorStateRegistry` address. | -| `[52, 52 + 32 * n)` | Ordered intermediate output roots. | - -where: - -```text -n = BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL -``` - -The final intermediate output root must equal `rootClaim`. - -### Initialization - -`initializeWithInitData(proof)` can only run once. It verifies the calldata size so that unused -bytes cannot create multiple factory UUIDs for the same logical proposal. - -During initialization the game: - -1. Checks that the final intermediate root matches `rootClaim`. -2. Resolves the starting root. If `parentAddress` is the registry address, the starting root is - `AnchorStateRegistry.getStartingAnchorRoot()`. Otherwise the parent must be a valid registered - game. -3. Requires: - - ```text - l2SequenceNumber == startingL2SequenceNumber + BLOCK_INTERVAL - ``` - -4. Records `createdAt`, `wasRespectedGameTypeWhenCreated`, and an initial `expectedResolution`. -5. Verifies the claimed L1 origin hash in the initialization proof against either `blockhash()` or - EIP-2935 history. -6. Verifies the supplied TEE or ZK proof. -7. Records the initial prover, sets `bondRecipient` to `gameCreator`, and deposits the bond into - `DelayedWETH`. - -The initialization proof format is: - -| Bytes | Description | -| ----------- | -------------------------------------- | -| `[0, 1)` | `ProofType`: `0` for TEE, `1` for ZK. | -| `[1, 33)` | L1 origin hash. | -| `[33, 65)` | L1 origin block number. | -| `[65, end)` | Proof bytes for the selected verifier. | - -The L1 origin block must be in the past. Native `blockhash()` is used for block ages up to 256 -blocks. EIP-2935 history is used up to 8191 blocks. Older or unavailable L1 origin blocks revert. - -### Additional Proofs - -`verifyProposalProof(proofBytes)` adds the missing proof type while a game is in progress and not -over. It does not re-read a new L1 origin from calldata. Instead, it uses the `l1Head()` captured -by the factory at clone creation. - -The additional proof format is: - -| Bytes | Description | -| ---------- | -------------------------------------- | -| `[0, 1)` | `ProofType`: `0` for TEE, `1` for ZK. | -| `[1, end)` | Proof bytes for the selected verifier. | - -A game cannot store more than one proof of the same type. - -### Proof Journals - -TEE and ZK proofs commit to the same transition shape: - -```text -proposer -l1OriginHash -startingRoot -startingL2SequenceNumber -endingRoot -endingL2SequenceNumber -intermediateRoots -CONFIG_HASH -proof-system-specific hash -``` - -For TEE proofs, the final field is `TEE_IMAGE_HASH` and the journal is checked by `TEEVerifier`. -The game calls: - -```text -TEE_VERIFIER.verify(proposer || signature, TEE_IMAGE_HASH, keccak256(journal)) -``` - -For ZK proofs, the final field is `ZK_RANGE_HASH` and the proof is checked by `ZKVerifier`. The -game calls: - -```text -ZK_VERIFIER.verify(proofBytes, ZK_AGGREGATE_HASH, keccak256(journal)) -``` - -### Resolution Delay - -`expectedResolution` is derived from the number of currently accepted proofs: - -| Proof count | Delay | -| ----------- | ------------------------------------------- | -| `0` | Never resolvable. | -| `1` | `SLOW_FINALIZATION_DELAY`, fixed at 7 days. | -| `2` | `FAST_FINALIZATION_DELAY`, fixed at 1 day. | - -Adding a proof can only decrease `expectedResolution`. Nullifying a proof can increase it. A -challenge with a ZK proof sets `expectedResolution` to 7 days from the challenge so the challenge -can itself be nullified. - -### Challenge - -`challenge(proofBytes, intermediateRootIndex, intermediateRootToProve)` challenges a TEE-backed -proposal with a ZK proof for one intermediate interval. - -The call is accepted only when: - -- the game is still `IN_PROGRESS` -- the game itself is valid according to the registry -- the parent has not resolved with `CHALLENGER_WINS` -- the game has a TEE proof -- the game does not already have a ZK proof -- the supplied proof type is ZK -- the challenged index is in range -- the supplied root differs from the currently proposed intermediate root - -If the ZK proof verifies, the game records the ZK prover, increments `proofCount`, stores the -1-based countered intermediate index, and emits `Challenged`. When the game resolves, the challenger -receives the bond and the game status becomes `CHALLENGER_WINS`. - -### Nullification - -`nullify(proofBytes, intermediateRootIndex, intermediateRootToProve)` removes an already accepted -proof by proving a contradictory intermediate root. - -For an unchallenged game, the target root must differ from the proposed intermediate root. For a -challenged game, only the challenged index can be nullified, only with a ZK proof, and the supplied -root must match the original proposed intermediate root. - -After a successful nullification: - -- the prover slot for that proof type is deleted -- `proofCount` decreases -- `expectedResolution` is recalculated -- the countered index is cleared if the ZK challenge was nullified -- the corresponding verifier contract is nullified - -Verifier nullification is a global safety stop. Once `TEE_VERIFIER.nullify()` or -`ZK_VERIFIER.nullify()` succeeds, future proof verification through that verifier reverts until the -system is upgraded or reconfigured. - -### Resolve, Close, and Bonds - -`resolve()` can be called by anyone. The parent must be resolved unless the parent is the registry -itself. If the parent resolved with `CHALLENGER_WINS`, or later became blacklisted or retired, the -child also resolves with `CHALLENGER_WINS`. Otherwise the game must be over and must have at least -`PROOF_THRESHOLD` accepted proofs. - -If the game was challenged, `resolve()` sets `CHALLENGER_WINS` and moves the bond recipient to the -ZK prover. Otherwise it sets `DEFENDER_WINS`. - -`closeGame()` is permissionless. It reverts while the registry is paused, requires the game to be -resolved and finalized by the registry, and then attempts `AnchorStateRegistry.setAnchorState()`. -The anchor update is best-effort: if the registry rejects the game because it is no longer the -newest valid claim, `closeGame()` swallows that registry revert. - -`claimCredit()` has two phases: - -1. Unlock the bond in `DelayedWETH`. -2. After the `DelayedWETH` delay, withdraw WETH and send ETH to `bondRecipient`. - -If accepted proofs have been nullified and `expectedResolution` is reset to the never-resolvable -sentinel, `claimCredit()` is blocked until 14 days after `createdAt`. This prevents a stuck game -from locking the bond forever. - -## ZKVerifier - -`ZKVerifier` adapts the Succinct SP1 verifier gateway to the common `IVerifier` interface used by -`AggregateVerifier`. - -The call: - -```text -verify(proofBytes, imageId, journal) -``` - -performs: - -```text -SP1_VERIFIER.verifyProof(imageId, abi.encodePacked(journal), proofBytes) -``` - -and returns `true` if the SP1 gateway does not revert. `imageId` is the aggregate program -verification key supplied by the game, and `journal` is the hash of the public inputs assembled by -the game. - -`ZKVerifier` inherits verifier nullification. After a proper respected game nullifies the verifier, -all future `verify()` calls revert. - -## TEEVerifier - -`TEEVerifier` verifies TEE proof signatures against the `TEEProverRegistry`. - -The proof bytes passed to `TEEVerifier` are: - -| Bytes | Description | -| ---------- | ------------------------ | -| `[0, 20)` | Proposer address. | -| `[20, 85)` | 65-byte ECDSA signature. | - -The signature is recovered over the journal hash directly. It is not wrapped with the Ethereum -signed-message prefix. - -A TEE proof is valid only when: - -- the proof is at least 85 bytes -- the signature recovers cleanly -- the proposer is allowlisted in `TEEProverRegistry` -- the recovered signer is registered in `TEEProverRegistry` -- the signer's registered image hash equals the `imageId` supplied by the calling game - -The image-hash check prevents an enclave registered for one image from producing accepted proofs -for a game type or upgrade that expects another image. - -`TEEVerifier` also inherits verifier nullification. - -## TEEProverRegistry - -`TEEProverRegistry` manages TEE signer registration and proposer allowlisting. - -The registry has: - -- an owner -- a manager -- a `NitroEnclaveVerifier` -- a `DisputeGameFactory` -- a configurable `gameType` -- registered signer state -- proposer allowlist state - -The owner can set proposer addresses and update the `gameType`. The owner or manager can register -and deregister signers. - -### Expected Image Hash - -The registry reads the expected TEE image hash from the current game implementation: - -```text -DisputeGameFactory.gameImpls(gameType).TEE_IMAGE_HASH() -``` - -`setGameType()` validates that this call succeeds and returns a non-zero hash. `isValidSigner()` -returns true only when the signer is registered and its stored image hash matches the current -expected hash. - -Signer registration itself is PCR0-agnostic. This lets operators pre-register signers for a future -image before a game-type migration. Those signers do not become valid for proof submission until -the game implementation's `TEE_IMAGE_HASH` matches their registered image hash. - -### Signer Registration - -`registerSigner(output, proofBytes)` calls: - -```text -NITRO_VERIFIER.verify(output, ZkCoProcessorType.RiscZero, proofBytes) -``` - -The returned journal must have `VerificationResult.Success`. The attestation timestamp must not be -older than `MAX_AGE`, which is fixed at 60 minutes. The public key must be exactly 65 bytes in -uncompressed ANSI X9.62 form: - -```text -0x04 || x || y -``` - -The registry derives the signer address as: - -```text -address(uint160(uint256(keccak256(x || y)))) -``` - -The registry extracts PCR0 from the journal and stores: - -```text -signerImageHash[signer] = keccak256(pcr0.first || pcr0.second) -``` - -It then marks the signer as registered and adds it to an enumerable signer set. - -### Deregistration - -`deregisterSigner(signer)` deletes the signer's registration and image hash, removes the signer -from the enumerable set, and emits `SignerDeregistered`. - -`getRegisteredSigners()` returns the current enumerable set. Ordering is not guaranteed. - -## NitroEnclaveVerifier - -`NitroEnclaveVerifier` verifies ZK proofs of AWS Nitro Enclave attestation documents. It is the -attestation verifier used by `TEEProverRegistry`. - -The contract supports: - -- single-attestation verification -- batch attestation verification -- RISC Zero and Succinct SP1 proof systems -- root certificate configuration -- trusted intermediate certificate caching -- certificate revocation -- route-specific verifier selection -- permanently frozen proof routes - -### Roles and Configuration - -The owner controls: - -- `rootCert` -- `maxTimeDiff` -- `proofSubmitter` -- `revoker` -- ZK verifier configuration -- verifier program IDs -- aggregator program IDs -- route-specific verifier overrides -- route freezing - -The `revoker` can also revoke trusted intermediate certificates. `proofSubmitter` is the only -address allowed to call `verify()` or `batchVerify()`. - -`zkConfig[zkCoProcessor]` stores: - -| Field | Purpose | -| -------------- | ----------------------------------------------- | -| `verifierId` | Program ID for single-attestation verification. | -| `aggregatorId` | Program ID for batch verification. | -| `zkVerifier` | Default verifier contract address. | - -Route-specific verifier overrides are keyed by `(zkCoProcessor, selector)`, where `selector` is -the first four bytes of `proofBytes`. If a route is frozen, verification through that route -permanently reverts. - -### Single Verification - -`verify(output, zkCoprocessor, proofBytes)`: - -1. Requires `msg.sender == proofSubmitter`. -2. Resolves the verifier route from the proof selector. -3. Verifies the ZK proof against `zkConfig[zkCoprocessor].verifierId`. -4. Decodes `output` as a `VerifierJournal`. -5. Validates the journal. -6. Emits `AttestationSubmitted`. -7. Returns the journal with its final verification result. - -For RISC Zero, proof verification uses: - -```text -IRiscZeroVerifier.verify(proofBytes, programId, sha256(output)) -``` - -For Succinct, proof verification uses: - -```text -ISP1Verifier.verifyProof(programId, output, proofBytes) -``` - -### Batch Verification - -`batchVerify(output, zkCoprocessor, proofBytes)`: - -1. Requires `msg.sender == proofSubmitter`. -2. Verifies the ZK proof against `zkConfig[zkCoprocessor].aggregatorId`. -3. Decodes `output` as a `BatchVerifierJournal`. -4. Requires `batchJournal.verifierVk == getVerifierProofId(zkCoprocessor)`. -5. Validates every embedded `VerifierJournal`. -6. Emits `BatchAttestationSubmitted`. -7. Returns the validated journals. - -### Journal Validation - -A successful journal remains successful only when: - -- the trusted certificate prefix length is non-zero -- the first certificate equals `rootCert` -- every trusted intermediate certificate is still trusted and unexpired -- every newly supplied certificate is unexpired -- the attestation timestamp is not too old -- the attestation timestamp is not in the future - -Attestation timestamps are provided in milliseconds and converted to seconds. The timestamp is -valid only when: - -```text -timestamp + maxTimeDiff > block.timestamp -timestamp < block.timestamp -``` - -New certificates beyond the trusted prefix are cached with their expiry timestamps after successful -validation. A revoked certificate can become trusted again only if it appears in a later successful -attestation proof and is cached again. - -## Cross-Contract Safety Properties - -The proof contracts rely on the following cross-contract properties: - -- Factory uniqueness: a logical `(gameType, rootClaim, extraData)` can create at most one game. -- Parent validity: non-anchor games can only start from a registered, respected, non-retired, - non-blacklisted parent that has not lost. -- Monotonic checkpoints: each child game must advance exactly `BLOCK_INTERVAL` L2 blocks from its - starting root. -- Intermediate accountability: every proposal commits to all intermediate roots, so challengers - can target the first invalid checkpoint interval. -- Verifier separation: TEE and ZK proofs use different verifier contracts and different journal - domain separators (`TEE_IMAGE_HASH` versus `ZK_RANGE_HASH`). -- Fast finality requires diversity: a game with two accepted proof types can resolve after one day, - while a game with one proof waits seven days. -- Registry finality is separate from game resolution: a game can resolve before the - `AnchorStateRegistry` accepts it as a valid claim. -- Safety controls fail closed: pause, blacklist, retirement, verifier nullification, route - freezing, and certificate revocation all prevent acceptance rather than expanding trust. - -## Administrative Surfaces - -| Contract | Privileged role | Privileged actions | -| ---------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------- | -| `DisputeGameFactory` | Owner | Set game implementations, implementation args, and initialization bonds. | -| `AnchorStateRegistry` | Guardian from `SystemConfig` | Set respected game type, blacklist games, update retirement timestamp. | -| `DelayedWETH` | Proxy admin owner | Recover ETH and hold WETH from accounts. | -| `TEEProverRegistry` | Owner | Set proposers, update game type, transfer ownership or management. | -| `TEEProverRegistry` | Owner or manager | Register and deregister TEE signers. | -| `NitroEnclaveVerifier` | Owner | Configure root certificate, time tolerance, proof submitter, revoker, ZK routes, and program IDs. | -| `NitroEnclaveVerifier` | Owner or revoker | Revoke trusted intermediate certificates. | - -These surfaces are intentionally narrow but high impact. Operational changes to them can affect -which games are respected, which proofs verify, and which attestations can register new TEE -signers. diff --git a/docs/specs/pages/protocol/proofs/index.md b/docs/specs/pages/protocol/proofs/index.md deleted file mode 100644 index 05ff1001fc..0000000000 --- a/docs/specs/pages/protocol/proofs/index.md +++ /dev/null @@ -1,19 +0,0 @@ -# Proofs - -The proof system is the set of offchain services and onchain contracts that make L2 checkpoint -proposals verifiable from Ethereum. A proposal claims an output root for a fixed L2 block range. -Independent proof actors recompute that claim, provide proof material, and dispute the game if the -claim is invalid. - -This section describes the component roles used by the Azul proof system. - -- [Challenger](./challenger): checks in-progress games against canonical L2 state and disputes - invalid claims. -- [Proposer](./proposer): creates new checkpoint proposals. -- [Registrar](./registrar): maintains the onchain registry of accepted TEE signer identities. -- [TEE Provers](./tee-provers): produce Nitro Enclave-backed proofs for the common proposal path. -- [ZK Prover](./zk-prover): produces permissionless proofs for proposal and dispute paths. -- [Contracts](./contracts): verify proof material, track game state, and release withdrawals and - bonds according to the game result. - -The legacy interactive fault-proof design is specified separately in [Fault Proofs](/protocol/fault-proof). diff --git a/docs/specs/pages/protocol/proofs/proposer.md b/docs/specs/pages/protocol/proofs/proposer.md deleted file mode 100644 index 528a73fdb3..0000000000 --- a/docs/specs/pages/protocol/proofs/proposer.md +++ /dev/null @@ -1,343 +0,0 @@ -# Proposer - -The proposer is an offchain service that turns canonical L2 checkpoint ranges into -`AggregateVerifier` games on L1. It selects the next checkpoint from the latest onchain parent -state, obtains a TEE proof for that range, validates the proof against canonical L2 state, and -creates the next dispute game through `DisputeGameFactory`. - -The production proposer is controlled by its configured L1 transaction signer. Its output is still -self-validating: each game is uniquely identified by the game type, claimed output root, parent, -L2 block number, and intermediate output roots, and the proof can be checked by the onchain verifier -and by independent challengers. - -## Responsibilities - -A conforming proposer performs the following work: - -1. Read the active `AggregateVerifier` implementation and proposal parameters from L1. -2. Recover the latest onchain parent state from `AnchorStateRegistry` and `DisputeGameFactory`. -3. Select the next checkpoint block that is no later than the chosen safe head. -4. Build a `prover_prove` request for the checkpoint range. -5. Accept only TEE proof results for proposal creation. -6. Revalidate the aggregate output root and all intermediate roots against canonical L2 state - immediately before L1 submission. -7. Optionally pre-check the TEE signer against `TEEProverRegistry`. -8. Submit `DisputeGameFactory.createWithInitData()` with the required bond. -9. Retry transient proof, RPC, and transaction failures without creating out-of-order games. - -The proposer does not challenge games, resolve games, claim bonds, or decide withdrawal finality. -Those responsibilities belong to the challenger and proof contracts. - -## Startup Configuration - -At startup, the proposer connects to: - -- an L1 execution RPC for contract reads and transaction submission -- an L2 execution RPC for agreed L2 block headers -- a rollup RPC for sync status and output roots -- a prover RPC that implements `prover_prove` -- `AnchorStateRegistry` -- `DisputeGameFactory` -- an optional `TEEProverRegistry` - -The proposer reads the game implementation address from: - -```text -DisputeGameFactory.gameImpls(gameType) -``` - -The implementation address must be non-zero. The proposer then reads: - -```text -AggregateVerifier.BLOCK_INTERVAL() -AggregateVerifier.INTERMEDIATE_BLOCK_INTERVAL() -DisputeGameFactory.initBonds(gameType) -``` - -`BLOCK_INTERVAL` must be at least `2`, `INTERMEDIATE_BLOCK_INTERVAL` must be non-zero, and -`BLOCK_INTERVAL % INTERMEDIATE_BLOCK_INTERVAL` must be `0`. The number of intermediate roots in a -proposal is: - -```text -BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL -``` - -The proposer defaults to finalized L2 state. If explicitly configured to allow non-finalized -proposals, it may use the rollup node's safe L2 state instead. - -## Parent Recovery - -The proposer recovers the latest onchain parent state from L1 before planning new work. The parent -state is: - -```text -parentAddress -parentOutputRoot -parentL2BlockNumber -``` - -If no matching games exist, the parent is the anchor root from `AnchorStateRegistry`: - -```text -parentAddress = AnchorStateRegistry address -parentOutputRoot = AnchorStateRegistry.getAnchorRoot().root -parentL2BlockNumber = AnchorStateRegistry.getAnchorRoot().l2BlockNumber -``` - -If games exist, the proposer performs a deterministic forward walk from the anchor root, or from a -cached recovered tip when the cache is still valid. At each step: - -1. Compute: - - ```text - expectedBlock = parentL2BlockNumber + BLOCK_INTERVAL - ``` - -2. Fetch the canonical output root for every intermediate checkpoint: - - ```text - parentL2BlockNumber + INTERMEDIATE_BLOCK_INTERVAL * i - ``` - - for `i` in `1..=BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL`. - -3. Treat the final intermediate root as the canonical root claim for `expectedBlock`. -4. Encode `extraData` from `expectedBlock`, `parentAddress`, and the ordered intermediate roots. -5. Look up the expected game: - - ```text - DisputeGameFactory.games(gameType, rootClaim, extraData) - ``` - -6. If the lookup returns `address(0)`, stop. The current parent is the latest recovered state. -7. Otherwise, advance the parent to the returned game proxy and continue. - -This recovery method does not scan factory indices for a "best" game. It uses the game's unique -factory key, so only the canonical next game for the recovered parent can advance the chain of -parents. A game with the wrong root, parent, L2 block number, or intermediate roots has a different -key and is ignored by parent recovery. - -## Checkpoint Selection - -After recovery, the next proposal target is: - -```text -targetBlock = parentL2BlockNumber + BLOCK_INTERVAL -``` - -The proposer must not request or submit a proof for `targetBlock` unless: - -```text -targetBlock <= safeHead -``` - -where `safeHead` is either: - -- `finalized_l2.number`, by default -- `safe_l2.number`, only when non-finalized proposals are explicitly enabled - -When parallel proving is enabled, the proposer may request proofs for multiple future checkpoint -targets, but L1 submissions remain strictly sequential. At most one proposal transaction is in -flight, and the next transaction is not submitted until all earlier checkpoint games are recovered -or confirmed. - -## Proof Request - -For a checkpoint range, the proposer builds a `ProofRequest` with: - -| Field | Value | -| ----------------------------- | ---------------------------------------------------------- | -| `l1_head` | Hash of the latest L1 block at request construction time | -| `l1_head_number` | Number of the latest L1 block at request construction time | -| `agreed_l2_head_hash` | L2 block hash at `parentL2BlockNumber` | -| `agreed_l2_output_root` | Parent output root recovered from L1 | -| `claimed_l2_output_root` | Rollup RPC output root at `targetBlock` | -| `claimed_l2_block_number` | `targetBlock` | -| `proposer` | L1 address that will submit the proposal transaction | -| `intermediate_block_interval` | `INTERMEDIATE_BLOCK_INTERVAL` | -| `image_hash` | Expected TEE image hash | - -The prover RPC method is: - -```text -prover_prove(ProofRequest) -> ProofResult -``` - -The proposer accepts `ProofResult::Tee` for proposal creation. A ZK proof result is not valid input -for the current proposer path. - -## TEE Proposal Journal - -The TEE prover returns: - -- an aggregate proposal for the full checkpoint range -- per-block proposals for the blocks in that range - -The aggregate proposal contains: - -```text -outputRoot -signature -l1OriginHash -l1OriginNumber -l2BlockNumber -prevOutputRoot -configHash -``` - -The TEE signature is over: - -```text -keccak256(journal) -``` - -where `journal` is packed as: - -```text -proposer(20) -|| l1OriginHash(32) -|| prevOutputRoot(32) -|| startingL2Block(8) -|| outputRoot(32) -|| endingL2Block(8) -|| intermediateRoots(32 * N) -|| configHash(32) -|| teeImageHash(32) -``` - -For aggregate proposals: - -```text -startingL2Block = parentL2BlockNumber -endingL2Block = targetBlock -prevOutputRoot = parentOutputRoot -outputRoot = claimed root at targetBlock -``` - -The ordered `intermediateRoots` are sampled every `INTERMEDIATE_BLOCK_INTERVAL` blocks and include -the final target block root. - -## Pre-Submission Validation - -Immediately before submitting to L1, the proposer must re-check the proof against canonical L2 -state: - -1. Fetch the rollup output root at `targetBlock`. -2. Require it to equal the aggregate proposal's `outputRoot`. -3. Extract the intermediate roots from the per-block proposals. -4. Fetch the canonical output root for each intermediate checkpoint. -5. Require every proposed intermediate root to equal its canonical root. - -If the aggregate root or any intermediate root no longer matches canonical state, the proposer -discards the pending work and restarts recovery. This protects against stale proof results after L1 -or L2 reorgs. - -When `TEEProverRegistry` is configured, the proposer should recover the TEE signer from the -aggregate proposal signature and call: - -```text -TEEProverRegistry.isValidSigner(signer) -``` - -If the registry returns `false`, the proposer must not submit that proof. It should discard the -proof and request a new one. If the registry check itself fails because of an RPC or deployment -issue, the proposer may continue to submission and rely on the onchain verifier to enforce signer -validity. - -## Game Creation - -The proposer creates a game with: - -```solidity -DisputeGameFactory.createWithInitData{value: initBond}( - gameType, - rootClaim, - extraData, - initData -) -``` - -where: - -```text -rootClaim = aggregateProposal.outputRoot -``` - -`extraData` is packed, not ABI-encoded: - -```text -l2BlockNumber(32) || parentAddress(20) || intermediateRoots(32 * N) -``` - -`l2BlockNumber` is encoded as a 32-byte big-endian integer. `parentAddress` is the recovered parent -game proxy address, or the `AnchorStateRegistry` address for the first game after the anchor. - -`initData` is the TEE proof bytes for `AggregateVerifier.initializeWithInitData()`: - -```text -proofType(1) || l1OriginHash(32) || l1OriginNumber(32) || signature(65) -``` - -For TEE proofs: - -```text -proofType = 0 -``` - -The ECDSA `v` value in the signature must be normalized to `27` or `28` before submission. - -`initBond` is read from `DisputeGameFactory.initBonds(gameType)` at startup and is sent as the -transaction value. Nonce management, fee bumping, signing, and transaction resubmission are handled -by the L1 transaction manager. - -## Duplicate Games - -The factory key for a game is: - -```text -gameType || rootClaim || extraData -``` - -If `createWithInitData()` reverts with `GameAlreadyExists`, the proposer treats the target as -already submitted. It refreshes recovery from L1 and continues from the recovered tip. This handles -the case where a previous transaction succeeded but the proposer did not observe the receipt, or -where another valid proposer submitted the same game first. - -## Retry Behavior - -The proposer retries transient failures on later ticks: - -| Failure | Required behavior | -| ------------------------------------- | ----------------------------------------------------------- | -| Recovery RPC or contract read failure | Skip the current tick and retry recovery on the next tick | -| Proof request failure | Retry the target on a later tick | -| Repeated proof failure | Reset pipeline state and recover from L1 | -| L1 submission failure | Keep the proved result and retry submission on a later tick | -| L1 submission timeout | Treat as a submission failure and retry after recovery | -| `GameAlreadyExists` | Treat as success, refresh recovery, and continue | -| Canonical root mismatch | Reset pipeline state and re-prove from recovered L1 state | -| Invalid TEE signer | Discard the proof and request a new one | - -The current implementation retries a single proof target up to three times before resetting pipeline -state. Proposal submission is bounded by a ten minute timeout. - -## Admin Interface - -The proposer may expose an optional JSON-RPC admin interface. When enabled, it provides: - -| Method | Result | -| ----------------------- | --------------------------------------- | -| `admin_startProposer` | Starts the proving pipeline | -| `admin_stopProposer` | Stops the proving pipeline | -| `admin_proposerRunning` | Returns whether the pipeline is running | - -Starting an already running proposer and stopping a stopped proposer are errors. - -## Dry Run Mode - -In dry run mode, the proposer performs recovery, checkpoint selection, proof sourcing, and -pre-submission validation, but it does not submit L1 transactions. Instead, it logs the game that -would have been created. - -Dry run mode is useful for validating prover and RPC behavior, but it does not advance the onchain -proposal chain. diff --git a/docs/specs/pages/protocol/proofs/registrar.md b/docs/specs/pages/protocol/proofs/registrar.md deleted file mode 100644 index 665b038168..0000000000 --- a/docs/specs/pages/protocol/proofs/registrar.md +++ /dev/null @@ -1,7 +0,0 @@ -# Registrar - -This page will specify the TEE prover registrar component. - -The registrar is responsible for maintaining the onchain registry of accepted TEE signer identities. -The full registrar specification will define prover discovery, attestation verification, signer -registration, signer removal, and restart recovery behavior. diff --git a/docs/specs/pages/protocol/proofs/tee-provers.md b/docs/specs/pages/protocol/proofs/tee-provers.md deleted file mode 100644 index a977d9db91..0000000000 --- a/docs/specs/pages/protocol/proofs/tee-provers.md +++ /dev/null @@ -1,7 +0,0 @@ -# TEE Provers - -This page will specify the TEE prover component. - -TEE provers produce Nitro Enclave-backed proof material for checkpoint proposals and disputes. The -full TEE prover specification will define witness inputs, enclave execution, attestation, signer -identity handling, proof encoding, and verifier expectations. diff --git a/docs/specs/pages/protocol/proofs/zk-prover.md b/docs/specs/pages/protocol/proofs/zk-prover.md deleted file mode 100644 index b83f0b0a51..0000000000 --- a/docs/specs/pages/protocol/proofs/zk-prover.md +++ /dev/null @@ -1,297 +0,0 @@ -# ZK Prover - -The ZK prover is an offchain service that uses SP1 programs to produce permissionless proofs for -checkpoint proposals and disputes. A proving service accepts block-range requests, persists proof -state, submits work to SP1 proving infrastructure, and returns receipts that callers can submit to -`AggregateVerifier`. - -The ZK path is permissionless: any operator with canonical L1 and L2 RPC access, a configured SP1 -backend, and an L1 transaction signer can request proofs and submit valid proof material onchain. - -## Responsibilities - -A conforming ZK prover stack performs the following work: - -1. Accept proving requests for L2 block ranges. -2. Generate witness input from canonical L1, L2, and beacon RPCs. -3. Prove the range program with SP1. -4. For Groth16 requests, aggregate the completed range proof into an onchain-verifiable SNARK. -5. Persist proof request and backend session state so work can recover across process restarts. -6. Expose proof status and receipt retrieval over gRPC. -7. Encode receipts in the format expected by challengers, proposers, and `ZKVerifier`. - -The ZK prover does not decide whether a game is valid. Proposers and challengers choose the range to -prove, recompute canonical roots themselves, and recheck game state before submitting proof material -onchain. - -## Proving Service API - -The proving service exposes: - -```text -ProveBlock(ProveBlockRequest) -> ProveBlockResponse -GetProof(GetProofRequest) -> GetProofResponse -``` - -`ProveBlock` enqueues a proof request and returns a `session_id`. `GetProof` returns the current -status and, once complete, the requested receipt bytes. - -### ProveBlock Request - -`ProveBlockRequest` contains: - -| Field | Meaning | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| `start_block_number` | L2 block whose output root is the trusted starting state for the range. | -| `number_of_blocks_to_prove` | Number of L2 blocks to prove after `start_block_number`. | -| `sequence_window` | Optional L1 block lookahead used when deriving an L1 head for witness generation. | -| `proof_type` | `PROOF_TYPE_COMPRESSED` or `PROOF_TYPE_SNARK_GROTH16`. | -| `session_id` | Optional caller-supplied UUID used for idempotent requests. | -| `prover_address` | L1 address committed into the Groth16 journal so a proof cannot be replayed by another sender. Required for Groth16. | -| `l1_head` | Optional 32-byte hex L1 block hash used for witness generation. | - -If `session_id` is supplied, duplicate requests with the same UUID return the existing session. This -lets challengers derive deterministic session IDs from `(game address, invalid checkpoint index)` -and retry safely across process restarts. - -Callers supply `l1_head` when the proof journal must match a specific game context already -committed onchain (for example, dispute proofs against an existing game). When omitted, the service -derives an L1 head from the L2 block's L1 origin plus the request or service sequence window, which -is appropriate for fresh proposals where the caller has not yet committed to an L1 head. - -`PROOF_TYPE_SNARK_GROTH16` requires `prover_address`: the aggregation program commits this address -into the journal digest, and `AggregateVerifier` rechecks the same digest before accepting the -proof, so a Groth16 receipt is bound to the L1 sender that requested it. - -### Proof Types - -The service supports two proof types: - -| Proof type | Backend sessions | Result | -| ----------------------------- | ---------------- | ---------------------------------------------------------------------- | -| `PROOF_TYPE_COMPRESSED` | `STARK` | A compressed SP1 range proof. | -| `PROOF_TYPE_SNARK_GROTH16` | `STARK`, `SNARK` | A range proof plus a Groth16 aggregation proof suitable for onchain use. | - -For `PROOF_TYPE_SNARK_GROTH16`, the service first submits the range program as a compressed STARK -session. After that session completes, the service submits the aggregation program as a Groth16 -SNARK session. - -## Request Lifecycle - -A proof request begins as `CREATED` once the request and outbox entry have been persisted. A -worker then claims the outbox task and moves the request to `PENDING` while it prepares and -submits backend work. After at least one backend session exists, the request is `RUNNING`. The -request becomes `SUCCEEDED` once all sessions required by the proof type complete and the receipt -bytes are stored, or `FAILED` if validation, witness generation, backend submission, backend -execution, receipt download, or retry recovery fails permanently. - -Backend sessions track `RUNNING`, `COMPLETED`, or `FAILED` independently of the proof request. A -compressed request succeeds when all STARK sessions complete. A Groth16 request succeeds only -after both the STARK and SNARK sessions complete. Any failed session fails the parent request. - -## Receipt Retrieval - -`GetProofRequest` contains: - -| Field | Meaning | -| -------------- | ----------------------------------------------------------- | -| `session_id` | UUID returned by `ProveBlock`. | -| `receipt_type` | Optional receipt selector. Defaults to `RECEIPT_TYPE_STARK`. | - -The receipt selector can be: - -| Receipt type | Response bytes | -| ----------------------------- | ----------------------------------------------------------------------------------------- | -| `RECEIPT_TYPE_STARK` | Serialized SP1 proof-with-public-values for the range proof. | -| `RECEIPT_TYPE_SNARK` | Serialized SP1 proof-with-public-values for the aggregation proof. | -| `RECEIPT_TYPE_ON_CHAIN_SNARK` | Onchain proof bytes extracted from the stored SNARK receipt for the SP1 Groth16 verifier. | - -`GetProof` returns empty receipt bytes while a request is `CREATED`, `PENDING`, or `RUNNING`. -Failed requests return `STATUS_FAILED` and the stored error message. A successful response always -carries non-empty receipt bytes; if the stored request is `Succeeded` but the requested receipt -kind is absent, `GetProof` returns gRPC `NOT_FOUND` rather than an empty success. - -Callers are responsible for wrapping returned receipt bytes in the `AggregateVerifier` proof format. -For challenge, nullification, and additional-proof submission, the caller prefixes the ZK proof-type -byte before the receipt. For game initialization, the caller also includes the L1 origin fields -required by `initializeWithInitData()`. See [Contracts](./contracts) for the verifier-side framing. - -## Backend Modes - -The proving service supports these backend modes: - -| Mode | Purpose | -| --------- | ----------------------------------------------------------------------- | -| `mock` | Produces fake receipts for local tests without witness generation. | -| `cluster` | Submits work to a self-hosted SP1 cluster with Redis or S3 artifacts. | -| `network` | Submits work to the SP1 Network with the configured fulfillment policy. | - -The `cluster` and `network` backends share the same witness generation path; only submission, -polling, and artifact retrieval differ. The `mock` backend skips witness generation entirely. - -## SP1 Range Program - -The range program proves a Base L2 state transition over a contiguous block range. Its stdin -contains: - -```text -rkyv(DefaultWitnessData) -intermediateRootInterval -``` - -The program reconstructs the preimage oracle and beacon blob provider from the witness, runs the -Ethereum DA witness executor, and commits a `BootInfoStruct`. - -The committed boot info contains: - -| Field | Meaning | -| -------------------------- | --------------------------------------------------------------- | -| `l2PreRoot` | Output root for the trusted starting L2 block. | -| `l2PreBlockNumber` | Starting L2 block number. | -| `l2PostRoot` | Output root after executing the requested range. | -| `l2BlockNumber` | Ending L2 block number. | -| `l1Head` | L1 block hash used for derivation data. | -| `rollupConfigHash` | Hash of the rollup configuration used during execution. | -| `intermediateRoots` | Ordered output roots sampled every intermediate-root interval. | - -The final intermediate root must correspond to the ending L2 block for the range being proven. - -## SP1 Aggregation Program - -The aggregation program turns completed range proofs into the journal digest used by onchain -verification. Its inputs are: - -```text -AggregationInputs (sp1_zkvm::io::read) -L1 headers (CBOR-encoded) (sp1_zkvm::io::read_vec) -compressed range proofs (SP1 proof-input channel) -``` - -The compressed range proofs are passed via SP1's proof-input mechanism, not via plain stdin bytes, -and are verified inside the program with `sp1_lib::verify::verify_sp1_proof`. - -`AggregationInputs` contains the range boot infos, the latest L1 checkpoint head, the range-program -verification key, and the prover address. - -The aggregation program verifies: - -1. At least one range boot info is present. -2. Adjacent range boot infos are sequential: - - ```text - previous.l2PostRoot == next.l2PreRoot - previous.l2BlockNumber == next.l2PreBlockNumber - ``` - -3. Every range uses the same `rollupConfigHash`. -4. Every compressed range proof verifies against the supplied range verification key. -5. The provided L1 headers form a linked chain ending at `latest_l1_checkpoint_head`. -6. Every range `l1Head` appears in that header chain. - -The program then flattens all intermediate roots and builds one aggregate output: - -```text -proverAddress -l1Head -l2PreRoot -startingL2SequenceNumber -l2PostRoot -endingL2SequenceNumber -intermediateRoots -rollupConfigHash -imageHash -``` - -`imageHash` is the range-program verification key commitment. The aggregation program commits: - -```text -keccak256(abi.encodePacked(AggregationOutputs)) -``` - -This digest matches the journal hash assembled by `AggregateVerifier` for ZK proof verification. In -[Contracts](./contracts) terminology, `imageHash` is `ZK_RANGE_HASH`, and the aggregation -verification key configured on `ZKVerifier` is `ZK_AGGREGATE_HASH`. - -## ELF Reproducibility - -SP1 ELF binaries are built on demand and are not committed. The repository pins expected ELF -SHA-256 hashes in `crates/proof/succinct/elf/manifest.toml`. A code change that changes either SP1 -program must rebuild the ELFs and update `manifest.toml` in the same change. - -The range verification key commitment (`ZK_RANGE_HASH`) and aggregation verification key hash -(`ZK_AGGREGATE_HASH`) are onchain security parameters. Operators must deploy or configure verifier -contracts with values derived from the same ELFs used by the proving service. - -## Retry Behavior - -The service retries transient conditions without changing the logical proof request: - -| Condition | Required behavior | -| -------------------------------------------------- | ------------------------------------------------------------------------------ | -| Outbox task already claimed | Skip the duplicate worker. | -| Stuck `PENDING` request without an active session | Reset to `CREATED` with a new outbox entry until the retry limit is exhausted. | -| Backend status polling error | Leave the request `RUNNING` and retry on a later poll. | -| Proof artifact unavailable after backend success | Leave the session `RUNNING` or retry download on a later poll. | -| Backend reports failed or unfulfillable work | Mark the session and proof request `FAILED`. | -| Groth16 stage-two submission fails after STARK | Mark the proof request `FAILED`. | - -Callers should treat `FAILED` as terminal for that stored request. If the proof is still needed, the -caller should submit or retry the same logical request using its deterministic `session_id`. - -## Service Lifecycle - -At startup, the proving service: - -1. Connects to Postgres. -2. Optionally starts rate-limited local proxies for L1, L2, and beacon RPCs. -3. Loads rollup configuration from the rollup RPC. -4. Computes the range and aggregation proving and verifying keys. -5. Initializes the configured backend. -6. Starts the outbox processor. -7. Starts the status poller. -8. Starts the gRPC server and reflection service. - -The outbox processor turns persisted requests into backend sessions. The status poller syncs running -sessions, downloads receipts, triggers Groth16 stage two when needed, and retries or fails stuck -requests. - -## Operator Inputs - -A ZK prover service needs: - -- L1 execution RPC endpoint. -- L1 beacon RPC endpoint. -- L2 execution RPC endpoint. -- Rollup RPC endpoint. -- Postgres connection settings. -- SP1 backend configuration. -- Artifact storage configuration for cluster mode. -- Poll intervals, stuck-request timeout, and retry limits. -- Metrics and logging configuration. - -Network mode additionally needs an SP1 Network signer or KMS requester configuration. Cluster mode -additionally needs an SP1 cluster endpoint and exactly one artifact storage backend. - -## Onchain Expectations - -ZK proof bytes are submitted to `AggregateVerifier` as proof type `ZK`. The game assembles the -expected journal from the proposal or dispute context and calls `ZKVerifier.verify()` with the -configured aggregation verification key. - -A valid Groth16 receipt proves that the aggregation program committed the expected journal digest. -It does not replace caller-side state checks. Proposers and challengers must still recompute -canonical roots and recheck game state before submitting proof material. - -## Safety Requirements - -A ZK prover implementation must preserve these safety properties: - -- Use the caller-provided `l1_head` when present, so dispute proofs match the game context stored - onchain. -- Require `prover_address` for Groth16 proofs, because it is committed into the aggregation journal. -- Keep request creation idempotent for deterministic `session_id` values. -- Do not return onchain SNARK bytes unless the stored SNARK receipt deserializes successfully. -- Persist backend session metadata before relying on asynchronous backend completion. -- Pin ELF hashes so verification keys and onchain configuration do not silently drift. -- Treat unavailable RPC data, backend polling failures, and artifact download failures as retryable - service conditions rather than proof validity results. diff --git a/docs/specs/pages/reference/configurability.md b/docs/specs/pages/reference/configurability.md deleted file mode 100644 index a32e3b37c3..0000000000 --- a/docs/specs/pages/reference/configurability.md +++ /dev/null @@ -1,68 +0,0 @@ -# Configuration - -There are four categories of Base configuration: - -- **Consensus Parameters**: Fixed at genesis or changeable through privileged accounts or protocol upgrades. -- **Policy Parameters**: Changeable without breaking consensus, within protocol-imposed constraints. -- **Admin Roles**: Accounts that can upgrade contracts, change role owners, or update protocol parameters. Typically cold/multisig wallets. -- **Service Roles**: Accounts used for day-to-day operations. Typically hot wallets. - -## Consensus Parameters - -| Parameter | Description | Administrator | -|-----------|-------------|---------------| -| [Batch Inbox Address](glossary.md#batch-inbox) | L1 address where [batcher transactions](glossary.md#batcher-transaction) are posted | Static | -| [Batcher Hash](glossary.md#batcher-hash) | Versioned hash of the authorized batcher sender(s) | [System Config Owner](#admin-roles) | -| Chain ID | Unique chain ID for transaction signature validation | Static | -| [Proof Maturity Delay](../protocol/fault-proof/stage-one/bridge-integration.md#fpac-optimismportal-mods-specification) | Time between proving and finalizing a withdrawal. 7 days. | [L1 Proxy Admin](#admin-roles) | -| [Dispute Game Finality](../protocol/fault-proof/stage-one/bridge-integration.md#fpac-optimismportal-mods-specification) | Time for `Guardian` to [blacklist a game](../protocol/fault-proof/stage-one/bridge-integration.md#blacklisting-disputegames) before withdrawals finalize. 3.5 days. | [L1 Proxy Admin](#admin-roles) | -| [Respected Game Type](../protocol/fault-proof/stage-one/bridge-integration.md#new-state-variables) | Game type `OptimismPortal` accepts for withdrawal finalization. `CANNON` (`0`); may fall back to `PERMISSIONED_CANNON` (`1`). | [Guardian](#service-roles) | -| [Fault Game Max Depth](../protocol/fault-proof/stage-one/fault-dispute-game.md#game-tree) | Maximum depth of fault dispute game trees. 73. | Static | -| [Fault Game Split Depth](../protocol/fault-proof/stage-one/fault-dispute-game.md#game-tree) | Depth after which claims correspond to VM state commitments. 30. | Static | -| [Max Game Clock Duration](../protocol/fault-proof/stage-one/fault-dispute-game.md#max_clock_duration) | Maximum time on a dispute game team's chess clock. 3.5 days. | Static | -| [Game Clock Extension](../protocol/fault-proof/stage-one/fault-dispute-game.md#clock_extension) | Clock credit when a team's remaining time falls below `CLOCK_EXTENSION`. 3 hours. | Static | -| [Bond Withdrawal Delay](../protocol/fault-proof/stage-one/bond-incentives.md#delay-period) | Time before dispute game bonds can be withdrawn. 7 days. | Static | -| [Min Large Preimage Size](../protocol/fault-proof/stage-one/fault-dispute-game.md#preimageoracle-interaction) | Minimum preimage size for the `PreimageOracle` large proposal process. 126,000 bytes. | Static | -| [Large Preimage Challenge Period](../protocol/fault-proof/stage-one/fault-dispute-game.md#preimageoracle-interaction) | Challenge window before large preimage proposals are published. 24 hours. | Static | -| [Fault Game Absolute Prestate](../protocol/fault-proof/stage-one/fault-dispute-game.md#execution-trace) | VM state commitment used as the fault proof VM starting point | Static | -| [Fault Game Genesis Block](../protocol/fault-proof/stage-one/fault-dispute-game.md#anchor-state) | Initial [anchor state](../protocol/fault-proof/stage-one/fault-dispute-game.md#anchor-state) block number. Any finalized block between bedrock and fault proof activation; `0` from genesis. | Static | -| [Fault Game Genesis Output Root](../protocol/fault-proof/stage-one/fault-dispute-game.md#anchor-state) | Output root at the Fault Game Genesis Block | Static | -| [Fee Scalar](glossary.md#fee-scalars) | Markup on transactions relative to raw L1 data cost. Fee margin between 0%–50%. | [System Config Owner](#admin-roles) | -| [Gas Limit](../protocol/consensus/derivation.md#system-configuration) | L2 block gas limit. ≤ 200,000,000 gas. | [System Config Owner](#admin-roles) | -| [Genesis State](../protocol/execution/evm/predeploys.md#overview) | Initial chain state including all predeploy code and storage. Standard predeploys and preinstalls only. | Static | -| L2 Block Time | Interval at which L2 blocks are produced via [derivation](../protocol/consensus/derivation.md). 1 or 2 seconds. | [L1 Proxy Admin](#admin-roles) | -| [Sequencing Window Size](glossary.md#sequencing-window) | Max batch submission gap before L1 fallback triggers. 3,600 L1 blocks (12 hours at 12s L1 block time). | Static | -| Start Block | L1 block where `SystemConfig` was first initialized | [L1 Proxy Admin](#admin-roles) | -| Superchain Target | `SuperchainConfig` and `ProtocolVersions` addresses for cross-L2 config. Mainnet or Sepolia. | Static | -| Governance Token | Governance token support is disabled. | n/a | -| [Operator Fee Params](../upgrades/isthmus/exec-engine.md#operator-fee) | Operator fee scalar and constant for fee calculation. Standard values are 0; non-zero for non-standard configurations such as op-succinct. | [System Config Owner](#admin-roles) | -| [DA Footprint Gas Scalar](../upgrades/jovian/exec-engine.md#DA-footprint-block-limit) | Scalar for DA footprint calculation | [System Config Owner](#admin-roles) | -| [Minimum Base Fee](../upgrades/jovian/exec-engine.md#minimum-base-fee) | Minimum base fee on L2 | [System Config Owner](#admin-roles) | - -## Policy Parameters - -| Parameter | Description | Administrator | -|-----------|-------------|---------------| -| [Data Availability Type](glossary.md#data-availability-provider) | Whether the batcher posts data as blobs or calldata. Ethereum (Blobs or Calldata); Alt-DA not supported. | [Batch Submitter](#service-roles) | -| Batch Submission Frequency | Frequency of [batcher transaction](glossary.md#batcher-transaction) submissions to L1. ≤ 1,800 L1 blocks (6 hours at 12s L1 block time). | [Batch Submitter](#service-roles) | -| Output Frequency | Frequency of output root submissions to L1. ≤ 43,200 L2 blocks (24 hours at 2s L2 block time); must be non-zero. Deprecated once fault proofs are enabled. | [L1 Proxy Admin](#admin-roles) | - -## Admin Roles - -| Role | Description | Administers | -|------|-------------|-------------| -| L1 Proxy Admin | `ProxyAdmin` from the latest `op-contracts` release, authorized to upgrade L1 contracts | L1 contracts | -| L1 ProxyAdmin Owner | Authorized to update the L1 Proxy Admin. [0x5a0Aae59D09fccBdDb6C6CcEB07B7279367C3d2A](https://etherscan.io/address/0x5a0Aae59D09fccBdDb6C6CcEB07B7279367C3d2A) | [L1 Proxy Admin](#admin-roles) | -| L2 Proxy Admin | `ProxyAdmin` at `0x4200000000000000000000000000000000000018`, authorized to upgrade L2 contracts | [Predeploys](../protocol/execution/evm/predeploys.md#overview) | -| L2 ProxyAdmin Owner | [Aliased](glossary.md#address-aliasing) L1 ProxyAdmin Owner; upgrades L2 contracts via `ProxyAdmin`. [0x6B1BAE59D09fCcbdDB6C6cceb07B7279367C4E3b](https://optimistic.etherscan.io/address/0x6B1BAE59D09fCcbdDB6C6cceb07B7279367C4E3b) | [L2 Proxy Admin](#admin-roles) | -| [System Config Owner](../protocol/consensus/derivation.md#system-configuration) | Authorized to change values in the `SystemConfig` contract | [Batch Submitter](#service-roles), [Sequencer P2P Signer](#service-roles), Fee Scalar, Gas Limit | - -## Service Roles - -| Role | Description | Administrator | -|------|-------------|---------------| -| [Batch Submitter](glossary.md#batcher) | Authenticates batches submitted to L1 | [System Config Owner](#admin-roles) | -| [Challenger](../protocol/fault-proof/stage-one/bridge-integration.md#permissioned-faultdisputegame) | Interacts with permissioned dispute games. Active only when respected game type is `PERMISSIONED_CANNON`. [0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A](https://etherscan.io/address/0x9BA6e03D8B90dE867373Db8cF1A58d2F7F006b3A) | [L1 Proxy Admin](#admin-roles) | -| Guardian | Pauses L1 withdrawals, blacklists dispute games, sets respected game type in `OptimismPortal`. [0x09f7150D8c019BeF34450d6920f6B3608ceFdAf2](https://etherscan.io/address/0x09f7150D8c019BeF34450d6920f6B3608ceFdAf2) | [L1 Proxy Admin](#admin-roles) | -| [Proposer](../protocol/fault-proof/stage-one/bridge-integration.md#permissioned-faultdisputegame) | Creates permissioned dispute games on L1. Active only when respected game type is `PERMISSIONED_CANNON`. | [L1 Proxy Admin](#admin-roles) | -| [Sequencer P2P Signer](glossary.md#unsafe-block-signer) | Signs unsafe/pre-submitted blocks at the P2P layer | [System Config Owner](#admin-roles) | diff --git a/docs/specs/pages/reference/glossary.md b/docs/specs/pages/reference/glossary.md deleted file mode 100644 index 173b769da3..0000000000 --- a/docs/specs/pages/reference/glossary.md +++ /dev/null @@ -1,879 +0,0 @@ -# Glossary - -## General Terms - -### Layer 1 (L1) - -[L1]: glossary.md#layer-1-L1 - -Refers to the Ethereum blockchain, used in contrast to [layer 2][L2], which refers to Base. - -### Layer 2 (L2) - -[L2]: glossary.md#layer-2-L2 - -Refers to Base Chain (specified in this repository), used in contrast to [layer 1][L1], which -refers to the Ethereum blockchain. - -### Block - -[block]: glossary.md#block - -Can refer to an [L1] block, or to an [L2] block, which are structured similarly. - -A block is a sequential list of transactions, along with a couple of properties stored in the _header_ of the block. A -description of these properties can be found in code comments [here][nano-header], or in the [Ethereum yellow paper -(pdf)][yellow], section 4.3. - -It is useful to distinguish between input block properties, which are known before executing the transactions in the -block, and output block properties, which are derived after executing the block's transactions. These include various -[Merkle Patricia Trie roots][mpt] that notably commit to the L2 state and to the log events emitted during execution. - -### EOA - -[EOA]: glossary.md#EOA - -"Externally Owned Account", an Ethereum term to designate addresses operated by users, as opposed to contract addresses. - -### Merkle Patricia Trie - -[mpt]: glossary.md#merkle-patricia-trie - -A [Merkle Patricia Trie (MPT)][mpt-details] is a sparse trie, which is a tree-like structure that maps keys to values. -The root hash of an MPT is a commitment to the contents of the tree, which allows a -proof to be constructed for any key-value mapping encoded in the tree. Such a proof is called a Merkle proof, and can be -verified against the Merkle root. - -### Chain Re-Organization - -[reorg]: glossary.md#chain-re-organization - -A re-organization, or re-org for short, is whenever the head of a blockchain (its last block) changes (as dictated by -the [fork choice rule][fork-choice-rule]) to a block that is not a child of the previous head. - -L1 re-orgs can happen because of network conditions or attacks. L2 re-orgs are a consequence of L1 re-orgs, mediated via -[L2 chain derivation][derivation]. - -### Predeployed Contract ("Predeploy") - -[predeploy]: glossary.md#predeployed-contract-predeploy - -A contract placed in the L2 genesis state (i.e. at the start of the chain). - -All predeploy contracts are specified in the [predeploys specification](../protocol/execution/evm/predeploys.md). - -### Preinstalled Contract ("Preinstall") - -[preinstall]: glossary.md#preinstalled-contract-preinstall - -A contract placed in the L2 genesis state (i.e. at the start of the chain). These contracts do not share the same -security guarantees as [predeploys](#predeployed-contract-predeploy), but are general use contracts made -available to improve the L2's UX. - -All preinstall contracts are specified in the [preinstalls specification](../protocol/execution/evm/preinstalls.md). - -### Precompiled Contract ("Precompile") - -[precompile]: glossary.md#precompiled-contract-precompile - -A contract implemented natively in the EVM that performs a specific operation more efficiently than a bytecode -(e.g. Solidity) implementation. Precompiles exist at predefined addresses. They are created and modified through -network upgrades. - -All precompile contracts are specified in the [precompiles specification](../protocol/execution/evm/precompiles.md). - -### Receipt - -[receipt]: glossary.md#receipt - -A receipt is an output generated by a transaction, comprising a status code, the amount of gas used, a list of log -entries, and a [bloom filter] indexing these entries. Log entries are most notably used to encode [Solidity events]. - -Receipts are not stored in blocks, but blocks store a [Merkle Patricia Trie root][mpt] for a tree containing the receipt -for every transaction in the block. - -Receipts are specified in the [yellow paper (pdf)][yellow] section 4.3.1. - -### Transaction Type - -[transaction-type]: glossary.md#transaction-type - -Ethereum provides a mechanism (as described in [EIP-2718]) for defining different transaction types. -Different transaction types can contain different payloads, and be handled differently by the protocol. - -[EIP-2718]: https://eips.ethereum.org/EIPS/eip-2718 - -### Fork Choice Rule - -[fork-choice-rule]: glossary.md#fork-choice-rule - -The fork choice rule is the rule used to determine which block is to be considered as the head of a blockchain. On L1, -this is determined by the proof of stake rules. - -L2 also has a fork choice rule, although the rules vary depending on whether we want the [safe L2 head][safe-l2-head], -the [unsafe L2 head][unsafe-l2-head] or the [finalized L2 head][finalized-l2-head]. - -### Priority Gas Auction - -Transactions in ethereum are ordered by the price that the transaction pays to the miner. Priority Gas Auctions -(PGAs) occur when multiple parties are competing to be the first transaction in a block. Each party continuously -updates the gas price of their transaction. PGAs occur when there is value in submitting a transaction before other -parties (like being the first deposit or submitting a deposit before there is not more guaranteed gas remaining). -PGAs tend to have negative externalities on the network due to a large amount of transactions being submitted in a -very short amount of time. - - -## Sequencing - -[sequencing]: glossary.md#sequencing - -Transactions in the rollup can be included in two ways: - -- Through a [deposited transaction](#deposited-transaction), enforced by the system -- Through a regular transaction, embedded in a [sequencer batch](#sequencer-batch) - -Submitting transactions for inclusion in a batch saves costs by reducing overhead, and enables the sequencer to -pre-confirm the transactions before the L1 confirms the data. - -### Sequencer - -[sequencer]: glossary.md#sequencer - -A sequencer is either a [rollup node][rollup-node] ran in sequencer mode, or the operator of this rollup node. - -The sequencer is a privileged actor, which receives L2 transactions from L2 users, creates L2 blocks using them, which -it then submits to [data availability provider][avail-provider] (via a [batcher]). It also submits [output -roots][l2-output] to L1. - -### Sequencing Window - -[sequencing-window]: glossary.md#sequencing-window - -A sequencing window is a range of L1 blocks from which a [sequencing epoch][sequencing-epoch] can be derived. - -A sequencing window whose first L1 block has number `N` contains [batcher transactions][batcher-transaction] for epoch -`N`. The window contains blocks `[N, N + SWS)` where `SWS` is the sequencer window size. - -The current default `sws` is 3600 epochs. - -Additionally, the first block in the window defines the [depositing transactions][depositing-tx] which determine the -[deposits] to be included in the first L2 block of the epoch. - -### Sequencing Epoch - -[sequencing-epoch]: glossary.md#sequencing-epoch - -A sequencing epoch is sequential range of L2 blocks derived from a [sequencing window](#sequencing-window) of L1 blocks. - -Each epoch is identified by an epoch number, which is equal to the block number of the first L1 block in the -sequencing window. - -Epochs can have variable size, subject to some constraints. See the [L2 chain derivation specification][derivation-spec] -for more details. - -### L1 Origin - -[l1-origin]: glossary.md#l1-origin - -The L1 origin of an L2 block is the L1 block corresponding to its [sequencing epoch][sequencing-epoch]. - - -## Deposits - -[deposits]: glossary.md#deposits - -In general, a deposit is an L2 transaction derived from an L1 block (by the [rollup driver]). - -While transaction deposits are notably (but not only) used to "deposit" (bridge) ETH and tokens to L2, the word -_deposit_ should be understood as "a transaction _deposited_ to L2 from L1". - -This term _deposit_ is somewhat ambiguous as these "transactions" exist at multiple levels. This section disambiguates -all deposit-related terms. - -Notably, a _deposit_ can refer to: - -- A [deposited transaction][deposited] (on L2) that is part of a deposit block. -- A [depositing call][depositing-call] that causes a [deposited transaction][deposited] to be derived. -- The event/log data generated by the [depositing call][depositing-call], which is what the [rollup driver] reads to - derive the [deposited transaction][deposited]. - -We sometimes also talk about _user deposit_ which is a similar term that explicitly excludes [L1 attributes deposited -transactions][l1-attr-deposit]. - -Deposits are specified in the [deposits specification][deposits-spec]. - -### Deposited Transaction - -[deposited]: glossary.md#deposited-transaction - -A _deposited transaction_ is an L2 transaction that was derived from L1 and included in an L2 block. - -There are two kinds of deposited transactions: - -- [L1 attributes deposited transaction][l1-attr-deposit], which submits the L1 block's attributes to the [L1 Attributes - Predeployed Contract][l1-attr-predeploy]. -- [User-deposited transactions][user-deposited], which are transactions derived from an L1 call to the [deposit - contract][deposit-contract]. - -### L1 Attributes Deposited Transaction - -[l1-attr-deposit]: glossary.md#l1-attributes-deposited-transaction - -An _L1 attributes deposited transaction_ is [deposited transaction][deposited] that is used to register the L1 block -attributes (number, timestamp, ...) on L2 via a call to the [L1 Attributes Predeployed Contract][l1-attr-predeploy]. -That contract can then be used to read the attributes of the L1 block corresponding to the current L2 block. - -L1 attributes deposited transactions are specified in the [L1 Attributes Deposit][l1-attributes-tx-spec] section of the -deposits specification. - -[l1-attributes-tx-spec]: ../protocol/bridging/deposits.md#l1-attributes-deposited-transaction - -### User-Deposited Transaction - -[user-deposited]: glossary.md#user-deposited-transaction - -A _user-deposited transaction_ is a [deposited transaction][deposited] which is derived from an L1 call to the [deposit -contract][deposit-contract] (a [depositing call][depositing-call]). - -User-deposited transactions are specified in the [Transaction Deposits][tx-deposits-spec] section of the deposits -specification. - -[tx-deposits-spec]: ../protocol/bridging/deposits.md#user-deposited-transactions - -### Depositing Call - -[depositing-call]: glossary.md#depositing-call - -A _depositing call_ is an L1 call to the [deposit contract][deposit-contract], which will be derived to a -[user-deposited transaction][user-deposited] by the [rollup driver]. - -This call specifies all the data (destination, value, calldata, ...) for the deposited transaction. - -### Depositing Transaction - -[depositing-tx]: glossary.md#depositing-transaction - -A _depositing transaction_ is an L1 transaction that makes one or more [depositing calls][depositing-call]. - -### Depositor - -[depositor]: glossary.md#depositor - -The _depositor_ is the L1 account (contract or [EOA]) that makes (is the `msg.sender` of) the [depositing -call][depositing-call]. The _depositor_ is **NOT** the originator of the depositing transaction (i.e. `tx.origin`). - -### Deposited Transaction Type - -[deposit-tx-type]: glossary.md#deposited-transaction-type - -The _deposited transaction type_ is an [EIP-2718] [transaction type][transaction-type], which specifies the input fields -and correct handling of a [deposited transaction][deposited]. - -See the [corresponding section][spec-deposit-tx-type] of the deposits spec for more information. - -[spec-deposit-tx-type]: ../protocol/bridging/deposits.md#the-deposited-transaction-type - -### Deposit Contract - -[deposit-contract]: glossary.md#deposit-contract - -The _deposit contract_ is an [L1] contract to which [EOAs][EOA] and contracts may send [deposits]. The deposits are -emitted as log records (in Solidity, these are called _events_) for consumption by [rollup nodes][rollup-node]. - -Advanced note: the deposits are not stored in calldata because they can be sent by contracts, in which case the calldata -is part of the _internal_ execution between contracts, and this intermediate calldata is not captured in one of the -[Merkle Patricia Trie roots][mpt] included in the L1 block. - -cf. [Deposits Specification][deposits-spec] - - -## Withdrawals - -> **TODO** expand this whole section to be clearer - -[withdrawals]: glossary.md#withdrawals - -In general, a withdrawal is a transaction sent from L2 to L1 that may transfer data and/or value. - -The term _withdrawal_ is somewhat ambiguous as these "transactions" exist at multiple levels. In order to differentiate -between the L1 and L2 components of a withdrawal we introduce the following terms: - -- A _withdrawal initiating transaction_ refers specifically to a transaction on L2 sent to the Withdrawals predeploy. -- A _withdrawal finalizing transaction_ refers specifically to an L1 transaction which finalizes and relays the - withdrawal. - -### Relayer - -[relayer]: glossary.md#relayer - -An EOA on L1 which finalizes a withdrawal by submitting the data necessary to verify its inclusion on L2. - -### Finalization Period - -[finalization-period]: glossary.md#finalization-period - -The finalization period — sometimes also called _withdrawal delay_ — is the minimum amount of time (in seconds) that -must elapse before a [withdrawal][withdrawals] can be finalized. - -The finalization period is necessary to afford sufficient time for [validators][validator] to make a [fault -proof][fault-proof]. - -> **TODO** specify current value for finalization period - - -## Configuration - -### Batch Inbox - -[batch-inbox]: glossary.md#batch-inbox - -The **Batch Inbox** is the address that Sequencer transaction batches are published to. Sequencers -publish transactions to the Batch Inbox by setting it as the `to` address on a transaction -containing batched L2 transactions either in calldata or as blobdata. - -### Batcher Hash - -[batcher-hash]: glossary.md#batcher-hash - -The **Batcher Hash** identifies the sender(s) whose transactions to the [Batch Inbox](#batch-inbox) -will be recognized by the L2 clients for a given Base chain. - -The Batcher Hash is versioned by the first byte of the hash. The structure of the V0 Batcher Hash -is a 32 byte hash defined as follows: - -| 1 byte | 11 bytes | 20 bytes | -| -------------- | -------- | -------- | -| version (0x00) | empty | address | - -This can also be understood as: - -```solidity -bytes32(address(batcher)) -``` - -Where `batcher` is the address of the account that sends transactions to the Batch Inbox. Put -simply, the V0 hash identifies a _single_ address whose transaction batches will be recognized by -L2 clients. This hash is versioned so that it could, for instance, be repurposed to be a commitment -to a list of permitted accounts or some other form of batcher identification. - -### Fee Scalars - -[fee-scalars]: glossary.md#fee-scalars - -The **Fee Scalars** are parameters used to calculate the L1 data fee for L2 transactions. These -parameters are also known as Gas Price Oracle (GPO) parameters. - -#### Pre-Ecotone Parameters - -Before the Ecotone upgrade, these include: - -- **Scalar**: A multiplier applied to the L1 base fee, interpreted as a big-endian `uint256` -- **Overhead**: A constant gas overhead, interpreted as a big-endian `uint256` - -#### Post-Ecotone Parameters - -After the Ecotone upgrade: - -- The **Scalar** attribute encodes additional scalar information in a versioned encoding scheme -- The **Overhead** value is ignored and does not affect the L2 state-transition output - -#### Post-Ecotone Scalar Encoding - -The Scalar is encoded as big-endian `uint256`, interpreted as `bytes32`, and composed as follows: - -- Byte `0`: scalar-version byte -- Bytes `[1, 32)`: depending on scalar-version: - - Scalar-version `0`: - - Bytes `[1, 28)`: padding, should be zero - - Bytes `[28, 32)`: big-endian `uint32`, encoding the L1-fee `baseFeeScalar` - - This version implies the L1-fee `blobBaseFeeScalar` is set to 0 - - If there are non-zero bytes in the padding area, `baseFeeScalar` must be set to MaxUint32 - - Scalar-version `1`: - - Bytes `[1, 24)`: padding, must be zero - - Bytes `[24, 28)`: big-endian `uint32`, encoding the `blobBaseFeeScalar` - - Bytes `[28, 32)`: big-endian `uint32`, encoding the `baseFeeScalar` - -The `baseFeeScalar` corresponds to the share of the user-transaction (per byte) in the total -regular L1 EVM gas usage consumed by the data-transaction of the batch-submitter. For blob -transactions, this is the fixed intrinsic gas cost of the L1 transaction. - -The `blobBaseFeeScalar` corresponds to the share of a user-transaction (per byte) in the total -blobdata that is introduced by the data-transaction of the batch-submitter. - -### Unsafe Block Signer - -[unsafe-block-signer]: glossary.md#unsafe-block-signer - -The **Unsafe Block Signer** is an Ethereum address whose corresponding private key is used to sign -"unsafe" blocks before they are published to L1. This signature allows nodes in the P2P network to -recognize these blocks as the canonical unsafe blocks, preventing denial of service attacks on the -P2P layer. - -To ensure that its value can be fetched with a storage proof in a storage layout independent -manner, it is stored at a special storage slot corresponding to -`keccak256("systemconfig.unsafeblocksigner")`. - -Unlike other system config parameters, the Unsafe Block Signer only operates on blockchain policy -and is not a consensus level parameter. - -### L2 Gas Limit - -[l2-gas-limit]: glossary.md#l2-gas-limit - -The **L2 Gas Limit** defines the maximum amount of gas that can be used in a single L2 block. -This parameter ensures that L2 blocks remain of reasonable size to be processed and proven. - -Changes to the L2 gas limit are fully applied in the first L2 block with the L1 origin that -introduced the change. - -The gas limit may not be set to a value larger than the -[maximum gas limit](../protocol/consensus/derivation.md#system-configuration). This is to ensure that L2 blocks are provable and can be processed by consensus and execution software. -## Batch Submission - -[batch-submission]: glossary.md#batch-submission - -### Data Availability - -[data-availability]: glossary.md#data-availability - -Data availability is the guarantee that some data will be "available" (i.e. _retrievable_) during a reasonably long time -window. In Base's case, the data in question are [sequencer batches][sequencer-batch] that [validators][validator] -need in order to verify the sequencer's work and validate the L2 chain. - -The [finalization period][finalization-period] should be taken as the lower bound on the availability window, since -that is when data availability is the most crucial, as it is needed to perform a [fault proof][fault-proof]. - -"Availability" **does not** mean guaranteed long-term storage of the data. - -### Data Availability Provider - -[avail-provider]: glossary.md#data-availability-provider - -A data availability provider is a service that can be used to make data available. See the [Data -Availability][data-availability] for more information on what this means. - -Ideally, a good data availability provider provides strong _verifiable_ guarantees of data availability - -At present, the supported data availability providers include Ethereum call data and blob data. - -### Sequencer Batch - -[sequencer-batch]: glossary.md#sequencer-batch - -A sequencer batch is list of L2 transactions (that were submitted to a sequencer) tagged with an [epoch -number](#sequencing-epoch) and an L2 block timestamp (which can trivially be converted to a block number, given our -block time is constant). - -Sequencer batches are part of the [L2 derivation inputs][deriv-inputs]. Each batch represents the inputs needed to build -**one** L2 block (given the existing L2 chain state) — except for the first block of each epoch, which also needs -information about deposits (cf. the section on [L2 derivation inputs][deriv-inputs]). - -### Channel - -[channel]: glossary.md#channel - -A channel is a sequence of [sequencer batches][sequencer-batch] (for sequential blocks) compressed together. The reason -to group multiple batches together is simply to obtain a better compression rate, hence reducing data availability -costs. - -A channel can be split in [frames][channel-frame] in order to be transmitted via [batcher -transactions][batcher-transaction]. The reason to split a channel into frames is that a channel might be too large to -include in a single batcher transaction. - -A channel is uniquely identified by its timestamp (UNIX time at which the channel was created) and a random value. See -the [Frame Format][frame-format] section of the L2 Chain Derivation specification for more information. - -[frame-format]: ../protocol/consensus/derivation.md#frame-format - -On the side of the [rollup node][rollup-node] (which is the consumer of channels), a channel is considered to be -_opened_ if its final frame (explicitly marked as such) has not been read, or closed otherwise. - -### Channel Frame - -[channel-frame]: glossary.md#channel-frame - -A channel frame is a chunk of data belonging to a [channel]. [Batcher transactions][batcher-transaction] carry one or -multiple frames. The reason to split a channel into frames is that a channel might too large to include in a single -batcher transaction. - -### Batcher - -[batcher]: glossary.md#batcher - -A batcher is a software component (independent program) that is responsible to make channels available on a data -availability provider. The batcher communicates with the rollup node in order to retrieve the channels. The channels are -then made available using [batcher transactions][batcher-transaction]. - -> **TODO** In the future, we might want to make the batcher responsible for constructing the channels, letting it only -> query the rollup node for L2 block inputs. - -### Batcher Transaction - -[batcher-transaction]: glossary.md#batcher-transaction - -A batcher transaction is a transaction submitted by a [batcher] to a data availability provider, in order to make -channels available. These transactions carry one or more full frames, which may belong to different channels. A -channel's frames may be split between multiple batcher transactions. - -When submitted to Ethereum calldata, the batcher transaction's receiver must be the sequencer inbox address. The -transaction must also be signed by a recognized batch submitter account. The recognized batch submitter account -is stored in the [System Configuration][system-config]. - -### Batch submission frequency - -Within the [sequencing-window] constraints the batcher is permitted by the protocol to submit L2 blocks for -data-availability at any time. The batcher software allows for dynamic policy configuration by its operator. -The rollup enforces safety guarantees and liveness through the sequencing window, if the batcher does not submit -data within this allotted time. - -By submitting new L2 data in smaller more frequent steps, there is less delay in confirmation of the L2 block -inputs. This allows verifiers to ensure safety of L2 blocks sooner. This also reduces the time to finality of -the data on L1, and thus the time to L2 input-finality. - -By submitting new L2 data in larger less frequent steps, there is more time to aggregate more L2 data, and -thus reduce fixed overhead of the batch-submission work. This can reduce batch-submission costs, especially -for lower throughput chains that do not fill data-transactions (typically 128 KB of calldata, or 800 KB -of blobdata) as quickly. - -### Channel Timeout - -[channel-timeout]: glossary.md#channel-timeout - -The channel timeout is a duration (in L1 blocks) during which [channel frames][channel-frame] may land on L1 within -[batcher transactions][batcher-transaction]. - -The acceptable time range for the frames of a [channel][channel] is `[channel_id.timestamp, channel_id.timestamp + -CHANNEL_TIMEOUT]`. The acceptable L1 block range for these frames are any L1 block whose timestamp falls inside this -time range. (Note that `channel_id.timestamp` must be lower than the L1 block timestamp of any L1 block in which frames -of the channel are seen, or else these frames are ignored.) - -The purpose of channel timeouts is dual: - -- Avoid keeping old unclosed channel data around forever (an unclosed channel is a channel whose final frame was not - sent). -- Bound the number of L1 blocks we have to look back in order to decode [sequencer batches][sequencer-batch] from - channels. This is particularly relevant during L1 re-orgs, see the [Resetting Channel Buffering][reset-channel-buffer] - section of the L2 Chain Derivation specification for more information. - -[reset-channel-buffer]: ../protocol/consensus/derivation.md#resetting-channel-buffering - -> **TODO** specify `CHANNEL_TIMEOUT` - - -## L2 Output Root Proposals - -[l2-output-root-proposals]: glossary.md#l2-output-root-proposals - -### Proposer - -[proposer]: glossary.md#proposer - -The proposer's role is to construct and submit output roots, which are commitments to the L2's state, to the -L2OutputOracle contract on L1 (the settlement layer). To do this, the proposer periodically queries the rollup node for -the latest output root derived from the latest finalized L1 block. It then takes the output root and submits it to the -L2OutputOracle contract on the settlement layer (L1). - - -## L2 Chain Derivation - -[derivation]: glossary.md#L2-chain-derivation - -L2 chain derivation is a process that reads [L2 derivation inputs][deriv-inputs] from L1 in order to derive the L2 -chain. - -See the [L2 chain derivation specification][derivation-spec] for more details. - -### L2 Derivation Inputs - -[deriv-inputs]: glossary.md#l2-derivation-inputs - -This term refers to data that is found in L1 blocks and is read by the [rollup node][rollup-node] to construct [payload -attributes][payload-attr]. - -L2 derivation inputs include: - -- L1 block attributes - - block number - - timestamp - - basefee - - blob base fee -- [deposits] (as log data) -- [sequencer batches][sequencer-batch] (as transaction data) -- [System configuration][system-config] updates (as log data) - -### System Configuration - - -This term refers to the collection of dynamically configurable rollup parameters maintained -by the [`SystemConfig`](../protocol/consensus/derivation.md#system-configuration) contract on L1 and read by the L2 [derivation] process. -These parameters enable keys to be rotated regularly and external cost parameters to be adjusted -without the network upgrade overhead of a hardfork. - -See the [System Configuration](../protocol/consensus/derivation.md#system-configuration) section for a full overview. - -### Payload Attributes - -[payload-attr]: glossary.md#payload-attributes - -This term refers to an object that can be derived from [L2 chain derivation inputs][deriv-inputs] found on L1, which are -then passed to the [execution engine][execution-engine] to construct L2 blocks. - -The payload attributes object essentially encodes [a block without output properties][block]. - -Payload attributes are originally specified in the [Ethereum Engine API specification][engine-api], which we expand in -the [Execution Engine Specification][exec-engine]. - -See also the [Building The Payload Attributes][building-payload-attr] section of the rollup node specification. - -[building-payload-attr]: ../protocol/consensus/index.md#building-the-payload-attributes - -### L2 Genesis Block - -[l2-genesis]: glossary.md#l2-genesis-block - -The L2 genesis block is the first block of the L2 chain in its current version. - -The state of the L2 genesis block comprises: - -- State inherited from the previous version of the L2 chain. - - This state was possibly modified by "state surgeries". For instance, the migration to Bedrock entailed changes on - how native ETH balances were stored in the storage trie. -- [Predeployed contracts][predeploy] - -The timestamp of the L2 genesis block must be a multiple of the [block time][block-time] (i.e. a even number, since the -block time is 2 seconds). - -When updating the rollup protocol to a new version, we may perform a _squash fork_, a process that entails the creation -of a new L2 genesis block. This new L2 genesis block will have block number `X + 1`, where `X` is the block number of -the final L2 block before the update. - -A squash fork is not to be confused with a _re-genesis_, a similar process that we employed in the past, which also -resets L2 block numbers, such that the new L2 genesis block has number 0. We will not employ re-genesis in the future. - -Squash forks are superior to re-geneses because they avoid duplicating L2 block numbers, which breaks a lot of external -tools. - -### L2 Chain Inception - -[l2-chain-inception]: glossary.md#L2-chain-inception - -The L1 block number at which the output roots for the [genesis block][l2-genesis] were proposed on the [output -oracle][output-oracle] contract. - -In the current implementation, this is the L1 block number at which the output oracle contract was deployed or upgraded. - -### Safe L2 Block - -[safe-l2-block]: glossary.md#safe-l2-block - -A safe L2 block is an L2 block that can be derived entirely from L1 by a [rollup node][rollup-node]. This can vary -between different nodes, based on their view of the L1 chain. - -### Safe L2 Head - -[safe-l2-head]: glossary.md#safe-l2-head - -The safe L2 head is the highest [safe L2 block][safe-l2-block] that a [rollup node][rollup-node] knows about. - -### Unsafe L2 Block - -[unsafe-l2-block]: glossary.md#unsafe-l2-block - -An unsafe L2 block is an L2 block that a [rollup node][rollup-node] knows about, but which was not derived from the L1 -chain. In sequencer mode, this will be a block sequenced by the sequencer itself. In validator mode, this will be a -block acquired from the sequencer via [unsafe sync][unsafe-sync]. - -### Unsafe L2 Head - -[unsafe-l2-head]: glossary.md#unsafe-l2-head - -The unsafe L2 head is the highest [unsafe L2 block][unsafe-l2-block] that a [rollup node][rollup-node] knows about. - -### Unsafe Block Consolidation - -[consolidation]: glossary.md#unsafe-block-consolidation - -Unsafe block consolidation is the process through which the [rollup node][rollup-node] attempts to move the [safe L2 -head][safe-l2-head] a block forward, so that the oldest [unsafe L2 block][unsafe-l2-block] becomes the new safe L2 head. - -In order to perform consolidation, the node verifies that the [payload attributes][payload-attr] derived from the L1 -chain match the oldest unsafe L2 block exactly. - -See the [Engine Queue section][engine-queue] of the L2 chain derivation spec for more information. - -[engine-queue]: ../protocol/consensus/derivation.md#engine-queue - -### Finalized L2 Head - -[finalized-l2-head]: glossary.md#finalized-l2-head - -The finalized L2 head is the highest L2 block that can be derived from _[finalized][finality]_ L1 blocks — i.e. L1 -blocks older than two L1 epochs (64 L1 [time slots][time-slot]). - -[finality]: https://hackmd.io/@prysmaticlabs/finality - - -## Other L2 Chain Concepts - -### Address Aliasing - -[address-aliasing]: glossary.md#address-aliasing - -When a contract submits a [deposit][deposits] from L1 to L2, its address (as returned by `ORIGIN` and `CALLER`) will be -aliased with a modified representation of the address of a contract. - -- cf. [Deposit Specification](../protocol/bridging/deposits.md#address-aliasing) - -### Rollup Node - -[rollup-node]: glossary.md#rollup-node - -The rollup node is responsible for [deriving the L2 chain][derivation] from the L1 chain (L1 [blocks][block] and their -associated [receipts][receipt]). - -The rollup node can run either in _validator_ or _sequencer_ mode. - -In sequencer mode, the rollup node receives L2 transactions from users, which it uses to create L2 blocks. These are -then submitted to a [data availability provider][avail-provider] via [batch submission][batch-submission]. The L2 chain -derivation then acts as a sanity check and a way to detect L1 chain [re-orgs][reorg]. - -In validator mode, the rollup node performs derivation as indicated above, but is also able to "run ahead" of the L1 -chain by getting blocks directly from the sequencer, in which case derivation serves to validate the sequencer's -behavior. - -A rollup node running in validator mode is sometimes called _a replica_. - -> **TODO** expand this to include output root submission - -See the [rollup node specification][rollup-node-spec] for more information. - -### Rollup Driver - -[rollup driver]: glossary.md#rollup-driver - -The rollup driver is the [rollup node][rollup-node] component responsible for [deriving the L2 chain][derivation] -from the L1 chain (L1 [blocks][block] and their associated [receipts][receipt]). - -> **TODO** delete this entry, alongside its reference — can be replaced by "derivation process" or "derivation logic" -> where needed - -### L1 Attributes Predeployed Contract - -[l1-attr-predeploy]: glossary.md#l1-attributes-predeployed-contract - -A [predeployed contract][predeploy] on L2 that can be used to retrieve the L1 block attributes of L1 blocks with a given -block number or a given block hash. - -cf. [L1 Attributes Predeployed Contract Specification](../protocol/bridging/deposits.md#l1-attributes-predeployed-contract) - -### L2 Output Root - -[l2-output]: glossary.md#l2-output-root - -A 32 byte value which serves as a commitment to the current state of the L2 chain. - -cf. [Proposer](../protocol/fault-proof/proposer.md) - -### L2 Output Oracle Contract - -[output-oracle]: glossary.md#l2-output-oracle-contract - -An L1 contract to which [L2 output roots][l2-output] are posted by the [sequencer]. - -### Validator - -[validator]: glossary.md#validator - -A validator is an entity (individual or organization) that runs a [rollup node][rollup-node] in validator mode. - -Doing so grants a lot of benefits similar to running an Ethereum node, such as the ability to simulate L2 transactions -locally, without rate limiting. - -It also lets the validator verify the work of the [sequencer], by re-deriving [output roots][l2-output] and comparing -them against those submitted by the sequencer. In case of a mismatch, the validator can perform a [fault -proof][fault-proof]. - -### Fault Proof - -[fault-proof]: glossary.md#fault-proof - -An on-chain _interactive_ proof, performed by [validators][validator], that demonstrates that a [sequencer] provided -erroneous [output roots][l2-output]. - -cf. [Fault Proofs](../protocol/fault-proof/index.md) - -### Time Slot - -[time-slot]: glossary.md#time-slot - -On L2, there is a block every 2 seconds (this duration is known as the [block time][block-time]). - -We say that there is a "time slot" every multiple of 2s after the timestamp of the [L2 genesis block][l2-genesis]. - -On L1, post-[merge], the time slots are every 12s. However, an L1 block may not be produced for every time slot, in case -of even benign consensus issues. - -### Block Time - -[block-time]: glossary.md#block-time - -The L2 block time is 2 seconds, meaning there is an L2 block at every 2s [time slot][time-slot]. - -Post-[merge], it could be said that the L1 block time is 12s as that is the L1 [time slot][time-slot]. However, in -reality the block time is variable as some time slots might be skipped. - -Pre-merge, the L1 block time is variable, though it is on average 13s. - -### Unsafe Sync - -[unsafe-sync]: glossary.md#unsafe-sync - -Unsafe sync is the process through which a [validator][validator] learns about [unsafe L2 blocks][unsafe-l2-block] from -the [sequencer][sequencer]. - -These unsafe blocks will later need to be confirmed by the L1 chain (via [unsafe block consolidation][consolidation]). - - -## Execution Engine Concepts - -### Execution Engine - -[execution-engine]: glossary.md#execution-engine - -The execution engine is responsible for executing transactions in blocks and computing the resulting state roots, -receipts roots and block hash. - -Both L1 (post-[merge]) and L2 have an execution engine. - -On L1, the executed blocks can come from L1 block synchronization; or from a block freshly minted by the execution -engine (using transactions from the L1 [mempool]), at the request of the L1 consensus layer. - -On L2, the executed blocks are freshly minted by the execution engine at the request of the [rollup node][rollup-node], -using transactions [derived from L1 blocks][derivation]. - -In these specifications, "execution engine" always refer to the L2 execution engine, unless otherwise specified. - -- cf. [Execution Engine Specification][exec-engine] - - - -[deposits-spec]: ../protocol/bridging/deposits.md -[system-config]: ../protocol/consensus/derivation.md#system-configuration -[exec-engine]: ../protocol/execution/index.md -[derivation-spec]: ../protocol/consensus/derivation.md -[rollup-node-spec]: ../protocol/consensus/index.md - - - -[mpt-details]: https://github.com/norswap/nanoeth/blob/d4c0c89cc774d4225d16970aa44c74114c1cfa63/src/com/norswap/nanoeth/trees/patricia/README.md -[trie]: https://en.wikipedia.org/wiki/Trie -[bloom filter]: https://en.wikipedia.org/wiki/Bloom_filter -[Solidity events]: https://docs.soliditylang.org/en/latest/contracts.html?highlight=events#events -[nano-header]: https://github.com/norswap/nanoeth/blob/cc5d94a349c90627024f3cd629a2d830008fec72/src/com/norswap/nanoeth/blocks/BlockHeader.java#L22-L156 -[yellow]: https://ethereum.github.io/yellowpaper/paper.pdf -[engine-api]: https://github.com/ethereum/execution-apis/blob/main/src/engine/shanghai.md#PayloadAttributesV2 -[merge]: https://ethereum.org/en/eth2/merge/ -[mempool]: https://www.quicknode.com/guides/defi/how-to-access-ethereum-mempool -[L1 consensus layer]: https://github.com/ethereum/consensus-specs/#readme -[cannon]: https://github.com/ethereum-optimism/cannon -[eip4844]: https://www.eip4844.com/ diff --git a/docs/specs/pages/upgrades/azul/exec-engine.md b/docs/specs/pages/upgrades/azul/exec-engine.md deleted file mode 100644 index 03999866e2..0000000000 --- a/docs/specs/pages/upgrades/azul/exec-engine.md +++ /dev/null @@ -1,131 +0,0 @@ -# Azul: Execution Engine - -## EVM Changes - -### Transaction Gas Limit Cap - -[EIP-7825](https://eips.ethereum.org/EIPS/eip-7825) introduces a protocol-level maximum gas limit -of 16,777,216 (2^24) per transaction. Transactions exceeding this cap are rejected during validation. - -Base adopts the same cap as L1 to maximize Ethereum equivalence. - -:::note -Deposit transactions will be exempt from the transaction gas limit cap. They are already limited to [20,000,000 gas][gas-market] as that is the most -gas that can be included in an L1 block. -::: - - -[gas-market]: ../../protocol/bridging/deposits.md#default-values - -### Upper-Bound MODEXP - -[EIP-7823](https://eips.ethereum.org/EIPS/eip-7823) caps MODEXP precompile inputs to a maximum of -1024 bytes per field. Calls with larger inputs are rejected. - -### MODEXP Gas Cost Increase - -[EIP-7883](https://eips.ethereum.org/EIPS/eip-7883) raises the MODEXP precompile minimum gas cost -from 200 to 500 and triples the general cost calculation. - -### CLZ Opcode - -[EIP-7939](https://eips.ethereum.org/EIPS/eip-7939) adds a new `CLZ` opcode that counts the number -of leading zero bits in a 256-bit word, returning 256 if the input is zero. - -### secp256r1 Precompile Gas Cost - -[EIP-7951](https://eips.ethereum.org/EIPS/eip-7951) specifies the secp256r1 precompile at address `0x100` -with a gas cost of 3,450. - -Base already has the `p256Verify` precompile at the same address (added in Fjord via -[RIP-7212](https://github.com/ethereum/RIPs/blob/master/RIPS/rip-7212.md)) with a gas cost of 3,450. -From Azul, the gas cost increases to 6,900 to match the L1 gas cost specified in EIP-7951, maintaining -strict equivalence with L1 precompile pricing. - -## Networking Changes - -### eth/69 - -[EIP-7642](https://eips.ethereum.org/EIPS/eip-7642) updates the Ethereum wire protocol to version 69, -removing legacy fields from the `Status` message and simplifying the handshake. - -### Remove Account Balances & Receipts - -The `FlashblocksMetadata` payload transmitted over the Flashblocks WebSocket is simplified in Azul. -The `new_account_balances` and `receipts` fields are removed. The `access_list` field remains but -will not be populated in Azul. - -**Before:** - -```json -{ - "block_number": 43403718, - "new_account_balances": { - "0x4200000000000000000000000000000000000006": "0x35277a9715c6df1c99de" - }, - "receipts": { - "0x1ef9be45b3f7d44de9d98767ddb7c0e330b21777b67a3c79d469be9ffab091dd": { - "cumulativeGasUsed": "0x177d7bd", - "logs": [], - "status": "0x1", - "type": "0x2" - } - }, - "access_list": null -} -``` - -**After:** - -```json -{ - "block_number": 43403718, - "access_list": null -} -``` - -## RPC Changes - -### Engine API Usage - -At and after Azul activation, block production and import use the following Engine API methods: - -- `engine_forkchoiceUpdatedV3` for starting block builds and forkchoice synchronization. -- `engine_getPayloadV5` for fetching built payloads. -- `engine_newPayloadV4` for importing payloads into the execution engine. - -`engine_getPayloadV5` returns a V5 envelope, but the contained execution payload is still V4-shaped. -As a result, payload insertion continues through `engine_newPayloadV4` (there is no `engine_newPayloadV5` -path used by Base Azul clients). - -Azul constraints for this flow: - -- Blob-related Engine API inputs are constrained to empty values: - - `expectedBlobVersionedHashes` MUST be an empty array. - - `blobsBundle` in `engine_getPayloadV5` responses is expected to be empty. -- `executionRequests` in `engine_newPayloadV4` MUST be an empty array. - -### eth_config RPC Method - -[EIP-7910](https://eips.ethereum.org/EIPS/eip-7910) introduces the `eth_config` JSON-RPC method, -which returns chain configuration parameters such as fork activation timestamps. - -Base Azul exposes `eth_config` using the standard EIP-7910 response schema. - -The Base-specific behavior is: - -- `blobSchedule` is always returned as zeroed values for `current`, `next`, and `last`. - Base does not support native blob transactions, so it must not advertise synthetic Ethereum blob - schedule defaults. -- `precompiles` reflects the active EVM precompile set for that fork. This includes the standard - Ethereum precompiles plus any Base-active additions documented in the - [precompiles specification](../../protocol/execution/evm/precompiles.md). -- `systemContracts` is limited to the contracts representable by EIP-7910. On Base this means: - - `BEACON_ROOTS_ADDRESS` is included once Ecotone is active. - - `HISTORY_STORAGE_ADDRESS` is included once Isthmus is active. - - `DEPOSIT_CONTRACT_ADDRESS`, `CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS`, and - `WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS` are omitted. - -Base-specific predeploys and other rollup system contracts documented in the -[predeploys specification](../../protocol/execution/evm/predeploys.md) are not serialized into -`eth_config` unless they are part of the EIP-7910 schema. diff --git a/docs/specs/pages/upgrades/azul/overview.md b/docs/specs/pages/upgrades/azul/overview.md deleted file mode 100644 index a020b66658..0000000000 --- a/docs/specs/pages/upgrades/azul/overview.md +++ /dev/null @@ -1,42 +0,0 @@ -# Azul - -## Summary - -:::warning -Only `base-consensus` and `base-reth-node` will support the Base Azul hardfork. If you are running -`op-node`, `op-geth`, or any other clients, you will need to update them prior to the activation -date. -::: - -- Add Osaka Support -- Simplify Flashblocks Websocket Format -- Enable a new multi-proof system for faster withdrawals and a path to stronger decentralization -- Only Base Node Reth / Base Consensus will be supported - -## Activation Timestamps - -| Network | Activation timestamp | -| --------- | -------------------------------------- | -| `mainnet` | TBD | -| `sepolia` | `1776708000` (2026-04-20 18:00:00 UTC) | - -## Execution Layer - -- [EIP-7823: Upper-Bound MODEXP](/upgrades/azul/exec-engine#upper-bound-modexp) -- [EIP-7825: Transaction Gas Limit Cap](/upgrades/azul/exec-engine#transaction-gas-limit-cap) -- [EIP-7883: MODEXP Gas Cost Increase](/upgrades/azul/exec-engine#modexp-gas-cost-increase) -- [EIP-7939: CLZ Opcode](/upgrades/azul/exec-engine#clz-opcode) -- [EIP-7951: secp256r1 Precompile](/upgrades/azul/exec-engine#secp256r1-precompile-gas-cost) -- [EIP-7642: eth/69](/upgrades/azul/exec-engine#eth69) -- [EIP-7910: eth_config RPC Method](/upgrades/azul/exec-engine#eth_config-rpc-method) -- [Remove Account Balances & Receipts](/upgrades/azul/exec-engine#remove-account-balances--receipts) - -## Proofs - -- [Proof System](/upgrades/azul/proofs) -- [New/Changed Onchain Components](/upgrades/azul/proofs#newchanged-onchain-components) -- [Proposer](/upgrades/azul/proofs#proposer) -- [Challenger](/upgrades/azul/proofs#challenger) -- [TEE Provers](/upgrades/azul/proofs#tee-provers) -- [ZK Provers](/upgrades/azul/proofs#zk-provers) -- [Prover Registrar](/upgrades/azul/proofs#prover-registrar) diff --git a/docs/specs/pages/upgrades/azul/proofs.md b/docs/specs/pages/upgrades/azul/proofs.md deleted file mode 100644 index c67201b4a2..0000000000 --- a/docs/specs/pages/upgrades/azul/proofs.md +++ /dev/null @@ -1,128 +0,0 @@ -# Azul: Proof System - -Azul introduces a multi-proof system for the L2 checkpoints that secure withdrawals to L1. A -checkpoint is a fixed interval of L2 blocks summarized by an output root. Each proposal about that -checkpoint is submitted to `AggregateVerifier`, an L1 dispute game that can verify one or two -proofs for the same proposal before withdrawals rely on it. - -In the common path, a TEE prover creates the initial proposal proof. A permissionless ZK prover can -later back the same proposal or dispute an invalid one. `AggregateVerifier` delegates proof checks -to dedicated verifier contracts, while a prover registrar keeps the onchain registry of accepted -TEE signer identities up to date. - -## Why Change the Proof System - -Base's current [fault-proof system](/protocol/fault-proof) is optimistic and interactive: a -proposal resolves unless someone challenges it. That model has two limits for Azul. - -- Withdrawals take at least 7 days because every proposal inherits the full challenge window. -- Every bad proposal must be actively challenged. That creates an economic attack surface: if - challengers cannot fund every dispute, an incorrect state can finalize. Centralized guardrails - reduce that risk today, but that is not a long-term model for Stage 2 decentralization. - -Azul replaces that model with a multi-proof design built around TEE and ZK provers. TEE proofs -support the common path, ZK proofs provide a permissionless backstop, and the architecture leaves -room to adopt stronger proving systems over time. - -## Finality Model - -The Azul design supports three settlement paths for a proposal on Ethereum: - -| Proofs present | Settlement path | Target window | What it means | -| -------------- | --------------- | ------------- | ---------------------------------------- | -| TEE only | Long window | 7 days | Common path, still overridable by ZK | -| ZK only | Long window | 7 days | Permissionless path without TEE reliance | -| TEE + ZK | Short window | 1 day | Faster finality when both systems agree | - -The long window gives independent provers time to verify a claim and dispute it if needed. The -short window is available only when both proof systems back the same proposal. A ZK prover can also -dispute an invalid TEE-backed claim and claim the TEE prover's bond as a reward. In Azul, that delay -lives in `AggregateVerifier` itself. `OptimismPortal2` and `AnchorStateRegistry` no longer add a -separate 3.5 day delay, because keeping either legacy delay would eliminate the fast-finality path -even when both proofs are present. - -## Security and Decentralization - -- The TEE path is permissioned and optimized for the common case. -- The ZK path is permissionless and can override an invalid TEE-backed claim. -- The proof layer remains modular and can evolve toward stronger TEE implementations, different ZK - systems, or multi-ZK designs. - -## Overview - -### New/Changed Onchain Components - -- `AggregateVerifier`: Azul's dispute-game contract for checkpoint proposals. Each proposal is - initialized with one proof, a second proof can be added later for the same claimed root, and the - contract calls proof-specific verifier contracts and aggregates their results to determine how the - proposal resolves. This is also where the Azul finality delay now lives. -- `TEEVerifier` and `ZKVerifier`: proof-specific verifier contracts called by `AggregateVerifier`. - Their addresses are immutable on the `AggregateVerifier` implementation, so each deployment has - an explicit verifier set. -- `DelayedWETH`: still escrows the proposal bond for each game, but Azul reduces its withdrawal delay - to 1 day. That is sufficient here because the only bonds at stake are proposer bonds. -- `OptimismPortal2`: no longer adds the separate 3.5 day proof-maturity delay for these proposals. - That timing moves into `AggregateVerifier`, which keeps the 1 day path reachable instead of - forcing every proposal to inherit at least 3.5 days of extra delay. -- `AnchorStateRegistry`: Similar to `OptimismPortal2`, this no longer has a 3.5 day finalization - delay for proposals, allowing fast finality. - -### Proof Flow - -The proof flow for Azul is: - -1. The proposer identifies the next canonical checkpoint range and requests a TEE proof. -2. The TEE prover re-executes that L2 block range inside an AWS Nitro Enclave and signs the - resulting output root. -3. The proposer verifies the result against canonical Base L2 state and submits a new - `AggregateVerifier` game to L1. -4. A challenger can independently recompute the same checkpoint roots and, if it finds an invalid - claim, sources the ZK proof needed to dispute it. - -This architecture keeps the normal path simple, preserves a permissionless dispute path, and -supports faster settlement when both proof systems are available. - -## Proof Roles - -- The proposer turns canonical L2 checkpoints into new `AggregateVerifier` games on L1. -- A challenger checks in-progress games against canonical L2 state and disputes incorrect claims. -- TEE provers power the common proposal path. -- ZK provers provide the permissionless verification and override path. -- The registrar maintains the onchain registry of accepted TEE signer identities. -- `AggregateVerifier` and its verifier contracts verify claims before withdrawals on L1 can rely on - them. - -## Proposer - -The proposer turns safe or finalized Base L2 checkpoints into L1 `AggregateVerifier` games. It -finds the latest canonical parent state, requests a TEE proof for the next checkpoint interval, -verifies the returned output root against canonical L2 state, and submits the next proposal with -the required bond. - -## Challenger - -Anyone can run a challenger. A challenger independently recomputes checkpoint output roots for -in-progress games, identifies the first invalid claim, and submits the required dispute -transaction. The permissionless dispute path is a ZK proof challenge. Base will run a challenger as -a security backstop, and Base's challenger also has access to a TEE nullification path for invalid -TEE-backed proposals. - -## TEE Provers - -TEE provers are AWS Nitro Enclave-backed services used in the common proposal path. The host gathers -witness data from RPCs, the enclave re-executes the requested L2 block range in isolation, and the -enclave signs the resulting checkpoint outputs with a key that never leaves the enclave. - -## ZK Provers - -ZK provers are the permissionless proving backend in Azul. They are used when a dispute requires a -ZK proof, especially to challenge an invalid TEE-backed proposal or to invalidate a bad ZK claim. -In normal operation, the proposer does not depend on ZK provers to create new games. In the -future, the proposer may integrate ZK provers directly so new roots can carry both proof paths from -the start, unlocking faster finality for all roots. - -## Prover Registrar - -The prover registrar keeps the onchain `TEEProverRegistry` in sync with the live set of Nitro prover -signers. It discovers active provers, attests their signer identities onchain, and removes orphaned -signers with safeguards against transient outages. diff --git a/docs/specs/pages/upgrades/canyon/overview.md b/docs/specs/pages/upgrades/canyon/overview.md deleted file mode 100644 index 4416be62ec..0000000000 --- a/docs/specs/pages/upgrades/canyon/overview.md +++ /dev/null @@ -1,44 +0,0 @@ -# Canyon - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1704992401` (2024-01-11 17:00:01 UTC) | -| `sepolia` | `1699981200` (2023-11-14 17:00:00 UTC) | - -[eip3651]: https://eips.ethereum.org/EIPS/eip-3651 -[eip3855]: https://eips.ethereum.org/EIPS/eip-3855 -[eip3860]: https://eips.ethereum.org/EIPS/eip-3860 -[eip4895]: https://eips.ethereum.org/EIPS/eip-4895 -[eip6049]: https://eips.ethereum.org/EIPS/eip-6049 - -[block-validation]: ../../protocol/consensus/p2p.md#block-validation -[payload-attributes]: ../../protocol/consensus/derivation.md#building-individual-payload-attributes -[1559-params]: ../../protocol/execution/index.md#1559-parameters -[channel-reading]: ../../protocol/consensus/derivation.md#reading -[deposit-reading]: ../../protocol/bridging/deposits.md#deposit-receipt -[create2deployer]: ../../protocol/execution/evm/predeploys.md#create2deployer - -The Canyon upgrade contains the Shapella upgrade from L1 and some minor protocol fixes. -The Canyon upgrade uses a _L2 block-timestamp_ activation-rule, and is specified in both the -rollup-node (`canyon_time`) and execution engine (`config.canyonTime`). Shanghai time in the -execution engine should be set to the same time as the Canyon time. - -## Execution Layer - -- Shapella Upgrade - - [EIP-3651: Warm COINBASE][eip3651] - - [EIP-3855: PUSH0 instruction][eip3855] - - [EIP-3860: Limit and meter initcode][eip3860] - - [EIP-4895: Beacon chain push withdrawals as operations][eip4895] - - [Withdrawals are prohibited in P2P Blocks][block-validation] - - [Withdrawals should be set to the empty array with Canyon][payload-attributes] - - [EIP-6049: Deprecate SELFDESTRUCT][eip6049] -- [Modifies the EIP-1559 Denominator][1559-params] -- [Adds the deposit nonce & deposit nonce version to the deposit receipt hash][deposit-reading] -- [Deploys the create2Deployer to `0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2`][create2deployer] - -## Consensus Layer - -- [Channel Ordering Fix][channel-reading] diff --git a/docs/specs/pages/upgrades/delta/overview.md b/docs/specs/pages/upgrades/delta/overview.md deleted file mode 100644 index 9a8cc07445..0000000000 --- a/docs/specs/pages/upgrades/delta/overview.md +++ /dev/null @@ -1,16 +0,0 @@ -# Delta - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1708560000` (2024-02-22 00:00:00 UTC) | -| `sepolia` | `1703203200` (2023-12-22 00:00:00 UTC) | - -The Delta upgrade uses a _L2 block-timestamp_ activation-rule, and is specified only in the rollup-node (`delta_time`). - -## Consensus Layer - -[span-batches]: span-batches.md - -The Delta upgrade consists of a single consensus-layer feature: [Span Batches][span-batches]. diff --git a/docs/specs/pages/upgrades/delta/span-batches.md b/docs/specs/pages/upgrades/delta/span-batches.md deleted file mode 100644 index f6366feb9a..0000000000 --- a/docs/specs/pages/upgrades/delta/span-batches.md +++ /dev/null @@ -1,369 +0,0 @@ -# Span-batches - - - -[g-deposit-tx-type]: ../../reference/glossary.md#deposited-transaction-type -[derivation]: ../../protocol/consensus/derivation.md -[channel-format]: ../../protocol/consensus/derivation.md#channel-format -[batch-format]: ../../protocol/consensus/derivation.md#batch-format -[frame-format]: ../../protocol/consensus/derivation.md#frame-format -[batch-queue]: ../../protocol/consensus/derivation.md#batch-queue -[batcher]: ../../protocol/batcher.md - -## Introduction - -Span-batch is a new batching spec that reduces overhead, -introduced in the [Delta](overview.md) network upgrade. - -The overhead is reduced by representing a span of -consecutive L2 blocks in a more efficient manner, -while preserving the same consistency checks as regular batch data. - -Note that the [channel][channel-format] and -[frame][frame-format] formats stay the same: -data slicing, packing and multi-transaction transport is already optimized. - -The overhead in the [V0 batch format][derivation] comes from: - -- The meta-data attributes are repeated for every L2 block, while these are mostly implied already: - - parent hash (32 bytes) - - L1 epoch: blockhash (32 bytes) and block number (~4 bytes) - - timestamp (~4 bytes) -- The organization of block data is inefficient: - - Similar attributes are far apart, diminishing any chances of effective compression. - - Random data like hashes are positioned in-between the more compressible application data. -- The RLP encoding of the data adds unnecessary overhead - - The outer list does not have to be length encoded, the attributes are known - - Fixed-length attributes do not need any encoding - - The batch-format is static and can be optimized further -- Remaining meta-data for consistency checks can be optimized further: - - The metadata only needs to be secure for consistency checks. E.g. 20 bytes of a hash may be enough. - -Span-batches address these inefficiencies, with a new batch format version. - -## Span batch format - -[span-batch-format]: #span-batch-format - -Note that span-batches, unlike previous singular batches, -encode _a range of consecutive_ L2 blocks at the same time. - -Introduce version `1` to the [batch-format][batch-format] table: - -| `batch_version` | `content` | -| --------------- | ------------------- | -| 1 | `prefix ++ payload` | - -Notation: - -- `++`: concatenation of byte-strings -- `span_start`: first L2 block in the span -- `span_end`: last L2 block in the span -- `uvarint`: unsigned Base128 varint, as defined in [protobuf spec] -- `rlp_encode`: a function that encodes a batch according to the RLP format, - and `[x, y, z]` denotes a list containing items `x`, `y` and `z` - -[protobuf spec]: https://protobuf.dev/programming-guides/encoding/#varints - -Standard bitlists, in the context of span-batches, are encoded as big-endian integers, -left-padded with zeroes to the next multiple of 8 bits. - -Where: - -- `prefix = rel_timestamp ++ l1_origin_num ++ parent_check ++ l1_origin_check` - - `rel_timestamp`: `uvarint` relative timestamp since L2 genesis, - i.e. `span_start.timestamp - config.genesis.timestamp`. - - `l1_origin_num`: `uvarint` number of last l1 origin number. i.e. `span_end.l1_origin.number` - - `parent_check`: first 20 bytes of parent hash, the hash is truncated to 20 bytes for efficiency, - i.e. `span_start.parent_hash[:20]`. - - `l1_origin_check`: the block hash of the last L1 origin is referenced. - The hash is truncated to 20 bytes for efficiency, i.e. `span_end.l1_origin.hash[:20]`. -- `payload = block_count ++ origin_bits ++ block_tx_counts ++ txs`: - - `block_count`: `uvarint` number of L2 blocks. This is at least 1, empty span batches are invalid. - - `origin_bits`: standard bitlist of `block_count` bits: - 1 bit per L2 block, indicating if the L1 origin changed this L2 block. - - `block_tx_counts`: for each block, a `uvarint` of `len(block.transactions)`. - - `txs`: L2 transactions which is reorganized and encoded as below. -- `txs = contract_creation_bits ++ y_parity_bits ++ -tx_sigs ++ tx_tos ++ tx_datas ++ tx_nonces ++ tx_gases ++ protected_bits` - - `contract_creation_bits`: standard bitlist of `sum(block_tx_counts)` bits: - 1 bit per L2 transactions, indicating if transaction is a contract creation transaction. - - `y_parity_bits`: standard bitlist of `sum(block_tx_counts)` bits: - 1 bit per L2 transactions, indicating the y parity value when recovering transaction sender address. - - `tx_sigs`: concatenated list of transaction signatures - - `r` is encoded as big-endian `uint256` - - `s` is encoded as big-endian `uint256` - - `tx_tos`: concatenated list of `to` field. `to` field in contract creation transaction will be `nil` and ignored. - - `tx_datas`: concatenated list of variable length rlp encoded data, - matching the encoding of the fields as in the [EIP-2718] format of the `TransactionType`. - - `legacy`: `rlp_encode(value, gasPrice, data)` - - `1`: ([EIP-2930]): `0x01 ++ rlp_encode(value, gasPrice, data, accessList)` - - `2`: ([EIP-1559]): `0x02 ++ rlp_encode(value, max_priority_fee_per_gas, max_fee_per_gas, data, access_list)` - - `tx_nonces`: concatenated list of `uvarint` of `nonce` field. - - `tx_gases`: concatenated list of `uvarint` of gas limits. - - `legacy`: `gasLimit` - - `1`: ([EIP-2930]): `gasLimit` - - `2`: ([EIP-1559]): `gas_limit` - - `protected_bits`: standard bitlist of length of number of legacy transactions: - 1 bit per L2 legacy transactions, indicating if transaction is protected([EIP-155]) or not. - -[EIP-2718]: https://eips.ethereum.org/EIPS/eip-2718 -[EIP-2930]: https://eips.ethereum.org/EIPS/eip-2930 -[EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 -[EIP-155]: https://eips.ethereum.org/EIPS/eip-155 - -### Span Batch Size Limits - -The total size of an encoded span batch is limited to `MAX_RLP_BYTES_PER_CHANNEL`, which is defined in the -[Protocol Parameters table](../../protocol/consensus/derivation.md#protocol-parameters). -This is done at the channel level rather than at the span batch level. - -In addition to the byte limit, the number of blocks, and total transactions is limited to `MAX_SPAN_BATCH_ELEMENT_COUNT`. -This does imply that the max number of transactions per block is also `MAX_SPAN_BATCH_ELEMENT_COUNT`. -`MAX_SPAN_BATCH_ELEMENT_COUNT` is defined in [Protocol Parameters table](../../protocol/consensus/derivation.md#protocol-parameters). - -### Future batch-format extension - -This is an experimental extension of the span-batch format, and not activated with the Delta upgrade yet. - -Introduce version `2` to the [batch-format][batch-format] table: - -| `batch_version` | `content` | -| --------------- | ------------------- | -| 2 | `prefix ++ payload` | - -Where: - -- `prefix = rel_timestamp ++ l1_origin_num ++ parent_check ++ l1_origin_check`: - - Identical to `batch_version` 1 -- `payload = block_count ++ origin_bits ++ block_tx_counts ++ txs ++ fee_recipients`: - - An empty span-batch, i.e. with `block_count == 0`, is invalid and must not be processed. - - Every field definition identical to `batch_version` 1 except that `fee_recipients` is - added to support more decentralized sequencing. - - `fee_recipients = fee_recipients_idxs + fee_recipients_set` - - `fee_recipients_set`: concatenated list of unique L2 fee recipient address. - - `fee_recipients_idxs`: for each block, - `uvarint` number of index to decode fee recipients from `fee_recipients_set`. - -## Span Batch Activation Rule - -The span batch upgrade is activated based on timestamp. - -Activation Rule: `upgradeTime != null && span_start.l1_origin.timestamp >= upgradeTime` - -`span_start.l1_origin.timestamp` is the L1 origin block timestamp of the first block in the span batch. -This rule ensures that every chain activity regarding this span batch is done after the hard fork. -i.e. Every block in the span is created, submitted to the L1, and derived from the L1 after the hard fork. - -## Optimization Strategies - -### Truncating information and storing only necessary data - -The following fields stores truncated data: - -- `rel_timestamp`: We can save two bytes by storing `rel_timestamp` instead of the full `span_start.timestamp`. -- `parent_check` and `l1_origin_check`: We can save twelve bytes by truncating twelve bytes from the full hash, - while having enough safety. - -### `tx_data_headers` removal from initial specs - -We do not need to store length per each `tx_datas` elements even if those are variable length, -because the elements itself is RLP encoded, containing their length in RLP prefix. - -### `Chain ID` removal from initial specs - -Every transaction has chain id. We do not need to include chain id in span batch because L2 already knows its chain id, -and use its own value for processing span batches while derivation. - -### Reorganization of constant length transaction fields - -`signature`, `nonce`, `gaslimit`, `to` field are constant size, so these were split up completely and -are grouped into individual arrays. -This adds more complexity, but organizes data for improved compression by grouping data with similar data pattern. - -### RLP encoding for only variable length fields - -Further size optimization can be done by packing variable length fields, such as `access_list`. -However, doing this will introduce much more code complexity, compared to benefiting from size reduction. - -Our goal is to find the sweet spot on code complexity - span batch size tradeoff. -I decided that using RLP for all variable length fields will be the best option, -not risking codebase with gnarly custom encoding/decoding implementations. - -### Store `y_parity` and `protected_bit` instead of `v` - -Only legacy type transactions can be optionally protected. If protected([EIP-155]), `v = 2 * ChainID + 35 + y_parity`. -Else, `v = 27 + y_parity`. For other types of transactions, `v = y_parity`. -We store `y_parity`, which is single bit per L2 transaction. -We store `protected_bit`, which is single bit per L2 legacy type transactions to indicate that tx is protected. - -This optimization will benefit more when ratio between number of legacy type transactions over number of transactions -excluding deposit tx is higher. -Deposit transactions are excluded in batches and are never written at L1 so excluded while analyzing. - -### Adjust `txs` Data Layout for Better Compression - -There are (8 choose 2) \* 6! = 20160 permutations of ordering fields of `txs`. It is not 8! -because `contract_creation_bits` must be first decoded in order to decode `tx_tos`. We -experimented with different data layouts and found that segregating random data (`tx_sigs`, -`tx_tos`, `tx_datas`) from the rest most improved the zlib compression ratio. - -### `fee_recipients` Encoding Scheme - -Let `K` := number of unique fee recipients(cardinality) per span batch. Let `N` := number of L2 blocks. -If we naively encode each fee recipients by concatenating every fee recipients, it will need `20 * N` bytes. -If we manage `fee_recipients_idxs` and `fee_recipients_set`, It will need at most `max uvarint size * N = 8 * N`, -`20 * K` bytes each. If `20 * N > 8 * N + 20 * K` then maintaining an index of fee recipients is reduces the size. - -we thought sequencer rotation happens not much often, so assumed that `K` will be much lesser than `N`. -The assumption makes upper inequality to hold. Therefore, we decided to manage `fee_recipients_idxs` and -`fee_recipients_set` separately. This adds complexity but reduces data. - -## How Derivation works with Span Batches - -- Block Timestamp - - The first L2 block's block timestamp is `rel_timestamp + L2Genesis.Timestamp`. - - Then we can derive other blocks timestamp by adding L2 block time for each. -- L1 Origin Number - - The parent of the first L2 block's L1 origin number is `l1_origin_num - sum(origin_bits)` - - Then we can derive other blocks' L1 origin number with `origin_bits` - - `i-th block's L1 origin number = (i-1)th block's L1 origin number + (origin_bits[i] ? 1 : 0)` -- L1 Origin Hash - - We only need the `l1_origin_check`, the truncated L1 origin hash of the last L2 block of Span Batch. - - If the last block references canonical L1 chain as its origin, - we can ensure the all other blocks' origins are consistent with the canonical L1 chain. -- Parent hash - - In V0 Batch spec, we need batch's parent hash to validate if batch's parent is consistent with current L2 safe head. - - But in the case of Span Batch, because it contains consecutive L2 blocks in the span, - we do not need to validate all blocks' parent hash except the first block. -- Transactions - - Deposit transactions can be derived from its L1 origin, identical with V0 batch. - - User transactions can be derived by following way: - - Recover `V` value of TX signature from `y_parity_bits` and L2 chain id, as described in optimization strategies. - - When parsing `tx_tos`, `contract_creation_bits` is used to determine if the TX has `to` value or not. - -## Integration - -### Channel Reader (Batch Decoding) - -The Channel Reader decodes the span-batch, as described in the [span-batch format](#span-batch-format). - -A set of derived attributes is computed as described above. Then cached with the decoded result: - -### Batch Queue - -A span-batch is buffered as a singular large batch, -by its starting timestamp (transformed `rel_timestamp`). - -Span-batches share the same queue with v0 batches: batches are processed in L1 inclusion order. - -A set of modified validation rules apply to the span-batches. - -Rules are enforced with the [contextual definitions][batch-queue] as v0-batch validation: -`epoch`, `inclusion_block_number`, `next_timestamp` - -Definitions: - -- `batch` as defined in the [Span batch format section][span-batch-format]. -- `prev_l2_block` is the L2 block from the current safe chain, - whose timestamp is at `span_start.timestamp - l2_block_time` - -Span-batch rules, in validation order: - -- `batch_origin` is determined like with singular batches: - - `batch.epoch_num == epoch.number+1`: - - If `next_epoch` is not known -> `undecided`: - i.e. a batch that changes the L1 origin cannot be processed until we have the L1 origin data. - - If known, then define `batch_origin` as `next_epoch` -- `batch_origin.timestamp < span_batch_upgrade_timestamp` -> `drop`: - i.e. enforce the [span batch upgrade activation rule](#span-batch-activation-rule). -- `span_start.timestamp > next_timestamp` -> `future`: i.e. the batch must be ready to process, - but does not have to start exactly at the `next_timestamp`, since it can overlap with previously processed blocks, -- `span_end.timestamp < next_timestamp` -> `drop`: i.e. the batch must have at least one new block to process. -- If there's no `prev_l2_block` in the current safe chain -> `drop`: i.e. the timestamp must be aligned. -- `batch.parent_check != prev_l2_block.hash[:20]` -> `drop`: - i.e. the checked part of the parent hash must be equal to the same part of the corresponding L2 block hash. -- Sequencing-window checks: - - Note: The sequencing window is enforced for the _batch as a whole_: - if the batch was partially invalid instead, it would drop the oldest L2 blocks, - which makes the later L2 blocks invalid. - - Variables: - - `origin_changed_bit = origin_bits[0]`: `true` if the first L2 block changed its L1 origin, `false` otherwise. - - `start_epoch_num = batch.l1_origin_num - sum(origin_bits) + (origin_changed_bit ? 1 : 0)` - - `end_epoch_num = batch.l1_origin_num` - - Rules: - - `start_epoch_num + sequence_window_size < inclusion_block_number` -> `drop`: - i.e. the batch must be included timely. - - `start_epoch_num > prev_l2_block.l1_origin.number + 1` -> `drop`: - i.e. the L1 origin cannot change by more than one L1 block per L2 block. - - If `batch.l1_origin_check` does not match the canonical L1 chain at `end_epoch_num` -> `drop`: - verify the batch is intended for this L1 chain. - - After upper `l1_origin_check` check is passed, we don't need to check if the origin - is past `inclusion_block_number` because of the following invariant. - - Invariant: the epoch-num in the batch is always less than the inclusion block number, - if and only if the L1 epoch hash is correct. - - `start_epoch_num < prev_l2_block.l1_origin.number` -> `drop`: - epoch number cannot be older than the origin of parent block -- Max Sequencer time-drift & other L1 origin checks: - - Note: The max time-drift is enforced for the _batch as a whole_, to keep the possible output variants small. - - Variables: - - `block_input`: an L2 block from the span-batch, - with L1 origin as derived from the `origin_bits` and now established canonical L1 chain. - - `next_epoch`: `block_input.origin`'s next L1 block. - It may reach to the next origin outside the L1 origins of the span. - - Rules: - - For each `block_input` whose timestamp is greater than `safe_head.timestamp`: - - `block_input.l1_origin.number < safe_head.l1_origin.number` -> `drop`: enforce increasing L1 origins. - - `block_input.timestamp < block_input.origin.time` -> `drop`: enforce the min L2 timestamp rule. - - `block_input.timestamp > block_input.origin.time + max_sequencer_drift`: enforce the L2 timestamp drift rule, - but with exceptions to preserve above min L2 timestamp invariant: - - `len(block_input.transactions) == 0`: - - `origin_bits[i] == 0`: `i` is the index of `block_input` in the span batch. - So this implies the block_input did not advance the L1 origin, - and must thus be checked against `next_epoch`. - - If `next_epoch` is not known -> `undecided`: - without the next L1 origin we cannot yet determine if time invariant could have been kept. - - If `block_input.timestamp >= next_epoch.time` -> `drop`: - the batch could have adopted the next L1 origin without breaking the `L2 time >= L1 time` invariant. - - `len(block_input.transactions) > 0`: -> `drop`: - when exceeding the sequencer time drift, never allow the sequencer to include transactions. -- And for all transactions: - - `drop` if the `batch.tx_datas` list contains a transaction - that is invalid or derived by other means exclusively: - - any transaction that is empty (zero length `tx_data`) - - any [deposited transactions][g-deposit-tx-type] (identified by the transaction type prefix byte in `tx_data`) - - any transaction of a future type > 2 (note that - [Isthmus adds support](../isthmus/derivation.md#activation) - for `SetCode` transactions of type 4) -- Overlapped blocks checks: - - Note: If the span batch overlaps the current L2 safe chain, we must validate all overlapped blocks. - - Variables: - - `block_input`: an L2 block derived from the span-batch. - - `safe_block`: an L2 block from the current L2 safe chain, at same timestamp as `block_input` - - Rules: - - For each `block_input`, whose timestamp is less than `next_timestamp`: - - `block_input.l1_origin.number != safe_block.l1_origin.number` -> `drop` - - `block_input.transactions != safe_block.transactions` -> `drop` - - compare excluding deposit transactions - -Once validated, the batch-queue then emits a block-input for each of the blocks included in the span-batch. -The next derivation stage is thus only aware of individual block inputs, similar to the previous V0 batch, -although not strictly a "v0 batch" anymore. - -### Batcher - -Instead of transforming L2 blocks into batches, -the blocks should be buffered to form a span-batch. - -Ideally the L2 blocks are buffered as block-inputs, to maximize the span of blocks covered by the span-batch: -span-batches of single L2 blocks do not increase efficiency as much as with larger spans. - -This means that the `(c *channelBuilder) AddBlock` function is changed to -not directly call `(co *ChannelOut) AddBatch` but defer that until a minimum number of blocks have been buffered. - -Output-size estimation of the queued up blocks is not possible until the span-batch is written to the channel. -Past a given number of blocks, the channel may be written for estimation, and then re-written if more blocks arrive. - -The [batcher functionality][batcher] stays the same otherwise: unsafe blocks are transformed into batches, -encoded in compressed channels, and then split into frames for submission to L1. -Batcher implementations can implement different heuristics and re-attempts to build the most gas-efficient data-txs. diff --git a/docs/specs/pages/upgrades/ecotone/derivation.md b/docs/specs/pages/upgrades/ecotone/derivation.md deleted file mode 100644 index 0e92d5534d..0000000000 --- a/docs/specs/pages/upgrades/ecotone/derivation.md +++ /dev/null @@ -1,336 +0,0 @@ -# Derivation - -## Ecotone: Blob Retrieval - -With the Ecotone upgrade the retrieval stage is extended to support an additional DA source: -[EIP-4844] blobs. After the Ecotone upgrade we modify the iteration over batcher transactions to -treat transactions of transaction-type == `0x03` (`BLOB_TX_TYPE`) differently. If the batcher -transaction is a blob transaction, then its calldata MUST be ignored should it be present. Instead: - -- For each blob hash in `blob_versioned_hashes`, retrieve the blob that matches it. A blob may be - retrieved from any of a number different sources. Retrieval from a local beacon-node, through - the `/eth/v1/beacon/blob_sidecars/` endpoint, with `indices` filter to skip unrelated blobs, is - recommended. For each retrieved blob: - - The blob SHOULD (MUST, if the source is untrusted) be cryptographically verified against its - versioned hash. - - If the blob has a [valid encoding](#blob-encoding), decode it into its continuous byte-string - and pass that on to the next phase. Otherwise the blob is ignored. - -Note that batcher transactions of type blob must be processed in the same loop as other batcher -transactions to preserve the invariant that batches are always processed in the order they appear -in the block. We ignore calldata in blob transactions so that it may be used in the future for -batch metadata or other purposes. - -## Blob Encoding - -Each blob in a [EIP-4844] transaction really consists of `FIELD_ELEMENTS_PER_BLOB = 4096` field elements. - -Each field element is a number in a prime field of -`BLS_MODULUS = 52435875175126190479447740508185965837690552500527637822603658699938581184513`. -This number does not represent a full `uint256`: `math.log2(BLS_MODULUS) = 254.8570894...` - -The [L1 consensus-specs](https://github.com/ethereum/consensus-specs/blob/master/specs/deneb/polynomial-commitments.md) -describe the encoding of this polynomial. -The field elements are encoded as big-endian integers (`KZG_ENDIANNESS = big`). - -To save computational overhead, only `254` bits per field element are used for rollup data. - -For efficient data encoding, `254` bits (equivalent to `31.75` bytes) are utilized. -`4` elements combine to effectively use `127` bytes. - -`127` bytes of application-layer rollup data is encoded at a time, into 4 adjacent field elements of the blob: - -```python -# read(N): read the next N bytes from the application-layer rollup-data. The next read starts where the last stopped. -# write(V): append V (one or more bytes) to the raw blob. -bytes tailA = read(31) -byte x = read(1) -byte A = x & 0b0011_1111 -write(A) -write(tailA) - -bytes tailB = read(31) -byte y = read(1) -byte B = (y & 0b0000_1111) | (x & 0b1100_0000) >> 2) -write(B) -write(tailB) - -bytes tailC = read(31) -byte z = read(1) -byte C = z & 0b0011_1111 -write(C) -write(tailC) - -bytes tailD = read(31) -byte D = ((z & 0b1100_0000) >> 2) | ((y & 0b1111_0000) >> 4) -write(D) -write(tailD) -``` - -Each written field element looks like this: - -- Starts with one of the prepared 6-bit left-padded byte values, to keep the field element within valid range. -- Followed by 31 bytes of application-layer data, to fill the low 31 bytes of the field element. - -The written output should look like this: - -```text -<----- element 0 -----><----- element 1 -----><----- element 2 -----><----- element 3 -----> -| byte A | tailA... || byte B | tailB... || byte C | tailC... || byte D | tailD... | -``` - -The above is repeated 1024 times, to fill all `4096` elements, -with a total of `(4 * 31 + 3) * 1024 = 130048` bytes of data. - -When decoding a blob, the top-most two bits of each field-element must be 0, -to make the encoding/decoding bijective. - -The first byte of rollup-data (second byte in first field element) is used as a version-byte. - -In version `0`, the next 3 bytes of data are used to encode the length of the rollup-data, as big-endian `uint24`. -Any trailing data, past the length delimiter, must be 0, to keep the encoding/decoding bijective. -If the length is larger than `130048 - 4`, the blob is invalid. - -If any of the encoding is invalid, the blob as a whole must be ignored. - -[EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 - -## Network upgrade automation transactions - -The Ecotone hardfork activation block contains the following transactions, in this order: - -- L1 Attributes Transaction, using the pre-Ecotone `setL1BlockValues` -- User deposits from L1 -- Network Upgrade Transactions - - L1Block deployment - - GasPriceOracle deployment - - Update L1Block Proxy ERC-1967 Implementation Slot - - Update GasPriceOracle Proxy ERC-1967 Implementation Slot - - GasPriceOracle Enable Ecotone - - Beacon block roots contract deployment (EIP-4788) - -To not modify or interrupt the system behavior around gas computation, this block will not include any sequenced -transactions by setting `noTxPool: true`. - -### L1Block Deployment - -The `L1Block` contract is upgraded to process the new Ecotone L1-data-fee parameters and L1 blob base-fee. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000000` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `375,000` -- `data`: `0x60806040523480156100105...` (full bytecode) -- `sourceHash`: `0x877a6077205782ea15a6dc8699fa5ebcec5e0f4389f09cb8eda09488231346f8`, - computed with the "Upgrade-deposited" type, with `intent = "Ecotone: L1 Block Deployment" - -This results in the Ecotone L1Block contract being deployed to `0x07dbe8500fc591d1852B76feE44d5a05e13097Ff`, to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000000 -Computed Address: 0x07dbe8500fc591d1852B76feE44d5a05e13097Ff -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Ecotone: L1 Block Deployment")) -# 0x877a6077205782ea15a6dc8699fa5ebcec5e0f4389f09cb8eda09488231346f8 -``` - -Verify `data`: - -```bash -git checkout 5996d0bc1a4721f2169ba4366a014532f31ea932 -pnpm clean && pnpm install && pnpm build -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/L1Block.sol/L1Block.json -``` - -This transaction MUST deploy a contract with the following code hash -`0xc88a313aa75dc4fbf0b6850d9f9ae41e04243b7008cf3eadb29256d4a71c1dfd`. - -### GasPriceOracle Deployment - -The `GasPriceOracle` contract is upgraded to support the new Ecotone L1-data-fee parameters. Post fork this contract -will use the blob base fee to compute the gas price for L1-data-fee transactions. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000001` -- `to`: `null`, -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `1,000,000` -- `data`: `0x60806040523480156100...` (full bytecode) -- `sourceHash`: `0xa312b4510adf943510f05fcc8f15f86995a5066bd83ce11384688ae20e6ecf42` - computed with the "Upgrade-deposited" type, with `intent = "Ecotone: Gas Price Oracle Deployment" - -This results in the Ecotone GasPriceOracle contract being deployed to `0xb528D11cC114E026F138fE568744c6D45ce6Da7A`, -to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000001 -Computed Address: 0xb528D11cC114E026F138fE568744c6D45ce6Da7A -``` - -Verify `sourceHash`: - -```bash -❯ cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Ecotone: Gas Price Oracle Deployment")) -# 0xa312b4510adf943510f05fcc8f15f86995a5066bd83ce11384688ae20e6ecf42 -``` - -Verify `data`: - -```bash -git checkout 5996d0bc1a4721f2169ba4366a014532f31ea932 -pnpm clean && pnpm install && pnpm build -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/GasPriceOracle.sol/GasPriceOracle.json -``` - -This transaction MUST deploy a contract with the following code hash -`0x8b71360ea773b4cfaf1ae6d2bd15464a4e1e2e360f786e475f63aeaed8da0ae5`. - -### L1Block Proxy Update - -This transaction updates the L1Block Proxy ERC-1967 implementation slot to point to the new L1Block deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x4200000000000000000000000000000000000015` (L1Block Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe600000000000000000000000007dbe8500fc591d1852b76fee44d5a05e13097ff` -- `sourceHash`: `0x18acb38c5ff1c238a7460ebc1b421fa49ec4874bdf1e0a530d234104e5e67dbc` - computed with the "Upgrade-deposited" type, with `intent = "Ecotone: L1 Block Proxy Update" - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0x07dbe8500fc591d1852B76feE44d5a05e13097Ff) -0x3659cfe600000000000000000000000007dbe8500fc591d1852b76fee44d5a05e13097ff -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Ecotone: L1 Block Proxy Update")) -# 0x18acb38c5ff1c238a7460ebc1b421fa49ec4874bdf1e0a530d234104e5e67dbc -``` - -### GasPriceOracle Proxy Update - -This transaction updates the GasPriceOracle Proxy ERC-1967 implementation slot to point to the new GasPriceOracle -deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe6000000000000000000000000b528d11cc114e026f138fe568744c6d45ce6da7a` -- `sourceHash`: `0xee4f9385eceef498af0be7ec5862229f426dec41c8d42397c7257a5117d9230a` - computed with the "Upgrade-deposited" type, with `intent = "Ecotone: Gas Price Oracle Proxy Update"` - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0xb528D11cC114E026F138fE568744c6D45ce6Da7A) -0x3659cfe6000000000000000000000000b528d11cc114e026f138fe568744c6d45ce6da7a -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Ecotone: Gas Price Oracle Proxy Update")) -# 0xee4f9385eceef498af0be7ec5862229f426dec41c8d42397c7257a5117d9230a -``` - -### GasPriceOracle Enable Ecotone - -This transaction informs the GasPriceOracle to start using the Ecotone gas calculation formula. - -A deposit transaction is derived with the following attributes: - -- `from`: `0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001` (Depositer Account) -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `80,000` -- `data`: `0x22b90ab3` -- `sourceHash`: `0x0c1cb38e99dbc9cbfab3bb80863380b0905290b37eb3d6ab18dc01c1f3e75f93`, - computed with the "Upgrade-deposited" type, with `intent = "Ecotone: Gas Price Oracle Set Ecotone" - -Verify data: - -```bash -cast sig "setEcotone()" -0x22b90ab3 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Ecotone: Gas Price Oracle Set Ecotone")) -# 0x0c1cb38e99dbc9cbfab3bb80863380b0905290b37eb3d6ab18dc01c1f3e75f93 -``` - -### Beacon block roots contract deployment (EIP-4788) - -[EIP-4788] introduces a "Beacon block roots" contract, that processes and exposes the beacon-block-root values. -at address `BEACON_ROOTS_ADDRESS = 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02`. - -For deployment, [EIP-4788] defines a pre-[EIP-155] legacy transaction, sent from a key that is derived such that the -transaction signature validity is bound to message-hash, which is bound to the input-data, containing the init-code. - -However, this type of transaction requires manual deployment and gas-payments. -And since the processing is an integral part of the chain processing, and has to be repeated for Base, -the deployment is approached differently here. - -Some chains may already have a user-submitted instance of the [EIP-4788] transaction. -This is cryptographically guaranteed to be correct, but may result in the upgrade transaction -deploying a second contract, with the next nonce. The result of this deployment can be ignored. - -A Deposit transaction is derived with the following attributes: - -- `from`: `0x0B799C86a49DEeb90402691F1041aa3AF2d3C875`, as specified in the EIP. -- `to`: null -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `0x3d090`, as specified in the EIP. -- `isCreation`: `true` -- `data`: - `0x60618060095f395ff33373fffffffffffffffffffffffffffffffffffffffe14604d57602036146024575f5ffd5b5f35801560495762001fff810690815414603c575f5ffd5b62001fff01545f5260205ff35b5f5ffd5b62001fff42064281555f359062001fff015500` -- `isSystemTx`: `false`, even the system-generated transactions spend gas. -- `sourceHash`: `0x69b763c48478b9dc2f65ada09b3d92133ec592ea715ec65ad6e7f3dc519dc00c`, - computed with the "Upgrade-deposited" type, with `intent = "Ecotone: beacon block roots contract deployment"` - -The contract address upon deployment is computed as `rlp([sender, nonce])`, which will equal: - -- `BEACON_ROOTS_ADDRESS` if deployed -- a different address (`0xE3aE1Ae551eeEda337c0BfF6C4c7cbA98dce353B`) if `nonce = 1`: - when a user already submitted the EIP transaction before the upgrade. - -Verify `BEACON_ROOTS_ADDRESS`: - -```bash -cast compute-address --nonce=0 0x0B799C86a49DEeb90402691F1041aa3AF2d3C875 -# Computed Address: 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Ecotone: beacon block roots contract deployment")) -# 0x69b763c48478b9dc2f65ada09b3d92133ec592ea715ec65ad6e7f3dc519dc00c -``` - -[EIP-4788]: https://eips.ethereum.org/EIPS/eip-4788 -[EIP-155]: https://eips.ethereum.org/EIPS/eip-155 diff --git a/docs/specs/pages/upgrades/ecotone/l1-attributes.md b/docs/specs/pages/upgrades/ecotone/l1-attributes.md deleted file mode 100644 index cb7c9fe611..0000000000 --- a/docs/specs/pages/upgrades/ecotone/l1-attributes.md +++ /dev/null @@ -1,112 +0,0 @@ -# Ecotone L1 Attributes - -## Overview - -On the Ecotone activation block, and if Ecotone is not activated at Genesis, -the L1 Attributes Transaction includes a call to `setL1BlockValues()` -because the L1 Attributes transaction precedes the [Ecotone Upgrade Transactions][ecotone-upgrade-txs], -meaning that `setL1BlockValuesEcotone` is not guaranteed to exist yet. - -Every subsequent L1 Attributes transaction should include a call to the `setL1BlockValuesEcotone()` function. -The input args are no longer ABI encoded function parameters, -but are instead packed into 5 32-byte aligned segments (starting after the function selector). -Each unsigned integer argument is encoded as big-endian using a number of bytes corresponding to the underlying type. -The overall calldata layout is as follows: - -[ecotone-upgrade-txs]: derivation.md#network-upgrade-automation-transactions - -| Input arg | Type | Calldata bytes | Segment | -| ----------------- | ------- | -------------- | ------- | -| {0x440a5e20} | | 0-3 | n/a | -| baseFeeScalar | uint32 | 4-7 | 1 | -| blobBaseFeeScalar | uint32 | 8-11 | | -| sequenceNumber | uint64 | 12-19 | | -| l1BlockTimestamp | uint64 | 20-27 | | -| l1BlockNumber | uint64 | 28-35 | | -| basefee | uint256 | 36-67 | 2 | -| blobBaseFee | uint256 | 68-99 | 3 | -| l1BlockHash | bytes32 | 100-131 | 4 | -| batcherHash | bytes32 | 132-163 | 5 | - -Total calldata length MUST be exactly 164 bytes, implying the sixth and final segment is only -partially filled. This helps to slow database growth as every L2 block includes an L1 Attributes -deposit transaction. - -In the first L2 block after the Ecotone activation block, the Ecotone L1 attributes are first used. - -The pre-Ecotone values are migrated over 1:1. -Blocks after the Ecotone activation block contain all pre-Ecotone values 1:1, -and also set the following new attributes: - -- The `baseFeeScalar` is set to the pre-Ecotone `scalar` value. -- The `blobBaseFeeScalar` is set to `0`. -- The pre-Ecotone `overhead` attribute is dropped. -- The `blobBaseFee` is set to the L1 blob base fee of the L1 origin block. - Or `1` if the L1 block does not support blobs. - The `1` value is derived from the EIP-4844 `MIN_BLOB_GASPRICE`. - -Note that the L1 blob bas fee is _not_ exposed as a part of the L1 origin block. -It must be computed using an parameterized off-chain formula which takes the -excess blob gas field from the header of the L1 origin block as described in -[EIP-4844](https://eips.ethereum.org/EIPS/eip-4844#base-fee-per-blob-gas-update-rule). -The `BLOB_BASE_FEE_UPDATE_FRACTION` parameter in the formula varies -according to which L1 fork is active -at the origin block (see e.g. [EIP-7691](https://eips.ethereum.org/EIPS/eip-7691)). It is therefore -necessary for L2 consensus layer clients to know the blob parameters and activation -time for each L1 fork to compute the `blobBaseFee` correctly. Blob Parameter Only -(BPO) forks, introduced in [EIP-7892](https://eips.ethereum.org/EIPS/eip-7892) -can mean that `BLOB_BASE_FEE_UPDATE_FRACTION` is updated frequently: -that clients and proof programs therefore need to stay up to date with -such forks. - -## L1 Attributes Predeployed Contract - -[sys-config]: ../../protocol/consensus/derivation.md#system-configuration - -The L1 Attributes predeploy stores the following values: - -- L1 block attributes: - - `number` (`uint64`) - - `timestamp` (`uint64`) - - `basefee` (`uint256`) - - `hash` (`bytes32`) - - `blobBaseFee` (`uint256`) -- `sequenceNumber` (`uint64`): This equals the L2 block number relative to the start of the epoch, - i.e. the L2 block distance to the L2 block height that the L1 attributes last changed, - and reset to 0 at the start of a new epoch. -- System configurables tied to the L1 block, see [System configuration specification][sys-config]: - - `batcherHash` (`bytes32`): A versioned commitment to the batch-submitter(s) currently operating. - - `baseFeeScalar` (`uint32`): system configurable to scale the `basefee` in the Ecotone l1 cost computation - - `blobBasefeeScalar` (`uint32`): system configurable to scale the `blobBaseFee` in the Ecotone l1 cost computation - -The `overhead` and `scalar` values can continue to be accessed after the Ecotone activation block, -but no longer have any effect on system operation. These fields were also known as the `l1FeeOverhead` -and the `l1FeeScalar`. - -After running `pnpm build` in the `packages/contracts-bedrock` directory, the bytecode to add to -the genesis file will be located in the `deployedBytecode` field of the build artifacts file at -`/packages/contracts-bedrock/forge-artifacts/L1Block.sol/L1Block.json`. - -### Ecotone L1Block upgrade - -The L1 Attributes Predeployed contract, `L1Block.sol`, is upgraded as part of the Ecotone upgrade. -The version is incremented to `1.2.0`, one new storage slot is introduced, and one existing slot -begins to store additional data: - -- `blobBaseFee` (`uint256`): The L1 blob base fee. -- `blobBaseFeeScalar` (`uint32`): The scalar value applied to the L1 blob base fee portion of the L1 cost. -- `baseFeeScalar` (`uint32`): The scalar value applied to the L1 base fee portion of the L1 cost. - -The function called by the L1 attributes transaction depends on the network upgrade: - -- Before the Ecotone activation: - - `setL1BlockValues` is called, following the pre-Ecotone L1 attributes rules. -- At the Ecotone activation block: - - `setL1BlockValues` function MUST be called, except if activated at genesis. - The contract is upgraded later in this block, to support `setL1BlockValuesEcotone`. -- After the Ecotone activation: - - `setL1BlockValues` function is deprecated and MUST never be called. - - `setL1BlockValuesEcotone` MUST be called with the new Ecotone attributes. - -`setL1BlockValuesEcotone` uses a tightly packed encoding for its parameters, which is described in -[L1 Attributes Deposited Transaction Calldata](../../protocol/bridging/deposits.md#l1-attributes-deposited-transaction-calldata). diff --git a/docs/specs/pages/upgrades/ecotone/overview.md b/docs/specs/pages/upgrades/ecotone/overview.md deleted file mode 100644 index d80bd15f6c..0000000000 --- a/docs/specs/pages/upgrades/ecotone/overview.md +++ /dev/null @@ -1,39 +0,0 @@ -# Ecotone - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1710374401` (2024-03-14 00:00:01 UTC) | -| `sepolia` | `1708534800` (2024-02-21 17:00:00 UTC) | - -The Ecotone upgrade contains the Dencun upgrade from L1, and adopts EIP-4844 blobs for data-availability. - -## Execution Layer - -- Cancun (Execution Layer): - - [EIP-1153: Transient storage opcodes](https://eips.ethereum.org/EIPS/eip-1153) - - [EIP-4844: Shard Blob Transactions](https://eips.ethereum.org/EIPS/eip-4844) - - [Blob transactions are disabled](../../protocol/execution/index.md#ecotone-disable-blob-transactions) - - [EIP-4788: Beacon block root in the EVM](https://eips.ethereum.org/EIPS/eip-4788) - - [The L1 beacon block root is embedded into L2](../../protocol/execution/index.md#ecotone-beacon-block-root) - - [The Beacon roots contract deployment is automated](../../protocol/consensus/derivation.md#ecotone-beacon-block-roots-contract-deployment-eip-4788) - - [EIP-5656: MCOPY - Memory copying instruction](https://eips.ethereum.org/EIPS/eip-5656) - - [EIP-6780: SELFDESTRUCT only in same transaction](https://eips.ethereum.org/EIPS/eip-6780) - - [EIP-7516: BLOBBASEFEE opcode](https://eips.ethereum.org/EIPS/eip-7516) - - [BLOBBASEFEE always pushes 1 onto the stack](../../protocol/execution/index.md#ecotone-disable-blob-transactions) -- Deneb (Consensus Layer): _not applicable to L2_ - - [EIP-7044: Perpetually Valid Signed Voluntary Exits](https://eips.ethereum.org/EIPS/eip-7044) - - [EIP-7045: Increase Max Attestation Inclusion Slot](https://eips.ethereum.org/EIPS/eip-7045) - - [EIP-7514: Add Max Epoch Churn Limit](https://eips.ethereum.org/EIPS/eip-7514) - -## Consensus Layer - -[retrieval]: ../../protocol/consensus/derivation.md#ecotone-blob-retrieval -[predeploy]: l1-attributes.md#ecotone-l1block-upgrade - -- Blobs Data Availability: support blobs DA the [L1 Data-retrieval stage][retrieval]. -- Rollup fee update: support blobs DA in - [L1 Data Fee computation](../../protocol/execution/index.md#ecotone-l1-cost-fee-changes-eip-4844-da) -- Auto-upgrading and extension of the [L1 Attributes Predeployed Contract][predeploy] - (also known as `L1Block` predeploy) diff --git a/docs/specs/pages/upgrades/fjord/derivation.md b/docs/specs/pages/upgrades/fjord/derivation.md deleted file mode 100644 index 8c472e9ad3..0000000000 --- a/docs/specs/pages/upgrades/fjord/derivation.md +++ /dev/null @@ -1,241 +0,0 @@ -# Fjord L2 Chain Derivation Changes - -# Protocol Parameter Changes - -The following table gives an overview of the changes in parameters. - -| Parameter | Pre-Fjord (default) value | Fjord value | Notes | -| --------- | ------------------------- | ----------- | ----- | -| `max_sequencer_drift` | 600 | 1800 | Was a protocol parameter since Bedrock. Now becomes a constant. | -| `MAX_RLP_BYTES_PER_CHANNEL` | 10,000,000 | 100,000,000 | Protocol Constant is increasing. | -| `MAX_CHANNEL_BANK_SIZE` | 100,000,000 | 1,000,000,000 | Protocol Constant is increasing. | - -## Timestamp Activation - -Fjord, like other network upgrades, is activated at a timestamp. -Changes to the L2 Block execution rules are applied when the `L2 Timestamp >= activation time`. -Changes to derivation are applied when it is considering data from an L1 block whose timestamp -is greater than or equal to the activation timestamp. -The change of the `max_sequencer_drift` parameter activates with the L1 origin block timestamp. - -If Fjord is not activated at genesis, it must be activated at least one block after the Ecotone -activation block. This ensures that the network upgrade transactions don't conflict. - -## Constant Maximum Sequencer Drift - -With Fjord, the `max_sequencer_drift` parameter becomes a constant of value `1800` _seconds_, -translating to a fixed maximum sequencer drift of 30 minutes. - -Before Fjord, this was a chain parameter that was set once at chain creation, with a default -value of `600` seconds, i.e., 10 minutes. Most chains use this value currently. - -### Rationale - -Discussions amongst chain operators came to the unilateral conclusion that a larger value than the -current default would be easier to work with. If a sequencer's L1 connection breaks, this drift -value determines how long it can still produce blocks without violating the timestamp drift -derivation rules. - -It was furthermore agreed that configurability after this increase is not important. So it is being -made a constant. An alternative idea that is being considered for a future hardfork is to make this -an L1-configurable protocol parameter via the `SystemConfig` update mechanism. - -### Security Considerations - -The rules around the activation time are deliberately being kept simple, so no other logic needs to -be applied other than to change the parameter to a constant. The first Fjord block would in theory -accept older L1-origin timestamps than its predecessor. However, since the L1 origin timestamp must -also increase, the only noteworthy scenario that can happen is that the first few Fjord blocks will -be in the same epoch as the last pre-Fjord blocks, even if these blocks would not be allowed to -have these L1-origin timestamps according to pre-Fjord rules. So the same L1 timestamp would be -shared within a pre- and post-Fjord mixed epoch. This is considered a feature and is not considered -a security issue. - -## Increasing `MAX_RLP_BYTES_PER_CHANNEL` and `MAX_CHANNEL_BANK_SIZE` - -With Fjord, `MAX_RLP_BYTES_PER_CHANNEL` will be increased from 10,000,000 bytes to 100,000,000 bytes, -and `MAX_CHANNEL_BANK_SIZE` will be increased from 100,000,000 bytes to 1,000,000,000 bytes. - -The usage of `MAX_RLP_BYTES_PER_CHANNEL` is defined in [Channel Format](../../protocol/consensus/derivation.md#channel-format). -The usage of `MAX_CHANNEL_BANK_SIZE` is defined in [Channel Bank Pruning](../../protocol/consensus/derivation.md#pruning). - -Span Batches previously had a limit `MAX_SPAN_BATCH_SIZE` which was equal to `MAX_RLP_BYTES_PER_CHANNEL`. -Fjord creates a new constant `MAX_SPAN_BATCH_ELEMENT_COUNT` for the element count limit & removes -`MAX_SPAN_BATCH_SIZE`. The size of the channel is still checked with `MAX_RLP_BYTES_PER_CHANNEL`. - -The new value will be used when the timestamp of the L1 origin of the derivation pipeline >= the Fjord activation -timestamp. - -### Rationale - -A block with a gas limit of 30 Million gas has a maximum theoretical size of 7.5 Megabytes by being filled up -with transactions have only zeroes. Currently, a byte with the value `0` consumes 4 gas. -If the block gas limit is raised above 40 Million gas, it is possible to create a block that is large than -`MAX_RLP_BYTES_PER_CHANNEL`. -L2 blocks cannot be split across channels which means that a block that is larger than `MAX_RLP_BYTES_PER_CHANNEL` -cannot be batch submitted. -By raising this limit to 100,000,000 bytes, we can batch submit blocks with a gas limit of up to 400 Million Gas. -In addition, we are able to improve compression ratios by increasing the amount of data that can be inserted into a -single channel. -With 33% compression ratio over 6 blobs, we are currently submitting 2.2 MB of compressed data & 0.77 MB of uncompressed -data per channel. -This will allow use to use up to approximately 275 blobs per channel. - -Raising `MAX_CHANNEL_BANK_SIZE` is helpful to ensure that we are able to process these larger channels. We retain the -same ratio of 10 between `MAX_RLP_BYTES_PER_CHANNEL` and `MAX_CHANNEL_BANK_SIZE`. - -### Security Considerations - -Raising the these limits increases the amount of resources a rollup node would require. -Specifically nodes may have to allocate large chunks of memory for a channel and will have to potentially allocate more -memory to the channel bank. -`MAX_RLP_BYTES_PER_CHANNEL` was originally added to avoid zip bomb attacks. -The system is still exposed to these attacks, but these limits are straightforward to handle in a node. - -The Fault Proof environment is more constrained than a typical node and increasing these limits will require more -resources than are currently required. -The change in `MAX_CHANNEL_BANK_SIZE` is not relevant to the first implementation of Fault Proofs because this limit -only tells the node when to start pruning & once memory is allocated in the FPVM, it is not garbage collected. -This means that increasing `MAX_CHANNEL_BANK_SIZE` does not increase the maximum resource usage of the FPP. - -Increasing `MAX_RLP_BYTES_PER_CHANNEL` could cause more resource usage in FPVM; however, we consider this -increase reasonable because this increase is in the amount of data handled at once rather than the total -amount of data handled in the program. Instead of using a single channel, the batcher could submit 10 channels -prior to this change which would cause the Fault Proof Program to consume a very similar amount of resources. - -# Brotli Channel Compression - -[legacy-channel-format]: ../../protocol/consensus/derivation.md#channel-format - -Fjord introduces a new versioned channel encoding format to support alternate compression -algorithms, with the [legacy channel format][legacy-channel-format] remaining supported. The -versioned format is as follows: - -```text -channel_encoding = channel_version_byte ++ compress(rlp_batches) -``` - -The `channel_version_byte` must never have its 4 lower order bits set to `0b1000 = 8` or `0b1111 = -15`, which are reserved for usage by the header byte of zlib encoded data (see page 5 of -[RFC-1950][rfc1950]). This allows a channel decoder to determine if a channel encoding is legacy or -versioned format by testing for these bit values. If the channel encoding is determined to be -versioned format, the only valid `channel_version_byte` is `1`, which indicates `compress()` is the -Brotli compression algorithm (as specified in [RFC-7932][rfc7932]) with no custom dictionary. - -[rfc7932]: https://datatracker.ietf.org/doc/html/rfc7932 -[rfc1950]: https://www.rfc-editor.org/rfc/rfc1950.html - -# Network upgrade automation transactions - -The Fjord hardfork activation block contains the following transactions, in this order: - -- L1 Attributes Transaction -- User deposits from L1 -- Network Upgrade Transactions - - GasPriceOracle deployment - - Update GasPriceOracle Proxy ERC-1967 Implementation Slot - - GasPriceOracle Enable Fjord - -To not modify or interrupt the system behavior around gas computation, this block will not include any sequenced -transactions by setting `noTxPool: true`. - -## GasPriceOracle Deployment - -The `GasPriceOracle` contract is upgraded to support the new Fjord L1 data fee computation. Post fork this contract -will use FastLZ to compute the L1 data fee. - -To perform this upgrade, a deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000002` -- `to`: `null`, -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `1,450,000` -- `data`: `0x60806040523...` (full bytecode) -- `sourceHash`: `0x86122c533fdcb89b16d8713174625e44578a89751d96c098ec19ab40a51a8ea3` - computed with the "Upgrade-deposited" type, with `intent = "Fjord: Gas Price Oracle Deployment" - -This results in the Fjord GasPriceOracle contract being deployed to `0xa919894851548179A0750865e7974DA599C0Fac7`, -to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000002 -Computed Address: 0xa919894851548179A0750865e7974DA599C0Fac7 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Fjord: Gas Price Oracle Deployment")) -# 0x86122c533fdcb89b16d8713174625e44578a89751d96c098ec19ab40a51a8ea3 -``` - -Verify `data`: - -```bash -git checkout 52abfb507342191ae1f960b443ae8aec7598755c -pnpm clean && pnpm install && pnpm build -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/GasPriceOracle.sol/GasPriceOracle.json -``` - -This transaction MUST deploy a contract with the following code hash -`0xa88fa50a2745b15e6794247614b5298483070661adacb8d32d716434ed24c6b2`. - -## GasPriceOracle Proxy Update - -This transaction updates the GasPriceOracle Proxy ERC-1967 implementation slot to point to the new GasPriceOracle -deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe6000000000000000000000000a919894851548179a0750865e7974da599c0fac7` -- `sourceHash`: `0x1e6bb0c28bfab3dc9b36ffb0f721f00d6937f33577606325692db0965a7d58c6` - computed with the "Upgrade-deposited" type, with `intent = "Fjord: Gas Price Oracle Proxy Update"` - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0xa919894851548179A0750865e7974DA599C0Fac7) -# 0x3659cfe6000000000000000000000000a919894851548179a0750865e7974da599c0fac7 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Fjord: Gas Price Oracle Proxy Update")) -# 0x1e6bb0c28bfab3dc9b36ffb0f721f00d6937f33577606325692db0965a7d58c6 -``` - -## GasPriceOracle Enable Fjord - -This transaction informs the GasPriceOracle to start using the Fjord gas calculation formula. - -A deposit transaction is derived with the following attributes: - -- `from`: `0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001` (Depositer Account) -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `90,000` -- `data`: `0x8e98b106` -- `sourceHash`: `0xbac7bb0d5961cad209a345408b0280a0d4686b1b20665e1b0f9cdafd73b19b6b`, - computed with the "Upgrade-deposited" type, with `intent = "Fjord: Gas Price Oracle Set Fjord" - -Verify data: - -```bash -cast sig "setFjord()" -0x8e98b106 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Fjord: Gas Price Oracle Set Fjord")) -# 0xbac7bb0d5961cad209a345408b0280a0d4686b1b20665e1b0f9cdafd73b19b6b -``` diff --git a/docs/specs/pages/upgrades/fjord/exec-engine.md b/docs/specs/pages/upgrades/fjord/exec-engine.md deleted file mode 100644 index 040fd87434..0000000000 --- a/docs/specs/pages/upgrades/fjord/exec-engine.md +++ /dev/null @@ -1,62 +0,0 @@ -# L2 Execution Engine - -## Fees - -### L1-Cost fees (L1 Fee Vault) - -#### Fjord L1-Cost fee changes (FastLZ estimator) - -Fjord updates the L1 cost calculation function to use a FastLZ-based compression estimator. -The L1 cost is computed as: - -```pseudocode -l1FeeScaled = l1BaseFeeScalar*l1BaseFee*16 + l1BlobFeeScalar*l1BlobBaseFee -estimatedSizeScaled = max(minTransactionSize * 1e6, intercept + fastlzCoef*fastlzSize) -l1Fee = estimatedSizeScaled * l1FeeScaled / 1e12 -``` - -The final `l1Fee` computation is an unlimited precision unsigned integer computation, with the result in Wei and -having `uint256` range. The values in this computation, are as follows: - -| Input arg | Type | Description | Value | -|----------------------|-----------|-------------------------------------------------------------------|--------------------------| -| `l1BaseFee` | `uint256` | L1 base fee of the latest L1 origin registered in the L2 chain | varies, L1 fee | -| `l1BlobBaseFee` | `uint256` | Blob gas price of the latest L1 origin registered in the L2 chain | varies, L1 fee | -| `fastlzSize` | `uint256` | Size of the FastLZ-compressed RLP-encoded signed tx | varies, per transaction | -| `l1BaseFeeScalar` | `uint32` | L1 base fee scalar, scaled by `1e6` | varies, L2 configuration | -| `l1BlobFeeScalar` | `uint32` | L1 blob fee scalar, scaled by `1e6` | varies, L2 configuration | -| `intercept` | `int32` | Intercept constant, scaled by `1e6` (can be negative) | -42_585_600 | -| `fastlzCoef` | `uint32` | FastLZ coefficient, scaled by `1e6` | 836_500 | -| `minTransactionSize` | `uint32` | A lower bound on transaction size, in bytes | 100 | - -Previously, `l1BaseFeeScalar` and `l1BlobFeeScalar` were used to encode the compression ratio, due to the inaccuracy of -the L1 cost function. However, the new cost function takes into account the compression ratio, so these scalars should -be adjusted to account for any previous compression ratio they encoded. - -##### FastLZ Implementation - -All compression algorithms must be implemented equivalently to the `fastlz_compress` function in `fastlz.c` at the -following [commit](https://github.com/ariya/FastLZ/blob/344eb4025f9ae866ebf7a2ec48850f7113a97a42/fastlz.c#L482-L506). - -##### L1-Cost linear regression details - -The `intercept` and `fastlzCoef` constants are calculated by linear regression using a dataset -of previous L2 transactions. The dataset is generated by iterating over all transactions in a given time range, and -performing the following actions. For each transaction: - -1. Compress the payload using FastLZ. Record the size of the compressed payload as `fastlzSize`. -2. Emulate the change in batch size adding the transaction to a batch, compressed with Brotli 10. Record the change in - batch size as `bestEstimateSize`. - -Once this dataset is generated, a linear regression can be calculated using the `bestEstimateSize` as -the dependent variable and `fastlzSize` as the independent variable. - -We generated a dataset from two weeks of post-Ecotone transactions on Optimism Mainnet, as we found that was -the most representative of performance across multiple chains and time periods. More details on the linear regression -and datasets used can be found in this [repository](https://github.com/roberto-bayardo/compression-analysis/tree/main). - -### L1 Gas Usage Estimation - -The `L1GasUsed` property is deprecated due to it not capturing the L1 blob gas used by a transaction, and will be -removed in a future network upgrade. Users can continue to use the `L1Fee` field to retrieve the L1 fee for a given -transaction. diff --git a/docs/specs/pages/upgrades/fjord/overview.md b/docs/specs/pages/upgrades/fjord/overview.md deleted file mode 100644 index f6590116ca..0000000000 --- a/docs/specs/pages/upgrades/fjord/overview.md +++ /dev/null @@ -1,21 +0,0 @@ -# Fjord - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1720627201` (2024-07-10 16:00:01 UTC) | -| `sepolia` | `1716998400` (2024-05-29 16:00:00 UTC) | - -## Execution Layer - -- [RIP-7212: Precompile for secp256r1 Curve Support](/protocol/execution/evm/precompiles#P256VERIFY) -- [FastLZ compression for L1 data fee calculation](/upgrades/fjord/exec-engine#fees) -- [Deprecate the `getL1GasUsed` method on the `GasPriceOracle` contract](/upgrades/fjord/predeploys#l1-gas-usage-estimation) -- [Deprecate the `L1GasUsed` field on the transaction receipt](/upgrades/fjord/exec-engine#l1-gas-usage-estimation) - -## Consensus Layer - -- [Constant maximum sequencer drift](/upgrades/fjord/derivation#constant-maximum-sequencer-drift) -- [Brotli channel compression](/upgrades/fjord/derivation#brotli-channel-compression) -- [Increase Max Bytes Per Channel and Max Channel Bank Size](/upgrades/fjord/derivation#increasing-max_rlp_bytes_per_channel-and-max_channel_bank_size) diff --git a/docs/specs/pages/upgrades/fjord/predeploys.md b/docs/specs/pages/upgrades/fjord/predeploys.md deleted file mode 100644 index 77b61ee526..0000000000 --- a/docs/specs/pages/upgrades/fjord/predeploys.md +++ /dev/null @@ -1,70 +0,0 @@ -# Predeploys - -## GasPriceOracle - -Following the Fjord upgrade, three additional values used for L1 fee computation are: - -- costIntercept -- costFastlzCoef -- minTransactionSize - -These values are hard-coded constants in the `GasPriceOracle` contract. The -calculation follows the same formula outlined in the -[Fjord L1-Cost fee changes (FastLZ estimator)](exec-engine.md#fjord-l1-cost-fee-changes-fastlz-estimator) -section. - -A new method is introduced: `getL1FeeUpperBound(uint256)`. This method returns an upper bound for the L1 fee -for a given transaction size. It is provided for callers who wish to estimate L1 transaction costs in the -write path, and is much more gas efficient than `getL1Fee`. - -The upper limit overhead is assumed to be `original/255+16`, borrowed from LZ4. According to historical data, this -approach can encompass more than 99.99% of transactions. - -This is implemented as follows: - -```solidity -function getL1FeeUpperBound(uint256 unsignedTxSize) external view returns (uint256) { - // Add 68 to account for unsigned tx - uint256 txSize = unsignedTxSize + 68; - // txSize / 255 + 16 is the practical fastlz upper-bound covers 99.99% txs. - uint256 flzUpperBound = txSize + txSize / 255 + 16; - - int256 estimatedSize = costIntercept + costFastlzCoef * flzUpperBound; - if (estimatedSize < minTransactionSize) { - estimatedSize = minTransactionSize; - } - - uint256 l1FeeScaled = baseFeeScalar() * l1BaseFee() * 16 + blobBaseFeeScalar() * blobBaseFee(); - return uint256(estimatedSize) * l1FeeScaled / (10 ** (DECIMALS * 2)); -} -``` - -### L1 Gas Usage Estimation - -The `getL1GasUsed` method is updated to take into account the improved [compression estimation](exec-engine.md#fees) -accuracy as part of the Fjord upgrade. - -```solidity -function getL1GasUsed(bytes memory _data) public view returns (uint256) { - if (isFjord) { - // Add 68 to the size to account for the unsigned tx - int256 flzSize = LibZip.flzCompress(_data).length + 68; - - int256 estimatedSize = costIntercept + costFastlzCoef * flzSize; - if (estimatedSize < minTransactionSize) { - estimatedSize = minTransactionSize; - } - - // Assume the compressed data is mostly non-zero, and would pay 16 gas per calldata byte - return estimatedSize * 16; - } - // ... -} -``` - -The `getL1GasUsed` method is deprecated as of Fjord because it does not capture that there are -two kinds of gas being consumed due to the introduction of blobs. This function will revert when -called in a future upgrade. - -Users can continue to use the `getL1Fee` method to estimate the L1 fee for a given transaction, or the -new `getL1FeeUpperBound` method introduced by Fjord as a lower gas alternative. diff --git a/docs/specs/pages/upgrades/granite/derivation.md b/docs/specs/pages/upgrades/granite/derivation.md deleted file mode 100644 index 56c0b6a51f..0000000000 --- a/docs/specs/pages/upgrades/granite/derivation.md +++ /dev/null @@ -1,14 +0,0 @@ -# Granite L2 Chain Derivation Changes - -## Protocol Parameter Changes - -The following table gives an overview of the changes in parameters. - -| Parameter | Pre-Granite (default) value | Granite value | Notes | -| --------- | ------------------------- | ----------- | ----- | -| `CHANNEL_TIMEOUT` | 300 | 50 | Protocol Constant is reduced. | - -## Reduce Channel Timeout - -With Granite, the `CHANNEL_TIMEOUT` is reduced from 300 to 50 L1 Blocks. -The new rule activation timestamp is based on the blocktime of the L1 block that the channel frame is included. diff --git a/docs/specs/pages/upgrades/granite/exec-engine.md b/docs/specs/pages/upgrades/granite/exec-engine.md deleted file mode 100644 index 3c1d4b0d1c..0000000000 --- a/docs/specs/pages/upgrades/granite/exec-engine.md +++ /dev/null @@ -1,9 +0,0 @@ -# L2 Execution Engine - -## EVM Changes - -### `bn256Pairing` precompile input restriction - -The `bn256Pairing` precompile execution has additional validation on its input. -The precompile reverts if its input is larger than `112687` bytes. -This is the input size that consumes approximately 20 M gas given the latest `bn256Pairing` gas schedule on L2. diff --git a/docs/specs/pages/upgrades/granite/overview.md b/docs/specs/pages/upgrades/granite/overview.md deleted file mode 100644 index ae5138a718..0000000000 --- a/docs/specs/pages/upgrades/granite/overview.md +++ /dev/null @@ -1,16 +0,0 @@ -# Granite - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1726070401` (2024-09-11 16:00:01 UTC) | -| `sepolia` | `1723478400` (2024-08-12 16:00:00 UTC) | - -## Execution Layer - -- [Limit `bn256Pairing` precompile input size](/upgrades/granite/exec-engine#bn256pairing-precompile-input-restriction) - -## Consensus Layer - -- [Reduce Channel Timeout to 50](/upgrades/granite/derivation#reduce-channel-timeout) diff --git a/docs/specs/pages/upgrades/holocene/derivation.md b/docs/specs/pages/upgrades/holocene/derivation.md deleted file mode 100644 index 23d95da154..0000000000 --- a/docs/specs/pages/upgrades/holocene/derivation.md +++ /dev/null @@ -1,352 +0,0 @@ -# Holocene L2 Chain Derivation Changes - -# Holocene Derivation - -## Summary - -The Holocene hardfork introduces several changes to block derivation rules that render the -derivation pipeline mostly stricter and simpler, improve worst-case scenarios for Fault Proofs and -Interop. The changes are: - -- _Strict Batch Ordering_ required batches within and across channels to be strictly ordered. -- _Partial Span Batch Validity_ determines the validity of singular batches from a span batch -individually, only invalidating the remaining span batch upon the first invalid singular batch. -- _Fast Channel Invalidation_, similarly to Partial Span Batch Validity applied to the channel -layer, forward-invalidates a channel upon finding an invalid batch. -- _Steady Block Derivation_ derives invalid payload attributes immediately as deposit-only -blocks. - -The combined effect of these changes is that the impact of an invalid batch is contained to the -block number at hand, instead of propagating forwards or backwards in the safe chain, while also -containing invalid payloads at the engine stage to the engine, not propagating backwards in the -derivation pipeline. - -Holocene derivation comprises the following changes to the derivation pipeline to achieve the above. - -## Frame Queue - -The frame queue retains its function and queues all frames of the last batcher transaction(s) that -weren't assembled into a channel yet. Holocene still allows multiple frames per batcher transaction, -possibly from different channels. As before, this allows for optionally filling up the remaining -space of a batcher transaction with a starting frame of the next channel. - -However, Strict Batch Ordering leads to the following additional checks and rules to the frame -queue: - -- If a _non-first frame_ (i.e., a frame with index >0) decoded from a batcher transaction is _out of -order_, it is **immediately dropped**, where the frame is called _out of order_ if - - its frame number is not the previous frame's plus one, if it has the same channel ID, or - - the previous frame already closed the channel with the same ID, or - - the non-first frame has a different channel ID than the previous frame in the frame queue. -- If a _first frame_ is decoded while the previous frame isn't a _last frame_ (i.e., `is_last` is -`false`), all previous frames for the same channel are dropped and this new first frame remains in -the queue. - -These rules guarantee that the frame queue always holds frames whose indices are ordered, -contiguous and include the first frame, per channel. Plus, a first frame of a channel is either the -first frame in the queue, or is preceded by a closing frame of a previous channel. - -Note that these rules are in contrast to pre-Holocene rules, where out of order frames were -buffered. Pre-Holocene, frame validity checks were only done at the Channel Bank stage. Performing -these checks already at the Frame Queue stage leads to faster discarding of invalid frames, keeping -the memory consumption of any implementation leaner. - -## Channel Bank - -Because channel frames have to arrive in order, the Channel Bank becomes much simpler and only -holds at most a single channel at a time. - -### Pruning - -Pruning is vastly simplified as there is at most only one open channel in the channel bank. So the -channel bank's queue becomes effectively a staging slot for a single channel, the _staging channel_. -The `MAX_CHANNEL_BANK_SIZE` parameter is no longer used, and the buffered size of the staging -channel is required to be at most `MAX_RLP_BYTES_PER_CHANNEL` (else the channel is dropped). The -buffered size uses the same channel-size accounting as pre-Holocene channel-bank pruning: the sum of -all buffered frame data lengths, plus an additional frame-overhead of `200` bytes per frame. This -`200` byte value is derivation memory accounting, not the `23` byte frame wire-format overhead. - -This staging-channel size rule is both a distinct condition and distinct effect, compared to the -existing rule that the _uncompressed_ size of any given channel is _clipped_ to -`MAX_RLP_BYTES_PER_CHANNEL` [during decompression](../../protocol/consensus/derivation.md#channel-format). - -### Timeout - -The timeout is applied as before, just only to the single staging channel. - -### Reading & Frame Loading - -The frame queue is guaranteed to hold ordered and contiguous frames, per channel. So reading and -frame loading becomes simpler in the channel bank: - -- A first frame for a new channel starts a new channel as the staging channel. - - If there already is an open, non-completed staging channel, it is dropped and replaced by this - new channel. This is consistent with how the frame queue drops all frames of a non-closed channel - upon the arrival of a first frame for a new channel. -- If the current channel is timed-out, but not yet pruned, and the incoming frame would be the next -correct frame for this channel, the frame and channel are dropped, including all future frames for -the channel that might still be in the frame queue. Note that the equivalent rule was already -present pre-Holocene. -- After adding a frame to the staging channel, the channel is dropped if its buffered channel size is -larger than `MAX_RLP_BYTES_PER_CHANNEL`. The buffered channel size is computed as the sum of buffered -frame data lengths plus `200` bytes per buffered frame, matching the channel-size accounting defined -for pre-Holocene channel-bank pruning. This rule replaces the total limit of all channels' combined -sizes by `MAX_CHANNEL_BANK_SIZE` before Holocene. - -## Span Batches - -Partial Span Batch Validity changes the atomic validity model of [Span Batches](../delta/span-batches.md). -In Holocene, a span batch is treated as an optional stage in the derivation pipeline that sits -before the batch queue, so that the batch queue pulls singular batches from this previous Span Batch -stage. When encountering an invalid singular batch, it is dropped, as is the remaining span batch -for consistency reasons. We call this _forwards-invalidation_. However, we don't -_backwards-invalidate_ previous valid batches that came from the same span batch, as pre-Holocene. - -When a batch derived from the current staging channel is a singular batch, it is directly forwarded -to the batch queue. Otherwise, it is set as the current span batch in the span batch stage. The -following span batch validity checks are done, before singular batches are derived from it. -Definitions are borrowed from the [original Span Batch specs](../delta/span-batches.md). - -- If the span batch _L1 origin check_ is not part of the canonical L1 chain, the span batch is -invalid. -- A failed parent check invalidates the span batch. -- If `span_start.timestamp > next_timestamp`, the span batch is invalid, because we disallow gaps -due to the new strict batch ordering rules. -- If `span_end.timestamp < next_timestamp`, the span batch is set to have `past` validity, as it -doesn't contain any new batches (this would also happen if applying timestamp checks to each derived -singular batch individually). See below in the [Batch Queue](#batch-queue) section about the new -`past` validity. -- Note that we still allow span batches to overlap with the safe chain (`span_start.timestamp < -next_timestamp`). - -If any of the above checks invalidate the span batch, it is `drop`ped and the remaining channel from -which the span batch was derived, is also immediately dropped (see also [Fast Channel -Invalidation](#fast-channel-invalidation)). However, a `past` span batch is only dropped, without -dropping the remaining channel. - -> [!Note] -> A word regarding overlapping span batches: the existing batch queue rules already contain the rule -> to drop batches whose L1 origin is older than that of the L2 safe head. The Delta span batch -> checks also have an equivalent rule that applies to all singular batches past the safe head. -> Now full span batch checks aren't done any more in Holocene, but the batch queue rules are still -> applied to singular batches that are streamed out of span batches, so in particular this rule also -> still applies to the first singular batch past the current safe head coming from an overlapping -> span batch. -> -> It is a known footgun for implementations that the earliest point at which violations of this rule -> are detected is when the full array of singular batches is extracted from the span batch and their -> L1 origin hashes are populated. It is therefore important to treat singular batches with outdated -> or otherwise invalid L1 origin numbers as invalid, and consequently the span batch as invalid, and -> not generate a critical derivation error that stalls derivation. - -## Batch Queue - -The batch queue is also simplified in that batches are required to arrive strictly ordered, and any -batches that violate the ordering requirements are immediately dropped, instead of buffered. - -So the following changes are made to the [Bedrock Batch Queue](../../protocol/consensus/derivation.md#batch-queue): - -- The reordering step is removed, so that later checks will drop batches that are not sequential. -- The `future` batch validity status is removed, and batches that were determined to be in the -future are now directly `drop`-ped. This effectively disallows gaps, instead of buffering future -batches. -- A new batch validity `past` is introduced. A batch has `past` validity if its timestamp is before -or equal to the safe head's timestamp. This also applies to span batches. -- The other rules stay the same, including empty batch generation when the sequencing window -elapses. - -Note that these changes to batch validity rules also activate by the L1 inclusion block timestamp of -a batch, not with the batch timestamp. This is important to guarantee consistent validation rules -for the first channel after Holocene activation. - -The `drop` and `past` batch validities cause the following new behavior: - -- If a batch is found to be invalid and is dropped, the remaining span batch it originated from, if -applicable, is also discarded. -- If a batch is found to be from the `past`, it is silently dropped and the remaining span batch -continues to be processed. This applies to both, span and singular batches. - -Note that when the L1 origin of the batch queue moves forward, it is guaranteed that it is empty, -because future batches aren't buffered any more. Furthermore, because future batches are directly -dropped, the batch queue effectively becomes a simpler _batch stage_ that holds at most one span -batch from which singular batches are read from, and doesn't buffer singular batches itself in a -queue any more. A valid batch is directly forwarded to the next stage. - -### Fast Channel Invalidation - -Furthermore, upon finding an invalid batch, the remaining channel it got derived from is also discarded. - -## Engine Queue - -If the engine returns an `INVALID` status for a regularly derived payload, the payload is replaced -by a payload with the same fields, except for the `transaction_list`, which is trimmed to include -only its deposit transactions. - -As before, a failure to then process the deposit-only attributes is a critical error. - -If an invalid payload is replaced by a deposit-only payload, for consistency reasons, the remaining -span batch, if applicable, and channel it originated from are dropped as well. - -## Attributes Builder - -Starting after the fork activation block, the `PayloadAttributes` produced by the attributes builder will include -the `eip1559Params` field described in the [execution engine specs](exec-engine.md#eip1559params-encoding). This -value exists within the `SystemConfig`. - -On the fork activation block, the attributes builder will include a 0'd out `eip1559Params`, as to instruct -the engine to use the [canyon base fee parameter constants](../../protocol/execution/index.md#1559-parameters). This -is to prime the pipeline's view of the `SystemConfig` with the default EIP-1559 parameter values. After the first -Holocene payload has been processed, future payloads should use the `SystemConfig`'s EIP-1559 denominator and elasticity -parameter as the `eip1559Params` field's value. When the pipeline encounters a `UpdateType.EIP_1559_PARAMS`, -`ConfigUpdate` event, the pipeline's system config will be synchronized with the `SystemConfig` contract's. - -## Activation - -The new batch rules activate when the _L1 inclusion block timestamp_ is greater or equal to the -Holocene activation timestamp. Note that this is in contrast to how span batches activated in -[Delta](../delta/overview.md), namely via the span batch L1 origin timestamp. - -When the L1 traversal stage of the derivation pipeline moves its origin to the L1 block whose -timestamp is the first to be greater or equal to the Holocene activation timestamp, the derivation -pipeline's state is mostly reset by **discarding** - -- all frames in the frame queue, -- channels in the channel bank, and -- all batches in the batch queue. - -The three stages are then replaced by the new Holocene frame queue, channel bank and batch queue -(and, depending on the implementation, the optional span batch stage is added). - -Note that batcher implementations must be aware of this activation behavior, so any frames of a -partially submitted channel that were included pre-Holocene must be sent again. This is a very -unlikely scenario since production batchers are usually configured to submit a channel in a single -transaction. - -# Rationale - -## Strict Frame and Batch Ordering - -Strict Frame and Batch Ordering simplifies implementations of the derivation pipeline, and leads to -better worst-case cached data usage. - -- The frame queue only ever holds frames from a single batcher transaction. -- The channel bank only ever holds a single staging channel, that is either being built up by -incoming frames, or is is being processed by later stages. -- The batch queue only ever holds at most a single span batch (that is being processed) and a single singular -batch (from the span batch, or the staging channel directly) -- The sync start greatly simplifies in the average production case. - -This has advantages for Fault Proof program implementations. - -## Partial Span Batch Validity - -Partial Span Batch Validity guarantees that a valid singular batch derived from a span batch can -immediately be processed as valid and advance the safe chain, instead of being in an undecided state -until the full span batch is converted into singular batches. This leads to swifter derivation and -gives strong worst-case guarantees for Fault Proofs because the validity of a block doesn't depend -on the validity of any future blocks any more. Note that before Holocene, to verify the first block -of a span batch required validating the full span batch. - -## Fast Channel Invalidation - -The new Fast Channel Invalidation rule is a consistency implication of the Strict Ordering Rules. -Because batches inside channels must be ordered and contiguous, assuming that all batches inside a -channel are self-consistent (i.e., parent L2 hashes point to the block resulting from the previous -batch), an invalid batch also forward-invalidates all remaining batches of the same channel. - -## Steady Block Derivation - -Steady Block Derivation changes the derivation rules for invalid payload attributes, replacing an -invalid payload by a deposit-only/empty payload. Crucially, this means that the effect of an invalid -payload doesn't propagate backwards in the derivation pipeline. This has benefits for Fault Proofs -and Interop, because it guarantees that batch validity is not influenced by future stages and the -block derived from a valid batch will be determined by the engine stage before it pulls new payload -attributes from the previous stage. This avoids larger derivation pipeline resets. - -## Less Defensive Protocol - -The stricter derivation rules lead to a less defensive protocol. The old protocol rules allowed for -second chances for invalid payloads and submitting frames and batches within channels out of order. -Experiences from running Base for over one and a half years have shown that these relaxed -derivation rules are (almost) never needed, so stricter rules that improve worst-case scenarios for -Fault Proofs and Interop are favorable. - -Furthermore, the more relaxed rules created a lot more corner cases and complex interactions, which -made it harder to reason about and test the protocol, increasing the risk of chain splits between -different implementations. - -# Security and Implementation Considerations - -## Reorgs - -Before Steady Block Derivation, invalid payloads got second chances to be replaced by valid future -payloads. Because they will now be immediately replaced by as deposit-only payloads, there is a -theoretical heightened risk for unsafe chain reorgs. To the best of our knowledge, we haven't -experienced this on Base yet. - -The only conceivable scenarios in which a _valid_ batch leads to an _invalid_ payload are - -- a buggy or malicious sequencer+batcher -- in the future, that an previously valid Interop dependency referenced in that payload is later -invalidated, while the block that contained the Interop dependency got already batched. - -It is this latter case that inspired the Steady Block Derivation rule. It guarantees that the -secondary effects of an invalid Interop dependency are contained to a single block only, which -avoids a cascade of cross-L2 Interop reorgs that revisit L2 chains more than once. - -## Batcher Hardening - -In a sense, Holocene shifts some complexity from derivation to the batching phase. Simpler and -stricter derivation rules need to be met by a more complex batcher implementation. - -The batcher must be hardened to guarantee the strict ordering requirements. They are already mostly -met in practice by the current Go implementation, but more by accident than by design. There are -edge cases in which the batcher might violate the strict ordering rules. For example, if a channel -fails to submit within a set period, the blocks are requeued and some out of order batching might -occur. A batcher implementation also needs to take extra care that dynamic blobs/calldata switching -doesn't lead to out of order or gaps of batches in scenarios where blocks are requeued, while future -channels are already waiting in the mempool for inclusion. - -Batcher implementations are suggested to follow a fixed nonce to block-range assignment, once the -first batcher transaction (which is almost always the only batcher transaction for a channel for -current production batcher configurations) starts being submitted. This should avoid out-of-order or -gaps of batches. It might require to implement some form of persistence in the transaction -management, since it isn't possible to reliably recover all globally pending batcher transactions in -the L1 network. - -Furthermore, batcher implementations need to be made aware of the Steady Block Derivation rules, -namely that invalid payloads will be derived as deposit-only blocks. So in case of an unsafe reorg, -the batcher should wait on the sequencer until it has derived all blocks from L1 in order to only -start batching new blocks on top of the possibly deposit-only derived reorg'd chain segment. The -sync-status should repeatedly be queried and matched against the expected safe chain. In case of any -discrepancy, the batcher should then stop batching and wait for the sequencer to fully derive up -until the latest L1 batcher transactions, and only then continue batching. - -## Sync Start - -Thanks to the new strict frame and batch ordering rules, the sync start algorithm can be simplified -in the average case. The rules guarantee that - -- an incoming first frame for a new channel leads to discarding previous incomplete frames for a -non-closed previous channel in the frame queue and channel bank, and -- when the derivation pipeline L1 origin progresses, the batch queue is empty. - -So the sync start algorithm can optimistically select the last L2 unsafe, safe and finalized heads -from the engine and if the L2 safe head's L1 origin is _plausible_ (see the -[original sync start description](../../protocol/consensus/derivation.md#finding-the-sync-starting-point) for details), -start deriving from this L1 origin. - -- If the first frame we find is a _first frame_ for a channel that includes the safe head (TBD: or -even just the following L2 block with the current safe head as parent), we can -safely continue derivation from this channel because no previous derivation pipeline state could -have influenced the L2 safe head. -- If the first frame we find is a non-first frame, then we need to walk back a full channel -timeout window to see if we find the start of that channel. - - If we find the starting frame, we can continue derivation from it. - - If we don't find the starting frame, we need to go back a full channel timeout window before the - finalized L2 head's L1 origin. - -Note regarding the last case that if we don't find a starting frame within a channel timeout window, -the channel we did find a frame from must be timed out and would be discarded. The safe block we're -looking for can't be in any channel that timed out before its L1 origin so we wouldn't need to -search any further back, so we go back a channel timeout before the finalized L2 head. diff --git a/docs/specs/pages/upgrades/holocene/exec-engine.md b/docs/specs/pages/upgrades/holocene/exec-engine.md deleted file mode 100644 index f2b55a2f0e..0000000000 --- a/docs/specs/pages/upgrades/holocene/exec-engine.md +++ /dev/null @@ -1,100 +0,0 @@ -# L2 Execution Engine - -## Overview - -The EIP-1559 parameters are encoded in the block header's `extraData` field and can be configured dynamically through -the `SystemConfig`. - -## Timestamp Activation - -Holocene, like other network upgrades, is activated at a timestamp. Changes to the L2 Block execution rules are applied -when the `L2 Timestamp >= activation time`. - -## Dynamic EIP-1559 Parameters - -### EIP-1559 Parameters in Block Header - -With the Holocene upgrade, the `extraData` header field of each block must have the following format: - -| Name | Type | Byte Offset | -| ------------- | ------------------ | ----------- | -| `version` | `u8` | `[0, 1)` | -| `denominator` | `u32 (big-endian)` | `[1, 5)` | -| `elasticity` | `u32 (big-endian)` | `[5, 9)` | - -Additionally, - -- `version` must be `0`, -- `denominator` and `elasticity` must be non-zero, -- there is no additional data beyond these 9 bytes. - -Note that `extraData` has a maximum capacity of 32 bytes (to fit in the L1 beacon-chain `extraData` data-type) and its -format may be modified/extended by future upgrades. - -Note also that if the chain had Holocene genesis, the genesis block must have an above-formatted `extraData` representing -the initial parameters to be used by the chain. - -### EIP-1559 Parameters in `PayloadAttributesV3` - -The [`PayloadAttributesV3`](https://github.com/ethereum/execution-apis/blob/cea7eeb642052f4c2e03449dc48296def4aafc24/src/engine/cancun.md#payloadattributesv3) -type is extended with an additional value, `eip1559Params`: - -```rs -PayloadAttributesV3: { - timestamp: QUANTITY - prevRandao: DATA (32 bytes) - suggestedFeeRecipient: DATA (20 bytes) - withdrawals: array of WithdrawalV1 - parentBeaconBlockRoot: DATA (32 bytes) - transactions: array of DATA - noTxPool: bool - gasLimit: QUANTITY or null - eip1559Params: DATA (8 bytes) or null -} -``` - -#### Encoding - -At and after Holocene activation, `eip1559Parameters` in `PayloadAttributeV3` must be exactly 8 bytes with the following -format: - -| Name | Type | Byte Offset | -| ------------- | ------------------ | ----------- | -| `denominator` | `u32 (big-endian)` | `[0, 4)` | -| `elasticity` | `u32 (big-endian)` | `[4, 8)` | - -#### PayloadID computation - -If `eip1559Params != null`, the `eip1559Params` is included in the `PayloadID` hasher directly after the `gasLimit` -field. - -### Execution - -#### Payload Attributes Processing - -Prior to Holocene activation, `eip1559Parameters` in `PayloadAttributesV3` must be null and is otherwise considered -invalid. - -At and after Holocene activation, any `ExecutionPayload` corresponding to some `PayloadAttributesV3` must contain -`extraData` formatted as the [header value](#eip-1559-parameters-in-block-header). The `denominator` and `elasticity` -values within this `extraData` must correspond to those in `eip1559Parameters`, unless both are 0. When both are 0, the -[prior EIP-1559 constants](../../protocol/execution/index.md#1559-parameters) must be used to populate `extraData` instead. - -#### Base Fee Computation - -Prior to the Holocene upgrade, the EIP-1559 denominator and elasticity parameters used to compute the block base fee -were [constants](../../protocol/execution/index.md#1559-parameters). - -With the Holocene upgrade, these parameters are instead determined as follows: - -- if Holocene is not active in `parent_header.timestamp`, the [prior EIP-1559 - constants](../../protocol/execution/index.md#1559-parameters) are used. Note that `parent_header.extraData` is empty - prior to Holocene, except possibly for the genesis block. -- if Holocene is active at `parent_header.timestamp`, then the parameters from `parent_header.extraData` are used. - -### Rationale - -Placing the EIP-1559 parameters within the L2 block header allows us to retain the purity of the function that computes -the next block's base fee from its parent block header, while still allowing them to be dynamically configured. Dynamic -configuration is handled similarly to `gasLimit`, with the derivation pipeline providing the appropriate `SystemConfig` -contract values to the block builder via `PayloadAttributesV3` parameters. diff --git a/docs/specs/pages/upgrades/holocene/overview.md b/docs/specs/pages/upgrades/holocene/overview.md deleted file mode 100644 index aa07342548..0000000000 --- a/docs/specs/pages/upgrades/holocene/overview.md +++ /dev/null @@ -1,20 +0,0 @@ -# Holocene - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1736445601` (2025-01-09 18:00:01 UTC) | -| `sepolia` | `1732633200` (2024-11-26 15:00:00 UTC) | - -## Execution Layer - -- [Dynamic EIP-1559 Parameters](/upgrades/holocene/exec-engine#dynamic-eip-1559-parameters) - -## Consensus Layer - -- [Holocene Derivation](/upgrades/holocene/derivation#holocene-derivation) - -## Smart Contracts - -- [System Config](/upgrades/holocene/system-config) diff --git a/docs/specs/pages/upgrades/holocene/system-config.md b/docs/specs/pages/upgrades/holocene/system-config.md deleted file mode 100644 index b72cc522a8..0000000000 --- a/docs/specs/pages/upgrades/holocene/system-config.md +++ /dev/null @@ -1,69 +0,0 @@ -# System Config - -## Overview - -The `SystemConfig` is updated to allow for dynamic EIP-1559 parameters. - -### `ConfigUpdate` - -When the configuration is updated, a [`ConfigUpdate`](../../protocol/consensus/derivation.md#system-config-updates) event -MUST be emitted with the following parameters: - -| `version` | `updateType` | `data` | Usage | -| ---- | ----- | --- | -- | -| `uint256(0)` | `uint8(4)` | `abi.encode((uint256(_denominator) << 32) \| _elasticity)` | Modifies the EIP-1559 denominator and elasticity | - -Note that the above encoding is the format emitted by the SystemConfig event, which differs from the format in extraData -from the block header. - -### Initialization - -The following actions should happen during the initialization of the `SystemConfig`: - -- `emit ConfigUpdate.BATCHER` -- `emit ConfigUpdate.FEE_SCALARS` -- `emit ConfigUpdate.GAS_LIMIT` -- `emit ConfigUpdate.UNSAFE_BLOCK_SIGNER` - -Intentionally absent from this is `emit ConfigUpdate.EIP_1559_PARAMS`. -As long as these values are unset, the default values will be used. -Requiring 1559 parameters to be set during initialization would add a strict requirement -that the L2 hardforks before the L1 contracts are upgraded, and this is complicated to manage in a -world of many chains. - -### Modifying EIP-1559 Parameters - -A new `SystemConfig` `UpdateType` is introduced that enables the modification of -[EIP-1559](https://eips.ethereum.org/EIPS/eip-1559) parameters. This allows for the chain -operator to modify the `BASE_FEE_MAX_CHANGE_DENOMINATOR` and the `ELASTICITY_MULTIPLIER`. - -### Interface - -#### EIP-1559 Params - -##### `setEIP1559Params` - -This function MUST only be callable by the chain governor. - -```solidity -function setEIP1559Params(uint32 _denominator, uint32 _elasticity) -``` - -The `_denominator` and `_elasticity` MUST be set to values greater to than 0. -It is possible for the chain operator to set EIP-1559 parameters that result in poor user experience. - -##### `eip1559Elasticity` - -This function returns the currently configured EIP-1559 elasticity. - -```solidity -function eip1559Elasticity()(uint32) -``` - -##### `eip1559Denominator` - -This function returns the currently configured EIP-1559 denominator. - -```solidity -function eip1559Denominator()(uint32) -``` diff --git a/docs/specs/pages/upgrades/isthmus/derivation.md b/docs/specs/pages/upgrades/isthmus/derivation.md deleted file mode 100644 index 608ba2c9d5..0000000000 --- a/docs/specs/pages/upgrades/isthmus/derivation.md +++ /dev/null @@ -1,367 +0,0 @@ -# Isthmus L2 Chain Derivation Changes - -# Network upgrade automation transactions - -The Isthmus hardfork activation block contains the following transactions, in this order: - -- L1 Attributes Transaction -- User deposits from L1 -- Network Upgrade Transactions - - L1Block deployment - - GasPriceOracle deployment - - Operator Fee vault deployment - - Update L1Block Proxy ERC-1967 Implementation - - Update GasPriceOracle Proxy ERC-1967 Implementation - - Update Operator Fee vault Proxy ERC-1967 Implementation - - GasPriceOracle Enable Isthmus - - EIP-2935 Contract Deployment - -To not modify or interrupt the system behavior around gas computation, this block will not include any sequenced -transactions by setting `noTxPool: true`. - -## L1Block deployment - -The `L1Block` contract is upgraded to support the Isthmus operator fee feature. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000003` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `425,000` -- `data`: `0x60806040523480156100105...` (full bytecode) -- `sourceHash`: `0x3b2d0821ca2411ad5cd3595804d1213d15737188ae4cbd58aa19c821a6c211bf`, - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: L1 Block Deployment" - -This results in the Isthmus L1Block contract being deployed to `0xFf256497D61dcd71a9e9Ff43967C13fdE1F72D12`, to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000003 -Computed Address: 0xFf256497D61dcd71a9e9Ff43967C13fdE1F72D12 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: L1 Block Deployment")) -# 0x3b2d0821ca2411ad5cd3595804d1213d15737188ae4cbd58aa19c821a6c211bf -``` - -Verify `data`: - -```bash -git checkout 9436dba8c4c906e36675f5922e57d1b55582889e -make build-contracts -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/L1Block.sol/L1Block.json -``` - -This transaction MUST deploy a contract with the following code hash -`0x8e3fe7a416d3e5f3b7be74ddd4e7e58e516fa3f80b67c6d930e3cd7297da4a4b`. - -To verify the code hash: - -```bash -git checkout 9436dba8c4c906e36675f5922e57d1b55582889e -make build-contracts -cast k $(jq -r ".deployedBytecode.object" packages/contracts-bedrock/forge-artifacts/L1Block.sol/L1Block.json) -``` - -## GasPriceOracle deployment - -The `GasPriceOracle` contract is also upgraded to support the Isthmus operator fee feature. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000004` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `1,625,000` -- `data`: `0x60806040523480156100105...` (full bytecode) -- `sourceHash`: `0xfc70b48424763fa3fab9844253b4f8d508f91eb1f7cb11a247c9baec0afb8035`, - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: Gas Price Oracle Deployment" - -This results in the Isthmus GasPriceOracle contract being deployed to `0x93e57A196454CB919193fa9946f14943cf733845`, to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000003 -Computed Address: 0xFf256497D61dcd71a9e9Ff43967C13fdE1F72D12 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: Gas Price Oracle Deployment")) -# 0xfc70b48424763fa3fab9844253b4f8d508f91eb1f7cb11a247c9baec0afb8035 -``` - -Verify `data`: - -```bash -git checkout 9436dba8c4c906e36675f5922e57d1b55582889e -make build-contracts -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/GasPriceOracle.sol/GasPriceOracle.json -``` - -This transaction MUST deploy a contract with the following code hash -`0x4d195a9d7caf9fb6d4beaf80de252c626c853afd5868c4f4f8d19c9d301c2679`. - -To verify the code hash: - -```bash -git checkout 9436dba8c4c906e36675f5922e57d1b55582889e -make build-contracts -cast k $(jq -r ".deployedBytecode.object" packages/contracts-bedrock/forge-artifacts/GasPriceOracle.sol/GasPriceOracle.json) -``` - -## Operator fee vault deployment - -A new `OperatorFeeVault` contract has been created to receive the operator fees. The contract is created -with the following arguments: - -- Recipient address: The base fee vault -- Min withdrawal amount: 0 -- Withdrawal network: L2 - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000005` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `500,000` -- `data`: `0x60806040523480156100105...` (full bytecode) -- `sourceHash`: `0x107a570d3db75e6110817eb024f09f3172657e920634111ce9875d08a16daa96`, - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: Operator Fee Vault Deployment" - -This results in the Isthmus OperatorFeeVault contract being deployed to -`0x4fa2Be8cd41504037F1838BcE3bCC93bC68Ff537`, to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000003 -Computed Address: 0x4fa2Be8cd41504037F1838BcE3bCC93bC68Ff537 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: Operator Fee Vault Deployment")) -# 0x107a570d3db75e6110817eb024f09f3172657e920634111ce9875d08a16daa96 -``` - -Verify `data`: - -```bash -git checkout 9436dba8c4c906e36675f5922e57d1b55582889e -make build-contracts -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/OperatorFeeVault.sol/OperatorFeeVault.json -``` - -This transaction MUST deploy a contract with the following code hash -`0x57dc55c9c09ca456fa728f253fe7b895d3e6aae0706104935fe87c7721001971`. - -To verify the code hash: - -```bash -git checkout 9436dba8c4c906e36675f5922e57d1b55582889e -make build-contracts -export ETH_RPC_URL=https://mainnet.optimism.io # Any RPC running Cancun or Prague -cast k $(cast call --create $(jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/OperatorFeeVault.sol/OperatorFeeVault.json)) -``` - -Note that this verification differs from the other deployments because the `OperatorFeeVault` -inherits the `FeeVault` contract which contains immutables. So the deployment bytecode has to be -executed on an EVM to get the actual deployed contract bytecode. But it sets all immutables to fixed -constants, so the resulting code hash is constant. - -## L1Block Proxy Update - -This transaction updates the L1Block Proxy ERC-1967 implementation slot to point to the new L1Block deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x4200000000000000000000000000000000000015` (L1Block Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe6000000000000000000000000ff256497d61dcd71a9e9ff43967c13fde1f72d12` -- `sourceHash`: `0xebe8b5cb10ca47e0d8bda8f5355f2d66711a54ddeb0ef1d30e29418c9bf17a0e` - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: L1 Block Proxy Update" - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0xff256497d61dcd71a9e9ff43967c13fde1f72d12) -0x3659cfe6000000000000000000000000ff256497d61dcd71a9e9ff43967c13fde1f72d12 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: L1 Block Proxy Update")) -# 0xebe8b5cb10ca47e0d8bda8f5355f2d66711a54ddeb0ef1d30e29418c9bf17a0e -``` - -## GasPriceOracle Proxy Update - -This transaction updates the GasPriceOracle Proxy ERC-1967 implementation slot to point to the new GasPriceOracle -deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe600000000000000000000000093e57a196454cb919193fa9946f14943cf733845` -- `sourceHash`: `0xecf2d9161d26c54eda6b7bfdd9142719b1e1199a6e5641468d1bf705bc531ab0` - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: Gas Price Oracle Proxy Update"` - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0x93e57a196454cb919193fa9946f14943cf733845) -0x3659cfe600000000000000000000000093e57a196454cb919193fa9946f14943cf733845 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: Gas Price Oracle Proxy Update")) -# 0xecf2d9161d26c54eda6b7bfdd9142719b1e1199a6e5641468d1bf705bc531ab0 -``` - -## OperatorFeeVault Proxy Update - -This transaction updates the GasPriceOracle Proxy ERC-1967 implementation slot to point to the new GasPriceOracle -deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x420000000000000000000000000000000000001B` (Operator Fee Vault Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe60000000000000000000000004fa2be8cd41504037f1838bce3bcc93bc68ff537` -- `sourceHash`: `0xad74e1adb877ccbe176b8fa1cc559388a16e090ddbe8b512f5b37d07d887a927` - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: Operator Fee Vault Proxy Update"` - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0x4fa2be8cd41504037f1838bce3bcc93bc68ff537) -0x3659cfe60000000000000000000000004fa2be8cd41504037f1838bce3bcc93bc68ff537 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: Operator Fee Vault Proxy Update")) -# 0xad74e1adb877ccbe176b8fa1cc559388a16e090ddbe8b512f5b37d07d887a927 -``` - -## GasPriceOracle Enable Isthmus - -This transaction informs the GasPriceOracle to start using the Isthmus gas calculation formula. - -A deposit transaction is derived with the following attributes: - -- `from`: `0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001` (Depositer Account) -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `90,000` -- `data`: `0x291b0383` -- `sourceHash`: `0x3ddf4b1302548dd92939826e970f260ba36167f4c25f18390a5e8b194b295319`, - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: Gas Price Oracle Set Isthmus" - -Verify data: - -```bash -cast sig "setIsthmus()" -0x8e98b106 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: Gas Price Oracle Set Isthmus")) -# 0x3ddf4b1302548dd92939826e970f260ba36167f4c25f18390a5e8b194b295319 -``` - -## EIP-2935 Contract Deployment - -[EIP-2935](https://eips.ethereum.org/EIPS/eip-2935) requires a contract to be deployed. To deploy this contract, -a deposit transaction is created with attributes matching the EIP: - -- `from`: `0x3462413Af4609098e1E27A490f554f260213D685` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `250,000` -- `data`: `0x60538060095f395ff33373fffffffffffffffffffffffffffffffffffffffe14604657602036036042575f35600143038111604257611fff81430311604257611fff9006545f5260205ff35b5f5ffd5b5f35611fff60014303065500` -- `sourceHash`: `0xbfb734dae514c5974ddf803e54c1bc43d5cdb4a48ae27e1d9b875a5a150b553a` - computed with the "Upgrade-deposited" type, with `intent = "Isthmus: EIP-2935 Contract Deployment" - -This results in the EIP-2935 contract being deployed to `0x0000F90827F1C53a10cb7A02335B175320002935`, to verify: - -```bash -cast compute-address --nonce=0 0x3462413Af4609098e1E27A490f554f260213D685 -Computed Address: 0x0000F90827F1C53a10cb7A02335B175320002935 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Isthmus: EIP-2935 Contract Deployment")) -# 0xbfb734dae514c5974ddf803e54c1bc43d5cdb4a48ae27e1d9b875a5a150b553a -``` - -This transaction MUST deploy a contract with the following code hash -`0x6e49e66782037c0555897870e29fa5e552daf4719552131a0abce779daec0a5d`. - -# Span Batch Updates - -[Span batches](../delta/span-batches.md) are a span of consecutive L2 blocks than are batched submitted. - -Span batches contain the L1 transactions and transaction types that are posted containing the span of L2 blocks. -Since [EIP-7702] introduces a new transaction type, the Span Batch must be updated to support the [EIP-7702] -transaction. - -This corresponds with a new RLP-encoding of the `tx_datas` list as specified in -[the Delta span batch spec](../delta/span-batches.md), adding a new transaction type: - -Transaction type `4` ([EIP-7702] `SetCode`): -`0x04 ++ rlp_encode(value, max_priority_fee_per_gas, max_fee_per_gas, data, access_list, authorization_list)` - -The [EIP-7702] transaction extends [EIP-1559] to include a new `authorization_list` field. -`authorization_list` is an RLP-encoded list of authorization tuples. -The [EIP-7702] transaction format is as follows. - -- `value`: The transaction value as a `u256`. -- `max_priority_fee_per_gas`: The maximum priority fee per gas allowed as a `u256`. -- `max_fee_per_gas`: The maximum fee per gas as a `u256`. -- `data`: The transaction data bytes. -- `access_list`: The [EIP-2930] access list. -- `authorization_list`: The [EIP-7702] signed authorization list. - -## Activation - -Singular batches with transactions of type `4` must only be accepted if Isthmus is active at the -timestamp of the batch. If a singular batch contains a transaction of type `4` before Isthmus is -active, this batch must be _dropped_. Note that if Holocene is active, this will also -lead to the remaining span batch, and channel that contained it, to get dropped. - -Also note that this check must happen at the level of individual batches that are derived from span -batches, not to span batches as a whole. In particular, it is allowed for a span batch to span the -Isthmus activation timestamp and contain SetCode transactions in singular batches that have a -timestamp at or after the Isthmus activation time, even if the timestamp of the span batch is before -the Isthmus activation time. - -[EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 -[EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 -[EIP-2930]: https://eips.ethereum.org/EIPS/eip-2930 diff --git a/docs/specs/pages/upgrades/isthmus/exec-engine.md b/docs/specs/pages/upgrades/isthmus/exec-engine.md deleted file mode 100644 index 5c6d50236a..0000000000 --- a/docs/specs/pages/upgrades/isthmus/exec-engine.md +++ /dev/null @@ -1,264 +0,0 @@ -# L2 Execution Engine - - - -[l2-to-l1-mp]: ../../protocol/execution/evm/predeploys.md#L2ToL1MessagePasser -[output-root]: ../../reference/glossary.md#l2-output-root - -## Overview - -The storage root of the `L2ToL1MessagePasser` is included in the block header's -`withdrawalRoot` field. - -## Timestamp Activation - -Isthmus, like other network upgrades, is activated at a timestamp. -Changes to the L2 Block execution rules are applied when the `L2 Timestamp >= activation time`. - -## `L2ToL1MessagePasser` Storage Root in Header - -After Isthmus hardfork's activation, the L2 block header's `withdrawalsRoot` field will consist of the 32-byte -[`L2ToL1MessagePasser`][l2-to-l1-mp] account storage root from the world state identified by the stateRoot -field in the block header. The storage root should be the same root that is returned by `eth_getProof` -at the given block number. - -### Header Validity Rules - -Prior to isthmus activation: - -- the L2 block header's `withdrawalsRoot` field must be: - - `nil` if Canyon has not been activated. - - `keccak256(rlp(empty_string_code))` if Canyon has been activated. -- the L2 block header's `requestsHash` field must be omitted. - -After Isthmus activation, an L2 block header is valid iff: - -1. The `withdrawalsRoot` field - 1. Is 32 bytes in length. - 1. Matches the [`L2ToL1MessagePasser`][l2-to-l1-mp] account storage root, - as committed to in the `storageRoot` within the block header -1. The `requestsHash` field is equal to `sha256('') = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` - indicating no requests in the block. - -### Header Withdrawals Root - -| Byte offset | Description | -| ----------- | --------------------------------------------------------- | -| `[0, 32)` | [`L2ToL1MessagePasser`][l2-to-l1-mp] account storage root | - -#### Rationale - -Currently, to generate [L2 output roots][output-root] for historical blocks, an archival node is required. This directly -places a burden on users of the system in a post-fault-proofs world, where: - -1. A proposer must have an archive node to propose an output root at the safe head. -1. A user that is proving their withdrawal must have an archive node to verify that the output root they are proving - their withdrawal against is indeed valid and included within the safe chain. - -Placing the [`L2ToL1MessagePasser`][l2-to-l1-mp] account storage root in the `withdrawalsRoot` field alleviates this burden -for users and protocol participants alike, allowing them to propose and verify other proposals with lower operating costs. - -#### Genesis Block - -If Isthmus is active at genesis block, the `withdrawalsRoot` in the genesis block header is set to the -[`L2ToL1MessagePasser`][l2-to-l1-mp] account storage root. - -#### State Processing - -At the time of state processing, the header for which transactions are being validated should not make its `withdrawalsRoot` -available to the EVM/application layer. - -#### P2P - -During sync, we expect the withdrawals list in the block body to be empty (Base does not make -use of the withdrawals list) and hence the hash of the withdrawals list to be the MPT root of an empty list. -When verifying the header chain using the final header that is synced, the header timestamp is used to -determine whether Isthmus is active at that block. If it is, we expect that the header `withdrawalsRoot` -MPT hash can be any non-null value (since it is expected to contain the `L2ToL1MessagePasser`'s storage root). - -#### Backwards Compatibility Considerations - -Beginning at Canyon (which includes Shanghai hardfork support) and prior to Isthmus activation, -the `withdrawalsRoot` field is set to the MPT root of an empty withdrawals list. This is the -same root as an empty storage root. The withdrawals are captured in the L2 state, however -they are not reflected in the `withdrawalsRoot`. Hence, prior to Isthmus activation, -even if a `withdrawalsRoot` is present and an MPT root is present in the header, it should not be used. -Any implementation that calculates an output root should be careful not to use the header `withdrawalsRoot`. - -Note that there is always nonzero storage in the [`L2ToL1MessagePasser`][l2-to-l1-mp], -because it is a [proxied predeploy](../../protocol/execution/evm/predeploys.md) -- from genesis it -stores an implementation address and owner address. So from Isthmus, -the `withdrawalsRoot` will always be non-nil and never be the MPT root of an empty list. - -#### Forwards Compatibility Considerations - -As it stands, the `withdrawalsRoot` field is unused within Base's header consensus format, and will never be -used for other reasons that are currently planned. Setting this value to the account storage root of the withdrawal -directly fits with Base, and makes use of the existing field in the L1 header consensus format. - -#### Client Implementation Considerations - -Various EL clients store historical state of accounts differently. If, as a contrived case, Base did not have -an outbound withdrawal for a long period of time, the node may not have access to the account storage root of the -[`L2ToL1MessagePasser`][l2-to-l1-mp]. In this case, the client would be unable to keep consensus. However, most modern -clients are able to at the very least reconstruct the account storage root at a given block on the fly if it does not -directly store this information. - -##### Transaction Simulation - -In response to RPC methods like `eth_simulateV1` that allow simulation of arbitrary transactions within one or more blocks, -an empty withdrawals root should be included in the header of a block that consists of such simulated transactions. The same -is applicable for scenarios where the actual withdrawals root value is not readily available. - -## Deposit Requests - -[EIP-6110] shifts deposit to the execution layer, introducing a new [EIP-7685] deposit request of type -`DEPOSIT_REQUEST_TYPE`. Deposit requests then appear in the [EIP-7685] requests list. Base needs to ignore these -requests. Requests generation must be modified to exclude [EIP-6110] deposit requests. Note that since the [EIP-6110] -request type did _not_ exist prior to Pectra on L1 and the Isthmus hardfork on L2, no activation time is needed since these -deposit type requests may always be excluded. - -[EIP-6110]: https://eips.ethereum.org/EIPS/eip-6110 -[EIP-7685]: https://eips.ethereum.org/EIPS/eip-7685 - -## Block Body Withdrawals List - -Withdrawals list in the block body is encoded as an empty RLP list. - -## EVM Changes - -### BLS Precompiles - -Similar to the `bn256Pairing` precompile in the [granite hardfork](../granite/exec-engine.md), -[EIP-2537](https://eips.ethereum.org/EIPS/eip-2537) introduces a BLS -precompile that short-circuits depending on input size in the EVM. - -The input size limits of the BLS precompile contracts are listed below: - -- G1 multiple-scalar-multiply: `input_size <= 513760 bytes` -- G2 multiple-scalar-multiply: `input_size <= 488448 bytes` -- Pairing check: `input_size <= 235008 bytes` - -The rest of the BLS precompiles are fixed-size operations which have a fixed gas cost. - -All of the BLS precompiles should be [accelerated](../../protocol/fault-proof/index.md#precompile-accelerators) in fault proof -programs so they call out to the L1 instead of calculating the result inside the program. - -## Block Sealing - -In Base, `EIP-7685` is no-op'd, and the `requestsHash` is always set to `sha256('')` (as noted in -[header validity rules](#header-validity-rules)). As such, [EIP-6110](https://eips.ethereum.org/EIPS/eip-6110), -[EIP-7002](https://eips.ethereum.org/EIPS/eip-7002), and [EIP-7251](https://eips.ethereum.org/EIPS/eip-7251) are not -enabled either. The Base execution layer must ensure that the post-block filtering of events in the deposit contract -(EIP-6110) as well as the `EIP-7002` + `EIP-7251` system calls are _not invoked_ during the block sealing process after -Isthmus activation. - -Users of Base may still permissionlessly deploy these smart contracts, but they will not be treated as special -by the Base execution layer, and the system calls introduced in L1's Pectra hardfork are not considered. - -## Engine API Updates - -### Update to `ExecutionPayload` - -`ExecutionPayload` will contain an extra field for `withdrawalsRoot` after Isthmus hard fork. - -### `engine_newPayloadV4` API - -Post Isthmus, `engine_newPayloadV4` will be used. - -The `executionRequests` parameter MUST be an empty array. - -## Fees - -New rollup variants have different resource consumption patterns, and thus require a more flexible -pricing model. To enable more customizable fee structures, Isthmus adds a new component to the fee -calculation: the `operatorFee`, which is parameterized by two scalars: the `operatorFeeScalar` -and the `operatorFeeConstant`. - -### Operator Fee - -The operator fee is integrated directly into the EVM, alongside the standard gas fee and the Base-specific L1 data -fee. This fee follows the same semantics of existing fees charged in the EVM[^1], just with a new fee beneficiary account. - -#### Fee Formula - -$$ -\text{operatorFee} = (\text{gas} \times \text{operatorFeeScalar} \div 10^6) + \text{operatorFeeConstant} -$$ - -Where: - -- `gas` is the amount of gas that the transaction used. When calculating the amount of gas that is bought at the - beginning of the transaction, this should be the `gas_limit`. When determining how much gas should be refunded, - based off of how much of the `gas_limit` the transaction used, this should be the `gas_used`. -- `operatorFeeScalar` is a `uint32` scalar set by the chain operator, scaled by `1e6`. -- `operatorFeeConstant` is a `uint64` scalar set by the chain operator. - -Note that the operator fee's maximum value has 77 bits, which can be calculated from the maximum input parameters: - -```text -operatorFee_max = (uint64_max * uint32_max / 10^6) + uint64_max ≈ 7.924660923989131 * 10^22 -``` - -So implementations don't need to check for overflows if they perform the calculations with `uint256` types. - -#### Deposit Operator Fees - -Deposit transactions do not get charged operator fees. For all deposit transactions, regardless of the operator fee -parameter configuration, the operator fee should be **zero**. Deposit transactions also do not receive operator fee gas -refunds, since they never buy the operator fee gas to begin with. - -#### EVM Fee Semantics - -Like other fees in the EVM, the operator fee should be charged following the pattern below: - -1. During pre-execution validation, the account must have enough ETH to cover the existing worst-case gas + L1 data fees - _as well as_ the worst-case operator fee (for deposits, the worst-case fee is `0`). To compute this value, use the - [fee formula](#fee-formula) with `gas` set to the `gas_limit` of the transaction, and add it to the existing - worst-case transaction fee. -1. When buying gas prior to execution, charge the account the worst-case operator fee. To compute this value, use the - [fee formula](#fee-formula) with `gas` set to the `gas_limit` of the transaction. -1. After execution, when issuing refunds, transactions that bought operator fee gas should be refunded the operator fee - gas that was unused (i.e., the caller should only be charged the _effective_ operator fee.) The refund should be - calculated as $\text{opFeeRefund} = \text{opFeeWorstCase} - \text{opFeeActual}$, where: - - $\text{opFeeWorstCase}$ is as described in #1 + #2. - - $\text{opFeeActual}$ is the amount of the operator fee that was actually used. This value is computed using the - [fee formula](#fee-formula) with `gas` set to the `gas_limit - gas_used + refunded_gas`. `refunded_gas` is as - described in [EIP-3529](https://eips.ethereum.org/EIPS/eip-3529). -1. After execution, when rewarding the fee beneficiaries, send the _spent operator fee_ to the - [operator fee vault](#fee-vaults). This value is exactly $\text{opFeeActual}$ as described above. - -Implementations must ensure ETH is neither minted nor destroyed as a result of the operator fee. - -#### Transaction Pool Changes - -To account for the additional fee factored into transaction validity mentioned above, the transaction pool must reject -transactions that do not have enough balance to cover the worst-case cost of the transaction fee. This worst-case cost -of a transaction now includes the worst-case operator fee. - -#### Configuring Operator Fee Parameters - -`operatorFeeScalar` and `operatorFeeConstant` are loaded in a similar way to the `baseFeeScalar` and -`blobBaseFeeScalar` used in the [`L1Fee`](../../protocol/execution/index.md#ecotone-l1-cost-fee-changes-eip-4844-da). -calculation. In more detail, these parameters can be accessed in two interchangable ways. - -- read from the deposited L1 attributes (`operatorFeeScalar` and `operatorFeeConstant`) of the current L2 block -- read from the L1 Block Info contract (`0x4200000000000000000000000000000000000015`) - - using the respective solidity getter functions (`operatorFeeScalar`, `operatorFeeConstant`) - - using direct storage-reads: - - Operator fee scalar as big-endian `uint32` in slot `8` at offset `0`. - - Operator fee constant as big-endian `uint64` in slot `8` at offset `4`. - -### Fee Vaults - -These collected fees are sent to a new vault for the `operatorFee`: the [`OperatorFeeVault`](predeploys.md#operatorfeevault). - -Like the existing vaults, this is a hardcoded address, pointing at a pre-deployed proxy contract. -The proxy is backed by a vault contract deployment, based on `FeeVault`, to route vault funds to L1 securely. - -### Receipts - -After Isthmus activation, 2 new fields `operatorFeeScalar` and `operatorFeeConstant` are added to transaction receipts -if and only if at least one of them is non zero. - -[^1]: Wood, G., & Ethereum Contributors. (n.d.-a). Ethereum Yellow Paper. [https://ethereum.github.io/yellowpaper/paper.pdf](https://ethereum.github.io/yellowpaper/paper.pdf) Page 8, section 5: "Gas and Payment" diff --git a/docs/specs/pages/upgrades/isthmus/l1-attributes.md b/docs/specs/pages/upgrades/isthmus/l1-attributes.md deleted file mode 100644 index 9e9738ad9b..0000000000 --- a/docs/specs/pages/upgrades/isthmus/l1-attributes.md +++ /dev/null @@ -1,38 +0,0 @@ -# L1 Block Attributes - -## Overview - -The L1 block attributes transaction is updated to include the operator fee parameters. - -| Input arg | Type | Calldata bytes | Segment | -| ----------------- | ------- | -------------- | ------- | -| {0x098999be} | | 0-3 | n/a | -| baseFeeScalar | uint32 | 4-7 | 1 | -| blobBaseFeeScalar | uint32 | 8-11 | | -| sequenceNumber | uint64 | 12-19 | | -| l1BlockTimestamp | uint64 | 20-27 | | -| l1BlockNumber | uint64 | 28-35 | | -| basefee | uint256 | 36-67 | 2 | -| blobBaseFee | uint256 | 68-99 | 3 | -| l1BlockHash | bytes32 | 100-131 | 4 | -| batcherHash | bytes32 | 132-163 | 5 | -| operatorFeeScalar | uint32 | 164-167 | 6 | -| operatorFeeConstant | uint64 | 168-175 | | - -Note that the first input argument, in the same pattern as previous versions of the L1 attributes transaction, -is the function selector: the first four bytes of `keccak256("setL1BlockValuesIsthmus()")`. - -In the activation block, there are two possibilities: -- If Isthmus is active at genesis, there are no transactions in the activation block -and therefore no L1 Block Attributes transaction to consider. -- If Isthmus activates after genesis [`setL1BlockValuesEcotone()`](../ecotone/l1-attributes.md) -method must be used. This is because the L1 Block contract will not yet have been upgraded. - -In each subsequent L2 block, the `setL1BlockValuesIsthmus()` method must be used. - -When using this method, the pre-Isthmus values are migrated over 1:1 -and the transaction also sets the following new attributes to the values -from the [`SystemConfig`](../../protocol/consensus/derivation.md#system-configuration): - -- `operatorFeeScalar` -- `operatorFeeConstant` diff --git a/docs/specs/pages/upgrades/isthmus/overview.md b/docs/specs/pages/upgrades/isthmus/overview.md deleted file mode 100644 index 0e6007f66b..0000000000 --- a/docs/specs/pages/upgrades/isthmus/overview.md +++ /dev/null @@ -1,36 +0,0 @@ -# Isthmus - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1746806401` (2025-05-09 16:00:01 UTC) | -| `sepolia` | `1744905600` (2025-04-17 16:00:00 UTC) | - -## Execution Layer - -- [Pectra](https://eips.ethereum.org/EIPS/eip-7600) (Execution Layer): - - [EIP-7702](https://eips.ethereum.org/EIPS/eip-7702) - - [Span Batch Updates](/upgrades/isthmus/derivation#span-batch-updates) - - [EIP-2537](https://eips.ethereum.org/EIPS/eip-2537) - - [EIP-2935](https://eips.ethereum.org/EIPS/eip-2935) - - [EIP-2935 Contract Deployment](/upgrades/isthmus/derivation#eip-2935-contract-deployment) - - [EIP-7002](https://eips.ethereum.org/EIPS/eip-7002) - - The EIP-7002 predeploy contract and syscall are not adopted as part of Base. - - [EIP-7251](https://eips.ethereum.org/EIPS/eip-7251) - - The EIP-7251 predeploy contract and syscall are not adopted as part of Base. - - [EIP-7623](https://eips.ethereum.org/EIPS/eip-7623) - - [EIP-6110](https://eips.ethereum.org/EIPS/eip-6110) - - [EIP-7685](https://eips.ethereum.org/EIPS/eip-7685) -- [L2ToL1MessagePasser Storage Root in Header](/upgrades/isthmus/exec-engine#l2tol1messagepasser-storage-root-in-header) -- [Operator Fee](/upgrades/isthmus/exec-engine#operator-fee) - -## Consensus Layer - -- [Isthmus Derivation](/upgrades/isthmus/derivation) - -## Smart Contracts - -- [Predeploys](/upgrades/isthmus/predeploys) -- [L1 Block Attributes](/upgrades/isthmus/l1-attributes) -- [System Config](/upgrades/isthmus/system-config) diff --git a/docs/specs/pages/upgrades/isthmus/predeploys.md b/docs/specs/pages/upgrades/isthmus/predeploys.md deleted file mode 100644 index 764672fe07..0000000000 --- a/docs/specs/pages/upgrades/isthmus/predeploys.md +++ /dev/null @@ -1,32 +0,0 @@ -# Predeploys - -## Overview - -### L1Block - -#### Interface - -##### `setIsthmus` - -This function is meant to be called once on the activation block of the Isthmus network upgrade. -It MUST only be callable by the `DEPOSITOR_ACCOUNT` once. When it is called, it MUST call -call each getter for the network specific config and set the returndata into storage. - -### GasPriceOracle - -Following the Isthmus upgrade, a new method is introduced: `getOperatorFee(uint256)`. This method -returns the operator fee for the given `gasUsed`. The operator fee calculation follows the formula -outlined in the [Operator Fee](exec-engine.md#) section of the execution engine spec. - -The value returned by `getOperatorFee(uint256)` is capped at `U256` max value. - -### OperatorFeeVault - -This vault implements `FeeVault`, like `BaseFeeVault`, `SequencerFeeVault`, and `L1FeeVault`. -No special logic is needed in order to insert or withdraw funds. - -Its address will be `0x420000000000000000000000000000000000001b`. - -See also [Fee Vaults](exec-engine.md#fee-vaults). - -## Security Considerations diff --git a/docs/specs/pages/upgrades/isthmus/system-config.md b/docs/specs/pages/upgrades/isthmus/system-config.md deleted file mode 100644 index 74efd3983d..0000000000 --- a/docs/specs/pages/upgrades/isthmus/system-config.md +++ /dev/null @@ -1,68 +0,0 @@ -# Isthmus: System Config - -## Operator Fee Parameter Configuration - -Isthmus adds configuration variables `operatorFeeScalar` (`uint32`) -and `operatorFeeConstant` (`uint64`) to `SystemConfig` to control the operator fee parameters. - -### `ConfigUpdate` - -The following `ConfigUpdate` event is defined where the `CONFIG_VERSION` is `uint256(0)`: - -| Name | Value | Definition | Usage | -| ---- | ----- | --- | -- | -| `BATCHER` | `uint8(0)` | `abi.encode(address)` | Modifies the account that is authorized to progress the safe chain | -| `FEE_SCALARS` | `uint8(1)` | `(uint256(0x01) << 248) \| (uint256(_blobbasefeeScalar) << 32) \| _basefeeScalar` | Modifies the fee scalars | -| `GAS_LIMIT` | `uint8(2)` | `abi.encode(uint64 _gasLimit)` | Modifies the L2 gas limit | -| `UNSAFE_BLOCK_SIGNER` | `uint8(3)` | `abi.encode(address)` | Modifies the account that is authorized to progress the unsafe chain | -| `EIP_1559_PARAMS` | `uint8(4)` | `uint256(uint64(uint32(_denominator))) << 32 \| uint64(uint32(_elasticity))` | Modifies the EIP-1559 denominator and elasticity | -| `OPERATOR_FEE_PARAMS` | `uint8(5)` | `uint256(_operatorFeeScalar) << 64 \| _operatorFeeConstant` | Modifies the operator fee scalar and constant | - -### Initialization - -The following actions should happen during the initialization of the `SystemConfig`: - -- `emit ConfigUpdate.BATCHER` -- `emit ConfigUpdate.FEE_SCALARS` -- `emit ConfigUpdate.GAS_LIMIT` -- `emit ConfigUpdate.UNSAFE_BLOCK_SIGNER` -- `emit ConfigUpdate.EIP_1559_PARAMS` - -These actions MAY only be triggered if there is a diff to the value. - -The `operatorFeeScalar` and `operatorFeeConstant` are initialized to 0. - -### Modifying Operator Fee Parameters - -A new `SystemConfig` `UpdateType` is introduced that enables the modification of -the `operatorFeeScalar` and `operatorFeeConstant` by the `SystemConfig` owner. - -### Interface - -#### Operator fee parameters - -##### `operatorFeeScalar` - -This function returns the currently configured operator fee scalar. - -```solidity -function operatorFeeScalar()(uint32) -``` - -##### `operatorFeeConstant` - -This function returns the currently configured operator fee constant. - -```solidity -function operatorFeeConstant()(uint64) -``` - -##### `setOperatorFeeScalars` - -This function sets the `operatorFeeScalar` and `operatorFeeConstant`. - -This function MUST only be callable by the `SystemConfig` owner. - -```solidity -function setOperatorFeeScalar(uint32 _operatorFeeScalar, uint64 _operatorFeeConstant) -``` diff --git a/docs/specs/pages/upgrades/jovian/derivation.md b/docs/specs/pages/upgrades/jovian/derivation.md deleted file mode 100644 index ce43099b6b..0000000000 --- a/docs/specs/pages/upgrades/jovian/derivation.md +++ /dev/null @@ -1,221 +0,0 @@ -# Derivation - -## Activation Block Rules - -The first block with a timestamp at or after the Jovian activation time is considered the _Jovian activation block_. - -To not modify or interrupt the system behavior regarding gas computations, the activation block must not include any -non-deposit transactions. Sequencer must enforce this by setting `noTxPool` to `true` in the payload attributes. This -rule must be checked during derivation at the batch verification stage, and if the batch for the activation block -contains any transactions, it must be `DROP`ped. - -On the Jovian activation block, in addition to the L1 attributes deposit and potentially any user deposits from L1, a -set of deposit transaction-based upgrade transactions are deterministically generated by the derivation pipeline in the -following order: - -- L1 Attributes Transaction (still calling the old `L1Block.setL1BlockValuesIsthmus()`) -- User deposits from L1 (if any) -- Network Upgrade Transactions - - L1Block deployment - - Update L1Block Proxy ERC-1967 Implementation - - GasPriceOracle deployment - - Update GasPriceOracle Proxy ERC-1967 Implementation - - GasPriceOracle Enable Jovian call - -The network upgrade transactions are specified in the next section. - -## Network Upgrade Transactions - -The upgrade transaction details below are based on the monorepo at commit hash -`b3299e0ddb55442e6496512084d16c439ea2da77`, and will be updated once a contracts release is made. - -### L1Block Deployment - - -The `L1Block` contract is deployed. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000006` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `nonce`: `0` -- `gasLimit`: `447315` -- `data`: `0x0x608060405234801561001057600080...` (full bytecode) -- `sourceHash`: `0x98faf23b9795967bc0b1c543144739d50dba3ea40420e77ad6ca9848dbfb62e8`, - computed with the "Upgrade-deposited" type, with `intent = "Jovian: L1Block Deployment"` - -This results in the Jovian L1Block contract being deployed to -`0x3Ba4007f5C922FBb33C454B41ea7a1f11E83df2C`, to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000006 -Computed Address: 0x3Ba4007f5C922FBb33C454B41ea7a1f11E83df2C -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Jovian: L1Block Deployment")) -# 0x98faf23b9795967bc0b1c543144739d50dba3ea40420e77ad6ca9848dbfb62e8 -``` - -Verify `data`: - -```bash -git checkout 773798a67678ab28c3ef7ee3405f25c04616af19 -make build-contracts -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/L1Block.sol/L1Block.json -``` - -This transaction MUST deploy a contract with the following code hash -`0x5f885ca815d2cf27a203123e50b8ae204fdca910b6995d90b2d7700cbb9240d1`. - -To verify the code hash: - -```bash -git checkout 773798a67678ab28c3ef7ee3405f25c04616af19 -make build-contracts -cast k $(jq -r ".deployedBytecode.object" packages/contracts-bedrock/forge-artifacts/L1Block.sol/L1Block.json) -``` - -### L1Block Proxy Update - -This transaction updates the L1Block Proxy ERC-1967 -implementation slot to point to the new L1Block deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x4200000000000000000000000000000000000015` (L1Block Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe60000000000000000000000003ba4007f5c922fbb33c454b41ea7a1f11e83df2c` -- `sourceHash`: `0x08447273a4fbce97bc8c515f97ac74efc461f6a4001553712f31ebc11288bad2` - computed with the "Upgrade-deposited" type, with `intent = "Jovian: L1Block Proxy Update"` - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0x3Ba4007f5C922FBb33C454B41ea7a1f11E83df2C) -# 0x3659cfe60000000000000000000000003ba4007f5c922fbb33c454b41ea7a1f11e83df2c -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Jovian: L1Block Proxy Update")) -# 0x08447273a4fbce97bc8c515f97ac74efc461f6a4001553712f31ebc11288bad2 -``` - -### GasPriceOracle Deployment - - -The `GasPriceOracle` contract is deployed. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x4210000000000000000000000000000000000007` -- `to`: `null` -- `mint`: `0` -- `value`: `0` -- `nonce`: `0` -- `gasLimit`: `1750714` -- `data`: `0x0x608060405234801561001057600080...` (full bytecode) -- `sourceHash`: `0xd939cca6eca7bd0ee0c7e89f7e5b5cf7bf6f7afe7b6966bb45dfb95344b31545`, - computed with the "Upgrade-deposited" type, with `intent = "Jovian: GasPriceOracle Deployment"` - -This results in the Jovian GasPriceOracle contract being deployed to -`0x4f1db3c6AbD250ba86E0928471A8F7DB3AFd88F1`, to verify: - -```bash -cast compute-address --nonce=0 0x4210000000000000000000000000000000000007 -Computed Address: 0x4f1db3c6AbD250ba86E0928471A8F7DB3AFd88F1 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Jovian: GasPriceOracle Deployment")) -# 0xd939cca6eca7bd0ee0c7e89f7e5b5cf7bf6f7afe7b6966bb45dfb95344b31545 -``` - -Verify `data`: - -```bash -git checkout 773798a67678ab28c3ef7ee3405f25c04616af19 -make build-contracts -jq -r ".bytecode.object" packages/contracts-bedrock/forge-artifacts/GasPriceOracle.sol/GasPriceOracle.json -``` - -This transaction MUST deploy a contract with the following code hash -`0xe9fc7c96c4db0d6078e3d359d7e8c982c350a513cb2c31121adf5e1e8a446614`. - -To verify the code hash: - -```bash -git checkout 773798a67678ab28c3ef7ee3405f25c04616af19 -make build-contracts -cast k $(jq -r ".deployedBytecode.object" packages/contracts-bedrock/forge-artifacts/GasPriceOracle.sol/GasPriceOracle.json) -``` - -### GasPriceOracle Proxy Update - -This transaction updates the GasPriceOracle Proxy ERC-1967 -implementation slot to point to the new GasPriceOracle deployment. - -A deposit transaction is derived with the following attributes: - -- `from`: `0x0000000000000000000000000000000000000000` -- `to`: `0x420000000000000000000000000000000000000F` (GasPriceOracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `50,000` -- `data`: `0x3659cfe60000000000000000000000004f1db3c6abd250ba86e0928471a8f7db3afd88f1` -- `sourceHash`: `0x46b597e2d8346ed7749b46734074361e0b41a0ab9af7afda5bb4e367e072bcb8` - computed with the "Upgrade-deposited" type, with `intent = "Jovian: GasPriceOracle Proxy Update"` - -Verify data: - -```bash -cast concat-hex $(cast sig "upgradeTo(address)") $(cast abi-encode "upgradeTo(address)" 0x4f1db3c6AbD250ba86E0928471A8F7DB3AFd88F1) -# 0x3659cfe60000000000000000000000004f1db3c6abd250ba86e0928471a8f7db3afd88f1 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Jovian: GasPriceOracle Proxy Update")) -# 0x46b597e2d8346ed7749b46734074361e0b41a0ab9af7afda5bb4e367e072bcb8 -``` - -### GasPriceOracle Enable Jovian - -This transaction informs the GasPriceOracle to start using the Jovian operator fee formula. - -A deposit transaction is derived with the following attributes: - -- `from`: `0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001` (Depositer Account) -- `to`: `0x420000000000000000000000000000000000000F` (Gas Price Oracle Proxy) -- `mint`: `0` -- `value`: `0` -- `gasLimit`: `90,000` -- `data`: `0xb3d72079` -- `sourceHash`: `0xe836db6a959371756f8941be3e962d000f7e12a32e49e2c9ca42ba177a92716c`, - computed with the "Upgrade-deposited" type, with `intent = "Jovian: Gas Price Oracle Set Jovian"` - -Verify data: - -```bash -cast sig "setJovian()" -# 0xb3d72079 -``` - -Verify `sourceHash`: - -```bash -cast keccak $(cast concat-hex 0x0000000000000000000000000000000000000000000000000000000000000002 $(cast keccak "Jovian: Gas Price Oracle Set Jovian")) -# 0xe836db6a959371756f8941be3e962d000f7e12a32e49e2c9ca42ba177a92716c -``` diff --git a/docs/specs/pages/upgrades/jovian/exec-engine.md b/docs/specs/pages/upgrades/jovian/exec-engine.md deleted file mode 100644 index 0ba211271f..0000000000 --- a/docs/specs/pages/upgrades/jovian/exec-engine.md +++ /dev/null @@ -1,175 +0,0 @@ -# Jovian: Execution Engine - -## Minimum Base Fee - -Jovian introduces a -[configurable minimum base fee](https://github.com/ethereum-optimism/design-docs/blob/main/protocol/minimum-base-fee.md) -to reduce the duration of priority-fee auctions on Base. - -The minimum base fee is configured via `SystemConfig` (see [System Configuration](../../protocol/consensus/derivation.md#system-configuration)) and enforced by the execution engine -via the block header `extraData` encoding and the Engine API `PayloadAttributesV3` parameters. - -### Minimum Base Fee in Block Header - -Like [Holocene's dynamic EIP-1559 parameters](../holocene/exec-engine.md#dynamic-eip-1559-parameters), Jovian encodes -fee parameters in the `extraData` field of each L2 block header. The format is extended to include an additional -`u64` field for the minimum base fee in wei. - -| Name | Type | Byte Offset | -| ------------------- | ------------------ | ----------- | -| `minBaseFee` | `u64 (big-endian)` | `[9, 17)` | - -Constraints: - -- `version` MUST be `1` (incremented from Holocene's `0`). -- There MUST NOT be any data beyond these 17 bytes. - -The `minBaseFee` field is an absolute minimum expressed in wei. During base fee computation, if the -computed `baseFee` is less than `minBaseFee`, it MUST be clamped to `minBaseFee`. - -```javascript -if (baseFee < minBaseFee) { - baseFee = minBaseFee -} -``` - -Note: `extraData` has a maximum capacity of 32 bytes (to fit the L1 beacon-chain `extraData` type) and may be -extended by future upgrades. - -### Minimum Base Fee in `PayloadAttributesV3` - -The Engine API [`PayloadAttributesV3`](../../protocol/execution/index.md#extended-payloadattributesv3) is extended with a new -field `minBaseFee`. The existing `eip1559Params` remains 8 bytes (Holocene format). - -```text -PayloadAttributesV3: { - timestamp: QUANTITY - prevRandao: DATA (32 bytes) - suggestedFeeRecipient: DATA (20 bytes) - withdrawals: array of WithdrawalV1 - parentBeaconBlockRoot: DATA (32 bytes) - transactions: array of DATA - noTxPool: bool - gasLimit: QUANTITY or null - eip1559Params: DATA (8 bytes) or null - minBaseFee: QUANTITY or null -} -``` - -The `minBaseFee` MUST be `null` prior to the Jovian fork, and MUST be non-`null` after the Jovian fork. - -### Rationale - -As with [Holocene's dynamic EIP-1559 parameters](../holocene/exec-engine.md#rationale), placing the -minimum base fee in the block header allows us to avoid reaching into the state during block sealing. -This retains the purity of the function that computes the next block's base fee from its parent block -header, while still allowing them to be dynamically configured. Dynamic configuration is handled -similarly to `gasLimit`, with the derivation pipeline providing the appropriate `SystemConfig` -contract values to the block builder via `PayloadAttributesV3` parameters. - -## DA Footprint Block Limit - -A _DA footprint block limit_ is introduced to limit the total amount of estimated compressed -transaction data that can fit into a block. -For each transaction, a new resource called DA footprint is tracked, next to its gas usage. -It is scaled to the gas dimension so that its block total can also be limited by -the block gas limit, like a block's total gas usage. - -Let a block's `daFootprint` be defined as follows: - -```python -def daFootprint(block: Block) -> int: - daFootprint = 0 - - for tx in block.transactions: - if tx.type == DEPOSIT_TX_TYPE: - continue - - daUsageEstimate = max( - minTransactionSize, - (intercept + fastlzCoef * tx.fastlzSize) // 1e6 - ) - daFootprint += daUsageEstimate * daFootprintGasScalar - - return daFootprint -``` - -where `intercept`, `minTransactionSize`, `fastlzCoef` and `fastlzSize` -are defined in the [Fjord specs](../fjord/exec-engine.md), `DEPOSIT_TX_TYPE` is `0x7E`, -and `//` represents integer floor division. - -From Jovian, the `blobGasUsed` property of each block header is set to that block's `daFootprint`. Note that pre-Jovian, -since Ecotone, it was set to 0, as Base does not support blobs. It is now repurposed to store the DA footprint. - -During block building and header validation, it must be guaranteed and checked, respectively, that the block's -`daFootprint` stays below the `gasLimit`, just like the `gasUsed` property. -Note that this implies that blocks may have no more than `gasLimit/daFootprintGasScalar` total estimated DA usage bytes. - -Furthermore, from Jovian, the base fee update calculation now uses `gasMetered := max(gasUsed, blobGasUsed)` -in place of the `gasUsed` value used before. -As a result, blocks with high DA usage may cause the base fee to increase in subsequent blocks. - -### Scalar loading - -The `daFootprintGasScalar` is loaded in a similar way to the `operatorFeeScalar` and `operatorFeeConstant` -[included](../isthmus/exec-engine.md#operator-fee) in the Isthmus fork. It can be read in two interchangable ways: - -- read from the deposited L1 attributes (`daFootprintGasScalar`) of the current L2 block -(decoded according to the [jovian schema](l1-attributes.md)) -- read from the L1 Block Info contract (`0x4200000000000000000000000000000000000015`) - - using the solidity getter function `daFootprintGasScalar` - - using a direct storage-read: big-endian `uint16` in slot `8` at offset `12`. - -It takes on a default value as described in the section on [L1 Attributes](l1-attributes.md). - -### Receipts - -After Jovian activation, a new field `daFootprintGasScalar` is added to transaction receipts that is populated -with the DA footprint gas scalar of the transaction's block. -Furthermore, the `blobGasUsed` receipt field is set to the DA footprint of the transaction. - -### Rationale - -While the current L1 fee mechanism charges for DA usage based on an estimate of the DA footprint of a transaction, no -protocol mechanism currently reflects the limited available _DA throughput on L1_. E.g. on Ethereum L1 with Pectra -enabled, the available blob throughput is `~96 kB/s` (with a target of `~64 kB/s`), but the calldata floor gas price of -`40` for calldata-heavy L2 transactions allows for more incompressible transaction data to be included on most Base -chains than the Ethereum blob space could handle. This is currently mitigated at the policy level by batcher-sequencer -throttling: a mechanism which artificially constricts block building. This can cause base fees to fall, which implies -unnecessary losses for chain operators and a negative user experience (transaction inclusion delays, priority fee -auctions). So hard-limiting a block's DA footprint in a way that also influences the base fee mitigates the -aforementioned problems of policy-based solutions. - -## Operator Fee - -### Fee Formula Update - -Jovian updates the operator fee calculation so that higher fees may be charged. -Starting at the Jovian activation, the operator fee MUST be computed as: - -$$ -\text{operatorFee} = (\text{gas} \times \text{operatorFeeScalar} \times 100) + \text{operatorFeeConstant} -$$ - -The effective per-gas scalar applied is therefore `100 * operatorFeeScalar`. Otherwise, the data types and operator fee -semantics described in the [Isthmus spec](../isthmus/exec-engine.md#operator-fee) continue to apply. - -### Maximum value - -With the new formula, the operator fee's maximum value has 103 bits: - -```text -operatorFee_max = (uint64_max * uint32_max * 100) + uint64_max ≈ 7.924660923989131 * 10^30 -``` - -Implementations that use `uint256` for intermediate arithmetic do not need additional overflow checks. - -## EVM Changes - -### Precompile Input Size Restrictions - -Some precompiles have changes to the input size restrictions. The new input size restrictions are: -- `bn256Pairing`: 81,984 bytes (427 pairs) -- `BLS12-381 G1 MSM`: 288,960 bytes (1,806 pairs) -- `BLS12-381 G2 MSM`: 278,784 bytes (968 pairs) -- `BLS12-381 Pairing`: 156,672 bytes (408 pairs) diff --git a/docs/specs/pages/upgrades/jovian/l1-attributes.md b/docs/specs/pages/upgrades/jovian/l1-attributes.md deleted file mode 100644 index 3404b4102c..0000000000 --- a/docs/specs/pages/upgrades/jovian/l1-attributes.md +++ /dev/null @@ -1,36 +0,0 @@ -# L1 Block Attributes - -## Overview - -The L1 block attributes transaction is updated to include the DA footprint gas scalar. - -| Input arg | Type | Calldata bytes | Segment | -| ----------------- | ------- | -------------- | ------- | -| {0x3db6be2b} | | 0-3 | n/a | -| baseFeeScalar | uint32 | 4-7 | 1 | -| blobBaseFeeScalar | uint32 | 8-11 | | -| sequenceNumber | uint64 | 12-19 | | -| l1BlockTimestamp | uint64 | 20-27 | | -| l1BlockNumber | uint64 | 28-35 | | -| basefee | uint256 | 36-67 | 2 | -| blobBaseFee | uint256 | 68-99 | 3 | -| l1BlockHash | bytes32 | 100-131 | 4 | -| batcherHash | bytes32 | 132-163 | 5 | -| operatorFeeScalar | uint32 | 164-167 | 6 | -| operatorFeeConstant | uint64 | 168-175 | | -| daFootprintGasScalar | uint16 | 176-177 | | - -Note that the first input argument, in the same pattern as previous versions of the L1 attributes transaction, -is the function selector: the first four bytes of `keccak256("setL1BlockValuesJovian()")`. - -In the activation block, there are two possibilities: -- If Jovian is active at genesis, there are no transactions in the activation block -and therefore no L1 Block Attributes transaction to consider. -- If Jovian activates after genesis [`setL1BlockValuesIsthmus()`](../isthmus/l1-attributes.md) method must be used. - This is because the L1 Block contract will not yet have been upgraded. - -In each subsequent L2 block, the `setL1BlockValuesJovian()` method must be used. - -When using this method, the pre-Jovian values are migrated over 1:1 -and the transaction also sets `daFootprintGasScalar` to the -value from the [`SystemConfig`](../../protocol/consensus/derivation.md#system-configuration). If that value is `0`, then a default of `400` is set. diff --git a/docs/specs/pages/upgrades/jovian/overview.md b/docs/specs/pages/upgrades/jovian/overview.md deleted file mode 100644 index 426dc8c1ae..0000000000 --- a/docs/specs/pages/upgrades/jovian/overview.md +++ /dev/null @@ -1,24 +0,0 @@ -# Jovian - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | `1764691201` (2025-12-02 16:00:01 UTC) | -| `sepolia` | `1763568001` (2025-11-19 16:00:01 UTC) | - -## Execution Layer - -- [Minimum Base Fee](/upgrades/jovian/exec-engine#minimum-base-fee) -- [DA Footprint Limit](/upgrades/jovian/exec-engine#da-footprint-limit) -- [Operator Fee](/upgrades/jovian/exec-engine#operator-fee) - -## Consensus Layer - -- [Network upgrade transactions](/upgrades/jovian/derivation#network-upgrade-transactions) applied during derivation -- Auto-upgrading and extension of the [L1 Attributes Predeployed Contract](/upgrades/jovian/l1-attributes) - (also known as `L1Block` predeploy) - -## Smart Contracts - -- [System Config](/upgrades/jovian/system-config) diff --git a/docs/specs/pages/upgrades/jovian/system-config.md b/docs/specs/pages/upgrades/jovian/system-config.md deleted file mode 100644 index aee0fb3990..0000000000 --- a/docs/specs/pages/upgrades/jovian/system-config.md +++ /dev/null @@ -1,96 +0,0 @@ -# Jovian: System Config - -## Minimum Base Fee Configuration - -Jovian adds a configuration value to `SystemConfig` to control the minimum base fee used by the EIP-1559 fee market -on Base. The value is a minimum base fee in wei. - -| Name | Type | Default | Meaning | -|--------------|----------|---------|-------------------------| -| `minBaseFee` | `uint64` | `0` | Minimum base fee in wei | - -The configuration is updated via a new method on `SystemConfig`: - -```solidity -function setMinBaseFee(uint64 minBaseFee) external onlyOwner; -``` - -### `ConfigUpdate` - -When the configuration is updated, a [`ConfigUpdate`](../../protocol/consensus/derivation.md#system-config-updates) event -MUST be emitted with the following parameters: - -| `version` | `updateType` | `data` | Usage | -| ---- | ----- | --- | -- | -| `uint256(0)` | `uint8(6)` | `abi.encode(uint64(_minBaseFee))` | Modifies the minimum base fee (wei) | - -### Initialization - -The following actions should happen during the initialization of the `SystemConfig`: - -- `emit ConfigUpdate.BATCHER` -- `emit ConfigUpdate.FEE_SCALARS` -- `emit ConfigUpdate.GAS_LIMIT` -- `emit ConfigUpdate.UNSAFE_BLOCK_SIGNER` - -Intentionally absent from this is `emit ConfigUpdate.EIP_1559_PARAMS` and `emit ConfigUpdate.MIN_BASE_FEE`. -As long as these values are unset, the default values will be used. -Requiring these parameters to be set during initialization would add a strict requirement -that the L2 hardforks before the L1 contracts are upgraded, and this is complicated to manage in a -world of many chains. - -### Modifying Minimum Base Fee - -Upon update, the contract emits the `ConfigUpdate` event above, enabling nodes -to derive the configuration from L1 logs. - -Implementations MUST incorporate the configured value into the block header `extraData` as specified in -`./exec-engine.md`. Until the first such event is emitted, a default value of `0` should be used. - -### Interface - -#### Minimum Base Fee Parameters - -##### `minBaseFee` - -This function returns the currently configured minimum base fee in wei. - -```solidity -function minBaseFee() external view returns (uint64); -``` - -## DA Footprint Configuration - -Jovian adds a `uint16` configuration value to `SystemConfig` to control the [`daFootprintGasScalar`](derivation.md). - -The configuration is updated via a new method on `SystemConfig`: - -```solidity -function setDAFootprintGasScalar(uint16 daFootprintGasScalar) external onlyOwner; -``` - -### `ConfigUpdate` - -When the configuration is updated, a [`ConfigUpdate`](../../protocol/consensus/derivation.md#system-config-updates) event -MUST be emitted with the following parameters: - -| `version` | `updateType` | `data` | Usage | -| ---- | ----- | --- | -- | -| `uint256(0)` | `uint8(7)` | `abi.encode(uint16(_daFootprintGasScalar))` | Modifies the DA footprint gas scalar | - -### Modifying DA Footprint Gas Scalar - -Upon update, the contract emits the `ConfigUpdate` event above, enabling nodes -to derive the configuration from L1 logs. - -### Interface - -#### DA Footprint Gas Scalar Parameters - -##### `daFootprintGasScalar` - -This function returns the currently configured DA footprint gas scalar. - -```solidity -function daFootprintGasScalar() external view returns (uint16); -``` diff --git a/docs/specs/pages/upgrades/pectra-blob-schedule/derivation.md b/docs/specs/pages/upgrades/pectra-blob-schedule/derivation.md deleted file mode 100644 index 4314ac9a61..0000000000 --- a/docs/specs/pages/upgrades/pectra-blob-schedule/derivation.md +++ /dev/null @@ -1,35 +0,0 @@ -# Pectra Blob Schedule Derivation - -## If enabled - -If this hardfork is enabled (i.e. if there is a non nil hardfork activation timestamp set), the following rules apply: - -When setting the [L1 Attributes Deposited Transaction](../../reference/glossary.md#l1-attributes-deposited-transaction), -the adoption of the Pectra blob base fee update fraction -(see [EIP-7691](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7691.md)) -occurs for L2 blocks with an L1 origin equal to or greater than the hard fork timestamp. -For L2 blocks with an L1 origin less than the hard fork timestamp, the Cancun blob base fee update fraction is used -(see [EIP-4844](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-4844.md)). - -## If disabled (default) - -If the hardfork activation timestamp is nil, the blob base fee update rules which are active -at any given L1 block will apply to the L1 Attributes Deposited Transaction. - -## Motivation and Rationale - -Due to a consensus layer bug, rollup chains on Holesky and Sepolia running officially released op-node software -did not update their blob base fee update fraction (for L1 Attributes Deposited Transaction) -in tandem with the Prague upgrade on L1. - -These chains, or any rollup chain with a sequencer running -the buggy consensus code[^1] when Holesky/Sepolia activated Pectra, -will have an inaccurate blob base fee in the [L1Block](../../protocol/execution/evm/predeploys.md#l1block) contract. -This optional fork is a mechanism to bring those chains back in line. -It is unnecessary for chains using Ethereum mainnet for L1 and running op-node -[v1.12.0](https://github.com/ethereum-optimism/optimism/releases/tag/op-node%2Fv1.12.0) -or later before Pectra activates on L1. - -Activating by L1 origin preserves the invariant that the L1BlockInfo is constant for blocks with the same epoch. - -[^1]: This is any commit _before_ the code was fixed in [aabf3fe054c5979d6a0008f26fe1a73fdf3aad9f](https://github.com/ethereum-optimism/optimism/commit/aabf3fe054c5979d6a0008f26fe1a73fdf3aad9f) diff --git a/docs/specs/pages/upgrades/pectra-blob-schedule/overview.md b/docs/specs/pages/upgrades/pectra-blob-schedule/overview.md deleted file mode 100644 index cfd4946f9b..0000000000 --- a/docs/specs/pages/upgrades/pectra-blob-schedule/overview.md +++ /dev/null @@ -1,22 +0,0 @@ -# Pectra Blob Schedule (Sepolia) - -## Activation Timestamps - -| Network | Activation timestamp | -| --- | --- | -| `mainnet` | Not activated | -| `sepolia` | `1742486400` (2025-03-20 16:00:00 UTC) | - -The Pectra Blob Schedule hardfork is an optional hardfork which delays the adoption of the -Prague blob base fee update fraction until the specified time. Until that time, the Cancun -update fraction from the previous fork is retained. - -Note that the activation logic for this upgrade is different to most other upgrades. -Usually, specific behavior is activated at the _hard fork timestamp_, if it is not nil, -and continues until overridden by another hardfork. -Here, specific behavior is activated for all times up to the hard fork timestamp, -if it is not nil, and then _deactivated_ at the hard fork timestamp. - -## Consensus Layer - -- [Derivation](/upgrades/pectra-blob-schedule/derivation) diff --git a/docs/specs/public/assets/base/favicon.png b/docs/specs/public/assets/base/favicon.png deleted file mode 100644 index 0ffcd7ee53..0000000000 Binary files a/docs/specs/public/assets/base/favicon.png and /dev/null differ diff --git a/docs/specs/public/assets/base/logo-white.svg b/docs/specs/public/assets/base/logo-white.svg deleted file mode 100644 index 7d1837b755..0000000000 --- a/docs/specs/public/assets/base/logo-white.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/docs/specs/public/assets/base/logo.svg b/docs/specs/public/assets/base/logo.svg deleted file mode 100644 index 05f4921f77..0000000000 --- a/docs/specs/public/assets/base/logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/docs/specs/public/logo.png b/docs/specs/public/logo.png deleted file mode 100644 index 5dc999b136..0000000000 Binary files a/docs/specs/public/logo.png and /dev/null differ diff --git a/docs/specs/public/static/assets/attack.png b/docs/specs/public/static/assets/attack.png deleted file mode 100755 index 9857ecdbcd..0000000000 Binary files a/docs/specs/public/static/assets/attack.png and /dev/null differ diff --git a/docs/specs/public/static/assets/batch-deriv-chain.svg b/docs/specs/public/static/assets/batch-deriv-chain.svg deleted file mode 100644 index 52426d0bca..0000000000 --- a/docs/specs/public/static/assets/batch-deriv-chain.svg +++ /dev/null @@ -1,839 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
100
-
-
-
100 -
-
- - - - - - - -
-
-
102
-
-
-
102 -
-
- - - - - - - -
-
-
101
-
-
-
101 -
-
- - - - - - - -
-
-
3
-
-
-
3 -
-
- - - - - - - -
-
-
4
-
-
-
4 -
-
- - - - - - - -
-
-
5
-
-
-
5 -
-
- - - - - - - -
-
-
6
-
-
-
6 -
-
- - - - - - - -
-
-
7
-
-
-
7 -
-
- - - - - - - -
-
-
8
-
-
-
8 -
-
- - - - - - - - - - -
-
-
Compressed & encoded batch data
-
-
-
Compressed & encoded batch data -
-
- - - - - -
-
-
A0
-
-
-
A0 -
-
- - - - -
-
-
B0
-
-
-
B0 -
-
- - - - -
-
-
B1
-
-
-
B1 -
-
- - - - - - - - -
-
-
9
-
-
-
9 -
-
- - - - - - - -
-
-
10
-
-
-
10 -
-
- - - - - - - -
-
-
11
-
-
-
11 -
-
- - - - - - - - - - - - -
-
-
A1
-
-
-
A1 -
-
- - - - -
-
-
B2
-
-
-
B2 -
-
- - - - - - - - - - - - - - - - - -
-
-
L1 Transactions,
~128 KB each
-
-
-
L1 Transactions,... -
-
- - - - -
-
-
Channels,
with timeout
-
-
-
Channels,... -
-
- - - - -
-
-
Batches,
1 batch = 1 L2 block tx list
-
-
-
Batches,... -
-
- - - - -
-
-
L2 Blocks
(a.k.a. execution payloads)
-
-
-
L2 Blocks... -
-
- - - - -
-
-
Channel A, Frame 0
-
-
-
Channel A, Frame 0 -
-
- - - - -
-
-
Channel A, Frame 1
-
-
-
Channel A, Frame 1 -
-
- - - - -
-
-
Channel B,
Frame 0
-
-
-
Channel B,... -
-
- - - - -
-
-
Channel B, Frame 1
-
-
-
Channel B, Frame 1 -
-
- - - - -
-
-
Channel B, Frame 2
-
-
-
Channel B, Frame 2 -
-
- - - - -
-
-
Channel C, etc...
-
-
-
Channel C, etc... -
-
- - - - -
-
-
L1 Blocks,
These may not be as
frequent/consistent
as L2 blocks.
-
-
-
L1 Blocks,... -
-
- - - - -
-
-
Actual inclusion on L1:
channels are valid
within a timeout
-
-
-
Actual inclusion on L1:... -
-
- - - - -
-
-
Channel B was seen first,
and will be decoded into batches first.
-
-
-
Channel B was seen first,... -
-
- - - - - - - - - - - - - -
-
-
Batches can be buffered
for up to a full sequencing window
worth of L1 blocks
to get the L2 ordering back.
-
-
-
Batches can be buffered... -
-
- - - - -
-
-
Time
-
-
-
Time -
-
- - - - - -
-
-
older L2 data
-
-
-
older L2 data -
-
- - - - -
-
-
B1
-
-
-
B1 -
-
- - - - -
-
-
B0
-
-
-
B0 -
-
- - - - -
-
-
A1
-
-
-
A1 -
-
- - - - - -
-
-
B2
-
-
-
B2 -
-
- - - - -
-
-
A0
-
-
-
A0 -
-
- - - - -
-
-
100-0
-
-
-
100-0 -
-
- - - - -
-
-
100-1
-
-
-
100-1 -
-
- - - - -
-
-
100-2
-
-
-
100-2 -
-
- - - - -
-
-
100-3
-
-
-
100-3 -
-
- - - - -
-
-
100-4
-
-
-
100-4 -
-
- - - - -
-
-
101-0
-
-
-
101-0 -
-
- - - - -
-
-
99-5
-
-
-
99-5 -
-
- - - - -
-
-
99-4
-
-
-
99-4 -
-
- - - - -
-
-
99-3
-
-
-
99-3 -
-
- - - - -
-
-
99-2
-
-
-
99-2 -
-
- - - - - - - -
-
-
99
-
-
-
99 -
-
- - - - -
-
-
Each L2 block has a tx with info
about the "origin" L1 block
-
-
-
Each L2 block has a tx with info... -
-
- - - - -
-
-
The "sequence number"
helps differentiate between
L2 blocks with the same origin.
-
-
-
The "sequence number"... -
-
- - - - -
-
-
deposit
-
-
-
deposit -
-
- - - - -
-
-
deposit
-
-
-
deposit -
-
- - - - -
-
-
Deposits are L1 log events,
parsed from EVM receipts
-
-
-
Deposits are L1 log events,... -
-
- - - - -
-
-
deposit
-
-
-
deposit -
-
- - - - -
-
-
deposit
-
-
-
deposit -
-
- - - - -
-
-
Deposits get included
the first L2 block that
adopts the L1 origin the
deposits were made in.
-
-
-
Deposits get included... -
-
- - - - -
-
-
Security types on L2:
- "unsafe": not submitted on L1
- "safe": is confirmed on L1
- "finalized": fully derived from finalized L1 data
-
-
-
Security types on L2:... -
-
- - - - -
-
-
Security types on L1:
- "unsafe": very new
- "safe": decent attestation ratio
- "finalized": with FFG finality gadget
-
-
-
Security types on L1:... -
-
-
- - Text is not SVG - cannot display - -
diff --git a/docs/specs/public/static/assets/batch-deriv-pipeline.svg b/docs/specs/public/static/assets/batch-deriv-pipeline.svg deleted file mode 100644 index 324a845ab7..0000000000 --- a/docs/specs/public/static/assets/batch-deriv-pipeline.svg +++ /dev/null @@ -1,609 +0,0 @@ - - - - - - - - - - -
-
-
110
-
-
-
110 -
-
- - - - -
-
-
110
-
-
-
110 -
-
- - - - -
-
-
110
-
-
-
110 -
-
- - - - - -
-
-
100
-
-
-
100 -
-
- - - - -
-
-
Batch Queue
-
-
-
Batch Queue -
-
- - - - -
-
-
Engine Queue
-
-
-
Engine Queue -
-
- - - - -
-
-
-
PayloadAttributes
-
Queue
-
-
-
-
PayloadAttributes... -
-
- - - - -
-
-
-
Channel
-
Input-reader
-
-
-
-
Channel... -
-
- - - - -
-
-
-
Channel
-
Bank
-
-
-
-
Channel... -
-
- - - - -
-
-
L1 Traversal
-
-
-
L1 Traversal -
-
- - - - -
-
-
100
-
-
-
100 -
-
- - - - - -
-
-
110
-
-
-
110 -
-
- - - - -
-
-
L1 Deposits
-
-
-
L1 Deposits -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
110
-
-
-
110 -
-
- - - - -
-
-
110
-
-
-
110 -
-
- - - - -
-
-
110
-
-
-
110 -
-
- - - - -
-
-
100
-
-
-
100 -
-
- - - - -
-
-
100
-
-
-
100 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - -
-
-
1) find safe L2 block
with canonical L1 origin
-
-
-
1) find safe L2 block... -
-
- - - - -
-
-
3) reset all stage origins,
traverse chain back
-
-
-
3) reset all stage orig... -
-
- - - - -
-
-
4) dry-run pipeline
to heal stage buffers
-
-
-
4) dry-run pipeline... -
-
- - - - -
-
-
5) new L1 data
can now stream into L2
-
-
-
5) new L1 data... -
-
- - - - -
-
-
2) Stages with gaps will
reconstruct buffer data
-
-
-
2) Stages with gaps wi... -
-
- - - - - - -
-
-
Numbers for illustration,
referring to L1 origin
block numbers
-
-
-
Numbers for illustrati... -
-
- - - - -
-
-
"ResetStep":
reset stage with
distance from next
-
-
-
"ResetStep":... -
-
- - - - -
-
-
"Step":
progress pipeline.
Start closest to L2,
visit previous stage
for more input data.
-
-
-
"Step":... -
-
- - - - -
-
-
L1 block hash > Data TXs > Channel Frames > Batches > Ordered batches > PayloadAttributes > L2 payloads
-
-
-
L1 block hash > Data TXs > Channel Frames > Batches > Ordered batches > PayloadAttributes > L2 payl... -
-
- - - - -
-
-
Resets
Channel
timeout
-
-
-
Resets... -
-
- - - - -
-
-
-
Resets
Sequencing
window -
-
-
-
ResetsSequenc... -
-
- - - - -
-
-
L1 Retrieval
-
-
-
L1 Retrieval -
-
- - - - -
-
-
100
-
-
-
100 -
-
- - - - -
-
-
100
-
-
-
100 -
-
- - - - -
-
-
130
-
-
-
130 -
-
- - - - - - -
-
-
L1 Data
-
-
-
L1 Data -
-
- - - - -
-
-
L1 Chain
-
-
-
L1 Chain -
-
- - - - -
-
-
L2 Exec
Engine
-
-
-
L2 Exec... -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
Reset:
-
-
-
Reset: -
-
- - - - -
-
-
-
110
-
-
-
-
110 -
-
- - - - -
-
-
110
-
-
-
110 -
-
-
- - Text is not SVG - cannot display - -
\ No newline at end of file diff --git a/docs/specs/public/static/assets/challenger-attestation-dispute-game-created.png b/docs/specs/public/static/assets/challenger-attestation-dispute-game-created.png deleted file mode 100644 index 58fc2bae05..0000000000 Binary files a/docs/specs/public/static/assets/challenger-attestation-dispute-game-created.png and /dev/null differ diff --git a/docs/specs/public/static/assets/challenger-attestation-output-proposed.png b/docs/specs/public/static/assets/challenger-attestation-output-proposed.png deleted file mode 100644 index 8f41fc5871..0000000000 Binary files a/docs/specs/public/static/assets/challenger-attestation-output-proposed.png and /dev/null differ diff --git a/docs/specs/public/static/assets/challenger-attestation.png b/docs/specs/public/static/assets/challenger-attestation.png deleted file mode 100644 index 4f28ab6758..0000000000 Binary files a/docs/specs/public/static/assets/challenger-attestation.png and /dev/null differ diff --git a/docs/specs/public/static/assets/defend.png b/docs/specs/public/static/assets/defend.png deleted file mode 100755 index 9909ed4ec0..0000000000 Binary files a/docs/specs/public/static/assets/defend.png and /dev/null differ diff --git a/docs/specs/public/static/assets/fault-proof.svg b/docs/specs/public/static/assets/fault-proof.svg deleted file mode 100644 index 51a998145f..0000000000 --- a/docs/specs/public/static/assets/fault-proof.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
L2 Oracle
L2 Oracle
L2 Engine API
L2 Engine API
L2 OracleEngine
L2 OracleEngine
OracleBackedL2Chain
OracleBackedL2Chain
L2 pre-image
fetcher
L2 pre-image...
Main configuration: chain and rollup configs
Main configuration:...
Preimage KV Store
Preimage KV Store
L1 OracleEthClient
L1 OracleEthClient
prologue:
dispute and
L1 lookup
prologue:...
Pre-image Oracle
Client
Pre-image Oracle...
L1 Oracle
L1 Oracle
epilogue:
output root construction
& claim check
epilogue:...
Program Client:
- stateless
- no temp errors
- no environment access
- onchain
Program Client:...
Program Host / VM:
- stateful
- pre-image store on disk
- offchain
Program Host / VM:...
execution trace
execution trace
Pre-image Hint
Writer
Pre-image Hint...
derivation loop
derivation loop
Pre-image Hint
Reader
Pre-image Hint...
Pre-image Oracle
Server
Pre-image Oracle...
Program tools:
- pre-image fetching
- retry on fetch errors
Program tools:...
L1 pre-image
fetcher
L1 pre-image...
Pre-image hint router
Pre-image hint router
No-op when onchain / readonly
No-op when onchain / readon...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/specs/public/static/assets/legacy-l2oo-list.png b/docs/specs/public/static/assets/legacy-l2oo-list.png deleted file mode 100644 index 0c18608a9f..0000000000 Binary files a/docs/specs/public/static/assets/legacy-l2oo-list.png and /dev/null differ diff --git a/docs/specs/public/static/assets/ob-tree.png b/docs/specs/public/static/assets/ob-tree.png deleted file mode 100644 index 05317f63ce..0000000000 Binary files a/docs/specs/public/static/assets/ob-tree.png and /dev/null differ diff --git a/docs/specs/public/static/assets/valid-moves.png b/docs/specs/public/static/assets/valid-moves.png deleted file mode 100755 index da9d43465e..0000000000 Binary files a/docs/specs/public/static/assets/valid-moves.png and /dev/null differ diff --git a/docs/specs/public/static/bytecode/ecotone-gas-price-oracle-deployment.txt b/docs/specs/public/static/bytecode/ecotone-gas-price-oracle-deployment.txt deleted file mode 100644 index 63b4ce2a83..0000000000 --- a/docs/specs/public/static/bytecode/ecotone-gas-price-oracle-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b50610fb5806100206000396000f3fe608060405234801561001057600080fd5b50600436106100f55760003560e01c806354fd4d5011610097578063de26c4a111610066578063de26c4a1146101da578063f45e65d8146101ed578063f8206140146101f5578063fe173b97146101cc57600080fd5b806354fd4d501461016657806368d5dca6146101af5780636ef25c3a146101cc578063c5985918146101d257600080fd5b8063313ce567116100d3578063313ce5671461012757806349948e0e1461012e5780634ef6e22414610141578063519b4bd31461015e57600080fd5b80630c18c162146100fa57806322b90ab3146101155780632e0f26251461011f575b600080fd5b6101026101fd565b6040519081526020015b60405180910390f35b61011d61031e565b005b610102600681565b6006610102565b61010261013c366004610b73565b610541565b60005461014e9060ff1681565b604051901515815260200161010c565b610102610565565b6101a26040518060400160405280600581526020017f312e322e3000000000000000000000000000000000000000000000000000000081525081565b60405161010c9190610c42565b6101b76105c6565b60405163ffffffff909116815260200161010c565b48610102565b6101b761064b565b6101026101e8366004610b73565b6106ac565b610102610760565b610102610853565b6000805460ff1615610296576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602860248201527f47617350726963654f7261636c653a206f76657268656164282920697320646560448201527f707265636174656400000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa1580156102f5573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103199190610cb5565b905090565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663e591b2826040518163ffffffff1660e01b8152600401602060405180830381865afa15801561037d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103a19190610cce565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff1614610481576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604160248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e2073657420697345636f746f6e6520666c6160648201527f6700000000000000000000000000000000000000000000000000000000000000608482015260a40161028d565b60005460ff1615610514576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a2045636f746f6e6520616c72656164792060448201527f6163746976650000000000000000000000000000000000000000000000000000606482015260840161028d565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055565b6000805460ff161561055c57610556826108b4565b92915050565b61055682610958565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16635cf249696040518163ffffffff1660e01b8152600401602060405180830381865afa1580156102f5573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff166368d5dca66040518163ffffffff1660e01b8152600401602060405180830381865afa158015610627573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103199190610d04565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663c59859186040518163ffffffff1660e01b8152600401602060405180830381865afa158015610627573d6000803e3d6000fd5b6000806106b883610ab4565b60005490915060ff16156106cc5792915050565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa15801561072b573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061074f9190610cb5565b6107599082610d59565b9392505050565b6000805460ff16156107f4576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a207363616c61722829206973206465707260448201527f6563617465640000000000000000000000000000000000000000000000000000606482015260840161028d565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa1580156102f5573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663f82061406040518163ffffffff1660e01b8152600401602060405180830381865afa1580156102f5573d6000803e3d6000fd5b6000806108c083610ab4565b905060006108cc610565565b6108d461064b565b6108df906010610d71565b63ffffffff166108ef9190610d9d565b905060006108fb610853565b6109036105c6565b63ffffffff166109139190610d9d565b905060006109218284610d59565b61092b9085610d9d565b90506109396006600a610efa565b610944906010610d9d565b61094e9082610f06565b9695505050505050565b60008061096483610ab4565b9050600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa1580156109c7573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906109eb9190610cb5565b6109f3610565565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610a52573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610a769190610cb5565b610a809085610d59565b610a8a9190610d9d565b610a949190610d9d565b9050610aa26006600a610efa565b610aac9082610f06565b949350505050565b80516000908190815b81811015610b3757848181518110610ad757610ad7610f41565b01602001517fff0000000000000000000000000000000000000000000000000000000000000016600003610b1757610b10600484610d59565b9250610b25565b610b22601084610d59565b92505b80610b2f81610f70565b915050610abd565b50610aac82610440610d59565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600060208284031215610b8557600080fd5b813567ffffffffffffffff80821115610b9d57600080fd5b818401915084601f830112610bb157600080fd5b813581811115610bc357610bc3610b44565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f01168101908382118183101715610c0957610c09610b44565b81604052828152876020848701011115610c2257600080fd5b826020860160208301376000928101602001929092525095945050505050565b600060208083528351808285015260005b81811015610c6f57858101830151858201604001528201610c53565b81811115610c81576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016929092016040019392505050565b600060208284031215610cc757600080fd5b5051919050565b600060208284031215610ce057600080fd5b815173ffffffffffffffffffffffffffffffffffffffff8116811461075957600080fd5b600060208284031215610d1657600080fd5b815163ffffffff8116811461075957600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b60008219821115610d6c57610d6c610d2a565b500190565b600063ffffffff80831681851681830481118215151615610d9457610d94610d2a565b02949350505050565b6000817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0483118215151615610dd557610dd5610d2a565b500290565b600181815b80851115610e3357817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04821115610e1957610e19610d2a565b80851615610e2657918102915b93841c9390800290610ddf565b509250929050565b600082610e4a57506001610556565b81610e5757506000610556565b8160018114610e6d5760028114610e7757610e93565b6001915050610556565b60ff841115610e8857610e88610d2a565b50506001821b610556565b5060208310610133831016604e8410600b8410161715610eb6575081810a610556565b610ec08383610dda565b807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04821115610ef257610ef2610d2a565b029392505050565b60006107598383610e3b565b600082610f3c577f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b500490565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610fa157610fa1610d2a565b506001019056fea164736f6c634300080f000a diff --git a/docs/specs/public/static/bytecode/ecotone-l1-block-deployment.txt b/docs/specs/public/static/bytecode/ecotone-l1-block-deployment.txt deleted file mode 100644 index 49ff697162..0000000000 --- a/docs/specs/public/static/bytecode/ecotone-l1-block-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b5061053e806100206000396000f3fe608060405234801561001057600080fd5b50600436106100f55760003560e01c80638381f58a11610097578063c598591811610066578063c598591814610229578063e591b28214610249578063e81b2c6d14610289578063f82061401461029257600080fd5b80638381f58a146101e35780638b239f73146101f75780639e8c496614610200578063b80777ea1461020957600080fd5b806354fd4d50116100d357806354fd4d50146101335780635cf249691461017c57806364ca23ef1461018557806368d5dca6146101b257600080fd5b8063015d8eb9146100fa57806309bd5a601461010f578063440a5e201461012b575b600080fd5b61010d61010836600461044c565b61029b565b005b61011860025481565b6040519081526020015b60405180910390f35b61010d6103da565b61016f6040518060400160405280600581526020017f312e322e3000000000000000000000000000000000000000000000000000000081525081565b60405161012291906104be565b61011860015481565b6003546101999067ffffffffffffffff1681565b60405167ffffffffffffffff9091168152602001610122565b6003546101ce9068010000000000000000900463ffffffff1681565b60405163ffffffff9091168152602001610122565b6000546101999067ffffffffffffffff1681565b61011860055481565b61011860065481565b6000546101999068010000000000000000900467ffffffffffffffff1681565b6003546101ce906c01000000000000000000000000900463ffffffff1681565b61026473deaddeaddeaddeaddeaddeaddeaddeaddead000181565b60405173ffffffffffffffffffffffffffffffffffffffff9091168152602001610122565b61011860045481565b61011860075481565b3373deaddeaddeaddeaddeaddeaddeaddeaddead000114610342576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603b60248201527f4c31426c6f636b3a206f6e6c7920746865206465706f7369746f72206163636f60448201527f756e742063616e20736574204c3120626c6f636b2076616c7565730000000000606482015260840160405180910390fd5b6000805467ffffffffffffffff98891668010000000000000000027fffffffffffffffffffffffffffffffff00000000000000000000000000000000909116998916999099179890981790975560019490945560029290925560038054919094167fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000009190911617909255600491909155600555600655565b3373deaddeaddeaddeaddeaddeaddeaddeaddead00011461040357633cc50b456000526004601cfd5b60043560801c60035560143560801c600055602435600155604435600755606435600255608435600455565b803567ffffffffffffffff8116811461044757600080fd5b919050565b600080600080600080600080610100898b03121561046957600080fd5b6104728961042f565b975061048060208a0161042f565b9650604089013595506060890135945061049c60808a0161042f565b979a969950949793969560a0850135955060c08501359460e001359350915050565b600060208083528351808285015260005b818110156104eb578581018301518582016040015282016104cf565b818111156104fd576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01692909201604001939250505056fea164736f6c634300080f000a diff --git a/docs/specs/public/static/bytecode/fjord-gas-price-oracle-deployment.txt b/docs/specs/public/static/bytecode/fjord-gas-price-oracle-deployment.txt deleted file mode 100644 index 210c3b6b17..0000000000 --- a/docs/specs/public/static/bytecode/fjord-gas-price-oracle-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b506117f6806100206000396000f3fe608060405234801561001057600080fd5b50600436106101365760003560e01c80636ef25c3a116100b2578063de26c4a111610081578063f45e65d811610066578063f45e65d81461025b578063f820614014610263578063fe173b971461020d57600080fd5b8063de26c4a114610235578063f1c7a58b1461024857600080fd5b80636ef25c3a1461020d5780638e98b10614610213578063960e3a231461021b578063c59859181461022d57600080fd5b806349948e0e11610109578063519b4bd3116100ee578063519b4bd31461019f57806354fd4d50146101a757806368d5dca6146101f057600080fd5b806349948e0e1461016f5780634ef6e2241461018257600080fd5b80630c18c1621461013b57806322b90ab3146101565780632e0f262514610160578063313ce56714610168575b600080fd5b61014361026b565b6040519081526020015b60405180910390f35b61015e61038c565b005b610143600681565b6006610143565b61014361017d3660046112a1565b610515565b60005461018f9060ff1681565b604051901515815260200161014d565b610143610552565b6101e36040518060400160405280600581526020017f312e332e3000000000000000000000000000000000000000000000000000000081525081565b60405161014d9190611370565b6101f86105b3565b60405163ffffffff909116815260200161014d565b48610143565b61015e610638565b60005461018f90610100900460ff1681565b6101f8610832565b6101436102433660046112a1565b610893565b6101436102563660046113e3565b61098d565b610143610a69565b610143610b5c565b6000805460ff1615610304576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602860248201527f47617350726963654f7261636c653a206f76657268656164282920697320646560448201527f707265636174656400000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610363573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061038791906113fc565b905090565b3373deaddeaddeaddeaddeaddeaddeaddeaddead000114610455576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604160248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e2073657420697345636f746f6e6520666c6160648201527f6700000000000000000000000000000000000000000000000000000000000000608482015260a4016102fb565b60005460ff16156104e8576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a2045636f746f6e6520616c72656164792060448201527f616374697665000000000000000000000000000000000000000000000000000060648201526084016102fb565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055565b60008054610100900460ff16156105355761052f82610bbd565b92915050565b60005460ff16156105495761052f82610bdc565b61052f82610c80565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16635cf249696040518163ffffffff1660e01b8152600401602060405180830381865afa158015610363573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff166368d5dca66040518163ffffffff1660e01b8152600401602060405180830381865afa158015610614573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103879190611415565b3373deaddeaddeaddeaddeaddeaddeaddeaddead0001146106db576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603f60248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e20736574206973466a6f726420666c61670060648201526084016102fb565b60005460ff1661076d576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527f47617350726963654f7261636c653a20466a6f72642063616e206f6e6c79206260448201527f65206163746976617465642061667465722045636f746f6e650000000000000060648201526084016102fb565b600054610100900460ff1615610804576040517f08c379a0000000000000000000000000000000000000000000000000000000008152602060048201526024808201527f47617350726963654f7261636c653a20466a6f726420616c726561647920616360448201527f746976650000000000000000000000000000000000000000000000000000000060648201526084016102fb565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff16610100179055565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663c59859186040518163ffffffff1660e01b8152600401602060405180830381865afa158015610614573d6000803e3d6000fd5b60008054610100900460ff16156108da57620f42406108c56108b484610dd4565b516108c090604461146a565b6110f1565b6108d0906010611482565b61052f91906114bf565b60006108e583611150565b60005490915060ff16156108f95792915050565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610958573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061097c91906113fc565b610986908261146a565b9392505050565b60008054610100900460ff16610a25576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603660248201527f47617350726963654f7261636c653a206765744c314665655570706572426f7560448201527f6e64206f6e6c7920737570706f72747320466a6f72640000000000000000000060648201526084016102fb565b6000610a3283604461146a565b90506000610a4160ff836114bf565b610a4b908361146a565b610a5690601061146a565b9050610a61816111e0565b949350505050565b6000805460ff1615610afd576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a207363616c61722829206973206465707260448201527f656361746564000000000000000000000000000000000000000000000000000060648201526084016102fb565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa158015610363573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663f82061406040518163ffffffff1660e01b8152600401602060405180830381865afa158015610363573d6000803e3d6000fd5b600061052f610bcb83610dd4565b51610bd790604461146a565b6111e0565b600080610be883611150565b90506000610bf4610552565b610bfc610832565b610c079060106114fa565b63ffffffff16610c179190611482565b90506000610c23610b5c565b610c2b6105b3565b63ffffffff16610c3b9190611482565b90506000610c49828461146a565b610c539085611482565b9050610c616006600a611646565b610c6c906010611482565b610c7690826114bf565b9695505050505050565b600080610c8c83611150565b9050600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa158015610cef573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610d1391906113fc565b610d1b610552565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610d7a573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610d9e91906113fc565b610da8908561146a565b610db29190611482565b610dbc9190611482565b9050610dca6006600a611646565b610a6190826114bf565b6060610f63565b818153600101919050565b600082840393505b838110156109865782810151828201511860001a1590930292600101610dee565b825b60208210610e5b578251610e26601f83610ddb565b52602092909201917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090910190602101610e11565b8115610986578251610e706001840383610ddb565b520160010192915050565b60006001830392505b6101078210610ebc57610eae8360ff16610ea960fd610ea98760081c60e00189610ddb565b610ddb565b935061010682039150610e84565b60078210610ee957610ee28360ff16610ea960078503610ea98760081c60e00189610ddb565b9050610986565b610a618360ff16610ea98560081c8560051b0187610ddb565b610f5b828203610f3f610f2f84600081518060001a8160011a60081b178160021a60101b17915050919050565b639e3779b90260131c611fff1690565b8060021b6040510182815160e01c1860e01b8151188152505050565b600101919050565b6180003860405139618000604051016020830180600d8551820103826002015b81811015611096576000805b50508051604051600082901a600183901a60081b1760029290921a60101b91909117639e3779b9810260111c617ffc16909101805160e081811c878603811890911b90911890915284019081830390848410610feb5750611026565b600184019350611fff8211611020578251600081901a600182901a60081b1760029190911a60101b1781036110205750611026565b50610f8f565b838310611034575050611096565b600183039250858311156110525761104f8787888603610e0f565b96505b611066600985016003850160038501610de6565b9150611073878284610e7b565b96505061108b8461108686848601610f02565b610f02565b915050809350610f83565b50506110a88383848851850103610e0f565b925050506040519150618000820180820391508183526020830160005b838110156110dd5782810151828201526020016110c5565b506000920191825250602001604052919050565b60008061110183620cc394611482565b61112b907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd763200611652565b905061113b6064620f42406116c6565b81121561052f576109866064620f42406116c6565b80516000908190815b818110156111d35784818151811061117357611173611782565b01602001517fff00000000000000000000000000000000000000000000000000000000000000166000036111b3576111ac60048461146a565b92506111c1565b6111be60108461146a565b92505b806111cb816117b1565b915050611159565b50610a618261044061146a565b6000806111ec836110f1565b905060006111f8610b5c565b6112006105b3565b63ffffffff166112109190611482565b611218610552565b611220610832565b61122b9060106114fa565b63ffffffff1661123b9190611482565b611245919061146a565b905061125360066002611482565b61125e90600a611646565b6112688284611482565b610a6191906114bf565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6000602082840312156112b357600080fd5b813567ffffffffffffffff808211156112cb57600080fd5b818401915084601f8301126112df57600080fd5b8135818111156112f1576112f1611272565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190838211818310171561133757611337611272565b8160405282815287602084870101111561135057600080fd5b826020860160208301376000928101602001929092525095945050505050565b600060208083528351808285015260005b8181101561139d57858101830151858201604001528201611381565b818111156113af576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016929092016040019392505050565b6000602082840312156113f557600080fd5b5035919050565b60006020828403121561140e57600080fd5b5051919050565b60006020828403121561142757600080fd5b815163ffffffff8116811461098657600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b6000821982111561147d5761147d61143b565b500190565b6000817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04831182151516156114ba576114ba61143b565b500290565b6000826114f5577f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b500490565b600063ffffffff8083168185168183048111821515161561151d5761151d61143b565b02949350505050565b600181815b8085111561157f57817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff048211156115655761156561143b565b8085161561157257918102915b93841c939080029061152b565b509250929050565b6000826115965750600161052f565b816115a35750600061052f565b81600181146115b957600281146115c3576115df565b600191505061052f565b60ff8411156115d4576115d461143b565b50506001821b61052f565b5060208310610133831016604e8410600b8410161715611602575081810a61052f565b61160c8383611526565b807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0482111561163e5761163e61143b565b029392505050565b60006109868383611587565b6000808212827f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0384138115161561168c5761168c61143b565b827f80000000000000000000000000000000000000000000000000000000000000000384128116156116c0576116c061143b565b50500190565b60007f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff6000841360008413858304851182821616156117075761170761143b565b7f800000000000000000000000000000000000000000000000000000000000000060008712868205881281841616156117425761174261143b565b6000871292508782058712848416161561175e5761175e61143b565b878505871281841616156117745761177461143b565b505050929093029392505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036117e2576117e261143b565b506001019056fea164736f6c634300080f000a \ No newline at end of file diff --git a/docs/specs/public/static/bytecode/interop-cross-l2-inbox-deployment.txt b/docs/specs/public/static/bytecode/interop-cross-l2-inbox-deployment.txt deleted file mode 100644 index 839a0df9f8..0000000000 --- a/docs/specs/public/static/bytecode/interop-cross-l2-inbox-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x6080604052348015600e575f80fd5b506106828061001c5f395ff3fe608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063331b637f1461004357806354fd4d5014610069578063ab4d6f75146100b2575b5f80fd5b610056610051366004610512565b6100c7565b6040519081526020015b60405180910390f35b6100a56040518060400160405280600581526020017f312e302e3100000000000000000000000000000000000000000000000000000081525081565b604051610060919061053b565b6100c56100c036600461058e565b61039e565b005b5f67ffffffffffffffff801683602001511115610110576040517fd1f79e8200000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b604083015163ffffffff1015610152576040517f94338eba00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b606083015167ffffffffffffffff1015610198576040517f596a19a900000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b82516040515f916101dd91859060200160609290921b7fffffffffffffffffffffffffffffffffffffffff000000000000000000000000168252601482015260340190565b604080518083037fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe00181528282528051602091820120878201516060890151898501515f9487018590527fffffffffffffffff00000000000000000000000000000000000000000000000060c084811b8216602c8a015283901b1660348801527fffffffff0000000000000000000000000000000000000000000000000000000060e082901b16603c88015292965090949093919291016040516020818303038152906040526102ac906105bc565b90505f85826040516020016102cb929190918252602082015260400190565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0818403018152828252805160209182012060808d01519184018190529183015291505f90606001604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081840301815291905280516020909101207effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff167f0300000000000000000000000000000000000000000000000000000000000000179a9950505050505050505050565b5f6103b76103b136859003850185610601565b836100c7565b90505f6103c38261043b565b509050806103fd576040517fe3c0081600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b827f5c37832d2e8d10e346e55ad62071a6a2f9fa5130614ef2ec6617555c6f467ba78560405161042d9190610622565b60405180910390a250505050565b5f805a835491505a6103e891031115939092509050565b803573ffffffffffffffffffffffffffffffffffffffff81168114610475575f80fd5b919050565b5f60a0828403121561048a575f80fd5b60405160a0810181811067ffffffffffffffff821117156104d2577f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b6040529050806104e183610452565b8152602083013560208201526040830135604082015260608301356060820152608083013560808201525092915050565b5f8060c08385031215610523575f80fd5b61052d848461047a565b9460a0939093013593505050565b602081525f82518060208401528060208501604085015e5f6040828501015260407fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f83011684010191505092915050565b5f8082840360c08112156105a0575f80fd5b60a08112156105ad575f80fd5b50919360a08501359350915050565b805160208083015191908110156105fb577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8160200360031b1b821691505b50919050565b5f60a08284031215610611575f80fd5b61061b838361047a565b9392505050565b60a0810173ffffffffffffffffffffffffffffffffffffffff61064484610452565b168252602083013560208301526040830135604083015260608301356060830152608083013560808301529291505056fea164736f6c6343000819000a diff --git a/docs/specs/public/static/bytecode/interop-l2-to-l2-cross-domain-messenger-deployment.txt b/docs/specs/public/static/bytecode/interop-l2-to-l2-cross-domain-messenger-deployment.txt deleted file mode 100644 index 8ca472e4bb..0000000000 --- a/docs/specs/public/static/bytecode/interop-l2-to-l2-cross-domain-messenger-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x6080604052348015600e575f80fd5b506111928061001c5f395ff3fe6080604052600436106100b8575f3560e01c80637056f41f116100715780638d1d298f1161004c5780638d1d298f14610253578063b1b1b20914610266578063ecc7042814610294575f80fd5b80637056f41f146101b65780637936cbee146101d557806382e3702d14610215575f80fd5b806352617f3c116100a157806352617f3c1461011c57806354fd4d50146101425780636b0c3c5e14610197575f80fd5b806324794462146100bc57806338ffde18146100e3575b5f80fd5b3480156100c7575f80fd5b506100d06102c8565b6040519081526020015b60405180910390f35b3480156100ee575f80fd5b506100f7610347565b60405173ffffffffffffffffffffffffffffffffffffffff90911681526020016100da565b348015610127575f80fd5b5061012f5f81565b60405161ffff90911681526020016100da565b34801561014d575f80fd5b5061018a6040518060400160405280600581526020017f312e322e3000000000000000000000000000000000000000000000000000000081525081565b6040516100da9190610ca9565b3480156101a2575f80fd5b506100d06101b1366004610d2b565b6103c6565b3480156101c1575f80fd5b506100d06101d0366004610da2565b6104b2565b3480156101e0575f80fd5b506101e96106e5565b6040805173ffffffffffffffffffffffffffffffffffffffff90931683526020830191909152016100da565b348015610220575f80fd5b5061024361022f366004610dfa565b60026020525f908152604090205460ff1681565b60405190151581526020016100da565b61018a610261366004610e11565b610789565b348015610271575f80fd5b50610243610280366004610dfa565b5f6020819052908152604090205460ff1681565b34801561029f575f80fd5b506001547dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff166100d0565b5f7ff13569814868ede994184d5a425471fb19e869768a33421cb701a2ba3d420c0a5c610321576040517fbca35af600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b507f711dfa3259c842fffc17d6e1f1e0fc5927756133a2345ca56b4cb8178589fee75c90565b5f7ff13569814868ede994184d5a425471fb19e869768a33421cb701a2ba3d420c0a5c6103a0576040517fbca35af600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b507fb83444d07072b122e2e72a669ce32857d892345c19856f4e7142d06a167ab3f35c90565b5f61040a874688888888888080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250610b0c92505050565b5f8181526002602052604090205490915060ff16610454576040517f6eca2e4b00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b858473ffffffffffffffffffffffffffffffffffffffff16887f382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f3208887876040516104a093929190610e67565b60405180910390a49695505050505050565b5f4685036104ec576040517f8ed9a95d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fffffffffffffffffffffffffbdffffffffffffffffffffffffffffffffffffdd73ffffffffffffffffffffffffffffffffffffffff85160161055b576040517f4faa250900000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f6105856001547dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff1690565b90506105ca864683338989898080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250610b0c92505050565b5f81815260026020526040812080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016600190811790915580549294507dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff909216919061063583610ed0565b91906101000a8154817dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff02191690837dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff16021790555050808573ffffffffffffffffffffffffffffffffffffffff16877f382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f3203388886040516106d493929190610e67565b60405180910390a450949350505050565b5f807ff13569814868ede994184d5a425471fb19e869768a33421cb701a2ba3d420c0a5c61073f576040517fbca35af600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b50507fb83444d07072b122e2e72a669ce32857d892345c19856f4e7142d06a167ab3f35c907f711dfa3259c842fffc17d6e1f1e0fc5927756133a2345ca56b4cb8178589fee75c90565b60607ff13569814868ede994184d5a425471fb19e869768a33421cb701a2ba3d420c0a5c156107e4576040517f37ed32e800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60017ff13569814868ede994184d5a425471fb19e869768a33421cb701a2ba3d420c0a5d73420000000000000000000000000000000000002361082a6020860186610f31565b73ffffffffffffffffffffffffffffffffffffffff1614610877576040517f7987c15700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b73420000000000000000000000000000000000002273ffffffffffffffffffffffffffffffffffffffff1663ab4d6f758585856040516108b8929190610f4c565b6040519081900381207fffffffff0000000000000000000000000000000000000000000000000000000060e085901b1682526108f79291600401610f5b565b5f604051808303815f87803b15801561090e575f80fd5b505af1158015610920573d5f803e3d5ffd5b505050505f805f805f6109338888610b4a565b94509450945094509450468514610976576040517f31ac221100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60808901355f61098a878387878a88610b0c565b5f8181526020819052604090205490915060ff16156109d5576040517f9ca9480b00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5f81815260208190526040902080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055610a158285610c13565b5f8673ffffffffffffffffffffffffffffffffffffffff163485604051610a3c9190610fb4565b5f6040518083038185875af1925050503d805f8114610a76576040519150601f19603f3d011682016040523d82523d5f602084013e610a7b565b606091505b509950905080610a8d57885189602001fd5b8186847fc270d73e26d2d39dee7ef92093555927e344e243415547ecc350b2b5385b68a28c80519060200120604051610ac891815260200190565b60405180910390a4610ada5f80610c13565b50505050505050505f7ff13569814868ede994184d5a425471fb19e869768a33421cb701a2ba3d420c0a5d9392505050565b5f868686868686604051602001610b2896959493929190610fca565b6040516020818303038152906040528051906020012090509695505050505050565b5f808080606081610b5e602082898b611020565b810190610b6b9190610dfa565b90507f382409ac69001e11931a28435afef442cbfd20d9891907e8fa373ba7d351f3208114610bc6576040517fdf1eb58600000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b610bd460806020898b611020565b810190610be19190611047565b91975095509350610bf5876080818b611020565b810190610c0291906110a9565b969995985093965092949392505050565b817f711dfa3259c842fffc17d6e1f1e0fc5927756133a2345ca56b4cb8178589fee75d807fb83444d07072b122e2e72a669ce32857d892345c19856f4e7142d06a167ab3f35d5050565b5f81518084528060208401602086015e5f6020828601015260207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f83011685010191505092915050565b602081525f610cbb6020830184610c5d565b9392505050565b73ffffffffffffffffffffffffffffffffffffffff81168114610ce3575f80fd5b50565b5f8083601f840112610cf6575f80fd5b50813567ffffffffffffffff811115610d0d575f80fd5b602083019150836020828501011115610d24575f80fd5b9250929050565b5f805f805f8060a08789031215610d40575f80fd5b86359550602087013594506040870135610d5981610cc2565b93506060870135610d6981610cc2565b9250608087013567ffffffffffffffff811115610d84575f80fd5b610d9089828a01610ce6565b979a9699509497509295939492505050565b5f805f8060608587031215610db5575f80fd5b843593506020850135610dc781610cc2565b9250604085013567ffffffffffffffff811115610de2575f80fd5b610dee87828801610ce6565b95989497509550505050565b5f60208284031215610e0a575f80fd5b5035919050565b5f805f83850360c0811215610e24575f80fd5b60a0811215610e31575f80fd5b5083925060a084013567ffffffffffffffff811115610e4e575f80fd5b610e5a86828701610ce6565b9497909650939450505050565b73ffffffffffffffffffffffffffffffffffffffff8416815260406020820152816040820152818360608301375f818301606090810191909152601f9092017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016010192915050565b5f7dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff808316818103610f27577f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b6001019392505050565b5f60208284031215610f41575f80fd5b8135610cbb81610cc2565b818382375f9101908152919050565b60c081018335610f6a81610cc2565b73ffffffffffffffffffffffffffffffffffffffff1682526020848101359083015260408085013590830152606080850135908301526080938401359382019390935260a0015290565b5f82518060208501845e5f920191825250919050565b8681528560208201528460408201525f73ffffffffffffffffffffffffffffffffffffffff808616606084015280851660808401525060c060a083015261101460c0830184610c5d565b98975050505050505050565b5f808585111561102e575f80fd5b8386111561103a575f80fd5b5050820193919092039150565b5f805f60608486031215611059575f80fd5b83359250602084013561106b81610cc2565b929592945050506040919091013590565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b5f80604083850312156110ba575f80fd5b82356110c581610cc2565b9150602083013567ffffffffffffffff808211156110e1575f80fd5b818501915085601f8301126110f4575f80fd5b8135818111156111065761110661107c565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190838211818310171561114c5761114c61107c565b81604052828152886020848701011115611164575f80fd5b826020860160208301375f602084830101528095505050505050925092905056fea164736f6c6343000819000a diff --git a/docs/specs/public/static/bytecode/isthmus-gas-price-oracle-deployment.txt b/docs/specs/public/static/bytecode/isthmus-gas-price-oracle-deployment.txt deleted file mode 100644 index 4c3eef4a08..0000000000 --- a/docs/specs/public/static/bytecode/isthmus-gas-price-oracle-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b50611c3c806100206000396000f3fe608060405234801561001057600080fd5b50600436106101775760003560e01c806368d5dca6116100d8578063c59859181161008c578063f45e65d811610066578063f45e65d8146102ca578063f8206140146102d2578063fe173b971461026957600080fd5b8063c59859181461029c578063de26c4a1146102a4578063f1c7a58b146102b757600080fd5b80638e98b106116100bd5780638e98b1061461026f578063960e3a2314610277578063b54501bc1461028957600080fd5b806368d5dca61461024c5780636ef25c3a1461026957600080fd5b8063313ce5671161012f5780634ef6e224116101145780634ef6e224146101de578063519b4bd3146101fb57806354fd4d501461020357600080fd5b8063313ce567146101c457806349948e0e146101cb57600080fd5b8063275aedd211610160578063275aedd2146101a1578063291b0383146101b45780632e0f2625146101bc57600080fd5b80630c18c1621461017c57806322b90ab314610197575b600080fd5b6101846102da565b6040519081526020015b60405180910390f35b61019f6103fb565b005b6101846101af36600461168e565b610584565b61019f61070f565b610184600681565b6006610184565b6101846101d93660046116d6565b610937565b6000546101eb9060ff1681565b604051901515815260200161018e565b61018461096e565b61023f6040518060400160405280600581526020017f312e342e3000000000000000000000000000000000000000000000000000000081525081565b60405161018e91906117a5565b6102546109cf565b60405163ffffffff909116815260200161018e565b48610184565b61019f610a54565b6000546101eb90610100900460ff1681565b6000546101eb9062010000900460ff1681565b610254610c4e565b6101846102b23660046116d6565b610caf565b6101846102c536600461168e565b610da9565b610184610e85565b610184610f78565b6000805460ff1615610373576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602860248201527f47617350726963654f7261636c653a206f76657268656164282920697320646560448201527f707265636174656400000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa1580156103d2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103f69190611818565b905090565b3373deaddeaddeaddeaddeaddeaddeaddeaddead0001146104c4576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604160248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e2073657420697345636f746f6e6520666c6160648201527f6700000000000000000000000000000000000000000000000000000000000000608482015260a40161036a565b60005460ff1615610557576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a2045636f746f6e6520616c72656164792060448201527f6163746976650000000000000000000000000000000000000000000000000000606482015260840161036a565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055565b6000805462010000900460ff1661059d57506000919050565b610709620f42406106668473420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16634d5d9a2a6040518163ffffffff1660e01b8152600401602060405180830381865afa158015610607573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061062b9190611831565b63ffffffff167fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff821583830293840490921491909117011790565b6106709190611886565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff166316d3bc7f6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156106cf573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106f391906118c1565b67ffffffffffffffff1681019081106000031790565b92915050565b3373deaddeaddeaddeaddeaddeaddeaddeaddead0001146107d8576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604160248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e20736574206973497374686d757320666c6160648201527f6700000000000000000000000000000000000000000000000000000000000000608482015260a40161036a565b600054610100900460ff1661086f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527f47617350726963654f7261636c653a20497374686d75732063616e206f6e6c7960448201527f2062652061637469766174656420616674657220466a6f726400000000000000606482015260840161036a565b60005462010000900460ff1615610908576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a20497374686d757320616c72656164792060448201527f6163746976650000000000000000000000000000000000000000000000000000606482015260840161036a565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ffff1662010000179055565b60008054610100900460ff16156109515761070982610fd9565b60005460ff16156109655761070982610ff8565b6107098261109c565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16635cf249696040518163ffffffff1660e01b8152600401602060405180830381865afa1580156103d2573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff166368d5dca66040518163ffffffff1660e01b8152600401602060405180830381865afa158015610a30573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103f69190611831565b3373deaddeaddeaddeaddeaddeaddeaddeaddead000114610af7576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603f60248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e20736574206973466a6f726420666c616700606482015260840161036a565b60005460ff16610b89576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527f47617350726963654f7261636c653a20466a6f72642063616e206f6e6c79206260448201527f65206163746976617465642061667465722045636f746f6e6500000000000000606482015260840161036a565b600054610100900460ff1615610c20576040517f08c379a0000000000000000000000000000000000000000000000000000000008152602060048201526024808201527f47617350726963654f7261636c653a20466a6f726420616c726561647920616360448201527f7469766500000000000000000000000000000000000000000000000000000000606482015260840161036a565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff16610100179055565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663c59859186040518163ffffffff1660e01b8152600401602060405180830381865afa158015610a30573d6000803e3d6000fd5b60008054610100900460ff1615610cf657620f4240610ce1610cd0846111f0565b51610cdc9060446118eb565b61150d565b610cec906010611903565b6107099190611886565b6000610d018361156c565b60005490915060ff1615610d155792915050565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610d74573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610d989190611818565b610da290826118eb565b9392505050565b60008054610100900460ff16610e41576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603660248201527f47617350726963654f7261636c653a206765744c314665655570706572426f7560448201527f6e64206f6e6c7920737570706f72747320466a6f726400000000000000000000606482015260840161036a565b6000610e4e8360446118eb565b90506000610e5d60ff83611886565b610e6790836118eb565b610e729060106118eb565b9050610e7d816115fc565b949350505050565b6000805460ff1615610f19576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a207363616c61722829206973206465707260448201527f6563617465640000000000000000000000000000000000000000000000000000606482015260840161036a565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa1580156103d2573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663f82061406040518163ffffffff1660e01b8152600401602060405180830381865afa1580156103d2573d6000803e3d6000fd5b6000610709610fe7836111f0565b51610ff39060446118eb565b6115fc565b6000806110048361156c565b9050600061101061096e565b611018610c4e565b611023906010611940565b63ffffffff166110339190611903565b9050600061103f610f78565b6110476109cf565b63ffffffff166110579190611903565b9050600061106582846118eb565b61106f9085611903565b905061107d6006600a611a8c565b611088906010611903565b6110929082611886565b9695505050505050565b6000806110a88361156c565b9050600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa15801561110b573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061112f9190611818565b61113761096e565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015611196573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906111ba9190611818565b6111c490856118eb565b6111ce9190611903565b6111d89190611903565b90506111e66006600a611a8c565b610e7d9082611886565b606061137f565b818153600101919050565b600082840393505b83811015610da25782810151828201511860001a159093029260010161120a565b825b60208210611277578251611242601f836111f7565b52602092909201917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe09091019060210161122d565b8115610da257825161128c60018403836111f7565b520160010192915050565b60006001830392505b61010782106112d8576112ca8360ff166112c560fd6112c58760081c60e001896111f7565b6111f7565b9350610106820391506112a0565b60078210611305576112fe8360ff166112c5600785036112c58760081c60e001896111f7565b9050610da2565b610e7d8360ff166112c58560081c8560051b01876111f7565b61137782820361135b61134b84600081518060001a8160011a60081b178160021a60101b17915050919050565b639e3779b90260131c611fff1690565b8060021b6040510182815160e01c1860e01b8151188152505050565b600101919050565b6180003860405139618000604051016020830180600d8551820103826002015b818110156114b2576000805b50508051604051600082901a600183901a60081b1760029290921a60101b91909117639e3779b9810260111c617ffc16909101805160e081811c878603811890911b909118909152840190818303908484106114075750611442565b600184019350611fff821161143c578251600081901a600182901a60081b1760029190911a60101b17810361143c5750611442565b506113ab565b8383106114505750506114b2565b6001830392508583111561146e5761146b878788860361122b565b96505b611482600985016003850160038501611202565b915061148f878284611297565b9650506114a7846114a28684860161131e565b61131e565b91505080935061139f565b50506114c4838384885185010361122b565b925050506040519150618000820180820391508183526020830160005b838110156114f95782810151828201526020016114e1565b506000920191825250602001604052919050565b60008061151d83620cc394611903565b611547907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd763200611a98565b90506115576064620f4240611b0c565b81121561070957610da26064620f4240611b0c565b80516000908190815b818110156115ef5784818151811061158f5761158f611bc8565b01602001517fff00000000000000000000000000000000000000000000000000000000000000166000036115cf576115c86004846118eb565b92506115dd565b6115da6010846118eb565b92505b806115e781611bf7565b915050611575565b50610e7d826104406118eb565b6000806116088361150d565b90506000611614610f78565b61161c6109cf565b63ffffffff1661162c9190611903565b61163461096e565b61163c610c4e565b611647906010611940565b63ffffffff166116579190611903565b61166191906118eb565b905061166f60066002611903565b61167a90600a611a8c565b6116848284611903565b610e7d9190611886565b6000602082840312156116a057600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b6000602082840312156116e857600080fd5b813567ffffffffffffffff8082111561170057600080fd5b818401915084601f83011261171457600080fd5b813581811115611726576117266116a7565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190838211818310171561176c5761176c6116a7565b8160405282815287602084870101111561178557600080fd5b826020860160208301376000928101602001929092525095945050505050565b600060208083528351808285015260005b818110156117d2578581018301518582016040015282016117b6565b818111156117e4576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016929092016040019392505050565b60006020828403121561182a57600080fd5b5051919050565b60006020828403121561184357600080fd5b815163ffffffff81168114610da257600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b6000826118bc577f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b500490565b6000602082840312156118d357600080fd5b815167ffffffffffffffff81168114610da257600080fd5b600082198211156118fe576118fe611857565b500190565b6000817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff048311821515161561193b5761193b611857565b500290565b600063ffffffff8083168185168183048111821515161561196357611963611857565b02949350505050565b600181815b808511156119c557817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff048211156119ab576119ab611857565b808516156119b857918102915b93841c9390800290611971565b509250929050565b6000826119dc57506001610709565b816119e957506000610709565b81600181146119ff5760028114611a0957611a25565b6001915050610709565b60ff841115611a1a57611a1a611857565b50506001821b610709565b5060208310610133831016604e8410600b8410161715611a48575081810a610709565b611a52838361196c565b807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04821115611a8457611a84611857565b029392505050565b6000610da283836119cd565b6000808212827f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03841381151615611ad257611ad2611857565b827f8000000000000000000000000000000000000000000000000000000000000000038412811615611b0657611b06611857565b50500190565b60007f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600084136000841385830485118282161615611b4d57611b4d611857565b7f80000000000000000000000000000000000000000000000000000000000000006000871286820588128184161615611b8857611b88611857565b60008712925087820587128484161615611ba457611ba4611857565b87850587128184161615611bba57611bba611857565b505050929093029392505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203611c2857611c28611857565b506001019056fea164736f6c634300080f000a diff --git a/docs/specs/public/static/bytecode/isthmus-l1-block-deployment.txt b/docs/specs/public/static/bytecode/isthmus-l1-block-deployment.txt deleted file mode 100644 index 28583f01f4..0000000000 --- a/docs/specs/public/static/bytecode/isthmus-l1-block-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b506106ae806100206000396000f3fe608060405234801561001057600080fd5b50600436106101825760003560e01c806364ca23ef116100d8578063b80777ea1161008c578063e591b28211610066578063e591b282146103b0578063e81b2c6d146103d2578063f8206140146103db57600080fd5b8063b80777ea14610337578063c598591814610357578063d84447151461037757600080fd5b80638381f58a116100bd5780638381f58a146103115780638b239f73146103255780639e8c49661461032e57600080fd5b806364ca23ef146102e157806368d5dca6146102f557600080fd5b80634397dfef1161013a57806354fd4d501161011457806354fd4d501461025d578063550fcdc91461029f5780635cf24969146102d857600080fd5b80634397dfef146101fc578063440a5e20146102245780634d5d9a2a1461022c57600080fd5b806309bd5a601161016b57806309bd5a60146101a457806316d3bc7f146101c057806321326849146101ed57600080fd5b8063015d8eb914610187578063098999be1461019c575b600080fd5b61019a6101953660046105bc565b6103e4565b005b61019a610523565b6101ad60025481565b6040519081526020015b60405180910390f35b6008546101d49067ffffffffffffffff1681565b60405167ffffffffffffffff90911681526020016101b7565b604051600081526020016101b7565b6040805173eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee815260126020820152016101b7565b61019a61052d565b6008546102489068010000000000000000900463ffffffff1681565b60405163ffffffff90911681526020016101b7565b60408051808201909152600581527f312e362e3000000000000000000000000000000000000000000000000000000060208201525b6040516101b7919061062e565b60408051808201909152600381527f45544800000000000000000000000000000000000000000000000000000000006020820152610292565b6101ad60015481565b6003546101d49067ffffffffffffffff1681565b6003546102489068010000000000000000900463ffffffff1681565b6000546101d49067ffffffffffffffff1681565b6101ad60055481565b6101ad60065481565b6000546101d49068010000000000000000900467ffffffffffffffff1681565b600354610248906c01000000000000000000000000900463ffffffff1681565b60408051808201909152600581527f45746865720000000000000000000000000000000000000000000000000000006020820152610292565b60405173deaddeaddeaddeaddeaddeaddeaddeaddead000181526020016101b7565b6101ad60045481565b6101ad60075481565b3373deaddeaddeaddeaddeaddeaddeaddeaddead00011461048b576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603b60248201527f4c31426c6f636b3a206f6e6c7920746865206465706f7369746f72206163636f60448201527f756e742063616e20736574204c3120626c6f636b2076616c7565730000000000606482015260840160405180910390fd5b6000805467ffffffffffffffff98891668010000000000000000027fffffffffffffffffffffffffffffffff00000000000000000000000000000000909116998916999099179890981790975560019490945560029290925560038054919094167fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000009190911617909255600491909155600555600655565b61052b610535565b565b61052b610548565b61053d610548565b60a43560a01c600855565b73deaddeaddeaddeaddeaddeaddeaddeaddead000133811461057257633cc50b456000526004601cfd5b60043560801c60035560143560801c60005560243560015560443560075560643560025560843560045550565b803567ffffffffffffffff811681146105b757600080fd5b919050565b600080600080600080600080610100898b0312156105d957600080fd5b6105e28961059f565b97506105f060208a0161059f565b9650604089013595506060890135945061060c60808a0161059f565b979a969950949793969560a0850135955060c08501359460e001359350915050565b600060208083528351808285015260005b8181101561065b5785810183015185820160400152820161063f565b8181111561066d576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01692909201604001939250505056fea164736f6c634300080f000a diff --git a/docs/specs/public/static/bytecode/isthmus-operator-fee-deployment.txt b/docs/specs/public/static/bytecode/isthmus-operator-fee-deployment.txt deleted file mode 100644 index 36f7e64a05..0000000000 --- a/docs/specs/public/static/bytecode/isthmus-operator-fee-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x60e060405234801561001057600080fd5b5073420000000000000000000000000000000000001960a0526000608052600160c05260805160a05160c0516107ef6100a7600039600081816101b3015281816102450152818161044b015261048601526000818160b8015281816101800152818161039a01528181610429015281816104c201526105b70152600081816101ef01528181610279015261029d01526107ef6000f3fe60806040526004361061009a5760003560e01c806382356d8a1161006957806384411d651161004e57806384411d651461021d578063d0e12f9014610233578063d3e5792b1461026757600080fd5b806382356d8a146101a45780638312f149146101e057600080fd5b80630d9019e1146100a65780633ccfd60b1461010457806354fd4d501461011b57806366d003ac1461017157600080fd5b366100a157005b600080fd5b3480156100b257600080fd5b506100da7f000000000000000000000000000000000000000000000000000000000000000081565b60405173ffffffffffffffffffffffffffffffffffffffff90911681526020015b60405180910390f35b34801561011057600080fd5b5061011961029b565b005b34801561012757600080fd5b506101646040518060400160405280600581526020017f312e302e3000000000000000000000000000000000000000000000000000000081525081565b6040516100fb9190610671565b34801561017d57600080fd5b507f00000000000000000000000000000000000000000000000000000000000000006100da565b3480156101b057600080fd5b507f00000000000000000000000000000000000000000000000000000000000000005b6040516100fb919061074e565b3480156101ec57600080fd5b507f00000000000000000000000000000000000000000000000000000000000000005b6040519081526020016100fb565b34801561022957600080fd5b5061020f60005481565b34801561023f57600080fd5b506101d37f000000000000000000000000000000000000000000000000000000000000000081565b34801561027357600080fd5b5061020f7f000000000000000000000000000000000000000000000000000000000000000081565b7f0000000000000000000000000000000000000000000000000000000000000000471015610376576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604a60248201527f4665655661756c743a207769746864726177616c20616d6f756e74206d75737460448201527f2062652067726561746572207468616e206d696e696d756d207769746864726160648201527f77616c20616d6f756e7400000000000000000000000000000000000000000000608482015260a4015b60405180910390fd5b60004790508060008082825461038c9190610762565b9091555050604080518281527f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff166020820152338183015290517fc8a211cc64b6ed1b50595a9fcb1932b6d1e5a6e8ef15b60e5b1f988ea9086bba9181900360600190a17f38e04cbeb8c10f8f568618aa75be0f10b6729b8b4237743b4de20cbcde2839ee817f0000000000000000000000000000000000000000000000000000000000000000337f000000000000000000000000000000000000000000000000000000000000000060405161047a94939291906107a1565b60405180910390a160017f000000000000000000000000000000000000000000000000000000000000000060018111156104b6576104b66106e4565b0361057a5760006104e77f000000000000000000000000000000000000000000000000000000000000000083610649565b905080610576576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603060248201527f4665655661756c743a206661696c656420746f2073656e642045544820746f2060448201527f4c322066656520726563697069656e7400000000000000000000000000000000606482015260840161036d565b5050565b6040517fc2b3e5ac00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015262061a80602482015260606044820152600060648201527342000000000000000000000000000000000000169063c2b3e5ac9083906084016000604051808303818588803b15801561062d57600080fd5b505af1158015610641573d6000803e3d6000fd5b505050505050565b6000610656835a8461065d565b9392505050565b6000806000806000858888f1949350505050565b600060208083528351808285015260005b8181101561069e57858101830151858201604001528201610682565b818111156106b0576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016929092016040019392505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b6002811061074a577f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fd5b9052565b6020810161075c8284610713565b92915050565b6000821982111561079c577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b500190565b84815273ffffffffffffffffffffffffffffffffffffffff848116602083015283166040820152608081016107d96060830184610713565b9594505050505056fea164736f6c634300080f000a diff --git a/docs/specs/public/static/bytecode/jovian-gas-price-oracle-deployment.txt b/docs/specs/public/static/bytecode/jovian-gas-price-oracle-deployment.txt deleted file mode 100644 index 2735e14272..0000000000 --- a/docs/specs/public/static/bytecode/jovian-gas-price-oracle-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b50611ea8806100206000396000f3fe608060405234801561001057600080fd5b506004361061018d5760003560e01c806368d5dca6116100e3578063c59859181161008c578063f45e65d811610066578063f45e65d8146102fc578063f820614014610304578063fe173b971461029357600080fd5b8063c5985918146102ce578063de26c4a1146102d6578063f1c7a58b146102e957600080fd5b8063960e3a23116100bd578063960e3a23146102a1578063b3d72079146102b3578063b54501bc146102bb57600080fd5b806368d5dca6146102765780636ef25c3a146102935780638e98b1061461029957600080fd5b80632e0f2625116101455780634ef6e2241161011f5780634ef6e22414610218578063519b4bd31461022557806354fd4d501461022d57600080fd5b80632e0f2625146101f6578063313ce567146101fe57806349948e0e1461020557600080fd5b806322b90ab31161017657806322b90ab3146101d1578063275aedd2146101db578063291b0383146101ee57600080fd5b80630c18c16214610192578063105d0b81146101ad575b600080fd5b61019a61030c565b6040519081526020015b60405180910390f35b6000546101c1906301000000900460ff1681565b60405190151581526020016101a4565b6101d961042d565b005b61019a6101e93660046118fa565b6105b6565b6101d9610776565b61019a600681565b600661019a565b61019a610213366004611942565b61099e565b6000546101c19060ff1681565b61019a6109db565b6102696040518060400160405280600581526020017f312e362e3000000000000000000000000000000000000000000000000000000081525081565b6040516101a49190611a11565b61027e610a3c565b60405163ffffffff90911681526020016101a4565b4861019a565b6101d9610ac1565b6000546101c190610100900460ff1681565b6101d9610cbb565b6000546101c19062010000900460ff1681565b61027e610ec2565b61019a6102e4366004611942565b610f23565b61019a6102f73660046118fa565b61101d565b61019a6110f1565b61019a6111e4565b6000805460ff16156103a5576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602860248201527f47617350726963654f7261636c653a206f76657268656164282920697320646560448201527f707265636174656400000000000000000000000000000000000000000000000060648201526084015b60405180910390fd5b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610404573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104289190611a84565b905090565b3373deaddeaddeaddeaddeaddeaddeaddeaddead0001146104f6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604160248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e2073657420697345636f746f6e6520666c6160648201527f6700000000000000000000000000000000000000000000000000000000000000608482015260a40161039c565b60005460ff1615610589576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a2045636f746f6e6520616c72656164792060448201527f6163746976650000000000000000000000000000000000000000000000000000606482015260840161039c565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055565b6000805462010000900460ff166105cf57506000919050565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16634d5d9a2a6040518163ffffffff1660e01b8152600401602060405180830381865afa158015610630573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106549190611a9d565b63ffffffff169050600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff166316d3bc7f6040518163ffffffff1660e01b8152600401602060405180830381865afa1580156106bd573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906106e19190611ac3565b67ffffffffffffffff169050600060039054906101000a900460ff161561072a578061070d8386611b1c565b610718906064611b1c565b6107229190611b59565b949350505050565b610722620f424083860286810485148715177fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff01176107699190611b71565b8281019081106000031790565b3373deaddeaddeaddeaddeaddeaddeaddeaddead00011461083f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152604160248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e20736574206973497374686d757320666c6160648201527f6700000000000000000000000000000000000000000000000000000000000000608482015260a40161039c565b600054610100900460ff166108d6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527f47617350726963654f7261636c653a20497374686d75732063616e206f6e6c7960448201527f2062652061637469766174656420616674657220466a6f726400000000000000606482015260840161039c565b60005462010000900460ff161561096f576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a20497374686d757320616c72656164792060448201527f6163746976650000000000000000000000000000000000000000000000000000606482015260840161039c565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ffff1662010000179055565b60008054610100900460ff16156109be576109b882611245565b92915050565b60005460ff16156109d2576109b882611264565b6109b882611308565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16635cf249696040518163ffffffff1660e01b8152600401602060405180830381865afa158015610404573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff166368d5dca66040518163ffffffff1660e01b8152600401602060405180830381865afa158015610a9d573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104289190611a9d565b3373deaddeaddeaddeaddeaddeaddeaddeaddead000114610b64576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603f60248201527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e20736574206973466a6f726420666c616700606482015260840161039c565b60005460ff16610bf6576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603960248201527f47617350726963654f7261636c653a20466a6f72642063616e206f6e6c79206260448201527f65206163746976617465642061667465722045636f746f6e6500000000000000606482015260840161039c565b600054610100900460ff1615610c8d576040517f08c379a0000000000000000000000000000000000000000000000000000000008152602060048201526024808201527f47617350726963654f7261636c653a20466a6f726420616c726561647920616360448201527f7469766500000000000000000000000000000000000000000000000000000000606482015260840161039c565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff16610100179055565b3373deaddeaddeaddeaddeaddeaddeaddeaddead000114610d6057604080517f08c379a00000000000000000000000000000000000000000000000000000000081526020600482015260248101919091527f47617350726963654f7261636c653a206f6e6c7920746865206465706f73697460448201527f6f72206163636f756e742063616e207365742069734a6f7669616e20666c6167606482015260840161039c565b60005462010000900460ff16610df8576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603a60248201527f47617350726963654f7261636c653a204a6f7669616e2063616e206f6e6c792060448201527f62652061637469766174656420616674657220497374686d7573000000000000606482015260840161039c565b6000546301000000900460ff1615610e92576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602560248201527f47617350726963654f7261636c653a204a6f7669616e20616c7265616479206160448201527f6374697665000000000000000000000000000000000000000000000000000000606482015260840161039c565b600080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ffffff166301000000179055565b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663c59859186040518163ffffffff1660e01b8152600401602060405180830381865afa158015610a9d573d6000803e3d6000fd5b60008054610100900460ff1615610f6a57620f4240610f55610f448461145c565b51610f50906044611b59565b611779565b610f60906010611b1c565b6109b89190611b71565b6000610f75836117d8565b60005490915060ff1615610f895792915050565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015610fe8573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061100c9190611a84565b6110169082611b59565b9392505050565b60008054610100900460ff166110b5576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603660248201527f47617350726963654f7261636c653a206765744c314665655570706572426f7560448201527f6e64206f6e6c7920737570706f72747320466a6f726400000000000000000000606482015260840161039c565b60006110c2836044611b59565b905060006110d160ff83611b71565b6110db9083611b59565b6110e6906010611b59565b905061072281611868565b6000805460ff1615611185576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152602660248201527f47617350726963654f7261636c653a207363616c61722829206973206465707260448201527f6563617465640000000000000000000000000000000000000000000000000000606482015260840161039c565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa158015610404573d6000803e3d6000fd5b600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff1663f82061406040518163ffffffff1660e01b8152600401602060405180830381865afa158015610404573d6000803e3d6000fd5b60006109b86112538361145c565b5161125f906044611b59565b611868565b600080611270836117d8565b9050600061127c6109db565b611284610ec2565b61128f906010611bac565b63ffffffff1661129f9190611b1c565b905060006112ab6111e4565b6112b3610a3c565b63ffffffff166112c39190611b1c565b905060006112d18284611b59565b6112db9085611b1c565b90506112e96006600a611cf8565b6112f4906010611b1c565b6112fe9082611b71565b9695505050505050565b600080611314836117d8565b9050600073420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16639e8c49666040518163ffffffff1660e01b8152600401602060405180830381865afa158015611377573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061139b9190611a84565b6113a36109db565b73420000000000000000000000000000000000001573ffffffffffffffffffffffffffffffffffffffff16638b239f736040518163ffffffff1660e01b8152600401602060405180830381865afa158015611402573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906114269190611a84565b6114309085611b59565b61143a9190611b1c565b6114449190611b1c565b90506114526006600a611cf8565b6107229082611b71565b60606115eb565b818153600101919050565b600082840393505b838110156110165782810151828201511860001a1590930292600101611476565b825b602082106114e35782516114ae601f83611463565b52602092909201917fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe090910190602101611499565b81156110165782516114f86001840383611463565b520160010192915050565b60006001830392505b6101078210611544576115368360ff1661153160fd6115318760081c60e00189611463565b611463565b93506101068203915061150c565b600782106115715761156a8360ff16611531600785036115318760081c60e00189611463565b9050611016565b6107228360ff166115318560081c8560051b0187611463565b6115e38282036115c76115b784600081518060001a8160011a60081b178160021a60101b17915050919050565b639e3779b90260131c611fff1690565b8060021b6040510182815160e01c1860e01b8151188152505050565b600101919050565b6180003860405139618000604051016020830180600d8551820103826002015b8181101561171e576000805b50508051604051600082901a600183901a60081b1760029290921a60101b91909117639e3779b9810260111c617ffc16909101805160e081811c878603811890911b9091189091528401908183039084841061167357506116ae565b600184019350611fff82116116a8578251600081901a600182901a60081b1760029190911a60101b1781036116a857506116ae565b50611617565b8383106116bc57505061171e565b600183039250858311156116da576116d78787888603611497565b96505b6116ee60098501600385016003850161146e565b91506116fb878284611503565b9650506117138461170e8684860161158a565b61158a565b91505080935061160b565b50506117308383848851850103611497565b925050506040519150618000820180820391508183526020830160005b8381101561176557828101518282015260200161174d565b506000920191825250602001604052919050565b60008061178983620cc394611b1c565b6117b3907ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd763200611d04565b90506117c36064620f4240611d78565b8112156109b8576110166064620f4240611d78565b80516000908190815b8181101561185b578481815181106117fb576117fb611e34565b01602001517fff000000000000000000000000000000000000000000000000000000000000001660000361183b57611834600484611b59565b9250611849565b611846601084611b59565b92505b8061185381611e63565b9150506117e1565b5061072282610440611b59565b60008061187483611779565b905060006118806111e4565b611888610a3c565b63ffffffff166118989190611b1c565b6118a06109db565b6118a8610ec2565b6118b3906010611bac565b63ffffffff166118c39190611b1c565b6118cd9190611b59565b90506118db60066002611b1c565b6118e690600a611cf8565b6118f08284611b1c565b6107229190611b71565b60006020828403121561190c57600080fd5b5035919050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60006020828403121561195457600080fd5b813567ffffffffffffffff8082111561196c57600080fd5b818401915084601f83011261198057600080fd5b81358181111561199257611992611913565b604051601f82017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f011681019083821181831017156119d8576119d8611913565b816040528281528760208487010111156119f157600080fd5b826020860160208301376000928101602001929092525095945050505050565b600060208083528351808285015260005b81811015611a3e57858101830151858201604001528201611a22565b81811115611a50576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016929092016040019392505050565b600060208284031215611a9657600080fd5b5051919050565b600060208284031215611aaf57600080fd5b815163ffffffff8116811461101657600080fd5b600060208284031215611ad557600080fd5b815167ffffffffffffffff8116811461101657600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b6000817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0483118215151615611b5457611b54611aed565b500290565b60008219821115611b6c57611b6c611aed565b500190565b600082611ba7577f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b500490565b600063ffffffff80831681851681830481118215151615611bcf57611bcf611aed565b02949350505050565b600181815b80851115611c3157817fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04821115611c1757611c17611aed565b80851615611c2457918102915b93841c9390800290611bdd565b509250929050565b600082611c48575060016109b8565b81611c55575060006109b8565b8160018114611c6b5760028114611c7557611c91565b60019150506109b8565b60ff841115611c8657611c86611aed565b50506001821b6109b8565b5060208310610133831016604e8410600b8410161715611cb4575081810a6109b8565b611cbe8383611bd8565b807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff04821115611cf057611cf0611aed565b029392505050565b60006110168383611c39565b6000808212827f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff03841381151615611d3e57611d3e611aed565b827f8000000000000000000000000000000000000000000000000000000000000000038412811615611d7257611d72611aed565b50500190565b60007f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600084136000841385830485118282161615611db957611db9611aed565b7f80000000000000000000000000000000000000000000000000000000000000006000871286820588128184161615611df457611df4611aed565b60008712925087820587128484161615611e1057611e10611aed565b87850587128184161615611e2657611e26611aed565b505050929093029392505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203611e9457611e94611aed565b506001019056fea164736f6c634300080f000a diff --git a/docs/specs/public/static/bytecode/jovian-l1-block-deployment.txt b/docs/specs/public/static/bytecode/jovian-l1-block-deployment.txt deleted file mode 100644 index 94c4e5a521..0000000000 --- a/docs/specs/public/static/bytecode/jovian-l1-block-deployment.txt +++ /dev/null @@ -1 +0,0 @@ -0x608060405234801561001057600080fd5b50610715806100206000396000f3fe608060405234801561001057600080fd5b50600436106101985760003560e01c806364ca23ef116100e3578063c59859181161008c578063e81b2c6d11610066578063e81b2c6d146103f0578063f8206140146103f9578063fe3d57101461040257600080fd5b8063c598591814610375578063d844471514610395578063e591b282146103ce57600080fd5b80638b239f73116100bd5780638b239f73146103435780639e8c49661461034c578063b80777ea1461035557600080fd5b806364ca23ef146102ff57806368d5dca6146103135780638381f58a1461032f57600080fd5b80634397dfef1161014557806354fd4d501161011f57806354fd4d501461027b578063550fcdc9146102bd5780635cf24969146102f657600080fd5b80634397dfef1461021a578063440a5e20146102425780634d5d9a2a1461024a57600080fd5b806316d3bc7f1161017657806316d3bc7f146101d657806321326849146102035780633db6be2b1461021257600080fd5b8063015d8eb91461019d578063098999be146101b257806309bd5a60146101ba575b600080fd5b6101b06101ab366004610623565b610433565b005b6101b0610572565b6101c360025481565b6040519081526020015b60405180910390f35b6008546101ea9067ffffffffffffffff1681565b60405167ffffffffffffffff90911681526020016101cd565b604051600081526020016101cd565b6101b0610585565b6040805173eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee815260126020820152016101cd565b6101b06105af565b6008546102669068010000000000000000900463ffffffff1681565b60405163ffffffff90911681526020016101cd565b60408051808201909152600581527f312e372e3000000000000000000000000000000000000000000000000000000060208201525b6040516101cd9190610695565b60408051808201909152600381527f455448000000000000000000000000000000000000000000000000000000000060208201526102b0565b6101c360015481565b6003546101ea9067ffffffffffffffff1681565b6003546102669068010000000000000000900463ffffffff1681565b6000546101ea9067ffffffffffffffff1681565b6101c360055481565b6101c360065481565b6000546101ea9068010000000000000000900467ffffffffffffffff1681565b600354610266906c01000000000000000000000000900463ffffffff1681565b60408051808201909152600581527f457468657200000000000000000000000000000000000000000000000000000060208201526102b0565b60405173deaddeaddeaddeaddeaddeaddeaddeaddead000181526020016101cd565b6101c360045481565b6101c360075481565b600854610420906c01000000000000000000000000900461ffff1681565b60405161ffff90911681526020016101cd565b3373deaddeaddeaddeaddeaddeaddeaddeaddead0001146104da576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152603b60248201527f4c31426c6f636b3a206f6e6c7920746865206465706f7369746f72206163636f60448201527f756e742063616e20736574204c3120626c6f636b2076616c7565730000000000606482015260840160405180910390fd5b6000805467ffffffffffffffff98891668010000000000000000027fffffffffffffffffffffffffffffffff00000000000000000000000000000000909116998916999099179890981790975560019490945560029290925560038054919094167fffffffffffffffffffffffffffffffffffffffffffffffff00000000000000009190911617909255600491909155600555600655565b61057a6105af565b60a43560a01c600855565b61058d6105af565b6dffff00000000000000000000000060b03560901c1660a43560a01c17600855565b73deaddeaddeaddeaddeaddeaddeaddeaddead00013381146105d957633cc50b456000526004601cfd5b60043560801c60035560143560801c60005560243560015560443560075560643560025560843560045550565b803567ffffffffffffffff8116811461061e57600080fd5b919050565b600080600080600080600080610100898b03121561064057600080fd5b61064989610606565b975061065760208a01610606565b9650604089013595506060890135945061067360808a01610606565b979a969950949793969560a0850135955060c08501359460e001359350915050565b600060208083528351808285015260005b818110156106c2578581018301518582016040015282016106a6565b818111156106d4576000604083870101525b50601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe01692909201604001939250505056fea164736f6c634300080f000a diff --git a/docs/specs/styles.css b/docs/specs/styles.css deleted file mode 100644 index 7dbda31955..0000000000 --- a/docs/specs/styles.css +++ /dev/null @@ -1,91 +0,0 @@ -.bcps-list { - display: flex; - flex-direction: column; - gap: 8px; - margin-top: 16px; -} - -.bcps-list__item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - border: 1px solid var(--vocs-color_border); - border-radius: 8px; - background: var(--vocs-color_background2); - text-decoration: none !important; - color: inherit; - transition: background 0.15s ease, border-color 0.15s ease; -} - -.bcps-list__item:hover { - background: var(--vocs-color_background4); - border-color: var(--vocs-color_border2); -} - -.bcps-list__icon { - flex-shrink: 0; - width: 18px; - height: 18px; - color: var(--vocs-color_textAccent); -} - -.bcps-list__body { - display: flex; - flex-direction: column; - gap: 3px; - flex: 1; - min-width: 0; -} - -.bcps-list__name { - font-size: 14px; - font-weight: 600; - line-height: 1.2; - color: var(--vocs-color_heading); -} - -.bcps-list__desc { - font-size: 13px; - line-height: 1.2; - color: var(--vocs-color_text2); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.bcps-list__badge { - flex-shrink: 0; - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - padding: 2px 8px; - border-radius: 999px; -} - -.mermaid-diagram { - margin: 1.5rem 0; - overflow-x: auto; -} - -.mermaid-diagram__content { - display: flex; - justify-content: center; - min-width: 100%; - width: max-content; -} - -.mermaid-diagram__content svg { - display: block; - height: auto; -} - -.mermaid-diagram__fallback, -.mermaid-diagram__loading { - margin: 0; - padding: 1rem; - border: 1px solid rgba(127, 127, 127, 0.3); - border-radius: 12px; - background: rgba(127, 127, 127, 0.08); -} diff --git a/docs/specs/vocs.config.ts b/docs/specs/vocs.config.ts deleted file mode 100644 index efa18dd9ac..0000000000 --- a/docs/specs/vocs.config.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { remarkMermaid } from './lib/remarkMermaid' -import rehypeKatex from 'rehype-katex' -import remarkMath from 'remark-math' -import { defineConfig, type SidebarItem } from 'vocs' - -const docsDir = fileURLToPath(new URL('./', import.meta.url)) -const pagesDir = fileURLToPath(new URL('./pages', import.meta.url)) - -type NodeInfo = { - hasIndex: boolean - indexTitle?: string - items: SidebarItem[] -} - -function toPosix(path: string) { - return path.split(sep).join('/') -} - -function getPathLink(filePath: string) { - const rel = toPosix(relative(pagesDir, filePath)).replace(/\.(md|mdx)$/i, '') - if (rel === 'index') return '/' - if (rel.endsWith('/index')) return `/${rel.slice(0, -6)}` - return `/${rel}` -} - -function formatSlug(slug: string) { - return slug - .split('-') - .filter(Boolean) - .map((part) => { - const upper = part.toUpperCase() - if (upper === 'L1' || upper === 'L2' || upper === 'EVM' || upper === 'P2P') return upper - return part.charAt(0).toUpperCase() + part.slice(1) - }) - .join(' ') -} - -function getTitle(filePath: string, fallback: string) { - const content = readFileSync(filePath, 'utf8') - const match = content.match(/^#\s+(.+)$/m) - return match ? match[1].trim() : fallback -} - -function sortName(a: string, b: string) { - const rank = (name: string) => { - if (name === 'index') return 0 - if (name === 'overview') return 1 - return 2 - } - const [an, bn] = [a.replace(/\.(md|mdx)$/i, ''), b.replace(/\.(md|mdx)$/i, '')] - const ar = rank(an) - const br = rank(bn) - if (ar !== br) return ar - br - return an.localeCompare(bn) -} - -function buildTree(dirPath: string): NodeInfo { - const entries = readdirSync(dirPath).sort(sortName) - const files = entries.filter((entry) => /\.(md|mdx)$/i.test(entry)) - const dirs = entries.filter((entry) => statSync(join(dirPath, entry)).isDirectory()) - - let hasIndex = false - let indexTitle: string | undefined - const items: SidebarItem[] = [] - - for (const file of files) { - const filePath = join(dirPath, file) - const basename = file.replace(/\.(md|mdx)$/i, '') - const title = getTitle(filePath, formatSlug(basename)) - if (basename === 'index') { - hasIndex = true - indexTitle = title - continue - } - items.push({ text: title, link: getPathLink(filePath) }) - } - - for (const dir of dirs) { - const dirPathChild = join(dirPath, dir) - const child = buildTree(dirPathChild) - if (!child.hasIndex && child.items.length === 0) continue - - const link = child.hasIndex ? getPathLink(join(dirPathChild, 'index.md')) : undefined - const text = child.indexTitle ?? formatSlug(dir) - - items.push({ - text, - ...(link ? { link } : {}), - ...(child.items.length ? { items: child.items } : {}), - }) - } - - return { hasIndex, indexTitle, items } -} - -function sectionItem(section: string, text: string): SidebarItem { - const sectionPath = join(pagesDir, section) - const tree = buildTree(sectionPath) - return { - text, - ...(tree.hasIndex ? { link: `/${section}` } : {}), - ...(tree.items.length ? { items: tree.items } : {}), - } -} - -const bcpsSection: SidebarItem = { - text: 'Base Change Proposals', - items: [ - { text: 'BCP List', link: '/bcps' }, - { text: 'BCP Process', link: '/bcps/bcp-0000' }, - ], -} - -const bridgingSection: SidebarItem = { - text: 'Bridging', - items: [ - { text: 'Deposits', link: '/protocol/bridging/deposits' }, - { text: 'Withdrawals', link: '/protocol/bridging/withdrawals' }, - { text: 'Standard Bridges', link: '/protocol/bridging/bridges' }, - { text: 'Cross Domain Messengers', link: '/protocol/bridging/messengers' }, - ], - collapsed: true, -} - -const consensusSection: SidebarItem = { - text: 'Consensus', - link: '/protocol/consensus', - items: [ - { text: 'Derivation', link: '/protocol/consensus/derivation' }, - { text: 'P2P', link: '/protocol/consensus/p2p' }, - { text: 'RPC', link: '/protocol/consensus/rpc' }, - ], - collapsed: true, -} - -const executionSection: SidebarItem = { - text: 'Execution', - link: '/protocol/execution', - items: [ - { text: 'Precompiles', link: '/protocol/execution/evm/precompiles' }, - { text: 'Predeploys', link: '/protocol/execution/evm/predeploys' }, - { text: 'Preinstalls', link: '/protocol/execution/evm/preinstalls' }, - { text: 'RPC', link: '/protocol/execution/evm/rpc' }, - ], - collapsed: true, -} - -const proofsSection: SidebarItem = { - text: 'Proofs', - link: '/protocol/proofs', - items: [ - { text: 'Challenger', link: '/protocol/proofs/challenger' }, - { text: 'Proposer', link: '/protocol/proofs/proposer' }, - { text: 'Registrar', link: '/protocol/proofs/registrar' }, - { text: 'TEE Provers', link: '/protocol/proofs/tee-provers' }, - { text: 'ZK Provers', link: '/protocol/proofs/zk-provers' }, - { text: 'Contracts', link: '/protocol/proofs/contracts' }, - ], - collapsed: true, -} - -const sidebar: SidebarItem[] = [ - { text: 'Home', link: '/' }, - { - text: 'Protocol', - items: [ - { text: 'Overview', link: '/protocol/overview' }, - consensusSection, - executionSection, - bridgingSection, - { text: 'Batcher', link: '/protocol/batcher' }, - proofsSection, - { ...sectionItem('protocol/fault-proof', 'Fault Proofs'), collapsed: true }, - ], - }, - bcpsSection, - { - text: 'Upgrades', - items: [ - { text: 'Azul', link: '/upgrades/azul/overview' }, - { - text: 'Inherited Upgrades', - collapsed: true, - items: [ - { text: 'Jovian', link: '/upgrades/jovian/overview' }, - { text: 'Isthmus', link: '/upgrades/isthmus/overview' }, - { text: 'Pectra Blob Schedule (Sepolia)', link: '/upgrades/pectra-blob-schedule/overview' }, - { text: 'Holocene', link: '/upgrades/holocene/overview' }, - { text: 'Granite', link: '/upgrades/granite/overview' }, - { text: 'Fjord', link: '/upgrades/fjord/overview' }, - { text: 'Ecotone', link: '/upgrades/ecotone/overview' }, - { text: 'Delta', link: '/upgrades/delta/overview' }, - { text: 'Canyon', link: '/upgrades/canyon/overview' }, - ], - }, - ], - }, - sectionItem('reference', 'Reference'), -] - -export default defineConfig({ - banner: '⚠️ This specification is under active development and subject to change.', - title: 'Base Chain Specification', - description: 'Base Chain protocol specification, upgrades, and reference documentation.', - logoUrl: { - light: '/assets/base/logo.svg', - dark: '/assets/base/logo-white.svg', - }, - iconUrl: '/assets/base/favicon.png', - topNav: [ - { text: 'Docs', link: 'https://docs.base.org/base-chain/' }, - { text: 'Blog', link: 'https://blog.base.dev/' }, - ], - markdown: { - remarkPlugins: [remarkMath, remarkMermaid], - rehypePlugins: [rehypeKatex], - }, - rootDir: '.', - vite: { - server: { - fs: { - allow: [docsDir, pagesDir], - }, - }, - }, - sidebar, - checkDeadlinks: 'error', -}) diff --git a/etc/docker/Dockerfile.nitro-host b/etc/docker/Dockerfile.nitro-host new file mode 100644 index 0000000000..199bdab01e --- /dev/null +++ b/etc/docker/Dockerfile.nitro-host @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +# Builds base-prover-nitro-host — the JSON-RPC server that forwards proving +# requests to the Nitro Enclave over vsock. + +# --- Builder --- +FROM --platform=linux/amd64 rust:1.93-trixie@sha256:51c04d7a2b38418ba23ecbfb373c40d3bd493dec1ddfae00ab5669527320195e AS builder +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git libclang-dev pkg-config curl build-essential mold protobuf-compiler && \ + rm -rf /var/lib/apt/lists/* + +COPY . . + +ARG PROFILE=maxperf +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,target=/app/target,id=prover-nitro-host-target,sharing=locked \ + cargo build --profile $PROFILE --locked --package base-prover-nitro-host --bin base-prover-nitro-host && \ + cp target/$([ "$PROFILE" = "dev" ] && echo debug || echo $PROFILE)/base-prover-nitro-host ./base-prover-nitro-host + +# --- Runtime --- +FROM --platform=linux/amd64 debian:trixie-slim@sha256:1d3c811171a08a5adaa4a163fbafd96b61b87aa871bbc7aa15431ac275d3d430 +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl && \ + rm -rf /var/lib/apt/lists/* && \ + useradd -r -m -s /sbin/nologin base + +WORKDIR /app +COPY --from=builder /app/base-prover-nitro-host ./ +COPY etc/docker/nitro-host/entrypoint.sh ./entrypoint.sh + +USER base + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/etc/docker/Dockerfile.rust-services b/etc/docker/Dockerfile.rust-services index d8a4ae65e0..adf9ab3417 100644 --- a/etc/docker/Dockerfile.rust-services +++ b/etc/docker/Dockerfile.rust-services @@ -7,7 +7,7 @@ ARG MOLD_SHA256_ARM=d82792748a81202423ddd2496fc8719404fe694493abdef691cc080392ee ARG MOLD_SHA256_X86_64=4c999e19ffa31afa5aa429c679b665d5e2ca5a6b6832ad4b79668e8dcf3d8ec1 RUN apt-get update && \ apt-get install -y --no-install-recommends \ - git libclang-dev pkg-config curl build-essential cmake && \ + git libclang-dev libssl-dev pkg-config curl build-essential cmake && \ rm -rf /var/lib/apt/lists/* RUN set -eux; \ case "$(uname -m)" in \ @@ -22,6 +22,35 @@ RUN set -eux; \ cp /tmp/mold-${MOLD_VERSION}-${MOLD_ARCH}-linux/bin/* /usr/local/bin/; \ rm -rf /tmp/mold* +FROM rust-builder-base AS zk-builder-base +RUN apt-get update && \ + apt-get install -y --no-install-recommends unzip && \ + rm -rf /var/lib/apt/lists/* +RUN set -eux; \ + GO_VERSION=1.23.3; \ + case "$(uname -m)" in \ + x86_64) GO_ARCH=amd64; GO_SHA256=a0afb9744c00648bafb1b90b4aba5bdb86f424f02f9275399ce0c20b93a2c3a8 ;; \ + aarch64|arm64) GO_ARCH=arm64; GO_SHA256=1f7cbd7f668ea32a107ecd41b6488aaee1f5d77a66efd885b175494439d4e1ce ;; \ + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz; \ + echo "${GO_SHA256} /tmp/go.tar.gz" | sha256sum -c -; \ + tar -C /usr/local -xzf /tmp/go.tar.gz; \ + rm /tmp/go.tar.gz +ENV PATH="/usr/local/go/bin:${PATH}" +RUN set -eux; \ + PROTOC_VERSION=33.4; \ + case "$(uname -m)" in \ + x86_64) PROTOC_ARCH=x86_64; PROTOC_SHA256=c0040ea9aef08fdeb2c74ca609b18d5fdbfc44ea0042fcfbfb38860d35f7dd66 ;; \ + aarch64|arm64) PROTOC_ARCH=aarch_64; PROTOC_SHA256=15aa988f4a6090636525ec236a8e4b3aab41eef402751bd5bb2df6afd9b7b5a5 ;; \ + *) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;; \ + esac; \ + PROTOC_ZIP="protoc-${PROTOC_VERSION}-linux-${PROTOC_ARCH}.zip"; \ + curl -fsSL "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_ZIP}" -o /tmp/protoc.zip; \ + echo "${PROTOC_SHA256} /tmp/protoc.zip" | sha256sum -c -; \ + unzip /tmp/protoc.zip -d /usr/local; \ + rm /tmp/protoc.zip + FROM rust-builder-base AS source COPY . . @@ -33,6 +62,14 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ cargo build --profile $PROFILE --package base-reth-node --bin base-reth-node && \ cp /app/target/$([ "$PROFILE" = "dev" ] && echo debug || echo "$PROFILE")/base-reth-node /app/base-client +FROM source AS base-builder +ARG PROFILE=release +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,target=/app/target,id=base-target,sharing=locked \ + cargo build --profile $PROFILE --package base --bin base && \ + cp /app/target/$([ "$PROFILE" = "dev" ] && echo debug || echo "$PROFILE")/base /app/base + FROM source AS builder-builder ARG PROFILE=release RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ @@ -89,6 +126,17 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ cargo build --profile $PROFILE --package base-batcher-bin --bin base-batcher && \ cp /app/target/$([ "$PROFILE" = "dev" ] && echo debug || echo "$PROFILE")/base-batcher /app/base-batcher +FROM zk-builder-base AS zk-source +COPY . . + +FROM zk-source AS zk-prover-builder +ARG PROFILE=release +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,target=/app/target,id=zk-prover-target,sharing=locked \ + BASE_SUCCINCT_ELF_REQUIRE=1 cargo build --profile $PROFILE --package base-prover-zk --bin base-prover-zk && \ + cp /app/target/$([ "$PROFILE" = "dev" ] && echo debug || echo "$PROFILE")/base-prover-zk /app/base-prover-zk + FROM public.ecr.aws/docker/library/debian:trixie-slim AS runtime-base RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates curl && \ @@ -99,6 +147,10 @@ FROM runtime-base AS client COPY --from=client-builder /app/base-client ./base-client ENTRYPOINT ["./base-client"] +FROM runtime-base AS base +COPY --from=base-builder /app/base ./base +ENTRYPOINT ["./base"] + FROM runtime-base AS builder COPY --from=builder-builder /app/base-builder ./base-builder ENTRYPOINT ["./base-builder"] @@ -127,3 +179,8 @@ ENTRYPOINT ["./audit-archiver"] FROM runtime-base AS batcher COPY --from=batcher-builder /app/base-batcher ./base-batcher ENTRYPOINT ["./base-batcher"] + +FROM runtime-base AS zk-prover +COPY --from=zk-prover-builder /app/base-prover-zk ./base-prover-zk +EXPOSE 9000 +ENTRYPOINT ["./base-prover-zk"] diff --git a/etc/docker/Justfile b/etc/docker/Justfile index fa8ad8b47c..9781b26f26 100644 --- a/etc/docker/Justfile +++ b/etc/docker/Justfile @@ -72,7 +72,7 @@ transfer-leader to='': # TUI dashboard: HA conductor cluster monitor (requires devnet running) conductor: - cargo run --quiet -p basectl -- conductor + cargo run --quiet -p basectl -- -c devnet conductor # Stops devnet+ingress, deletes data, and starts fresh with full ingress stack ingress: ingress-down _build-setup-image (_build-rust-images "ingress" "dev") @@ -112,6 +112,15 @@ tests: _install-nextest _build-contracts tests-ci: _install-nextest _build-contracts cargo nextest run -P ci --locked -p devnet --cargo-profile ci +# Runs devnet benchmarks +bench name: + #!/usr/bin/env bash + set -euo pipefail + case "{{ name }}" in + b20-zk-proving) cargo bench -p devnet --bench b20_zk_proving ;; + *) echo "unknown devnet bench: {{ name }}" >&2; exit 1 ;; + esac + [private] _install-nextest: @command -v cargo-nextest >/dev/null 2>&1 || cargo install cargo-nextest diff --git a/etc/docker/README.md b/etc/docker/README.md index 095ab777b9..e9ba051cb8 100644 --- a/etc/docker/README.md +++ b/etc/docker/README.md @@ -4,7 +4,7 @@ This directory contains the Dockerfiles and Compose configuration for the Base n ## Dockerfiles -`Dockerfile.rust-services` is the shared multi-target Dockerfile for the Debian-based Rust services. It provides `client`, `builder`, `consensus`, `proposer`, `websocket-proxy`, `ingress-rpc`, `audit-archiver`, and `batcher` targets. +`Dockerfile.rust-services` is the shared multi-target Dockerfile for the Debian-based Rust services. It provides `client`, `builder`, `consensus`, `proposer`, `websocket-proxy`, `ingress-rpc`, `audit-archiver`, `batcher`, and `zk-prover` targets. `Dockerfile.devnet` builds a utility image containing genesis generation tools (`eth-genesis-state-generator`, `eth2-val-tools`, `op-deployer`) and setup scripts. This image bootstraps L1 and L2 chain configurations for local development. @@ -18,6 +18,7 @@ The `docker-compose.yml` orchestrates a complete local devnet environment with b - The Base builder and client nodes on L2 - Base consensus layer nodes (`base-consensus`) for both builder and client - The `base-batcher` for submitting L2 data to L1 +- The `base-prover-zk` service in `SP1_PROVER=dry-run` mode with local Postgres storage All services read configuration from `devnet-env` in this directory. The devnet stores chain data in `.devnet/` which is created on first run. diff --git a/etc/docker/devnet-env b/etc/docker/devnet-env index f554caddc9..a58ccf67d1 100644 --- a/etc/docker/devnet-env +++ b/etc/docker/devnet-env @@ -89,9 +89,23 @@ L2_CLIENT_CL_RPC_PORT=8549 L2_CLIENT_CL_P2P_PORT=8003 L2_CLIENT_CL_METRICS_PORT=8300 +# L2 Base RPC Ports +L2_BASE_RPC_ADVERTISE_IP=172.30.0.24 +L2_BASE_RPC_HTTP_PORT=8645 +L2_BASE_RPC_WS_PORT=8646 +L2_BASE_RPC_AUTH_PORT=8651 +L2_BASE_RPC_P2P_PORT=8403 +L2_BASE_RPC_METRICS_PORT=8190 +L2_BASE_RPC_CL_RPC_PORT=8649 +L2_BASE_RPC_CL_P2P_PORT=8103 + # Batcher Ports BATCHER_METRICS_PORT=6060 +# ZK Prover Ports +ZK_PROVER_GRPC_PORT=9000 +ZK_PROVER_POSTGRES_PORT=5433 + # L2 Sequencer-1 Ports L2_SEQ1_HTTP_PORT=10545 L2_SEQ1_WS_PORT=10546 @@ -139,6 +153,9 @@ L2_BUILDER_OP_RPC_URL=http://localhost:7549 L2_CLIENT_RPC_URL=http://localhost:8545 L2_CLIENT_WS_URL=ws://localhost:8546 L2_CLIENT_OP_RPC_URL=http://localhost:8549 +L2_BASE_RPC_URL=http://localhost:8645 +L2_BASE_RPC_WS_URL=ws://localhost:8646 +L2_BASE_RPC_OP_RPC_URL=http://localhost:8649 L2_INGRESS_RPC_URL=http://localhost:8080 CONDUCTOR0_RPC_URL=http://localhost:6545 CONDUCTOR1_RPC_URL=http://localhost:6546 @@ -153,7 +170,6 @@ L2_INGRESS_METRICS_PORT=9002 AUDIT_METRICS_PORT=9003 # Ingress Infrastructure Ports -KAFKA_PORT=9092 MINIO_API_PORT=7000 MINIO_CONSOLE_PORT=7001 @@ -169,6 +185,9 @@ L2_CHAIN_ID=84538453 # Optional: set to a non-negative block number to schedule Base Azul in devnet. # Leave unset to avoid setting base.azul/osakaTime during genesis generation. L2_BASE_AZUL_BLOCK=20 +# Optional: set to a non-negative block number to schedule Base Beryl in devnet. +# Leave unset to avoid setting base.beryl during genesis generation. +L2_BASE_BERYL_BLOCK=21 # Metering Resource Limits # Whole-block budgets for gas/state-root/DA, plus a per-flashblock execution budget. diff --git a/etc/docker/docker-bake.hcl b/etc/docker/docker-bake.hcl index b739b42691..0c6f7f3409 100644 --- a/etc/docker/docker-bake.hcl +++ b/etc/docker/docker-bake.hcl @@ -2,6 +2,10 @@ variable "PROFILE" { default = "release" } +variable "ZK_PROVER_PROFILE" { + default = "release" +} + variable "RUST_VERSION" { default = "1.93" } @@ -20,6 +24,7 @@ group "default" { group "rust-services" { targets = [ + "base", "client", "builder", "consensus", @@ -28,15 +33,24 @@ group "rust-services" { "ingress-rpc", "audit-archiver", "batcher", + "zk-prover", ] } group "devnet" { - targets = ["builder", "consensus", "client", "batcher"] + targets = ["builder", "consensus", "client", "base", "batcher", "zk-prover"] } group "ingress" { - targets = ["builder", "consensus", "client", "ingress-rpc", "audit-archiver", "batcher"] + targets = [ + "builder", + "consensus", + "client", + "base", + "ingress-rpc", + "audit-archiver", + "batcher", + ] } target "_rust-service-common" { @@ -55,6 +69,12 @@ target "client" { tags = ["base-reth-node:local"] } +target "base" { + inherits = ["_rust-service-common"] + target = "base" + tags = ["base:local"] +} + target "builder" { inherits = ["_rust-service-common"] target = "builder" @@ -108,3 +128,16 @@ target "batcher" { "type=registry,ref=${REGISTRY_IMAGE}:cache-batcher-${PLATFORM_PAIR}", ] } + +target "zk-prover" { + inherits = ["_rust-service-common"] + target = "zk-prover" + args = { + PROFILE = "${ZK_PROVER_PROFILE}" + } + tags = ["base-prover-zk:local"] + cache-from = [ + "type=registry,ref=${REGISTRY_IMAGE}:cache-${PLATFORM_PAIR}", + "type=registry,ref=${REGISTRY_IMAGE}:cache-zk-prover-${PLATFORM_PAIR}", + ] +} diff --git a/etc/docker/docker-compose.ha.yml b/etc/docker/docker-compose.ha.yml index 51f2e04974..811ce53211 100644 --- a/etc/docker/docker-compose.ha.yml +++ b/etc/docker/docker-compose.ha.yml @@ -71,10 +71,9 @@ services: - /genesis/el/chain-config.json - --l1-slot-duration-override - "4" - - --rpc.addr - - "0.0.0.0" - --rpc.port - "${L2_BUILDER_CL_RPC_PORT}" + - --rpc.enable-admin - --p2p.listen.tcp - "${L2_BUILDER_CL_P2P_PORT}" - --p2p.listen.udp @@ -86,8 +85,6 @@ services: - --p2p.bootnodes-file - "${L2_CL_BOOTNODE_ENR_PATH}" - --metrics.enabled - - --metrics.addr - - "0.0.0.0" - --metrics.port - "${L2_BUILDER_CL_METRICS_PORT}" - --mode @@ -235,10 +232,9 @@ services: - /genesis/el/chain-config.json - --l1-slot-duration-override - "4" - - --rpc.addr - - "0.0.0.0" - --rpc.port - "${L2_SEQ1_CL_RPC_PORT}" + - --rpc.enable-admin - --p2p.listen.tcp - "${L2_SEQ1_CL_P2P_PORT}" - --p2p.listen.udp @@ -248,8 +244,6 @@ services: - --p2p.priv.path - /genesis/l2/sequencer-1-p2p-key.txt - --metrics.enabled - - --metrics.addr - - "0.0.0.0" - --metrics.port - "${L2_SEQ1_CL_METRICS_PORT}" - --mode @@ -376,10 +370,9 @@ services: - /genesis/el/chain-config.json - --l1-slot-duration-override - "4" - - --rpc.addr - - "0.0.0.0" - --rpc.port - "${L2_SEQ2_CL_RPC_PORT}" + - --rpc.enable-admin - --p2p.listen.tcp - "${L2_SEQ2_CL_P2P_PORT}" - --p2p.listen.udp @@ -389,8 +382,6 @@ services: - --p2p.priv.path - /genesis/l2/sequencer-2-p2p-key.txt - --metrics.enabled - - --metrics.addr - - "0.0.0.0" - --metrics.port - "${L2_SEQ2_CL_METRICS_PORT}" - --mode diff --git a/etc/docker/docker-compose.ingress.yml b/etc/docker/docker-compose.ingress.yml index 15ce7b6ec9..c8be8e0b05 100644 --- a/etc/docker/docker-compose.ingress.yml +++ b/etc/docker/docker-compose.ingress.yml @@ -5,46 +5,6 @@ x-rust-service-build: &rust-service-build PROFILE: "${CARGO_PROFILE:-dev}" services: - kafka: - image: apache/kafka:3.9.0 - container_name: kafka - ports: - - "${KAFKA_PORT}:9092" - environment: - # KRaft mode (no ZooKeeper) - - KAFKA_NODE_ID=1 - - KAFKA_PROCESS_ROLES=broker,controller - - KAFKA_CONTROLLER_QUORUM_VOTERS=1@kafka:9093 - - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER - # Listeners: PLAINTEXT for container-to-container, EXTERNAL for host access - - KAFKA_LISTENERS=PLAINTEXT://:29092,CONTROLLER://:9093,EXTERNAL://:9092 - - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:29092,EXTERNAL://localhost:${KAFKA_PORT} - - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT - - KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT - - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 - # Auto-create topics - - KAFKA_AUTO_CREATE_TOPICS_ENABLE=true - healthcheck: - test: ["CMD", "/opt/kafka/bin/kafka-topics.sh", "--bootstrap-server", "localhost:9092", "--list"] - interval: 5s - timeout: 10s - retries: 30 - start_period: 10s - restart: unless-stopped - - kafka-init: - image: apache/kafka:3.9.0 - container_name: kafka-init - depends_on: - kafka: - condition: service_healthy - entrypoint: > - /bin/sh -c " - /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:29092 --create --if-not-exists --topic tips-ingress --partitions 1 --replication-factor 1 && - /opt/kafka/bin/kafka-topics.sh --bootstrap-server kafka:29092 --create --if-not-exists --topic tips-audit --partitions 1 --replication-factor 1 && - echo 'Kafka topics created' - " - minio: image: minio/minio:latest container_name: minio @@ -87,13 +47,9 @@ services: condition: service_healthy base-client: condition: service_healthy - kafka-init: - condition: service_completed_successfully ports: - "${L2_INGRESS_HEALTH_PORT}:${L2_INGRESS_HEALTH_PORT}" - "${L2_INGRESS_METRICS_PORT}:${L2_INGRESS_METRICS_PORT}" - volumes: - - ./kafka:/kafka-config:ro environment: - TIPS_INGRESS_PORT=${L2_INGRESS_HTTP_PORT} - TIPS_INGRESS_HEALTH_CHECK_ADDR=0.0.0.0:${L2_INGRESS_HEALTH_PORT} @@ -104,17 +60,9 @@ services: # Fan out metering data to both builder and client; both expose # `base_setMeteringInformation` for trusted ingestion. - TIPS_INGRESS_BUILDER_RPCS=http://base-builder:${L2_BUILDER_HTTP_PORT},http://base-client:${L2_CLIENT_HTTP_PORT} - # proxyd routes transactions to the builder and optimistically forwards a - # copy to ingress-rpc, matching the production topology. Ingress does not - # need to submit transactions itself. - - TIPS_INGRESS_TX_SUBMISSION_METHOD=none - TIPS_INGRESS_SEND_TO_BUILDER=true - TIPS_INGRESS_CHAIN_ID=${L2_CHAIN_ID} - TIPS_INGRESS_BLOCK_TIME_MILLISECONDS=2000 - - TIPS_INGRESS_KAFKA_INGRESS_PROPERTIES_FILE=/kafka-config/ingress.properties - - TIPS_INGRESS_KAFKA_INGRESS_TOPIC=tips-ingress - - TIPS_INGRESS_KAFKA_AUDIT_PROPERTIES_FILE=/kafka-config/ingress.properties - - TIPS_INGRESS_KAFKA_AUDIT_TOPIC=tips-audit healthcheck: test: ["CMD", "curl", "-f", "http://localhost:${L2_INGRESS_HEALTH_PORT}/health"] interval: 5s @@ -155,19 +103,13 @@ services: target: audit-archiver container_name: audit-archiver depends_on: - kafka-init: - condition: service_completed_successfully minio-init: condition: service_completed_successfully ports: - "${AUDIT_METRICS_PORT}:${AUDIT_METRICS_PORT}" - volumes: - - ./kafka:/kafka-config:ro environment: - TIPS_AUDIT_METRICS_ADDR=0.0.0.0 - TIPS_AUDIT_METRICS_PORT=${AUDIT_METRICS_PORT} - - TIPS_AUDIT_KAFKA_PROPERTIES_FILE=/kafka-config/audit.properties - - TIPS_AUDIT_KAFKA_TOPIC=tips-audit - TIPS_AUDIT_S3_BUCKET=tips - TIPS_AUDIT_S3_CONFIG_TYPE=manual - TIPS_AUDIT_S3_ENDPOINT=http://minio:9000 diff --git a/etc/docker/docker-compose.yml b/etc/docker/docker-compose.yml index b7bbf5e49e..814d455bb5 100644 --- a/etc/docker/docker-compose.yml +++ b/etc/docker/docker-compose.yml @@ -152,6 +152,7 @@ services: - L1_CHAIN_ID=${L1_CHAIN_ID} - L2_CHAIN_ID=${L2_CHAIN_ID} - L2_BASE_AZUL_BLOCK=${L2_BASE_AZUL_BLOCK-} + - L2_BASE_BERYL_BLOCK=${L2_BASE_BERYL_BLOCK-} - OUTPUT_DIR=/devnet/l2/configs - L2_DATA_DIR=/data - DEPLOYER_ADDR=${DEPLOYER_ADDR} @@ -226,8 +227,6 @@ services: - ${L2_CHAIN_ID} - --l2-config-file - /genesis/l2/rollup.json - - --p2p.listen.ip - - 0.0.0.0 - --p2p.listen.tcp - "${L2_CL_BOOTNODE_P2P_PORT}" - --p2p.listen.udp @@ -377,10 +376,9 @@ services: - /genesis/el/chain-config.json - --l1-slot-duration-override - "4" - - --rpc.addr - - "0.0.0.0" - --rpc.port - "${L2_BUILDER_CL_RPC_PORT}" + - --rpc.enable-admin - --p2p.listen.tcp - "${L2_BUILDER_CL_P2P_PORT}" - --p2p.listen.udp @@ -392,8 +390,6 @@ services: - --p2p.bootnodes-file - "${L2_CL_BOOTNODE_ENR_PATH}" - --metrics.enabled - - --metrics.addr - - "0.0.0.0" - --metrics.port - "${L2_BUILDER_CL_METRICS_PORT}" - --mode @@ -575,8 +571,6 @@ services: - /genesis/el/chain-config.json - --l1-slot-duration-override - "4" - - --rpc.addr - - "0.0.0.0" - --rpc.port - "${L2_CLIENT_CL_RPC_PORT}" - --p2p.listen.tcp @@ -586,8 +580,6 @@ services: - --p2p.advertise.ip - base-client-cl - --metrics.enabled - - --metrics.addr - - "0.0.0.0" - --metrics.port - "${L2_CLIENT_CL_METRICS_PORT}" - --p2p.bootnodes-file @@ -604,6 +596,174 @@ services: start_period: 250ms restart: unless-stopped + base-rpc: + image: base:local + build: + <<: *rust-service-build + target: base + container_name: base-rpc + depends_on: + setup-l2: + condition: service_completed_successfully + base-el-bootnode: + condition: service_started + base-builder-cl: + condition: service_healthy + ports: + - "${L2_BASE_RPC_HTTP_PORT}:${L2_BASE_RPC_HTTP_PORT}" # HTTP RPC + - "${L2_BASE_RPC_WS_PORT}:${L2_BASE_RPC_WS_PORT}" # WebSocket + - "${L2_BASE_RPC_AUTH_PORT}:${L2_BASE_RPC_AUTH_PORT}" # Auth RPC (Engine API) + - "${L2_BASE_RPC_P2P_PORT}:${L2_BASE_RPC_P2P_PORT}" # P2P + - "${L2_BASE_RPC_P2P_PORT}:${L2_BASE_RPC_P2P_PORT}/udp" # Discovery + - "${L2_BASE_RPC_METRICS_PORT}:${L2_BASE_RPC_METRICS_PORT}" # Metrics + - "${L2_BASE_RPC_CL_RPC_PORT}:${L2_BASE_RPC_CL_RPC_PORT}" # Consensus RPC + - "${L2_BASE_RPC_CL_P2P_PORT}:${L2_BASE_RPC_CL_P2P_PORT}" # Consensus P2P TCP + - "${L2_BASE_RPC_CL_P2P_PORT}:${L2_BASE_RPC_CL_P2P_PORT}/udp" # Consensus P2P UDP + volumes: + - ../../.devnet/l2/base-rpc:/data + - ../../.devnet/l2/cl-bootnode-enr:/bootnodes:ro + - ../../.devnet/l1/configs:/genesis:ro + - ../../.devnet/l2/configs:/genesis/l2:ro + environment: + - BASE_CHAIN_NAME=dev + - BASE_CHAIN_L1_CHAIN_ID=${L1_CHAIN_ID} + - BASE_CHAIN_L2_CHAIN_ID=${L2_CHAIN_ID} + - OTEL_SERVICE_NAME=base-rpc + entrypoint: /app/base + command: + - rpc + - --chain + - dev + - --execution-chain=/genesis/l2/genesis.json + - --datadir=/data + - --http + - --http.addr=0.0.0.0 + - --http.port=${L2_BASE_RPC_HTTP_PORT} + # Devnet keeps `miner` on HTTP so local services can read and update + # the dynamic DA and gas-limit knobs end-to-end. + - --http.api=admin,eth,web3,net,rpc,debug,txpool,miner + - --http.corsdomain=* + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=${L2_BASE_RPC_WS_PORT} + - --ws.api=eth,web3,net,txpool,debug + - --ws.origins=* + - --authrpc.port=${L2_BASE_RPC_AUTH_PORT} + - --authrpc.addr=0.0.0.0 + - --authrpc.jwtsecret=/genesis/jwt.hex + - --auth-ipc.path=/tmp/base-engine.ipc + - --port=${L2_BASE_RPC_P2P_PORT} + - --discovery.port=${L2_BASE_RPC_P2P_PORT} + - --nat=extip:${L2_BASE_RPC_ADVERTISE_IP} + - --metrics=0.0.0.0:${L2_BASE_RPC_METRICS_PORT} + - --txpool.nolocals + - --rpc.txfeecap=0 + - --rpc.gascap=600000000 + - --rpc.eth-proof-window=1209600 + - --bootnodes=${L2_EL_BOOTNODE_ENODE} + - --rollup.discovery.v4 + - --l1-eth-rpc + - http://l1-el:${L1_HTTP_PORT} + - --l1-beacon + - http://l1-cl:${L1_CL_HTTP_PORT} + - --l2-config-file + - /genesis/l2/rollup.json + - --l1-config-file + - /genesis/el/chain-config.json + - --l1-slot-duration-override + - "4" + - --rpc.port + - "${L2_BASE_RPC_CL_RPC_PORT}" + - --rpc.enable-admin + - --p2p.listen.tcp + - "${L2_BASE_RPC_CL_P2P_PORT}" + - --p2p.listen.udp + - "${L2_BASE_RPC_CL_P2P_PORT}" + - --p2p.advertise.ip + - base-rpc-cl + - --p2p.bootnodes-file + - "${L2_CL_BOOTNODE_ENR_PATH}" + - --p2p.scoring + - Off + - --l1.verifier-confs + - "${BASE_NODE_VERIFIER_L1_CONFS}" + - -vvv + healthcheck: + test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/${L2_BASE_RPC_HTTP_PORT}"] + interval: 250ms + timeout: 1s + retries: 240 + start_period: 250ms + networks: + default: + ipv4_address: ${L2_BASE_RPC_ADVERTISE_IP} + aliases: + - base-rpc-cl + restart: unless-stopped + + zk-prover-postgres: + image: postgres:17-alpine + container_name: zk-prover-postgres + ports: + - "${ZK_PROVER_POSTGRES_PORT}:5432" + environment: + - POSTGRES_DB=prover + - POSTGRES_USER=prover + - POSTGRES_PASSWORD=prover + volumes: + - ../../.devnet/zk-prover/postgres:/var/lib/postgresql/data + - ../../crates/proof/zk/db/migrations:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U prover -d prover"] + interval: 250ms + timeout: 1s + retries: 240 + start_period: 250ms + restart: unless-stopped + + base-prover-zk: + image: base-prover-zk:local + build: + <<: *rust-service-build + target: zk-prover + container_name: base-prover-zk + depends_on: + l1-el: + condition: service_healthy + l1-cl: + condition: service_healthy + base-rpc: + condition: service_healthy + zk-prover-postgres: + condition: service_healthy + ports: + - "${ZK_PROVER_GRPC_PORT}:9000" + volumes: + - ../../.devnet/l1/configs/el/chain-config.json:/app/configs/L1/${L1_CHAIN_ID}.json:ro + environment: + - SP1_PROVER=dry-run + - PROXY_ENABLE=false + - GRPC_LISTEN_ADDR=0.0.0.0:9000 + - BASE_CONSENSUS_ADDRESS=http://base-rpc:${L2_BASE_RPC_CL_RPC_PORT} + - L1_NODE_ADDRESS=http://l1-el:${L1_HTTP_PORT} + - L1_BEACON_ADDRESS=http://l1-cl:${L1_CL_HTTP_PORT} + - L2_NODE_ADDRESS=http://base-rpc:${L2_BASE_RPC_HTTP_PORT} + - DEFAULT_SEQUENCE_WINDOW=50 + - POSTGRES_HOST=zk-prover-postgres + - POSTGRES_PORT=5432 + - POSTGRES_DB=prover + - POSTGRES_USER=prover + - POSTGRES_PASSWORD=prover + - POSTGRES_SSLMODE=disable + - OTEL_SERVICE_NAME=base-prover-zk + healthcheck: + test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/9000"] + interval: 250ms + timeout: 1s + retries: 240 + start_period: 250ms + restart: unless-stopped + jaeger: image: jaegertracing/all-in-one:1.56 container_name: jaeger @@ -611,6 +771,7 @@ services: - "3001:16686" # Jaeger UI environment: - COLLECTOR_OTLP_ENABLED=true + - MEMORY_MAX_TRACES=1000 restart: unless-stopped pyroscope: diff --git a/etc/docker/kafka/audit.properties b/etc/docker/kafka/audit.properties deleted file mode 100644 index 26f93be1af..0000000000 --- a/etc/docker/kafka/audit.properties +++ /dev/null @@ -1,3 +0,0 @@ -# Kafka properties for audit-archiver (container-to-container) -bootstrap.servers=kafka:29092 -group.id=audit-archiver diff --git a/etc/docker/kafka/host-audit.properties b/etc/docker/kafka/host-audit.properties deleted file mode 100644 index fc5829c841..0000000000 --- a/etc/docker/kafka/host-audit.properties +++ /dev/null @@ -1,2 +0,0 @@ -# Kafka properties for system tests running on the host -bootstrap.servers=localhost:9092 diff --git a/etc/docker/kafka/ingress.properties b/etc/docker/kafka/ingress.properties deleted file mode 100644 index af67ff3658..0000000000 --- a/etc/docker/kafka/ingress.properties +++ /dev/null @@ -1,2 +0,0 @@ -# Kafka properties for ingress-rpc (container-to-container) -bootstrap.servers=kafka:29092 diff --git a/etc/docker/nitro-host/entrypoint.sh b/etc/docker/nitro-host/entrypoint.sh new file mode 100755 index 0000000000..9b96070364 --- /dev/null +++ b/etc/docker/nitro-host/entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eux + +: "${VSOCK_CID:?required}" +: "${LISTEN_ADDR:?required}" +: "${L1_ETH_URL:?required}" +: "${L1_BEACON_URL:?required}" +: "${L2_ETH_URL:?required}" +: "${L2_CHAIN_ID:?required}" +: "${PROOF_REQUEST_TIMEOUT_SECS:=3600}" + +ADDITIONAL_ARGS=() +if [ -n "${TEE_PROVER_REGISTRY_ADDRESS:-}" ]; then + ADDITIONAL_ARGS+=(--tee-prover-registry-address="$TEE_PROVER_REGISTRY_ADDRESS") +fi + +exec ./base-prover-nitro-host \ + server \ + --l1-eth-url "$L1_ETH_URL" \ + --l1-beacon-url "$L1_BEACON_URL" \ + --l2-eth-url "$L2_ETH_URL" \ + --l2-chain-id "$L2_CHAIN_ID" \ + --listen-addr "$LISTEN_ADDR" \ + --vsock-cid "$VSOCK_CID" \ + --proof-request-timeout-secs "$PROOF_REQUEST_TIMEOUT_SECS" \ + --enable-experimental-witness-endpoint \ + "${ADDITIONAL_ARGS[@]}" diff --git a/etc/just/build.just b/etc/just/build.just index fb17a2964f..d8ee0c7dc3 100644 --- a/etc/just/build.just +++ b/etc/just/build.just @@ -11,11 +11,11 @@ all-targets: contracts elfs cargo build --workspace --all-targets # Builds all targets with ci profile -ci: contracts elfs +ci: contracts cargo build --locked --workspace --all-targets --profile ci # Builds only affected packages with ci profile -affected-ci base="main": contracts elfs +affected-ci base="main": contracts #!/usr/bin/env bash set -euo pipefail pkg_args_output="$(python3 {{justfile_directory()}}/etc/scripts/local/affected-crates.py {{ base }} --cargo-args)" diff --git a/etc/just/succinct.just b/etc/just/succinct.just index 6b28254c75..9d83e39cc0 100644 --- a/etc/just/succinct.just +++ b/etc/just/succinct.just @@ -27,6 +27,7 @@ ensure-elfs: result=$(python3 "$script" "$manifest" "$cache_dir") if [ "$result" != "match" ]; then echo "error: freshly built ELFs do not match manifest.toml ($result)." >&2 + python3 "$script" --print-hashes "$manifest" "$cache_dir" >&2 echo " If this is a legitimate change, run 'just succinct write-manifest' and commit." >&2 exit 1 fi diff --git a/etc/just/zk-prover.just b/etc/just/zk-prover.just new file mode 100644 index 0000000000..26e94d142f --- /dev/null +++ b/etc/just/zk-prover.just @@ -0,0 +1,27 @@ +set positional-arguments := true + +_script := justfile_directory() / "etc/scripts/zk-prover/grpc.sh" + +# Show ZK prover request commands +default: + @just --justfile {{ source_file() }} --list --list-prefix ' zk-prover::' + +# List services exposed by the configured ZK prover endpoint +list target='devnet': + bash "{{ _script }}" list "{{ target }}" + +# Describe the ZK prover gRPC service +describe target='devnet': + bash "{{ _script }}" describe "{{ target }}" + +# Request a proof or dry-run for a block range +prove start_block number_of_blocks='1' target='devnet' proof_type='PROOF_TYPE_COMPRESSED': + bash "{{ _script }}" prove "{{ start_block }}" "{{ number_of_blocks }}" "{{ target }}" "{{ proof_type }}" + +# Get proof status and, for dry-run mode, executionStats +get session_id target='devnet' receipt_type='': + bash "{{ _script }}" get "{{ session_id }}" "{{ target }}" "{{ receipt_type }}" + +# List recent proof requests +list-proofs target='devnet' limit='20' offset='0' status_filter='': + bash "{{ _script }}" list-proofs "{{ target }}" "{{ limit }}" "{{ offset }}" "{{ status_filter }}" diff --git a/etc/scripts/ci/check-succinct-elf-inputs.py b/etc/scripts/ci/check-succinct-elf-inputs.py new file mode 100755 index 0000000000..a7ad1fed02 --- /dev/null +++ b/etc/scripts/ci/check-succinct-elf-inputs.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Detect whether a change can affect the checked-in SP1 ELF manifest. + +The actual ELF binaries are ignored by git. CI uses this script as a cheap +preflight before deciding whether to run the expensive Docker-backed SP1 build +that verifies ``crates/proof/succinct/elf/manifest.toml``. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +MANIFEST = "crates/proof/succinct/elf/manifest.toml" + +INPUT_FILES = { + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", + "etc/just/succinct.just", +} + +INPUT_PREFIXES = ( + ".cargo/", + "crates/common/chains/", + "crates/common/consensus/", + "crates/common/evm/", + "crates/common/flz/", + "crates/common/genesis/", + "crates/common/precompile-macros/", + "crates/common/precompile-storage/", + "crates/common/precompiles/", + "crates/common/rpc-types-engine/", + "crates/consensus/derive/", + "crates/consensus/protocol/", + "crates/consensus/upgrades/", + "crates/proof/driver/", + "crates/proof/executor/", + "crates/proof/mpt/", + "crates/proof/preimage/", + "crates/proof/primitives/", + "crates/proof/proof/", + "crates/proof/succinct/programs/", + "crates/proof/succinct/utils/build/", + "crates/proof/succinct/utils/client/", + "crates/proof/succinct/utils/ethereum/client/", + "crates/utilities/metrics/", +) + + +def run_git_diff(args: list[str]) -> list[str] | None: + """Return changed files for a git diff invocation, or None on failure.""" + result = subprocess.run( + ["git", "diff", "--name-only", *args], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + return [line for line in result.stdout.splitlines() if line] + + +def changed_files(base_ref: str | None) -> list[str]: + """Return files changed between base_ref and HEAD.""" + if base_ref: + base_refs = [base_ref] + if not base_ref.startswith(("origin/", "refs/")): + base_refs.append(f"origin/{base_ref}") + for candidate in dict.fromkeys(base_refs): + for args in ([f"{candidate}...HEAD"], [candidate, "HEAD"]): + files = run_git_diff(args) + if files is not None: + return files + raise RuntimeError(f"could not diff against base ref {base_ref}") + + for args in (["HEAD^1...HEAD"], ["HEAD^", "HEAD"]): + files = run_git_diff(args) + if files is not None: + return files + raise RuntimeError("could not diff HEAD against a parent commit") + + +def is_elf_input(path: str) -> bool: + """Return true if path can affect a generated SP1 ELF.""" + return path in INPUT_FILES or any(path.startswith(prefix) for prefix in INPUT_PREFIXES) + + +def write_output(name: str, value: bool) -> None: + """Write a GitHub Actions boolean output when running in CI.""" + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + with Path(output_path).open("a", encoding="utf-8") as output: + output.write(f"{name}={str(value).lower()}\n") + + +def main(argv: list[str]) -> None: + base_ref = argv[1] if len(argv) > 1 and argv[1] else None + files = changed_files(base_ref) + input_changes = [path for path in files if is_elf_input(path)] + manifest_changed = MANIFEST in files + needs_rebuild = bool(input_changes or manifest_changed) + + write_output("input_changed", bool(input_changes)) + write_output("manifest_changed", manifest_changed) + write_output("needs_rebuild", needs_rebuild) + + if not needs_rebuild: + print("No SP1 ELF inputs changed.") + return + + if input_changes: + print("SP1 ELF input changes:") + for path in input_changes: + print(f" {path}") + if manifest_changed: + print(f"SP1 ELF manifest changed: {MANIFEST}") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/etc/scripts/devnet/check-factory-live.sh b/etc/scripts/devnet/check-factory-live.sh new file mode 100755 index 0000000000..a160cba57e --- /dev/null +++ b/etc/scripts/devnet/check-factory-live.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# check-factory-live.sh — end-to-end validation of the B-20 B20Factory precompile +# against a running devnet node using real cast transactions. +# +# Prerequisites: +# • Node running at RPC_URL (default: http://localhost:8545) +# • cast (foundry) in PATH +# +# Usage: +# ./check-factory-live.sh [rpc-url] +# +# Examples: +# ./check-factory-live.sh +# ./check-factory-live.sh http://localhost:8545 + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Colours ─────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[0;33m'; NC='\033[0m' + +pass() { + echo -e "${GREEN} [PASS] $1${NC}" + if [[ $# -gt 1 ]]; then shift; echo -e " $*"; fi +} +fail() { + echo -e "${RED} [FAIL] $1${NC}" >&2 + if [[ $# -gt 1 ]]; then shift; echo -e " $*" >&2; fi + exit 1 +} +section() { echo -e "\n${CYAN}=== $1 ===${NC}"; } +info() { echo -e "${YELLOW} → $1${NC}"; } + +# ── Config ──────────────────────────────────────────────────────────────────── + +# Source devnet accounts if the env file exists +ENV_FILE="$REPO_ROOT/etc/docker/devnet-env" +[[ -f "$ENV_FILE" ]] && source "$ENV_FILE" + +RPC_URL="${1:-${L2_CLIENT_RPC_URL:-http://localhost:8545}}" + +# Pick the first account pair that actually has ETH on this node. +# The devnet genesis may fund different accounts than the standard Anvil set. +ALICE_ADDR="" +ALICE_KEY="" +BOB_ADDR="" + +declare -a CANDIDATE_PAIRS=( + "${ANVIL_ACCOUNT_7_ADDR:-}:${ANVIL_ACCOUNT_7_KEY:-}" + "${ANVIL_ACCOUNT_2_ADDR:-}:${ANVIL_ACCOUNT_2_KEY:-}" + "${ANVIL_ACCOUNT_4_ADDR:-}:${ANVIL_ACCOUNT_4_KEY:-}" + "${ANVIL_ACCOUNT_0_ADDR:-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266}:${ANVIL_ACCOUNT_0_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" +) + +for pair in "${CANDIDATE_PAIRS[@]}"; do + addr="${pair%%:*}"; key="${pair##*:}" + [[ -z "$addr" || -z "$key" ]] && continue + bal=$(cast balance --rpc-url "$RPC_URL" "$addr" 2>/dev/null || echo "0") + # Compare as string: non-zero and not empty means funded + if [[ -n "$bal" && "$bal" != "0" ]]; then + ALICE_ADDR="$addr"; ALICE_KEY="$key"; break + fi +done +[[ -n "$ALICE_ADDR" ]] || { echo "No funded account found — check devnet genesis"; exit 1; } + +# Bob: pick a different funded account for the transfer recipient +declare -a BOB_CANDIDATES=( + "${ANVIL_ACCOUNT_8_ADDR:-}:${ANVIL_ACCOUNT_8_KEY:-}" + "${ANVIL_ACCOUNT_3_ADDR:-}:${ANVIL_ACCOUNT_3_KEY:-}" + "${ANVIL_ACCOUNT_1_ADDR:-0x70997970C51812dc3A010C7d01b50e0d17dc79C8}:${ANVIL_ACCOUNT_1_KEY:-}" +) +for pair in "${BOB_CANDIDATES[@]}"; do + addr="${pair%%:*}" + [[ -z "$addr" || "$addr" == "$ALICE_ADDR" ]] && continue + BOB_ADDR="$addr"; break +done +[[ -n "$BOB_ADDR" ]] || BOB_ADDR="0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + +# Factory precompile (singleton, fixed at chain genesis) +FACTORY="0xb20f000000000000000000000000000000000000" + +# Token creation parameters +TOKEN_NAME="Base USD" +TOKEN_SYMBOL="BUSD" +TOKEN_DECIMALS=18 +INITIAL_SUPPLY=1000000000000000000 # 1 BUSD (18 decimals → 1.000000) +SUPPLY_CAP=1000000000000000000000000 # 1 000 000 BUSD +# Unique salt per run so repeated executions always create a fresh token. +SALT="0x$(cast keccak "check-factory-live-$$-$(date +%s)" | sed 's/0x//')" + +# Transfer amount: 0.3 BUSD +TRANSFER_AMOUNT=300000000000000000 + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +# Trim whitespace, quotes, and cast's pretty-print suffix (e.g. "1000000 [1e6]" → "1000000") +trim() { echo "$1" | tr -d '"' | sed 's/ \[.*\]$//' | xargs; } + +# cast call wrapper — always read-only, does not consume gas +ccall() { + local addr="$1"; local sig="$2"; shift 2 + cast call --rpc-url "$RPC_URL" "$addr" "$sig" "$@" 2>&1 +} + + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$label" "expected=$expected actual=$actual" + else + fail "$label" "expected=$expected actual=$actual" + fi +} + +# ── 0. Pre-flight ───────────────────────────────────────────────────────────── + +section "0/5 Pre-flight checks" + +command -v cast >/dev/null 2>&1 || fail "cast not found — install foundry: https://getfoundry.sh" + +CHAIN_ID=$(cast chain-id --rpc-url "$RPC_URL" 2>&1) || \ + fail "Node not reachable at $RPC_URL — start the devnet first (just devnet up)" +info "Connected to chain $CHAIN_ID at $RPC_URL" +pass "node is reachable" + +ALICE_BAL=$(cast balance --rpc-url "$RPC_URL" "$ALICE_ADDR" 2>&1) +[[ -n "$ALICE_BAL" && "$ALICE_BAL" != "0" ]] || \ + fail "Alice ($ALICE_ADDR) has no ETH — check genesis allocation" +pass "Alice is funded ($ALICE_ADDR)" "balance=$(cast from-wei "$ALICE_BAL") ETH" + +# ── 1. Address prediction ───────────────────────────────────────────────────── + +section "1/5 Predict token address (read-only)" + +PREDICTED=$(ccall "$FACTORY" \ + "getB20Address(uint8,address,bytes32)(address)" \ + 1 "$ALICE_ADDR" "$SALT") || fail "getB20Address call failed" "$PREDICTED" +PREDICTED=$(trim "$PREDICTED") +[[ "$PREDICTED" =~ ^0x[0-9a-fA-F]{40}$ ]] || \ + fail "getB20Address returned bad address" "$PREDICTED" +info "Predicted token address: $PREDICTED" +pass "getB20Address returned a valid address" + +# Verify the prefix encodes the B-20 marker and variant=DEFAULT. +PREFIX=$(echo "${PREDICTED:2:22}" | tr '[:upper:]' '[:lower:]') +EXPECTED_PREFIX="b200000000000000000001" +[[ "$PREFIX" == "$EXPECTED_PREFIX" ]] || \ + fail "Token address does not encode DEFAULT variant" "expected prefix: 0x$EXPECTED_PREFIX got prefix: 0x$PREFIX" +pass "Address prefix encodes B-20 marker and DEFAULT variant" + +# isB20 is a prefix check and returns true before bytecode is installed. +IS_B20_BEFORE=$(ccall "$FACTORY" "isB20(address)(bool)" "$PREDICTED") +IS_B20_BEFORE=$(trim "$IS_B20_BEFORE") +assert_eq "isB20 is true before creation" "true" "$IS_B20_BEFORE" + +# ── 2. Create token ─────────────────────────────────────────────────────────── + +section "2/5 Create token (real transaction)" + +# Build B20CreateParams, then configure optional state through initCalls. +CREATE_PARAMS=$(cast abi-encode \ + "params(uint8,string,string,address)" \ + 1 "$TOKEN_NAME" "$TOKEN_SYMBOL" "$ALICE_ADDR") + +MINT_CALL=$(cast calldata "mint(address,uint256)" "$ALICE_ADDR" "$INITIAL_SUPPLY") +SUPPLY_CAP_CALL=$(cast calldata "updateSupplyCap(uint256)" "$SUPPLY_CAP") +CONTRACT_URI_CALL=$(cast calldata "updateContractURI(string)" "ipfs://check-factory-live") +INIT_CALLS="[$MINT_CALL,$SUPPLY_CAP_CALL,$CONTRACT_URI_CALL]" + +info "Sending createB20 transaction …" +TX_OUTPUT=$(cast send \ + --rpc-url "$RPC_URL" \ + --private-key "$ALICE_KEY" \ + --json \ + --confirmations 2 \ + "$FACTORY" \ + "createB20(uint8,bytes32,bytes,bytes[])" \ + 1 "$SALT" "$CREATE_PARAMS" "$INIT_CALLS") || fail "createB20 transaction failed" "$TX_OUTPUT" + +TX_HASH=$(echo "$TX_OUTPUT" | grep -o '"transactionHash":"[^"]*"' | cut -d'"' -f4) +TX_STATUS=$(echo "$TX_OUTPUT" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) +[[ "$TX_STATUS" == "0x1" ]] || fail "createB20 reverted (status=$TX_STATUS)" "tx=$TX_HASH" +info "Transaction: $TX_HASH (status=$TX_STATUS)" +pass "createB20 transaction mined and succeeded" + +# The token address must match the prediction +TOKEN="$PREDICTED" +info "Token deployed at: $TOKEN" + +# ── 3. Verify factory state ─────────────────────────────────────────────────── + +section "3/5 Verify factory state (read-only calls)" + +# isB20 must now be true +IS_B20=$(ccall "$FACTORY" "isB20(address)(bool)" "$TOKEN") +IS_B20=$(trim "$IS_B20") +assert_eq "isB20 is true after creation" "true" "$IS_B20" + +pass "Factory state is correct" + +# ── 4. Verify token metadata ────────────────────────────────────────────────── + +section "4/5 Verify token metadata (calls to token address)" + +NAME=$(trim "$(ccall "$TOKEN" "name()(string)")") +assert_eq "name()" "$TOKEN_NAME" "$NAME" + +SYMBOL=$(trim "$(ccall "$TOKEN" "symbol()(string)")") +assert_eq "symbol()" "$TOKEN_SYMBOL" "$SYMBOL" + +DECIMALS=$(trim "$(ccall "$TOKEN" "decimals()(uint8)")") +assert_eq "decimals()" "$TOKEN_DECIMALS" "$DECIMALS" + +TOTAL_SUPPLY=$(trim "$(ccall "$TOKEN" "totalSupply()(uint256)")") +assert_eq "totalSupply()" "$INITIAL_SUPPLY" "$TOTAL_SUPPLY" + +ALICE_TOKEN_BAL=$(trim "$(ccall "$TOKEN" "balanceOf(address)(uint256)" "$ALICE_ADDR")") +assert_eq "balanceOf(alice) = initialSupply" "$INITIAL_SUPPLY" "$ALICE_TOKEN_BAL" + +BOB_TOKEN_BAL=$(trim "$(ccall "$TOKEN" "balanceOf(address)(uint256)" "$BOB_ADDR")") +assert_eq "balanceOf(bob) = 0 before transfer" "0" "$BOB_TOKEN_BAL" + +pass "All metadata fields match creation parameters" + +# ── 5. Transfer tokens ──────────────────────────────────────────────────────── + +section "5/5 Transfer tokens (real transaction)" + +info "Sending transfer($BOB_ADDR, $TRANSFER_AMOUNT) from Alice …" +XFER_OUTPUT=$(cast send \ + --rpc-url "$RPC_URL" \ + --private-key "$ALICE_KEY" \ + --json \ + --confirmations 2 \ + "$TOKEN" \ + "transfer(address,uint256)" \ + "$BOB_ADDR" "$TRANSFER_AMOUNT") || fail "transfer transaction failed" "$XFER_OUTPUT" + +XFER_HASH=$(echo "$XFER_OUTPUT" | grep -o '"transactionHash":"[^"]*"' | cut -d'"' -f4) +XFER_STATUS=$(echo "$XFER_OUTPUT" | grep -o '"status":"[^"]*"' | cut -d'"' -f4) +[[ "$XFER_STATUS" == "0x1" ]] || fail "transfer reverted (status=$XFER_STATUS)" "tx=$XFER_HASH" +info "Transaction: $XFER_HASH (status=$XFER_STATUS)" +pass "transfer transaction mined and succeeded" + +# Verify balances changed correctly +EXPECTED_ALICE=$((INITIAL_SUPPLY - TRANSFER_AMOUNT)) +ALICE_BAL_AFTER=$(trim "$(ccall "$TOKEN" "balanceOf(address)(uint256)" "$ALICE_ADDR")") +assert_eq "Alice balance after transfer" "$EXPECTED_ALICE" "$ALICE_BAL_AFTER" + +BOB_BAL_AFTER=$(trim "$(ccall "$TOKEN" "balanceOf(address)(uint256)" "$BOB_ADDR")") +assert_eq "Bob balance after transfer" "$TRANSFER_AMOUNT" "$BOB_BAL_AFTER" + +# Total supply must be unchanged by a transfer +TOTAL_AFTER=$(trim "$(ccall "$TOKEN" "totalSupply()(uint256)")") +assert_eq "totalSupply unchanged after transfer" "$INITIAL_SUPPLY" "$TOTAL_AFTER" + +pass "Balances updated correctly; total supply preserved" + +# ── Summary ─────────────────────────────────────────────────────────────────── + +echo "" +echo -e "${GREEN}All live checks passed.${NC}" +echo "" +echo "Token: $TOKEN (chain $CHAIN_ID, RPC $RPC_URL)" +echo "" +echo "Verified:" +echo " • getB20Address → deterministic address with B-20 marker and variant" +echo " • isB20 = true before and after creation" +echo " • name='$TOKEN_NAME' symbol='$TOKEN_SYMBOL' decimals=$TOKEN_DECIMALS" +echo " • totalSupply=$INITIAL_SUPPLY balanceOf(alice)=$ALICE_TOKEN_BAL" +echo " • transfer($TRANSFER_AMOUNT to bob) → alice=$EXPECTED_ALICE bob=$TRANSFER_AMOUNT" +echo " • totalSupply unchanged after transfer" diff --git a/etc/scripts/devnet/prometheus.yml b/etc/scripts/devnet/prometheus.yml index bc1d5ed984..5cfce387ab 100644 --- a/etc/scripts/devnet/prometheus.yml +++ b/etc/scripts/devnet/prometheus.yml @@ -23,6 +23,10 @@ scrape_configs: static_configs: - targets: ['base-client-cl:8300'] + - job_name: 'l2_base_rpc' + static_configs: + - targets: ['base-rpc:8190'] + - job_name: 'batcher' static_configs: - targets: ['base-batcher:6060'] diff --git a/etc/scripts/devnet/setup-l2.sh b/etc/scripts/devnet/setup-l2.sh index 72d9a1cb16..57612d058c 100644 --- a/etc/scripts/devnet/setup-l2.sh +++ b/etc/scripts/devnet/setup-l2.sh @@ -8,6 +8,8 @@ L1_CHAIN_ID="${L1_CHAIN_ID:-1337}" L2_DATA_DIR="${L2_DATA_DIR:-/data}" TEMPLATE_DIR="${TEMPLATE_DIR:-/templates}" L2_BASE_AZUL_BLOCK="${L2_BASE_AZUL_BLOCK:-}" +L2_BASE_BERYL_BLOCK="${L2_BASE_BERYL_BLOCK:-}" +L2_ACTIVATION_ADMIN_ADDR="${L2_ACTIVATION_ADMIN_ADDR:-$SEQUENCER_ADDR}" L2_EL_BOOTNODE_P2P_KEY="${L2_EL_BOOTNODE_P2P_KEY:-1111111111111111111111111111111111111111111111111111111111111111}" L2_EL_BOOTNODE_ENODE_ID="${L2_EL_BOOTNODE_ENODE_ID:-4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa385b6b1b8ead809ca67454d9683fcf2ba03456d6fe2c4abe2b07f0fbdbb2f1c1}" L2_EL_BOOTNODE_ENODE="${L2_EL_BOOTNODE_ENODE:-enode://4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa385b6b1b8ead809ca67454d9683fcf2ba03456d6fe2c4abe2b07f0fbdbb2f1c1@172.30.0.10:9303}" @@ -18,16 +20,26 @@ if [ -n "$L2_BASE_AZUL_BLOCK" ] && ! [[ "$L2_BASE_AZUL_BLOCK" =~ ^[0-9]+$ ]]; th echo "ERROR: L2_BASE_AZUL_BLOCK must be a non-negative integer when set, got: $L2_BASE_AZUL_BLOCK" exit 1 fi +if [ -n "$L2_BASE_BERYL_BLOCK" ] && ! [[ "$L2_BASE_BERYL_BLOCK" =~ ^[0-9]+$ ]]; then + echo "ERROR: L2_BASE_BERYL_BLOCK must be a non-negative integer when set, got: $L2_BASE_BERYL_BLOCK" + exit 1 +fi echo "=== L2 Genesis Generator (Live Deployment) ===" echo "L1 RPC URL: $L1_RPC_URL" echo "L1 Chain ID: $L1_CHAIN_ID" echo "L2 Chain ID: $L2_CHAIN_ID" +echo "Activation admin address: $L2_ACTIVATION_ADMIN_ADDR" if [ -n "$L2_BASE_AZUL_BLOCK" ]; then echo "Base Azul activation block: $L2_BASE_AZUL_BLOCK" else echo "Base Azul activation block: " fi +if [ -n "$L2_BASE_BERYL_BLOCK" ]; then + echo "Base Beryl activation block: $L2_BASE_BERYL_BLOCK" +else + echo "Base Beryl activation block: " +fi echo "Output directory: $OUTPUT_DIR" # Wait for L1 RPC to be available @@ -134,6 +146,15 @@ op-deployer inspect rollup \ >"$OUTPUT_DIR/rollup.json" echo "Rollup config written to $OUTPUT_DIR/rollup.json" +TMP_GENESIS=$(mktemp) +jq \ + --arg activation_admin "$L2_ACTIVATION_ADMIN_ADDR" \ + '.config.activationAdminAddress = $activation_admin' \ + "$OUTPUT_DIR/genesis.json" \ + >"$TMP_GENESIS" +mv "$TMP_GENESIS" "$OUTPUT_DIR/genesis.json" +echo "Patched activation admin into genesis config" + L2_BLOCK_TIME=$(jq -re '.block_time' "$OUTPUT_DIR/rollup.json") L2_GENESIS_TIME=$(jq -re '.genesis.l2_time' "$OUTPUT_DIR/rollup.json") if [ -n "$L2_BASE_AZUL_BLOCK" ]; then @@ -172,6 +193,41 @@ else echo "Base Azul activation block is unset; leaving base.azul and osakaTime unchanged" fi +if [ -n "$L2_BASE_BERYL_BLOCK" ]; then + L2_BASE_BERYL_TIME=$((L2_GENESIS_TIME + L2_BLOCK_TIME * L2_BASE_BERYL_BLOCK)) + + echo "" + echo "=== Configuring Base Beryl Activation ===" + echo "L2 genesis time: $L2_GENESIS_TIME" + echo "L2 block time: $L2_BLOCK_TIME" + echo "Base Beryl activation block: $L2_BASE_BERYL_BLOCK" + echo "Derived Base Beryl activation timestamp: $L2_BASE_BERYL_TIME" + + TMP_ROLLUP=$(mktemp) + jq \ + --argjson beryl_time "$L2_BASE_BERYL_TIME" \ + '.base = ((.base // {}) + {beryl: $beryl_time})' \ + "$OUTPUT_DIR/rollup.json" \ + >"$TMP_ROLLUP" + mv "$TMP_ROLLUP" "$OUTPUT_DIR/rollup.json" + + TMP_GENESIS=$(mktemp) + jq \ + --argjson beryl_time "$L2_BASE_BERYL_TIME" \ + '.config.base = ((.config.base // {}) + {beryl: $beryl_time})' \ + "$OUTPUT_DIR/genesis.json" \ + >"$TMP_GENESIS" + mv "$TMP_GENESIS" "$OUTPUT_DIR/genesis.json" + + echo "Patched Base Beryl activation into rollup and genesis configs" +else + echo "" + echo "=== Configuring Base Beryl Activation ===" + echo "L2 genesis time: $L2_GENESIS_TIME" + echo "L2 block time: $L2_BLOCK_TIME" + echo "Base Beryl activation block is unset; leaving base.beryl unchanged" +fi + echo "Writing rollup-conductor.json (base fields stripped for op-conductor compatibility)..." jq 'del(.base)' "$OUTPUT_DIR/rollup.json" >"$OUTPUT_DIR/rollup-conductor.json" echo "rollup-conductor.json written to $OUTPUT_DIR/rollup-conductor.json" diff --git a/etc/scripts/local/check-elf-manifest.py b/etc/scripts/local/check-elf-manifest.py index 0deb5f4b7e..6890b16441 100755 --- a/etc/scripts/local/check-elf-manifest.py +++ b/etc/scripts/local/check-elf-manifest.py @@ -3,8 +3,9 @@ Usage:: - check_manifest.py # verify, print status - check_manifest.py --write # rewrite sha256 fields + check_manifest.py # verify, print status + check_manifest.py --write # rewrite sha256 fields + check_manifest.py --print-hashes # print expected/actual hashes Verify mode exit code is always 0; the resulting status is printed to stdout as one of ``match``, ``missing:``, or ``mismatch:`` so the just recipe @@ -84,19 +85,47 @@ def write(manifest_path: Path, cache_dir: Path) -> None: manifest_path.write_text(text) +def print_hashes(manifest_path: Path, cache_dir: Path) -> None: + """Print the manifest hash and current cache hash for each ELF.""" + entries = parse_manifest(manifest_path.read_text()) + if not entries: + print("empty-manifest") + return + for name, expected in entries: + target = cache_dir / name + actual = sha256_of(target) if target.exists() else "" + print(f'{name}: expected="{expected}" actual="{actual}"') + + def main(argv: list[str]) -> None: args = argv[1:] write_mode = False - if args and args[0] == "--write": - write_mode = True + print_hashes_mode = False + while args and args[0].startswith("--"): + flag = args[0] + if flag == "--write": + write_mode = True + elif flag == "--print-hashes": + print_hashes_mode = True + else: + print(f"unknown option: {flag}", file=sys.stderr) + sys.exit(2) args = args[1:] if len(args) != 2: - print("usage: check_manifest.py [--write] ", file=sys.stderr) + print( + "usage: check_manifest.py [--write] [--print-hashes] ", + file=sys.stderr, + ) sys.exit(2) manifest_path = Path(args[0]) cache_dir = Path(args[1]) if write_mode: write(manifest_path, cache_dir) + if print_hashes_mode: + print_hashes(manifest_path, cache_dir) + return + if print_hashes_mode: + print_hashes(manifest_path, cache_dir) else: print(verify(manifest_path, cache_dir)) diff --git a/etc/scripts/zk-prover/grpc.sh b/etc/scripts/zk-prover/grpc.sh new file mode 100755 index 0000000000..10c90718c0 --- /dev/null +++ b/etc/scripts/zk-prover/grpc.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + grpc.sh list [target] + grpc.sh describe [target] + grpc.sh prove [number_of_blocks] [target] [proof_type] + grpc.sh get [target] [receipt_type] + grpc.sh list-proofs [target] [limit] [offset] [status_filter] + +Targets: + devnet -> ZK_PROVER_DEVNET_ENDPOINT, defaults to localhost:9000 with -plaintext + zeronet -> ZK_PROVER_ZERONET_ENDPOINT + sepolia -> ZK_PROVER_SEPOLIA_ENDPOINT + mainnet -> ZK_PROVER_MAINNET_ENDPOINT + +Any other target value is treated as a literal grpc endpoint. +EOF +} + +require_grpcurl() { + command -v grpcurl >/dev/null 2>&1 || { + echo "grpcurl is required. Install it with: brew install grpcurl" >&2 + exit 1 + } +} + +resolve_endpoint() { + case "$1" in + devnet) echo "${ZK_PROVER_DEVNET_ENDPOINT:-localhost:9000}" ;; + zeronet) : "${ZK_PROVER_ZERONET_ENDPOINT:?set ZK_PROVER_ZERONET_ENDPOINT}" && echo "$ZK_PROVER_ZERONET_ENDPOINT" ;; + sepolia) : "${ZK_PROVER_SEPOLIA_ENDPOINT:?set ZK_PROVER_SEPOLIA_ENDPOINT}" && echo "$ZK_PROVER_SEPOLIA_ENDPOINT" ;; + mainnet) : "${ZK_PROVER_MAINNET_ENDPOINT:?set ZK_PROVER_MAINNET_ENDPOINT}" && echo "$ZK_PROVER_MAINNET_ENDPOINT" ;; + *) echo "$1" ;; + esac +} + +resolve_flags() { + local target="$1" + local endpoint="$2" + + case "$target" in + devnet) + if [ -n "${ZK_PROVER_DEVNET_GRPCURL_FLAGS+x}" ]; then + echo "${ZK_PROVER_DEVNET_GRPCURL_FLAGS}" + return + fi + + case "$endpoint" in + localhost:* | 127.*) echo "--plaintext" ;; + *) echo "${ZK_PROVER_GRPCURL_FLAGS:-}" ;; + esac + ;; + zeronet) echo "${ZK_PROVER_ZERONET_GRPCURL_FLAGS:-}" ;; + sepolia) echo "${ZK_PROVER_SEPOLIA_GRPCURL_FLAGS:-}" ;; + mainnet) echo "${ZK_PROVER_MAINNET_GRPCURL_FLAGS:-}" ;; + *) + case "$endpoint" in + localhost:* | 127.*) echo "${ZK_PROVER_GRPCURL_FLAGS:--plaintext}" ;; + *) echo "${ZK_PROVER_GRPCURL_FLAGS:-}" ;; + esac + ;; + esac +} + +run_grpcurl() { + local target="$1" + shift + + local endpoint flags + endpoint="$(resolve_endpoint "$target")" + flags="$(resolve_flags "$target" "$endpoint")" + + # Intentionally allow grpcurl flags to split so callers can pass multiple flags + # through ZK_PROVER_*_GRPCURL_FLAGS. + grpcurl ${flags} "$endpoint" "$@" +} + +run_grpcurl_with_data() { + local target="$1" + local payload="$2" + shift 2 + + local endpoint flags + endpoint="$(resolve_endpoint "$target")" + flags="$(resolve_flags "$target" "$endpoint")" + + # Intentionally allow grpcurl flags to split so callers can pass multiple flags + # through ZK_PROVER_*_GRPCURL_FLAGS. + grpcurl ${flags} -d "$payload" "$endpoint" "$@" +} + +json_payload() { + python3 - "$@" <<'PY' +import json +import sys + +payload = {} +for arg in sys.argv[1:]: + key, raw_value = arg.split("=", 1) + if raw_value.isdigit(): + payload[key] = int(raw_value) + elif raw_value: + payload[key] = raw_value + +print(json.dumps(payload, separators=(",", ":"))) +PY +} + +main() { + require_grpcurl + + local command="${1:-}" + if [ -z "$command" ]; then + usage >&2 + exit 1 + fi + shift + + case "$command" in + list) + local target="${1:-devnet}" + run_grpcurl "$target" list + ;; + describe) + local target="${1:-devnet}" + run_grpcurl "$target" describe prover.ProverService + ;; + prove) + local start_block="${1:?start_block is required}" + local number_of_blocks="${2:-1}" + local target="${3:-devnet}" + local proof_type="${4:-PROOF_TYPE_COMPRESSED}" + local payload + payload="$(json_payload \ + "startBlockNumber=$start_block" \ + "numberOfBlocksToProve=$number_of_blocks" \ + "proofType=$proof_type")" + run_grpcurl_with_data "$target" "$payload" prover.ProverService/ProveBlock + ;; + get) + local session_id="${1:?session_id is required}" + local target="${2:-devnet}" + local receipt_type="${3:-}" + local payload_args=("sessionId=$session_id") + if [ -n "$receipt_type" ]; then + payload_args+=("receiptType=$receipt_type") + fi + local payload + payload="$(json_payload "${payload_args[@]}")" + run_grpcurl_with_data "$target" "$payload" prover.ProverService/GetProof + ;; + list-proofs) + local target="${1:-devnet}" + local limit="${2:-20}" + local offset="${3:-0}" + local status_filter="${4:-}" + local payload_args=("limit=$limit" "offset=$offset") + if [ -n "$status_filter" ]; then + payload_args+=("statusFilter=$status_filter") + fi + local payload + payload="$(json_payload "${payload_args[@]}")" + run_grpcurl_with_data "$target" "$payload" prover.ProverService/ListProofs + ;; + -h | --help | help) + usage + ;; + *) + echo "unknown command: $command" >&2 + usage >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/lychee.toml b/lychee.toml index 30a2feb868..87a416484d 100644 --- a/lychee.toml +++ b/lychee.toml @@ -8,7 +8,6 @@ cache_exclude_status = [429] accept = [200, 403, 429, 502, 503] # 403 is often returned by private repos instead of 404; 429 is rate limiting; 502/503 is transient GitHub unavailability exclude_path = [ "crates/utilities/test-utils/contracts/", - "docs/specs/", "README\\.md", ] exclude = [