diff --git a/.agents/skills/depot-ci/SKILL.md b/.agents/skills/depot-ci/SKILL.md new file mode 100644 index 0000000000..be3ece0f50 --- /dev/null +++ b/.agents/skills/depot-ci/SKILL.md @@ -0,0 +1,13 @@ +--- +name: depot-ci +description: "Modify Base Depot CI workflows, especially PR cost controls, path filters, runner sizing, or optional integration checks." +--- + +# Depot CI + +Use the workflows in `.depot/workflows/` for Depot CI changes. Keep normal PR feedback focused on checks relevant to the changed subsystem, while preserving exhaustive validation in `ci-merge-queue.yml`. + +- Treat path filters as a risk boundary: include shared workspace, lockfile, toolchain, setup-action, and workflow inputs when they can affect a suite. Prefer running an unnecessary job to omitting relevant coverage. +- Do not make a required check disappear at workflow dispatch time without confirming branch-protection behavior. Prefer a stable workflow with a skipped job, and preserve manual dispatch or a reviewer-label escape hatch for optional work. +- `ci:system`, `ci:base-std`, and `ci:perf` opt a PR into the corresponding expensive suite; `ci:full` opts into all of them. Merge-queue runs are intentionally unconditional. +- Measure a proposal using runner-size-weighted duration and report the affected suites. Depot billing and wall-clock latency are separate metrics. diff --git a/.agents/skills/update-docs-index/SKILL.md b/.agents/skills/update-docs-index/SKILL.md new file mode 100644 index 0000000000..8c09dfcec5 --- /dev/null +++ b/.agents/skills/update-docs-index/SKILL.md @@ -0,0 +1,36 @@ +--- +name: update-docs-index +description: "Update llms.txt and llms-full.txt, the repo's index of every Markdown doc. Use after adding, moving, deleting, or editing any .md file, or when `just check::docs-index` fails." +--- + +# Update the docs index + +`llms.txt` lists every Markdown doc with a one-line summary. `llms-full.txt` adds a repository map and conventions above the same index. To find a doc, read `llms.txt` instead of grepping the tree. + +Do not edit the index regions of either file by hand. They are generated from `etc/docs-index.toml`, which holds one `summary` and one `digest` per doc. Run everything from the repository root. + +## Steps + +1. Run `python3 etc/scripts/local/docs-index.py check` to list what is out of date. +2. Fix each problem: + - **New doc:** run `python3 etc/scripts/local/docs-index.py sync`. It adds the doc to the manifest with a `TODO: summarize` summary. Read the doc and replace that summary in `etc/docs-index.toml`. + - **Edited doc:** re-read the doc and its summary. Edit the summary if it is no longer accurate. Then run `python3 etc/scripts/local/docs-index.py stamp ` to record the doc's new digest. Never stamp a doc you have not re-read. + - **Deleted or moved doc:** run `sync`. It drops the dead entry and adds the new path with a placeholder to fill in. + - **Edited the manifest by hand:** run `python3 etc/scripts/local/docs-index.py generate`. +3. Run `check` again until it prints `ok`. `sync`, `stamp`, and `generate` rewrite `llms.txt` and `llms-full.txt`, so commit those with the manifest. + +## Writing a summary + +- One sentence, at most 200 characters, with no newlines. +- Say what the doc covers and when someone would need it, not a restatement of the title. Name the commands, types, protocol, or decision. +- Claim only what the doc says. If a README is a stub, say so. + +## Optional: second opinion + +`python3 etc/scripts/local/docs-index.py validate [paths...]` asks a small model on the LLM gateway (needs `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY`) whether each summary is supported by its doc. It is advisory and not part of CI. Its answers vary between runs, so re-read the doc before changing a summary because of a flag. + +## Hand-written context in llms-full.txt + +Text between `` and `` in `llms-full.txt` is preserved verbatim on every regeneration. Edit it there when the repository layout, common commands, or conventions change. Everything between the `LLMS_AUTOGEN` markers is overwritten. + +CI runs `check` (the `metadata-checks` job in `.depot/workflows/ci-core.yml`, and `just check::docs-index` locally). diff --git a/.agents/skills/verify-base/SKILL.md b/.agents/skills/verify-base/SKILL.md new file mode 100644 index 0000000000..c29f4e69f8 --- /dev/null +++ b/.agents/skills/verify-base/SKILL.md @@ -0,0 +1,14 @@ +--- +name: verify-base +description: "Verifies Base local-devnet transaction inclusion using existing devnet commands and Foundry cast. Use when checking Base changes or demonstrating sequencer transaction inclusion." +--- + +# Verify Base + +Verify real behavior using Base's existing tools. + +1. Read the repository's `AGENTS.md` and the [feature map](references/features/README.md). +2. Exercise the relevant feature from the repository root. Investigate failures rather than reporting them as a pass; run relevant tests when checking a code change. +3. Report the tested revision and local changes, what was verified, and the supporting transaction/receipt/block evidence. + +Keep the feature map up to date when commands or behavior change. diff --git a/.agents/skills/verify-base/references/features/README.md b/.agents/skills/verify-base/references/features/README.md new file mode 100644 index 0000000000..1c0b0bea9f --- /dev/null +++ b/.agents/skills/verify-base/references/features/README.md @@ -0,0 +1,9 @@ +# Feature Map + +This is the index an agent reads before using Base's existing tools. Each linked page explains a behavior, how to exercise it, and what evidence establishes success. + +| Feature | Expected behavior | Tools | +| --- | --- | --- | +| [Local devnet transaction inclusion](local-devnet-transaction-inclusion.md) | A funded ETH transfer sent directly to the local sequencer succeeds and appears in its reported block. | `just devnet up-single`, `cast send`, `cast block` | + +Only this feature is in scope. The map is maintained with the commands and behavior it describes, not generated from package counts. diff --git a/.agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md b/.agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md new file mode 100644 index 0000000000..5e4e2ba6e2 --- /dev/null +++ b/.agents/skills/verify-base/references/features/local-devnet-transaction-inclusion.md @@ -0,0 +1,27 @@ +# Local Devnet Transaction Inclusion + +Start the local devnet, send an ETH transfer directly to the sequencer, and confirm it was included successfully. + +## Before starting + +Use Docker, `just`, and Foundry's `cast`. Startup and shutdown delete `.devnet` data; use a disposable devnet and check for conflicting containers/ports first. Never stop unrelated services. The devnet publishes ports on all interfaces, so use an isolated development machine. + +Use only the public test accounts in `etc/docker/devnet-env` against the local devnet. That file and `etc/docker/Justfile` are the source of truth for accounts, ports, and commands. + +## Verify + +From the repository root: + +```bash +just devnet up-single +source etc/docker/devnet-env +cast send "$ANVIL_ACCOUNT_2_ADDR" --value 0.001ether \ + --private-key "$ANVIL_ACCOUNT_1_KEY" \ + --rpc-url http://127.0.0.1:7545 --chain-id 84538453 --json +``` + +`cast send` waits for the receipt. Require status `0x1`, the expected sender/recipient, and a block number/hash. Use `cast block --rpc-url http://127.0.0.1:7545 --json` to confirm the block hash matches the receipt and its transactions include the submitted hash. A transaction hash alone is not a pass; this verifies sequencer inclusion, not L1 finality or validator synchronization. + +If startup or submission fails, inspect `just devnet ps` and `just devnet logs base-builder` before retrying. Do not blindly resend after a timeout: the transaction may already have been broadcast. + +Report the transaction hash, receipt status, and block number/hash. Shut down only the devnet you started with `just devnet down`, unless asked to leave it running. diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000000..707a09753f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[env] +# sp1-recursion-gnark-ffi runs `go build` and pins github.com/consensys/gnark-ignition-verifier, +# which was deleted from GitHub and is no longer served by proxy.golang.org. Fall back to a +# mirror that still has it. Go still verifies every module against go.sum and sum.golang.org, +# and an explicitly set GOPROXY takes precedence. Remove once SP1 drops the dependency. +GOPROXY = "https://proxy.golang.org,https://goproxy.cn,direct" diff --git a/.depot/actions/setup/action.yml b/.depot/actions/setup/action.yml index a53d2d9849..defe94e6e0 100644 --- a/.depot/actions/setup/action.yml +++ b/.depot/actions/setup/action.yml @@ -19,7 +19,7 @@ inputs: default: "false" sp1-version: description: SP1 release tag used when sp1 is enabled (matches etc/just/succinct.just) - default: "v6.4.0" + default: "v6.8.1" native-deps: description: Install native build dependencies (clang, llvm, etc.) default: "false" @@ -98,8 +98,8 @@ runs: shell: bash env: GITHUB_TOKEN: ${{ github.token }} - # Commit for succinctlabs/sp1 v6.4.0; update alongside SP1_VERSION. - SP1UP_REF: f66b4bff51d0ccff51d152e0f7f66b2ffedf3529 + # Commit for succinctlabs/sp1 v6.8.1; update alongside SP1_VERSION. + SP1UP_REF: c84ada1ed5911f28c4d3c9d0ed2f9e6cd7edb824 SP1_VERSION: ${{ inputs.sp1-version }} run: | if [ -x "$HOME/.sp1/bin/cargo-prove" ]; then diff --git a/.depot/workflows/acceptance.yml b/.depot/workflows/acceptance.yml new file mode 100644 index 0000000000..e1c55a47c3 --- /dev/null +++ b/.depot/workflows/acceptance.yml @@ -0,0 +1,238 @@ +name: Acceptance (advisory) + +on: + pull_request: + workflow_dispatch: + inputs: + scenario: + description: Acceptance scenario + type: choice + options: + - smoke + - denim-transition + - derivation + default: smoke + +concurrency: + group: acceptance-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + TESTED_SHA: ${{ github.sha }} + RUN_KEY: ${{ github.run_id }}-${{ github.run_attempt }} + +jobs: + select: + name: Select acceptance scenarios + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: depot-ubuntu-24.04-16 + timeout-minutes: 20 + outputs: + matrix: ${{ steps.select.outputs.matrix }} + started_at: ${{ steps.identity.outputs.started_at }} + steps: + - id: identity + name: Capture trusted workflow start time + shell: bash + run: echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - name: Test acceptance runner and publisher + run: | + cargo test --locked -p base-acceptance -p base-acceptance-cli + - id: select + shell: bash + env: + REQUESTED_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || 'smoke' }} + run: | + set -euo pipefail + case "$REQUESTED_SCENARIO" in + smoke|denim-transition|derivation) ;; + *) echo 'unsupported scenario' >&2; exit 2 ;; + esac + scenarios=("acceptance/scenarios/${REQUESTED_SCENARIO}.toml") + cargo run --locked -p base-acceptance-cli -- validate "${scenarios[@]}" + mkdir -p expected + cargo run --locked -p base-acceptance-cli -- manifest "${scenarios[@]}" \ + --run-id "$RUN_KEY" --tested-sha "$TESTED_SHA" --output expected/manifest.json + jq -e '.schema_version == 1 and .run_id == env.RUN_KEY and .tested_sha == env.TESTED_SHA and (.scenarios | length == 1)' expected/manifest.json + jq -cn --arg id "$REQUESTED_SCENARIO" --arg path "${scenarios[0]}" \ + '{include:[{id:$id,path:$path}]}' | sed 's/^/matrix=/' >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-expected-${{ github.run_attempt }} + path: expected/manifest.json + if-no-files-found: error + + scenario: + name: Acceptance / ${{ matrix.id }} + needs: select + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select.outputs.matrix) }} + runs-on: depot-ubuntu-24.04-16 + timeout-minutes: 60 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1 + - name: Build and load tested images + env: + PROFILE: dev + BASE_SUCCINCT_ELF_REQUIRE: "0" + run: | + set -euo pipefail + depot bake --project "${{ vars.DEPOT_PROJECT_ID }}" -f etc/docker/docker-bake.hcl \ + base op-batcher --set '*.platform=linux/amd64' --load + depot build --project "${{ vars.DEPOT_PROJECT_ID }}" --load \ + --tag devnet-setup:local-v2 -f etc/docker/Dockerfile.devnet . + - name: Run scenario + shell: bash + env: + SCENARIO_ID: ${{ matrix.id }} + SCENARIO_PATH: ${{ matrix.path }} + run: | + set -o pipefail + cargo run --locked -p base-acceptance-cli -- run "$SCENARIO_PATH" \ + --output "target/acceptance/$RUN_KEY/$SCENARIO_ID" --no-build \ + --run-id "$RUN_KEY" --tested-sha "$TESTED_SHA" + - name: Preserve startup failure evidence + if: always() + env: + SCENARIO_ID: ${{ matrix.id }} + run: | + report="target/acceptance/$RUN_KEY/$SCENARIO_ID/report" + mkdir -p "$report" + if [ ! -f "$report/result.json" ]; then + echo 'No scenario result; inspect the job log for the infrastructure failure.' > "$report/runner-status.txt" + fi + - name: Upload sanitized scenario report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-result-${{ matrix.id }}-${{ github.run_attempt }} + path: target/acceptance/${{ env.RUN_KEY }}/${{ matrix.id }}/report + if-no-files-found: warn + + aggregate: + name: Acceptance / Aggregate (advisory) + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + needs: [select, scenario] + runs-on: depot-ubuntu-24.04-16 + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + pattern: acceptance-* + path: downloaded + - name: Aggregate (missing and invalid results remain failures) + run: | + set -o pipefail + mkdir -p downloaded + cargo run --locked -p base-acceptance-cli -- aggregate \ + --expected "downloaded/acceptance-expected-${GITHUB_RUN_ATTEMPT}/manifest.json" \ + --results downloaded --output aggregate-report + - if: always() + run: test ! -f aggregate-report/summary.md || cat aggregate-report/summary.md >> "$GITHUB_STEP_SUMMARY" + - name: Preserve aggregation failure evidence + if: always() + run: | + mkdir -p aggregate-report + if [ ! -f aggregate-report/result.json ]; then + echo 'No aggregate result; inspect the job log for the infrastructure failure.' > aggregate-report/runner-status.txt + fi + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: acceptance-aggregate-${{ github.run_attempt }} + path: aggregate-report + if-no-files-found: warn + + publish: + name: Publish trusted acceptance summary + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: [select, aggregate] + runs-on: depot-ubuntu-24.04 + timeout-minutes: 20 + concurrency: + group: acceptance-publisher-${{ github.event.pull_request.number }} + cancel-in-progress: false + permissions: + actions: read + contents: read + pull-requests: write + steps: + - name: Checkout trusted base revision + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + persist-credentials: false + - name: Set up trusted Rust toolchain + if: hashFiles('acceptance/src/publish.rs') != '' + uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + - name: Build trusted publisher + if: hashFiles('acceptance/src/publish.rs') != '' + run: cargo build --locked -p base-acceptance-cli + - name: Download acceptance data + if: hashFiles('acceptance/src/publish.rs') != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true + with: + pattern: acceptance-* + path: downloaded + - name: Publish validated acceptance result + if: hashFiles('acceptance/src/publish.rs') != '' + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + TESTED_SHA: ${{ github.sha }} + BOT_LOGIN: ${{ vars.BOT_LOGIN }} + STARTED_AT: ${{ needs.select.outputs.started_at }} + run: | + target/debug/base-acceptance publish --event "$GITHUB_EVENT_PATH" \ + --result "downloaded/acceptance-aggregate-${GITHUB_RUN_ATTEMPT}/result.json" \ + --expected "downloaded/acceptance-expected-${GITHUB_RUN_ATTEMPT}/manifest.json" \ + --run-id "$RUN_KEY" --attempt "$GITHUB_RUN_ATTEMPT" --started-at "$STARTED_AT" + - name: First-introduction notice + if: hashFiles('acceptance/src/publish.rs') == '' + run: echo 'Trusted publisher is not on the base revision yet; reports are artifact-only.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.depot/workflows/base-anvil-package.yml b/.depot/workflows/base-anvil-package.yml index 9f12334cf5..0b75b9cbeb 100644 --- a/.depot/workflows/base-anvil-package.yml +++ b/.depot/workflows/base-anvil-package.yml @@ -20,12 +20,17 @@ on: description: "base/base-std ref to smoke test against" required: false default: main + base_upgrade: + description: "base upgrade passed to anvil --base" + required: false + default: denim concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false env: BASE_ANVIL_REF: ${{ github.event.inputs.base_anvil_ref || '98e7839c65f64aee9627b69a9b98b79afaeb1fae' }} BASE_STD_REF: ${{ github.event.inputs.base_std_ref || 'main' }} + BASE_UPGRADE: ${{ github.event.inputs.base_upgrade || 'denim' }} CARGO_TERM_COLOR: always REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/base-anvil RUSTC_WRAPPER: "sccache" @@ -105,25 +110,15 @@ jobs: - name: Resolve base-std ref id: base-std shell: bash - env: - GH_TOKEN: ${{ secrets.BASE_STD_TOKEN }} run: | set -euo pipefail - if base_std_sha="$(gh api repos/base/base-std/git/ref/heads/"$BASE_STD_REF" --jq '.object.sha' 2>/dev/null)" \ - && [[ -n "$base_std_sha" ]]; then - : - elif base_std_sha="$(gh api repos/base/base-std/git/ref/tags/"$BASE_STD_REF" --jq '.object.sha' 2>/dev/null)" \ - && [[ -n "$base_std_sha" ]]; then - : - else - base_std_sha="$(gh api "repos/base/base-std/git/ref/$BASE_STD_REF" --jq '.object.sha')" - fi - - if [[ -z "$base_std_sha" ]]; then - echo "::error::Unable to resolve base-std ref $BASE_STD_REF" - exit 1 - fi + resolve_dir="$RUNNER_TEMP/base-std-resolve" + rm -rf "$resolve_dir" + git init "$resolve_dir" + git -C "$resolve_dir" remote add origin https://github.com/base/base-std.git + git -C "$resolve_dir" fetch --depth 1 origin "$BASE_STD_REF" + base_std_sha="$(git -C "$resolve_dir" rev-parse FETCH_HEAD)" echo "sha=$base_std_sha" >> "$GITHUB_OUTPUT" - name: Build patched base-anvil binaries @@ -148,7 +143,6 @@ jobs: if: matrix.settings.smoke_test shell: bash env: - GH_TOKEN: ${{ secrets.BASE_STD_TOKEN }} BASE_STD_SHA: ${{ steps.base-std.outputs.sha }} run: | set -euo pipefail @@ -157,8 +151,8 @@ jobs: base_std_dir="$workdir/base-std" mkdir -p "$workdir" - gh auth setup-git - gh repo clone base/base-std "$base_std_dir" -- --filter=blob:none --no-checkout + git clone --filter=blob:none --no-checkout \ + https://github.com/base/base-std.git "$base_std_dir" git -C "$base_std_dir" fetch --depth 1 origin "$BASE_STD_SHA" git -C "$base_std_dir" checkout --detach FETCH_HEAD git -C "$base_std_dir" submodule update --init --recursive --depth 1 @@ -187,6 +181,7 @@ jobs: ANVIL_BIN="$BASE_ANVIL_DIR/target/release/anvil" \ FORGE_BIN="$BASE_ANVIL_DIR/target/release/forge" \ ANVIL_LOG="$RUNNER_TEMP/base-std-anvil.log" \ + BASE_UPGRADE="$BASE_UPGRADE" \ make fork-tests - name: Upload anvil log if: always() && matrix.settings.smoke_test @@ -232,12 +227,14 @@ jobs: --arg std_repo "base/base-std" \ --arg std_ref "$BASE_STD_REF" \ --arg std_sha "$BASE_STD_SHA" \ + --arg base_upgrade "$BASE_UPGRADE" \ --argjson smoke_tested "$SMOKE_TESTED" \ '{ package: $package, platform: $platform, base: {repo: $base_repo, ref: $base_ref, sha: $base_sha}, base_anvil: {repo: $anvil_repo, ref: $anvil_ref, sha: $anvil_sha}, + base_upgrade: $base_upgrade, base_std: { repo: $std_repo, ref: $std_ref, @@ -329,7 +326,7 @@ jobs: primary_tag="sha-$GITHUB_SHA" tags=("$REGISTRY_IMAGE:$primary_tag") - if [[ "$GITHUB_REF_NAME" == "main" && "$BASE_ANVIL_REF" == "98e7839c65f64aee9627b69a9b98b79afaeb1fae" && "$BASE_STD_REF" == "main" ]]; then + if [[ "$GITHUB_REF_NAME" == "main" && "$BASE_ANVIL_REF" == "98e7839c65f64aee9627b69a9b98b79afaeb1fae" && "$BASE_STD_REF" == "main" && "$BASE_UPGRADE" == "denim" ]]; then tags+=("$REGISTRY_IMAGE:main") tags+=("$REGISTRY_IMAGE:main-$commit_date-$short_sha") else diff --git a/.depot/workflows/base-std-fork-tests.yml b/.depot/workflows/base-std-fork-tests.yml index a73f819ae8..e40ec4d675 100644 --- a/.depot/workflows/base-std-fork-tests.yml +++ b/.depot/workflows/base-std-fork-tests.yml @@ -7,12 +7,14 @@ name: Base Std Interface Tests on: + # ci:base-std and ci:full must start the matrix immediately when applied. pull_request: + types: [opened, reopened, synchronize, labeled] merge_group: workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event.action != 'labeled' }} env: CARGO_TERM_COLOR: always RUSTC_WRAPPER: "sccache" @@ -20,8 +22,48 @@ permissions: contents: read pull-requests: write jobs: + changes: + name: Detect Base Std Interface Changes + # Merge-queue and manual runs always execute the matrix, so path detection + # is only useful for a PR. + if: github.event_name == 'pull_request' + runs-on: depot-ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + base-std: ${{ steps.filter.outputs.base-std }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + # These are the two base/base crates patched into base-anvil below. + # Workspace/toolchain inputs can alter that build, so retain them. + base-std: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'crates/common/chains/**' + - 'crates/common/precompiles/**' + - '.depot/actions/setup/**' + - '.depot/workflows/base-std-fork-tests.yml' fork-tests: name: Base Std Interface Tests (${{ matrix.fork }}) + needs: changes + if: >- + always() && (github.event_name != 'pull_request' || + needs.changes.outputs.base-std == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:base-std') || + contains(github.event.pull_request.labels.*.name, 'ci:full')) runs-on: depot-ubuntu-24.04-8 timeout-minutes: 120 strategy: @@ -37,7 +79,11 @@ jobs: - fork: Cobalt key: cobalt upgrade: cobalt - base_std_ref: 4571b325c1b9c4d6e21c59d2b7f9b4b60f62ec7c + base_std_ref: v1.1.0 + - fork: Denim + key: denim + upgrade: denim + base_std_ref: 7321d891c9039040b5dad7248618de6fe5973661 env: FORK_NAME: ${{ matrix.fork }} FORK_KEY: ${{ matrix.key }} @@ -45,6 +91,9 @@ jobs: BASE_STD_REF: ${{ matrix.base_std_ref }} # Consumed by base-std's fork harness to launch `anvil --base $BASE_UPGRADE`. BASE_UPGRADE: ${{ matrix.upgrade }} + # Runs fork tests with forge's `base` upgrade set to the selected fork. + # Figment maps FOUNDRY_BASE onto the `base` key. + FOUNDRY_BASE: ${{ matrix.upgrade }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -61,10 +110,6 @@ jobs: just: "false" - name: Checkout pinned fork-test pair shell: bash - env: - # BASE_STD_TOKEN must be a PAT with Contents:Read on base/base-std. - # GITHUB_TOKEN is repo-scoped and cannot access other repos, even internal ones. - GH_TOKEN: ${{ secrets.BASE_STD_TOKEN }} run: | set -euo pipefail @@ -74,8 +119,8 @@ jobs: # FETCH_HEAD supports commit SHAs and tags (including annotated tags), # keeping refs independently configurable for each repository. - gh repo clone base/base-std "$workdir/base-std" -- \ - --filter=blob:none --no-checkout + git clone --filter=blob:none --no-checkout \ + https://github.com/base/base-std.git "$workdir/base-std" git -C "$workdir/base-std" fetch --depth 1 origin "$BASE_STD_REF" git -C "$workdir/base-std" checkout --detach FETCH_HEAD git -C "$workdir/base-std" submodule update --init --recursive --depth 1 @@ -105,26 +150,15 @@ jobs: cd "$BASE_ANVIL_DIR" rustup show active-toolchain - - # anvil's lockfile pins these crates at 0.0.0 from a stale git rev. A - # release-version-sync PR bumps workspace.package.version, and cargo - # then ignores the path patch unless the lock is updated to the new - # version. Without this, Cobalt fork tests run pre-gating precompiles. - patch_args=( + base_patch=( --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\"" ) - cargo "${patch_args[@]}" update \ - -p base-common-precompiles \ - -p base-common-chains - - build_log="$RUNNER_TEMP/anvil-patch-build.log" - cargo "${patch_args[@]}" build --release --no-default-features \ - --features anvil/cli -p anvil -p forge 2>&1 | tee "$build_log" - if grep -E 'Patch `base-common-(precompiles|chains).+was not used' "$build_log"; then - echo "::error::base-common path patch was not applied; fork tests would use stale git precompiles" - exit 1 - fi + # The pinned lockfile uses 0.0.0; release crates have a new version and + # Cargo otherwise keeps the old git packages and ignores these patches. + cargo "${base_patch[@]}" update -p base-common-precompiles -p base-common-chains + cargo "${base_patch[@]}" \ + 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 @@ -202,8 +236,15 @@ jobs: fi aggregate: name: Base Std Interface Tests - if: always() - needs: fork-tests + # Keep the stable aggregate check for selected runs, but skip it with the + # matrix when this PR did not touch either patched base-anvil dependency. + if: >- + always() && ( + github.event_name != 'pull_request' || + needs.changes.outputs.base-std == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:base-std') || + contains(github.event.pull_request.labels.*.name, 'ci:full')) + needs: [changes, fork-tests] runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. steps: - name: Harden the runner (Audit all outbound calls) diff --git a/.depot/workflows/bench-fresh-devnet.yml b/.depot/workflows/bench-fresh-devnet.yml new file mode 100644 index 0000000000..a2a394a1fb --- /dev/null +++ b/.depot/workflows/bench-fresh-devnet.yml @@ -0,0 +1,181 @@ +name: Fresh devnet benchmarks (advisory) + +on: + pull_request: + # `labeled` starts the opt-in suite and `unlabeled` cancels an in-progress + # suite when its opt-in label is removed. + types: [opened, reopened, synchronize, labeled, unlabeled] + workflow_dispatch: + +concurrency: + # Only `bench:tps` label changes belong to the PR's benchmark concurrency group. Other label + # changes are skipped and receive a per-run group, so they cannot interrupt an active benchmark. + group: >- + ${{ github.workflow }}-${{ + github.event_name == 'pull_request' && + ( + ( + github.event.action != 'labeled' && + github.event.action != 'unlabeled' + ) || + github.event.label.name == 'bench:tps' + ) && + format('pr-{0}', github.event.pull_request.number) || + github.run_id + }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + BENCHMARK_OUTPUT: ${{ runner.temp }}/fresh-devnet-benchmark + BENCHMARK_VISUALIZER_REF: "582d987fd86a8ac5575d44bc53229297071a2db8" + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + name: Fresh devnet workload suite + # The suite is deliberately opt-in: it is expensive and starts services. Keep its write-capable + # token away from untrusted fork code; contributors can run it from a trusted base/base branch + # after applying the `bench:tps` label. + if: > + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'bench:tps') && + ((github.event.action != 'labeled' && github.event.action != 'unlabeled') || + github.event.label.name == 'bench:tps')) + # Depot's largest x86 runner: 64 vCPUs, 256 GB memory, and a 32 GB RAM disk accelerator. + runs-on: depot-ubuntu-24.04-64 + timeout-minutes: 90 + env: + BENCHMARK_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout benchmark target + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 1 + + - name: Setup Rust toolchain, cache, and dependencies + uses: ./.depot/actions/setup + with: + native-deps: "true" + foundry: "true" + + - name: Build system-test contracts + run: just build::contracts + + - name: Pre-pull devnet images + run: just devnet pull-images + + - name: Build base-bench + run: cargo build --locked --release -p base-system-tests --bin base-bench + + - name: Run fresh-devnet workload suite + continue-on-error: true + shell: bash + run: | + set -euo pipefail + target/release/base-bench local \ + --workload-config etc/benchmarks/fresh-devnet.yml \ + --output-dir "$BENCHMARK_OUTPUT" \ + --client-version "base/${BENCHMARK_SHA}" + + # This must run after a failed suite too: base-bench persists any completed workload's + # sidecar and the suite manifest before returning a nonzero exit code. + - name: Render benchmark results + if: always() + id: render + shell: bash + env: + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + python3 etc/scripts/ci/render_fresh_devnet_bench.py \ + --results-dir "$BENCHMARK_OUTPUT" \ + --output "$RUNNER_TEMP/fresh-devnet-bench-comment.md" \ + --run-url "$WORKFLOW_RUN_URL" \ + --commit "$BENCHMARK_SHA" + cat "$RUNNER_TEMP/fresh-devnet-bench-comment.md" >> "$GITHUB_STEP_SUMMARY" + if [ -f "$BENCHMARK_OUTPUT/metadata.json" ]; then + echo "has-results=true" >> "$GITHUB_OUTPUT" + else + echo "has-results=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: base/benchmark + ref: ${{ env.BENCHMARK_VISUALIZER_REF }} + path: benchmark-visualizer + fetch-depth: 1 + + - name: Setup Node.js for benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + + - name: Build benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + continue-on-error: true + shell: bash + run: | + set -euo pipefail + rm -rf benchmark-visualizer/output + ln -s "$BENCHMARK_OUTPUT" benchmark-visualizer/output + corepack enable + yarn --cwd benchmark-visualizer install --immutable + yarn --cwd benchmark-visualizer build + + - name: Upload raw benchmark results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fresh-devnet-benchmark-results + path: ${{ env.BENCHMARK_OUTPUT }} + if-no-files-found: warn + retention-days: 14 + + - name: Upload benchmark visualizer + if: always() && steps.render.outputs.has-results == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fresh-devnet-benchmark-visualizer + path: benchmark-visualizer/dist + if-no-files-found: warn + retention-days: 14 + + - name: Create or update benchmark results comment + if: > + always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + COMMENT_PATH: ${{ runner.temp }}/fresh-devnet-bench-comment.md + run: | + set -euo pipefail + marker='' + existing_id=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | startswith(\"${marker}\")) | .id" \ + | head -1) + + if [ -n "$existing_id" ]; then + gh api "repos/${REPO}/issues/comments/${existing_id}" \ + -X PATCH -F body=@"$COMMENT_PATH" > /dev/null + else + gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + -F body=@"$COMMENT_PATH" > /dev/null + fi diff --git a/.depot/workflows/ci-core.yml b/.depot/workflows/ci-core.yml index 7f27efed47..438b898c5d 100644 --- a/.depot/workflows/ci-core.yml +++ b/.depot/workflows/ci-core.yml @@ -35,13 +35,15 @@ permissions: contents: read jobs: changes: - name: Detect Docker Changes + name: Detect Expensive CI Changes runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. permissions: contents: read pull-requests: read outputs: docker: ${{ steps.filter.outputs.docker }} + proof-mpt-bench: ${{ steps.filter.outputs.proof-mpt-bench }} + system-tests: ${{ steps.system-tests.outputs.system-tests }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -49,7 +51,9 @@ jobs: egress-policy: audit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 1 + # PRs are checked out at their test merge commit. The affected-crate + # calculation below compares it to its first parent. + fetch-depth: 2 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: @@ -62,6 +66,55 @@ jobs: - '.env.mainnet' - '.env.sepolia' - '.dockerignore' + # This benchmark only exercises the MPT implementation. Keep it in + # merge-queue CI, but do not spend a runner on unrelated PRs. + proof-mpt-bench: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'crates/proof/mpt/**' + - 'crates/proof/primitives/**' + - 'crates/execution/trie/**' + - 'etc/scripts/ci/**' + - '.depot/workflows/ci-core.yml' + # Non-crate inputs which can affect the system-test environment. + # Crate changes are classified by the dependency-aware step below. + system-test-inputs: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'Justfile' + - 'etc/just/**' + - 'etc/docker/**' + - 'etc/systems/**' + - 'docker-compose.yml' + - '.env.mainnet' + - '.env.sepolia' + - '.depot/actions/setup/**' + - '.depot/workflows/ci-core.yml' + - name: Detect system-test dependency changes + id: system-tests + shell: bash + env: + SYSTEM_TEST_INPUTS: ${{ steps.filter.outputs.system-test-inputs }} + run: | + set -euo pipefail + + # Merge-queue CI is exhaustive. This job still runs there to select + # Docker work, but no dependency classification is necessary. + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo "system-tests=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$SYSTEM_TEST_INPUTS" = true ] || \ + python3 etc/scripts/local/affected-crates.py HEAD^1 | grep -Fxq base-system-tests; then + echo "system-tests=true" >> "$GITHUB_OUTPUT" + else + echo "system-tests=false" >> "$GITHUB_OUTPUT" + fi metadata-checks: name: Metadata Checks runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. @@ -82,6 +135,8 @@ jobs: run: cargo deny check bans --hide-inclusion-graph - name: Check crate dependencies run: ./etc/scripts/ci/check-crate-deps.sh + - name: Check docs index + run: python3 etc/scripts/local/docs-index.py check - name: Validate operator compose run: docker compose config -q format: @@ -180,6 +235,12 @@ jobs: report_paths: target/nextest/ci/test-results.xml bench: name: Benchmarks + needs: changes + if: >- + github.event_name != 'pull_request' || + needs.changes.outputs.proof-mpt-bench == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:perf') || + contains(github.event.pull_request.labels.*.name, 'ci:full') runs-on: depot-ubuntu-latest # was: ubuntu-latest. Mapped standard GitHub runner to Depot equivalent. steps: - name: Harden the runner (Audit all outbound calls) @@ -243,7 +304,13 @@ jobs: run: cargo test --locked -p base-cli-utils --test sigsegv_test --target x86_64-unknown-linux-musl system-tests: name: System Tests - if: inputs.run_system_tests + needs: changes + if: >- + inputs.run_system_tests && ( + github.event_name != 'pull_request' || + needs.changes.outputs.system-tests == 'true' || + contains(github.event.pull_request.labels.*.name, 'ci:system') || + contains(github.event.pull_request.labels.*.name, 'ci:full')) runs-on: depot-ubuntu-24.04-16 timeout-minutes: 90 steps: diff --git a/.depot/workflows/ci-main-cache.yml b/.depot/workflows/ci-main-cache.yml index db856c8309..9a3eaa1b87 100644 --- a/.depot/workflows/ci-main-cache.yml +++ b/.depot/workflows/ci-main-cache.yml @@ -9,6 +9,22 @@ name: CI Main Cache on: push: branches: [main] + # The persistent sccache is keyed by compiler inputs. Warming it after a + # documentation-only merge consumes two 8-vCPU runners without helping a + # later Rust build. + paths: + - 'Cargo.toml' + - 'Cargo.lock' + - '.cargo/**' + - 'rust-toolchain.toml' + - 'Justfile' + - 'crates/**' + - 'bin/**' + - 'etc/just/build.just' + - 'etc/scripts/local/affected-crates.py' + - 'crates/utilities/test-utils/contracts/**' + - '.depot/actions/setup/**' + - '.depot/workflows/ci-main-cache.yml' workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.depot/workflows/ci-pr.yml b/.depot/workflows/ci-pr.yml index 2e10c09403..c161dae6b3 100644 --- a/.depot/workflows/ci-pr.yml +++ b/.depot/workflows/ci-pr.yml @@ -1,9 +1,14 @@ name: CI on: + # A reviewer can add ci:system, ci:perf, or ci:full to request an + # integration/performance rerun without needing an otherwise-empty push. pull_request: + types: [opened, reopened, synchronize, labeled] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true + # A label is an explicit request for an optional suite. Do not discard useful + # build/test work already in flight merely to service that request. + cancel-in-progress: ${{ github.event.action != 'labeled' }} permissions: checks: write contents: read diff --git a/.depot/workflows/snapshot-manifests.yml b/.depot/workflows/snapshot-manifests.yml new file mode 100644 index 0000000000..9ce7187299 --- /dev/null +++ b/.depot/workflows/snapshot-manifests.yml @@ -0,0 +1,39 @@ +name: Snapshot Manifests +on: + pull_request: + merge_group: + schedule: + - cron: "30 7 * * *" + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" +permissions: + contents: read +jobs: + snapshot-manifests: + name: Snapshot Manifests + runs-on: depot-ubuntu-24.04-8 + timeout-minutes: 30 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + - uses: ./.depot/actions/setup + with: + native-deps: "true" + just: "false" + # Cargo builds this checkout's unified binary for CARGO_BIN_EXE_base. + - name: Test snapshot planning against local manifests + run: cargo test --locked --profile ci -p base --test snapshot_defaults + # Report failures normally; merge requirements are managed in branch protection. + - name: Check live discovery through the unified binary + if: github.event_name != 'merge_group' + run: cargo test --locked --profile ci -p base --test snapshot_defaults -- --ignored --nocapture diff --git a/.env.mainnet b/.env.mainnet index 0dec86c923..ffeb1762f2 100644 --- a/.env.mainnet +++ b/.env.mainnet @@ -30,5 +30,6 @@ BASE_NODE_L2_ENGINE_AUTH_RAW=688f5d737bad920bdfb2fc2f488d6b6209eebda1dae949a8de9 # PRUNING (OPTIONAL - UNCOMMENT TO ENABLE) # NOTE: Set to any number of blocks you want, but it should be >10064 # NOTE: The node type that was chosen when first running a node cannot be changed after the initial sync. Turning Archive into Pruned, or Pruned into Full is not supported [source](https://reth.rs/run/faq/pruning/). -# NOTE: The pruned snapshots provided are set with a distance of 1_339_200 (~31 days). +# NOTE: --full retains 13_392_000 blocks (~31 days at Denim's 200ms interval). +# NOTE: This does not restore history missing from a downloaded snapshot. # RETH_PRUNING_ARGS="--prune.senderrecovery.distance=50000 --prune.transactionlookup.distance=50000 --prune.receipts.distance=50000 --prune.accounthistory.distance=50000 --prune.storagehistory.distance=50000 --prune.bodies.distance=50000" diff --git a/.env.sepolia b/.env.sepolia index 79b5ad7df5..ca15883a8d 100644 --- a/.env.sepolia +++ b/.env.sepolia @@ -30,5 +30,6 @@ BASE_NODE_L2_ENGINE_AUTH_RAW=688f5d737bad920bdfb2fc2f488d6b6209eebda1dae949a8de9 # PRUNING (OPTIONAL - UNCOMMENT TO ENABLE) # NOTE: Set to any number of blocks you want, but it should be >10064 # NOTE: The node type that was chosen when first running a node cannot be changed after the initial sync. Turning Archive into Pruned, or Pruned into Full is not supported [source](https://reth.rs/run/faq/pruning/). -# NOTE: The pruned snapshots provided are set with a distance of 1_339_200 (~31 days). +# NOTE: --full retains 13_392_000 blocks (~31 days at Denim's 200ms interval). +# NOTE: This does not restore history missing from a downloaded snapshot. # RETH_PRUNING_ARGS="--prune.senderrecovery.distance=50000 --prune.transactionlookup.distance=50000 --prune.receipts.distance=50000 --prune.accounthistory.distance=50000 --prune.storagehistory.distance=50000 --prune.bodies.distance=50000" diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index a8cbd4c1cf..984df7d219 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -19,7 +19,7 @@ inputs: default: "false" sp1-version: description: SP1 release tag used when sp1 is enabled (matches etc/just/succinct.just) - default: "v6.4.0" + default: "v6.8.1" native-deps: description: Install native build dependencies (clang, llvm, etc.) default: "false" @@ -161,8 +161,8 @@ runs: shell: bash env: GITHUB_TOKEN: ${{ github.token }} - # Commit for succinctlabs/sp1 v6.4.0; update alongside SP1_VERSION. - SP1UP_REF: f66b4bff51d0ccff51d152e0f7f66b2ffedf3529 + # Commit for succinctlabs/sp1 v6.8.1; update alongside SP1_VERSION. + SP1UP_REF: c84ada1ed5911f28c4d3c9d0ed2f9e6cd7edb824 SP1_VERSION: ${{ inputs.sp1-version }} run: | if [ -x "$HOME/.sp1/bin/cargo-prove" ]; then diff --git a/.github/workflows/build-rc.yml b/.github/workflows/build-rc.yml new file mode 100644 index 0000000000..bf4d9a5e6b --- /dev/null +++ b/.github/workflows/build-rc.yml @@ -0,0 +1,39 @@ +name: Build RC +run-name: Build RC ${{ github.ref_name }} + +# Create RC dispatches this workflow at the immutable RC tag, not the branch. +# No concurrency group: newer RCs must not cancel or wait for older builds. +on: + workflow_dispatch: + +permissions: + actions: read + contents: write + packages: write + id-token: write + attestations: write + +jobs: + validate-tag: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Validate RC tag + run: | + if [[ "$GITHUB_REF_TYPE" != "tag" || ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "::error::Build RC must be dispatched at an RC tag (vX.Y.Z-rc.N), not a branch or final tag" + exit 1 + fi + + build: + needs: [validate-tag] + uses: ./.github/workflows/build-release.yml + with: + tag: ${{ github.ref_name }} + is_final: false + secrets: inherit diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 0a14945dd5..3fc1f42533 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -14,7 +14,7 @@ on: default: false env: - REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/node + REGISTRY_NAMESPACE: ghcr.io/${{ github.repository_owner }} CARGO_TERM_COLOR: always SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" @@ -190,6 +190,18 @@ jobs: image: - target: base name: node + - target: unified + name: base + - target: execution + name: base-reth-node + - target: consensus + name: base-consensus + - target: builder + name: base-builder + - target: basectl + name: basectl + - target: snapshotter + name: base-snapshotter runs-on: ${{ matrix.settings.runs-on }} @@ -255,12 +267,12 @@ jobs: - name: Upload digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: digests-${{ matrix.image.target }}-${{ env.PLATFORM_PAIR }} + name: digests-${{ matrix.image.name }}-${{ env.PLATFORM_PAIR }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 - # Check which Docker artifacts exist after partial matrix failures + # Publish successful Docker builds even when another matrix entry fails. find-docker-artifacts: needs: [build-and-push] if: ${{ !cancelled() }} @@ -282,18 +294,13 @@ jobs: GH_RUN_ID: ${{ github.run_id }} run: | set -euo pipefail - - artifact_names=$(gh api "repos/$GH_REPO/actions/runs/$GH_RUN_ID/artifacts" --paginate --jq '.artifacts[].name') - - has_digests=false - - while IFS= read -r name; do - if [[ "$name" == digests-* ]]; then - has_digests=true - fi - done <<< "$artifact_names" - - echo "has_digests=$has_digests" >> "$GITHUB_OUTPUT" + artifact_names=$(gh api "repos/$GH_REPO/actions/runs/$GH_RUN_ID/artifacts" \ + --paginate --jq '.artifacts[].name') + if grep -q '^digests-' <<< "$artifact_names"; then + echo "has_digests=true" >> "$GITHUB_OUTPUT" + else + echo "has_digests=false" >> "$GITHUB_OUTPUT" + fi # Merge Docker manifests and publish publish: @@ -317,9 +324,8 @@ jobs: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - path: ${{ runner.temp }}/digests-base - pattern: digests-base-* - merge-multiple: true + path: ${{ runner.temp }}/digests + pattern: digests-* - name: Login to GHCR uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -351,12 +357,19 @@ jobs: publish_image() { local image="$1" - local digest_dir="$2" - local digests=("$digest_dir"/*) + local package="$2" + local digests=("${{ runner.temp }}/digests/digests-${package}-linux-"*/*) if [[ ${#digests[@]} -eq 0 ]]; then - echo "No digests in $digest_dir — skipping $image" + echo "::warning::No successful architecture builds found for $image" return 1 fi + if [[ ${#digests[@]} -gt 2 ]]; then + echo "::error::Expected at most two architecture digests for $image, found ${#digests[@]}" + return 1 + fi + if [[ ${#digests[@]} -eq 1 ]]; then + echo "::warning::Publishing $image with only one successful architecture" + fi local tag_args=() IFS=',' read -ra tags <<< "$(image_tags "$image")" @@ -373,17 +386,26 @@ jobs: docker buildx imagetools inspect "${image}:${TAG}" } + packages=( + node + base + base-reth-node + base-consensus + base-builder + basectl + base-snapshotter + ) pushed=false - if publish_image "${{ env.REGISTRY_IMAGE }}" "${{ runner.temp }}/digests-base"; then - pushed=true - fi + published=() + for package in "${packages[@]}"; do + if publish_image "${{ env.REGISTRY_NAMESPACE }}/${package}" "$package"; then + pushed=true + published+=("$package") + fi + done - if [[ "$pushed" != "true" ]]; then - echo "::warning::No Docker digests found — all Docker builds may have failed, skipping manifest push" - echo "pushed=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "pushed=true" >> "$GITHUB_OUTPUT" + echo "pushed=$pushed" >> "$GITHUB_OUTPUT" + echo "published=$(IFS=,; echo "${published[*]}")" >> "$GITHUB_OUTPUT" - name: Create or reuse GitHub Release if: inputs.is_final && steps.create-manifest.outputs.pushed == 'true' @@ -412,6 +434,9 @@ jobs: echo "### Docker Images" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ env.REGISTRY_IMAGE }}:${TAG}" >> $GITHUB_STEP_SUMMARY + IFS=',' read -ra packages <<< "${{ steps.create-manifest.outputs.published }}" + for package in "${packages[@]}"; do + echo "docker pull ${{ env.REGISTRY_NAMESPACE }}/${package}:${TAG}" >> $GITHUB_STEP_SUMMARY + done echo "\`\`\`" >> $GITHUB_STEP_SUMMARY fi diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 51907b2172..0bb4fc7e69 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -40,6 +40,20 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1 + + - name: Record pre-review comment boundary + id: review-comments + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + shell: bash + run: | + set -euo pipefail + latest_comment_id="$(gh api --paginate --slurp "repos/$GH_REPO/issues/$PR_NUMBER/comments" | + jq '[.[][] | .id] | max // 0')" + echo "latest_comment_id=$latest_comment_id" >> "$GITHUB_OUTPUT" + - name: Run Claude Code id: claude-review uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1.0.29 @@ -69,13 +83,16 @@ jobs: - CI already enforces: clippy (52 rules, -D warnings), rustfmt (2024 edition), cargo-deny, cargo-udeps REVIEW GUIDELINES: - - Review like a senior Rust engineer on the team + - Review as a principal Rust auditor. Be thorough, but do not nit-pick for the sake of it - Focus on correctness, safety, and idiomatic Rust + - Verify that the logic matches the PR description and intended behavior + - Look for meaningful redundancy, unnecessary complexity, or missed reuse of existing abstractions - Review code using the PR context and the `gh pr diff` / `gh pr view` commands; do not rely on broad local repo reads - DO NOT comment on formatting or style — clippy and rustfmt handle that - - DO NOT post praise, "looks good", or filler comments. If everything looks fine, post nothing + - DO NOT post praise, "looks good", or filler comments. If there are no actionable findings, post nothing - Use inline comments for specific code feedback via mcp__github_inline_comment__create_inline_comment - - Post one summary comment at the end with your overall assessment (if you have findings) + - Keep each inline finding to one concise sentence where possible: state the issue and its concrete consequence. The Critical block-production finding requirements below take precedence over brevity + - Post one brief summary comment at the end with your overall assessment (if you have findings) DUPLICATE AVOIDANCE: Before posting any inline comment, first fetch all existing inline review comments on this PR by running: @@ -101,12 +118,47 @@ jobs: A Critical finding must explain the triggering input/state/config, affected code path, propagated error or panic, why block production or validator progression can halt/stall, and the missing mitigation or test. COMMENT MANAGEMENT: - Before posting your review summary, delete ONLY your own previous summary comments from earlier runs, identified by the leading marker ``. - Do NOT use `gh pr comment --delete-last`: it deletes the most recent github-actions[bot] top-level comment regardless of which workflow created it, so it wipes unrelated bot comments (e.g. the benchmark results comment) that also post as github-actions[bot]. - 1. List your prior summary comment ids: gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments --jq '.[] | select(.body | startswith("")) | .id' - 2. Delete each id individually: gh api -X DELETE repos/${{ github.repository }}/issues/comments/ - 3. Then post your new summary using: gh pr comment ${{ github.event.pull_request.number }} --body "\n\n" + If you have findings, post one summary using `gh pr comment ${{ github.event.pull_request.number }} --body ...`. + Start the body with the exact marker ``, followed by a blank line and your summary. + Do not edit or delete existing top-level comments. A dedicated workflow step removes older marked summaries after you finish. claude_args: | --model claude-opus-4-6-default - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git ls-files docs),Bash(git show HEAD:docs/:*),Bash(cat /home/runner/.claude/projects/:*),Bash(gh pr comment ${{ github.event.pull_request.number }} --body:*),Bash(gh pr comment ${{ github.event.pull_request.number }} --edit-last --body:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments:*),Bash(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments:*),Bash(gh api -X DELETE repos/${{ github.repository }}/issues/comments/:*)" + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git ls-files docs),Bash(git show HEAD:docs/:*),Bash(cat /home/runner/.claude/projects/:*),Bash(gh pr comment ${{ github.event.pull_request.number }} --body:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments:*),Bash(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments:*)" + + - name: Remove superseded review summaries + # A review can post its summary before a later action error. + if: ${{ !cancelled() && steps.review-comments.outcome == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PRE_REVIEW_COMMENT_ID: ${{ steps.review-comments.outputs.latest_comment_id }} + REVIEW_OUTCOME: ${{ steps.claude-review.outcome }} + shell: bash + run: | + set -euo pipefail + stale_comments="$(mktemp)" + trap 'rm -f "$stale_comments"' EXIT + + # Fetch every page before deleting anything. Keep the newest summary + # unless a successful review posted none, making all old summaries stale. + # Failed reviews without a replacement retain the latest prior summary. + # Never touch human comments or comments owned by other workflows. + gh api --paginate --slurp "repos/$GH_REPO/issues/$PR_NUMBER/comments" | + jq -r --argjson boundary "$PRE_REVIEW_COMMENT_ID" --arg outcome "$REVIEW_OUTCOME" '[.[][] + | select(.user.login == "github-actions[bot]" and .user.type == "Bot") + | select((.body // "") | startswith(""))] + | sort_by(.id) + | if $outcome == "success" and ((.[-1].id // 0) <= $boundary) + then .[] else .[:-1][] end + | .id' > "$stale_comments" + + cleanup_failed=0 + while IFS= read -r comment_id; do + if ! gh api --method DELETE "repos/$GH_REPO/issues/comments/$comment_id"; then + echo "::warning::Failed to delete stale review summary $comment_id" + cleanup_failed=1 + fi + done < "$stale_comments" + exit "$cleanup_failed" diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index e52a85030b..81cf5fa457 100644 --- a/.github/workflows/create-rc.yml +++ b/.github/workflows/create-rc.yml @@ -6,22 +6,18 @@ on: - 'releases/v*' permissions: - actions: read + actions: write contents: write - packages: write - id-token: write - attestations: write - -concurrency: - group: release-${{ github.ref_name }} - cancel-in-progress: false jobs: create-tag: runs-on: ubuntu-latest - outputs: - tag: ${{ steps.read-tag.outputs.tag }} - skip: ${{ steps.version-check.outputs.skip }} + # Serialize RC number allocation and dispatch, never the artifact builds. + # The default single pending slot cancels older queued RCs on rapid pushes. + concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + queue: max steps: - name: Harden the runner (Audit all outbound calls) @@ -32,7 +28,8 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.ref_name }} + # Tag the triggering commit even if the branch advances while queued. + ref: ${{ github.sha }} fetch-depth: 0 fetch-tags: true @@ -43,9 +40,9 @@ jobs: VERSION=$(get_cargo_version) if [[ "$VERSION" == "0.0.0" ]]; then echo "::notice::Cargo.toml version is 0.0.0 — version sync PR not yet merged. Skipping RC." - echo "skip=true" >> $GITHUB_OUTPUT + echo "skip=true" >> "$GITHUB_OUTPUT" else - echo "skip=false" >> $GITHUB_OUTPUT + echo "skip=false" >> "$GITHUB_OUTPUT" fi - name: Create and push tag @@ -62,29 +59,32 @@ jobs: exit 1 fi TAG=$(cat "$TAG_FILE") - echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Release tag: $TAG" + # GITHUB_TOKEN tag pushes do not trigger workflows; dispatch explicitly. + # This only waits for the dispatch request, not for the build to finish. + - name: Dispatch RC artifact build + if: steps.version-check.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.read-tag.outputs.tag }} + run: gh workflow run build-rc.yml --ref "$TAG" + - name: Summary run: | SKIP="${{ steps.version-check.outputs.skip }}" if [[ "$SKIP" == "true" ]]; then - echo "## RC Skipped" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Cargo.toml version is still 0.0.0. Merge the version sync PR first." >> $GITHUB_STEP_SUMMARY + echo "## RC Skipped" + echo "" + echo "Cargo.toml version is still 0.0.0. Merge the version sync PR first." else TAG="${{ steps.read-tag.outputs.tag }}" - echo "## Tag Created" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Tag**: \`$TAG\`" >> $GITHUB_STEP_SUMMARY - echo "**Type**: Release Candidate" >> $GITHUB_STEP_SUMMARY - fi - - build: - needs: [create-tag] - if: needs.create-tag.outputs.skip == 'false' - uses: ./.github/workflows/build-release.yml - with: - tag: ${{ needs.create-tag.outputs.tag }} - is_final: false - secrets: inherit + echo "## Tag Ready" + echo "" + echo "**Tag**: \`$TAG\`" + echo "**Type**: Release Candidate" + echo "" + echo "Artifact build dispatched to the independent **Build RC** workflow." + fi >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ghcr-cleanup.yml b/.github/workflows/ghcr-cleanup.yml index 5ec2b3fa00..0413e8559b 100644 --- a/.github/workflows/ghcr-cleanup.yml +++ b/.github/workflows/ghcr-cleanup.yml @@ -40,3 +40,35 @@ jobs: older-than: 60 days dry-run: ${{ inputs.dry_run }} validate: true + + # The cleanup action fails when any explicitly configured package does not + # exist, so wait until the first release has created the complete set. + - name: Find split release images + id: split-images + env: + GH_TOKEN: ${{ github.token }} + OWNER: ${{ github.repository_owner }} + run: | + set -euo pipefail + packages=(base base-reth-node base-consensus base-builder basectl base-snapshotter) + for package in "${packages[@]}"; do + if ! gh api "/orgs/${OWNER}/packages/container/${package}/versions?per_page=1" >/dev/null 2>&1; then + echo "::notice::Skipping split-image cleanup until ${package} exists and is readable" + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo "ready=true" >> "$GITHUB_OUTPUT" + + - name: Clean up split release images + if: steps.split-images.outputs.ready == 'true' + uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2 + with: + owner: ${{ github.repository_owner }} + packages: base,base-reth-node,base-consensus,base-builder,basectl,base-snapshotter + delete-tags: "*" + delete-untagged: true + exclude-tags: "v*" + older-than: 60 days + dry-run: ${{ inputs.dry_run }} + validate: true diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml index 574737b803..263a64012e 100644 --- a/.github/workflows/verify-release.yml +++ b/.github/workflows/verify-release.yml @@ -18,6 +18,11 @@ jobs: runs-on: ubuntu-24.04 steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@e14015d583714f6e62063499dc959a02595150a1 # v2.21.1 + with: + egress-policy: audit + - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/ACTIVATIONS.md b/ACTIVATIONS.md new file mode 100644 index 0000000000..7f1a404873 --- /dev/null +++ b/ACTIVATIONS.md @@ -0,0 +1,58 @@ +# Network activations and the Firehose block + +One row per Base upgrade defined in the code this fork ships, with its status on the networks we +serve and its effect on the Firehose block. Review new and changed entries on every upstream bump, +and refresh the statuses when an activation is announced. + +Denim and Everest are contract-backed: an L1 upgrade signal (`crates/utilities/upgrade-signal`) can +schedule them on a network with no new node release. Zenith can only be set in genesis. + +Statuses checked 2026-10-16 against Base `v1.5.0` (`crates/common/chains/src/config.rs`). + +| Upgrade | Base mainnet | Base Sepolia | Effect on the Firehose block | +|---------|--------------|--------------|------------------------------| +| Bedrock … Jovian | Active (Jovian 2025-12-02) | Active | Already in the stream. Canyon's `create2deployer` install is a direct DB write in `apply_pre_execution_changes`; it is emitted as a block-level code change when the block is traced with `v1.5.0-fh3.1` or later. | +| Azul | Active 2026-05-28 18:00 UTC | Active 2026-04-20 18:00 UTC | Already in the stream. | +| Beryl | Active 2026-06-25 18:00 UTC | Active 2026-06-18 18:00 UTC | Already in the stream. | +| Cobalt | Active 2026-09-30 18:00 UTC | Active 2026-09-23 18:00 UTC | B-20 V2 precompiles. Covered by `etc/systems/tests/firehose_cobalt_b20.rs`. | +| Denim | Not scheduled | 2026-10-21 18:00 UTC (`1_792_605_600`) | See [Denim](#denim). | +| Everest | Not scheduled | Not scheduled | See [Everest](#everest). **The node stops on the first EIP-8130 transaction.** | +| Zenith | Never (genesis only) | Never (genesis only) | Gates the EIP-8130 keystore (account configuration changes, non-k1 authenticators). Unreachable on real networks. | + +## Denim + +- **Activation block installs the BaseTime predeploy outside the EVM.** `BaseTime::ensure_predeploy` + (`crates/common/evm/src/base_time.rs`) runs in `apply_pre_execution_changes` and writes with + `db.commit`, which no tracer hook sees. `BasePreExecutionWrites` lists both locations, so the + block carries a block-level code change for the implementation account + `0xc0D3C0d3C0d3C0D3c0d3C0d3c0D3C0d3c0d30030` and a system call to the proxy + `0x4200000000000000000000000000000000000030` holding its EIP-1967 implementation slot write. + Covered by `etc/systems/tests/firehose_denim.rs`. +- **New deposit at tx index 1 in every block**: `DEPOSITOR_ACCOUNT` calls BaseTime + `setTimestampMillisPart(uint16)`. Traced like the L1 info deposit; the depositor nonce goes up by + 2 per block. No code change needed. +- **Up to 5 blocks per second share the header `timestamp`.** A block's timestamp is never lower + than its parent's but is often equal. The milliseconds are only in the tx[1] calldata and BaseTime + storage slot 0; the Firehose header keeps `nanos: 0`. +- First Denim block divides the gas limit by 10 and multiplies the EIP-1559 denominator by 10: + values only. +- B-20 precompiles switch to V3 (new policy rules). Same storage and event paths as V2. +- New post-execution checks (base time schedule) run before the Firehose block is printed on the + live and pipeline paths. + +## Everest + +- Gates EIP-8130 transactions (type `0x79` = 121) in the EVM, the transaction pool, RPC and + derivation. Before v1.5.0 this was Zenith, which no real network can activate. +- `reth-firehose` cannot map type 121: `firehose-tracer` 5.5.0 `TxType` and the block model have + no such type. The mapper panics on it with a message naming the transaction and its type + (`crates/firehose/src/mapper.rs` in `streamingfast/reth`). Halting is deliberate: dropping the + transaction would emit a block that differs from the chain, and failing block execution would + make the node report a valid block as invalid. +- Tracing 8130 properly needs a new transaction type in the block model and the tracer, the 8130 + fields (nonce key, payer, phases with value, account changes, phase statuses), and handling of + its execution shape: pre-call writes and `NonceIncremented` logs outside any call frame, one + depth-0 frame per phase, payer prepay and refund, and fee vault credits outside the EVM. +- The activation block writes a code stub to the nonce manager in `apply_pre_execution_changes` + (`ensure_eip8130_system_accounts`). `BasePreExecutionWrites` lists it, so it is emitted as a + block-level code change. diff --git a/CHANGELOG.sf.md b/CHANGELOG.sf.md index 393bbfe2fb..bbc35d0359 100644 --- a/CHANGELOG.sf.md +++ b/CHANGELOG.sf.md @@ -1,3 +1,29 @@ +## v1.5.0-fh3.1 + +### Changed + +* Rebased on upstream Base `v1.5.0` and `streamingfast/reth` `base-v2.5.2.8-fh3.1` (Base's reth + fork `base-v2.5.2.8` plus Firehose). Base Sepolia operators must run this release before the + Denim hardfork on 2026-10-21 18:00:00 UTC. Denim produces blocks every 200 ms, so up to 5 + consecutive Firehose blocks share the same header `timestamp`; every block gets a BaseTime + deposit at transaction index 1. + +### Fixed + +* Firehose now emits the state Base writes before a block's transactions without going through the + EVM, which the tracer could not see. The Denim activation block now carries the BaseTime + predeploy install: a block-level code change for `0xc0D3C0d3C0d3C0D3c0d3C0d3c0D3C0d3c0d30030`, + and a system call from `0xfffffffffffffffffffffffffffffffffffffffe` to + `0x4200000000000000000000000000000000000030` holding its implementation slot write. The Canyon + `create2deployer` install and the Everest nonce manager code are emitted the same way. + +### Known issues + +* EIP-8130 transactions (type `0x79`) are enabled by the Everest upgrade, which an L1 signal can + schedule without a new release. Firehose does not support them yet: the node stops on the first + one with an error naming the transaction and its type. No network has scheduled Everest. See + `ACTIVATIONS.md`. + ## v1.4.2-fh3.1-2 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 6b494eb5e7..c9d5120688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ ## Documentation and Lints +- Find docs through [llms.txt](llms.txt) (one-line summary per doc) rather than grepping the tree. After adding, moving, deleting, or editing any Markdown doc, follow `.agents/skills/update-docs-index/SKILL.md`; CI fails on a stale index. - Use `#![doc = include_str!("../README.md")]` for crate documentation in `lib.rs`; never use `//!` comments there. - Begin every `mod.rs` file with a `//!` module doc comment describing its contents. - Do not suppress Clippy warnings with `#![allow(missing_docs)]` or other allow-lints. Fix the underlying issue. @@ -77,8 +78,19 @@ let bal = wallet.account.balance(); - A trait method returns a non-constructible builder type like Alloy's `ProviderCall` or `EthGetBlock`. - The double needs one call log ordered across multiple trait methods. - Tests must mutate scripted responses while calls are in flight. -- Document the specific reason for a hand-rolled fake in its module doc comment. Examples live in `crates/consensus/service/src/test_utils/fake_engine_client.rs`, `fake_l1.rs`, and `fake_gossip.rs`. +- Document the specific reason for a hand-rolled fake in its module doc comment. Examples live in `crates/consensus/service/src/test_utils/fake_engine_client.rs` and `fake_l1.rs`. ## Firehose tracing tests The tracing-regression framework (capture, invariants, JSON projection, golden) is chain-agnostic and lives in the `firehose-tracer-test` crate of `evm-firehose-tracer-rs`; `base-firehose-tests` re-exports it and adds the Base bindings (`BaseFirehoseCapture::install`, `run_prestate`). Regenerate any golden with `GOLDEN_UPDATE=1`. The Docker-backed `base-system-tests` Firehose tests (`firehose_b20`, `firehose_cobalt_b20`) must run with `RUSTFLAGS="-C debug-assertions=off"` (reth's `deferred_trie::wait_cloned` debug-asserts from a Rayon worker) and `RUST_MIN_STACK=33554432` (an unoptimized node launch overflows libtest's 2 MiB thread stack). + +## Recurring Review Expectations + +- Keep changes scoped; split unrelated refactors and remove code and dependencies your change makes unused. +- Reuse existing types, APIs, and fixtures; add abstractions or configuration only for concrete supported use cases. +- Prefer domain types and named constants over magic strings and numbers; name values by their meaning and units. +- Keep modules and actor loops focused; put state-specific logic on the type that owns it. +- Document contracts and non-obvious reasons, not code narration or change history; keep docs consistent with behavior. +- Preserve historical fork behavior and parity across execution paths; test both old and new fork behavior. +- Avoid unnecessary I/O, cloning, and allocation on hot paths; benchmark production code when claiming performance gains. +- Make async lifetimes explicit: bound waits, distinguish retryable from terminal errors, and clean up tasks on failure and shutdown. diff --git a/Cargo.lock b/Cargo.lock index 617177a566..aea2d4008a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2046,19 +2046,15 @@ dependencies = [ [[package]] name = "audit-archiver" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "audit-archiver-lib", - "aws-config", - "aws-credential-types", - "aws-sdk-s3", "axum 0.8.9", "base-cli-utils", "clap", "dotenvy", "jsonrpsee", - "moka", "serde", "tokio", "tower 0.5.3", @@ -2067,25 +2063,17 @@ dependencies = [ [[package]] name = "audit-archiver-lib" -version = "1.4.2" +version = "1.5.0" dependencies = [ - "alloy-consensus 2.4.1", - "alloy-primitives", "anyhow", "async-trait", - "aws-config", - "aws-sdk-s3", "axum 0.8.9", - "base-bundles", "base-metrics", "base-observability-events", - "bytes", "chrono", - "futures", "jsonrpsee", "jsonrpsee-types", "metrics", - "moka", "serde", "serde_json", "sqlx", @@ -2093,9 +2081,9 @@ dependencies = [ "testcontainers-modules", "thiserror 2.0.19", "tokio", + "tower 0.5.3", "tower-http", "tracing", - "uuid", ] [[package]] @@ -2900,7 +2888,7 @@ dependencies = [ [[package]] name = "base" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "axum 0.8.9", @@ -2932,7 +2920,9 @@ dependencies = [ "reth-cli-runner", "reth-cli-util", "reth-node-core", + "rstest", "serde", + "serde_json", "tempfile", "tokio", "tokio-util", @@ -2940,9 +2930,34 @@ dependencies = [ "url", ] +[[package]] +name = "base-acceptance" +version = "1.5.0" +dependencies = [ + "chrono", + "clap", + "eyre", + "humantime", + "reqwest 0.13.4", + "serde", + "serde_json", + "tempfile", + "tokio", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "base-acceptance-cli" +version = "1.5.0" +dependencies = [ + "base-acceptance", + "clap", + "tokio", +] + [[package]] name = "base-action-harness" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -2961,7 +2976,6 @@ dependencies = [ "base-batcher-core", "base-batcher-encoder", "base-batcher-source", - "base-blobs", "base-builder-core", "base-common-chains", "base-common-consensus", @@ -2986,6 +3000,7 @@ dependencies = [ "base-test-utils", "base-tx-manager", "eyre", + "libp2p", "miniz_oxide 0.9.1", "reth-basic-payload-builder", "reth-db", @@ -2998,18 +3013,18 @@ dependencies = [ "reth-revm", "reth-transaction-pool", "reth-trie-common", + "rstest", "serde_json", "tempfile", "thiserror 2.0.19", "tokio", "tokio-util", "tracing", - "tracing-subscriber", ] [[package]] name = "base-balance-monitor" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-network 2.4.1", "alloy-node-bindings", @@ -3022,18 +3037,21 @@ dependencies = [ [[package]] name = "base-batcher-admin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-batcher-core", + "base-runtime", "derive_more 2.1.1", "eyre", "jsonrpsee", + "serde_json", + "tokio", "tracing", ] [[package]] name = "base-batcher-cli" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-signer-local 2.4.1", @@ -3054,13 +3072,12 @@ dependencies = [ [[package]] name = "base-batcher-core" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", "alloy-rpc-types-eth 2.4.1", "async-trait", - "auto_impl", "base-batcher-encoder", "base-batcher-source", "base-blobs", @@ -3068,6 +3085,7 @@ dependencies = [ "base-protocol", "base-runtime", "base-tx-manager", + "clap", "derive_more 2.1.1", "futures", "rstest", @@ -3079,19 +3097,22 @@ dependencies = [ [[package]] name = "base-batcher-encoder" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", "alloy-primitives", "alloy-rlp", + "base-blobs", "base-common-consensus", "base-common-genesis", "base-comp", + "base-consensus-derive", "base-metrics", "base-protocol", "clap", "derive_more 2.1.1", + "futures", "metrics", "rand 0.9.5", "rstest", @@ -3101,11 +3122,13 @@ dependencies = [ [[package]] name = "base-batcher-service" -version = "1.4.2" +version = "1.5.0" dependencies = [ + "alloy-node-bindings", "alloy-primitives", "alloy-provider", "alloy-rpc-types-eth 2.4.1", + "alloy-sol-types", "async-trait", "backon", "base-balance-monitor", @@ -3114,6 +3137,7 @@ dependencies = [ "base-batcher-encoder", "base-batcher-source", "base-common-consensus", + "base-common-genesis", "base-common-network", "base-consensus-rpc", "base-metrics", @@ -3127,6 +3151,9 @@ dependencies = [ "httpmock", "jsonrpsee", "metrics", + "mockall", + "rstest", + "serde_json", "tokio", "tokio-util", "tracing", @@ -3135,7 +3162,7 @@ dependencies = [ [[package]] name = "base-batcher-source" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -3153,7 +3180,7 @@ dependencies = [ [[package]] name = "base-blobs" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -3164,7 +3191,7 @@ dependencies = [ [[package]] name = "base-builder-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-builder-cli", "base-builder-core", @@ -3183,7 +3210,7 @@ dependencies = [ [[package]] name = "base-builder-cli" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "base-builder-core", @@ -3200,7 +3227,7 @@ dependencies = [ [[package]] name = "base-builder-core" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -3249,12 +3276,10 @@ dependencies = [ "base-txpool-rpc", "chrono", "clap", - "clap_builder", "criterion", "ctor", "dashmap", "derive_more 2.1.1", - "dirs-next", "either", "eyre", "futures", @@ -3301,7 +3326,6 @@ dependencies = [ "reth-rpc-engine-api", "reth-rpc-eth-types", "reth-rpc-layer", - "reth-storage-api", "reth-tasks", "reth-testing-utils", "reth-tracing-otlp", @@ -3321,7 +3345,6 @@ dependencies = [ "testcontainers", "thiserror 2.0.19", "tikv-jemallocator", - "time", "tokio", "tokio-tungstenite 0.28.0", "tokio-util", @@ -3334,7 +3357,7 @@ dependencies = [ [[package]] name = "base-builder-metering" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "base-builder-core", @@ -3347,7 +3370,7 @@ dependencies = [ [[package]] name = "base-builder-multiplex" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3390,7 +3413,7 @@ dependencies = [ [[package]] name = "base-builder-publish" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-metrics", "base-ring-buffer", @@ -3410,7 +3433,7 @@ dependencies = [ [[package]] name = "base-bundles" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -3426,7 +3449,7 @@ dependencies = [ [[package]] name = "base-challenger" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3471,7 +3494,7 @@ dependencies = [ [[package]] name = "base-challenger-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-challenger", "base-cli-utils", @@ -3482,21 +3505,26 @@ dependencies = [ [[package]] name = "base-challenger-e2e" -version = "1.4.2" +version = "1.5.0" dependencies = [ + "alloy-consensus 2.4.1", "alloy-node-bindings", "alloy-primitives", "alloy-provider", + "alloy-rpc-types-eth 2.4.1", "alloy-signer-local 2.4.1", "base-proof-contracts", + "base-proof-primitives", "base-proof-rpc", "base-proof-submission", + "base-prover-service-client", "base-prover-service-protocol", "base-tx-manager", "base-zk-fork-dispute", "clap", "eyre", "humantime", + "jsonrpsee", "reqwest 0.13.4", "tokio", "tracing", @@ -3505,7 +3533,7 @@ dependencies = [ [[package]] name = "base-challenger-e2e-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-challenger-e2e", "tokio", @@ -3515,7 +3543,7 @@ dependencies = [ [[package]] name = "base-cli-utils" -version = "1.4.2" +version = "1.5.0" dependencies = [ "backtrace", "base-metrics", @@ -3542,7 +3570,7 @@ dependencies = [ [[package]] name = "base-common-chains" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-eips 2.4.1", @@ -3557,11 +3585,12 @@ dependencies = [ [[package]] name = "base-common-consensus" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", "alloy-evm", + "alloy-genesis", "alloy-network 2.4.1", "alloy-primitives", "alloy-rlp", @@ -3570,7 +3599,10 @@ dependencies = [ "alloy-signer 2.4.1", "alloy-signer-local 2.4.1", "alloy-sol-types", + "ambassador", "arbitrary", + "base-common-chains", + "base-common-genesis", "bincode 2.0.1", "bytes", "modular-bitfield", @@ -3590,7 +3622,7 @@ dependencies = [ [[package]] name = "base-common-eip8130" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -3600,7 +3632,7 @@ dependencies = [ [[package]] name = "base-common-evm" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3631,7 +3663,7 @@ dependencies = [ [[package]] name = "base-common-evm2" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3650,7 +3682,7 @@ dependencies = [ [[package]] name = "base-common-flashblocks" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", @@ -3668,7 +3700,7 @@ dependencies = [ [[package]] name = "base-common-flz" -version = "1.4.2" +version = "1.5.0" dependencies = [ "criterion", "hex-literal 1.1.0", @@ -3678,7 +3710,7 @@ dependencies = [ [[package]] name = "base-common-genesis" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -3700,7 +3732,7 @@ dependencies = [ [[package]] name = "base-common-l1-fees" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "base-common-flz", @@ -3709,7 +3741,7 @@ dependencies = [ [[package]] name = "base-common-network" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-network 2.4.1", @@ -3726,7 +3758,7 @@ dependencies = [ [[package]] name = "base-common-precompiles" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-evm", "alloy-primitives", @@ -3744,7 +3776,7 @@ dependencies = [ [[package]] name = "base-common-rpc-types" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3766,11 +3798,12 @@ dependencies = [ "serde", "serde_json", "similar-asserts", + "thiserror 2.0.19", ] [[package]] name = "base-common-rpc-types-engine" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3793,7 +3826,7 @@ dependencies = [ [[package]] name = "base-common-signer" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3813,9 +3846,8 @@ dependencies = [ [[package]] name = "base-comp" -version = "1.4.2" +version = "1.5.0" dependencies = [ - "alloy-primitives", "base-common-genesis", "base-protocol", "brotli", @@ -3825,7 +3857,7 @@ dependencies = [ [[package]] name = "base-consensus" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-cli-utils", "base-consensus-cli", @@ -3834,7 +3866,7 @@ dependencies = [ [[package]] name = "base-consensus-cli" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-genesis", @@ -3883,7 +3915,7 @@ dependencies = [ [[package]] name = "base-consensus-derive" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3914,7 +3946,7 @@ dependencies = [ [[package]] name = "base-consensus-disc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-rlp", "backon", @@ -3937,7 +3969,7 @@ dependencies = [ [[package]] name = "base-consensus-engine" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -3984,7 +4016,7 @@ dependencies = [ [[package]] name = "base-consensus-gossip" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4006,9 +4038,9 @@ dependencies = [ "ipnet", "libp2p", "libp2p-identity", - "libp2p-stream", "lru 0.16.4", "metrics", + "metrics-exporter-prometheus", "multihash", "rand 0.9.5", "serde", @@ -4024,7 +4056,7 @@ dependencies = [ [[package]] name = "base-consensus-node" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4076,7 +4108,6 @@ dependencies = [ "httpmock", "jsonrpsee", "libp2p", - "libp2p-stream", "metrics", "metrics-util", "mockall", @@ -4086,6 +4117,7 @@ dependencies = [ "reqwest 0.13.4", "rstest", "serde", + "serde_json", "strum 0.27.2", "tempfile", "thiserror 2.0.19", @@ -4101,7 +4133,7 @@ dependencies = [ [[package]] name = "base-consensus-peers" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -4126,7 +4158,7 @@ dependencies = [ [[package]] name = "base-consensus-providers" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4164,7 +4196,7 @@ dependencies = [ [[package]] name = "base-consensus-rpc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -4181,17 +4213,19 @@ dependencies = [ "ipnet", "jsonrpsee", "libp2p", + "mockall", "rstest", "serde", "serde_json", "thiserror 2.0.19", "tokio", "tracing", + "url", ] [[package]] name = "base-consensus-safedb" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -4207,7 +4241,7 @@ dependencies = [ [[package]] name = "base-consensus-sources" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-rpc-client", @@ -4229,7 +4263,7 @@ dependencies = [ [[package]] name = "base-consensus-upgrades" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -4241,7 +4275,7 @@ dependencies = [ [[package]] name = "base-execution-chainspec" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4266,7 +4300,7 @@ dependencies = [ [[package]] name = "base-execution-cli" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4299,12 +4333,15 @@ dependencies = [ "base-txpool-rpc", "base-txpool-tracing", "base-upgrade-signal", + "blake3", "clap", + "criterion", "eyre", "futures", "humantime", "jsonrpsee", "proptest", + "rayon", "reqwest 0.13.4", "reth-chainspec", "reth-cli", @@ -4342,7 +4379,7 @@ dependencies = [ [[package]] name = "base-execution-consensus" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -4368,13 +4405,14 @@ dependencies = [ "reth-trie", "reth-trie-common", "revm", + "rstest", "thiserror 2.0.19", "tracing", ] [[package]] name = "base-execution-eip8130" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -4393,7 +4431,7 @@ dependencies = [ [[package]] name = "base-execution-eip8130-rpc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4405,9 +4443,12 @@ dependencies = [ "base-common-evm", "base-common-precompiles", "base-common-rpc-types", + "base-execution-chainspec", + "base-execution-evm", "jsonrpsee", "jsonrpsee-types", "reth-chainspec", + "reth-errors", "reth-evm", "reth-provider", "reth-rpc-eth-api", @@ -4419,7 +4460,7 @@ dependencies = [ [[package]] name = "base-execution-eip8130-rpc-node" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-genesis", @@ -4429,6 +4470,7 @@ dependencies = [ "alloy-rpc-client", "alloy-signer 2.4.1", "base-common-consensus", + "base-common-evm", "base-common-precompiles", "base-execution-chainspec", "base-execution-eip8130-rpc", @@ -4443,7 +4485,7 @@ dependencies = [ [[package]] name = "base-execution-evm" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4460,12 +4502,14 @@ dependencies = [ "base-execution-consensus", "base-protocol", "reth-chainspec", + "reth-errors", "reth-evm", "reth-execution-errors", "reth-execution-types", "reth-primitives-traits", "reth-revm", "reth-rpc-eth-api", + "reth-rpc-eth-types", "reth-storage-errors", "revm", "thiserror 2.0.19", @@ -4473,7 +4517,7 @@ dependencies = [ [[package]] name = "base-execution-exex" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4499,13 +4543,14 @@ dependencies = [ [[package]] name = "base-execution-firehose" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", "alloy-primitives", "base-common-consensus", "base-common-evm", + "base-common-precompiles", "base-execution-evm", "firehose-tracer", "reth-evm", @@ -4517,7 +4562,7 @@ dependencies = [ [[package]] name = "base-execution-payload-builder" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4570,10 +4615,11 @@ dependencies = [ [[package]] name = "base-execution-rpc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", + "alloy-evm", "alloy-json-rpc 2.4.1", "alloy-primitives", "alloy-rpc-client", @@ -4581,6 +4627,8 @@ dependencies = [ "alloy-rpc-types-engine", "alloy-rpc-types-eth 2.4.1", "alloy-serde 2.4.1", + "alloy-signer 2.4.1", + "alloy-signer-local 2.4.1", "alloy-transport", "alloy-transport-http", "async-trait", @@ -4598,6 +4646,7 @@ dependencies = [ "base-metrics", "base-observability-events", "base-protocol", + "criterion", "derive_more 2.1.1", "eyre", "futures", @@ -4612,7 +4661,9 @@ dependencies = [ "reth-basic-payload-builder", "reth-chain-state", "reth-chainspec", + "reth-errors", "reth-evm", + "reth-network-api", "reth-node-api", "reth-node-builder", "reth-payload-util", @@ -4621,6 +4672,7 @@ dependencies = [ "reth-revm", "reth-rpc", "reth-rpc-api", + "reth-rpc-convert", "reth-rpc-engine-api", "reth-rpc-eth-api", "reth-rpc-eth-types", @@ -4630,6 +4682,7 @@ dependencies = [ "reth-transaction-pool", "reth-trie-common", "revm", + "revm-inspectors", "serde", "thiserror 2.0.19", "tokio", @@ -4639,7 +4692,7 @@ dependencies = [ [[package]] name = "base-execution-trie" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4651,12 +4704,14 @@ dependencies = [ "bincode 2.0.1", "bytes", "criterion", + "dashmap", "derive_more 2.1.1", "eyre", "metrics", "mockall", "parking_lot", "rand 0.8.7", + "rayon", "reth-chainspec", "reth-codecs", "reth-db", @@ -4676,6 +4731,7 @@ dependencies = [ "reth-trie-common", "reth-trie-db", "rocksdb", + "rstest", "secp256k1 0.30.0", "serde", "serial_test", @@ -4690,7 +4746,7 @@ dependencies = [ [[package]] name = "base-execution-txpool" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4699,6 +4755,7 @@ dependencies = [ "alloy-signer-local 2.4.1", "alloy-sol-types", "async-trait", + "base-bundles", "base-common-chains", "base-common-consensus", "base-common-evm", @@ -4715,6 +4772,7 @@ dependencies = [ "c-kzg", "derive_more 2.1.1", "futures", + "hostname", "jsonrpsee", "lru 0.16.4", "metrics", @@ -4731,6 +4789,7 @@ dependencies = [ "reth-tasks", "reth-transaction-pool", "revm", + "rstest", "serde", "serde_json", "thiserror 2.0.19", @@ -4741,7 +4800,7 @@ dependencies = [ [[package]] name = "base-firehose-flashblocks" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4786,7 +4845,7 @@ dependencies = [ [[package]] name = "base-firehose-prestate" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-genesis", "base-common-chains", @@ -4804,7 +4863,7 @@ dependencies = [ [[package]] name = "base-firehose-tests" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4828,7 +4887,7 @@ dependencies = [ [[package]] name = "base-flashblocks" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -4886,7 +4945,7 @@ dependencies = [ [[package]] name = "base-flashblocks-node" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -4941,7 +5000,7 @@ dependencies = [ [[package]] name = "base-health" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-transport-http", "async-trait", @@ -4963,7 +5022,7 @@ dependencies = [ [[package]] name = "base-jwt" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-provider", @@ -4979,7 +5038,7 @@ dependencies = [ [[package]] name = "base-l1-head" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-provider", @@ -4993,7 +5052,7 @@ dependencies = [ [[package]] name = "base-load-tester-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-network 2.4.1", "alloy-primitives", @@ -5014,7 +5073,7 @@ dependencies = [ [[package]] name = "base-load-tests" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5034,12 +5093,14 @@ dependencies = [ "base-tx-manager", "eyre", "futures", + "httpmock", "humantime", "indicatif 0.18.6", "parking_lot", "rand 0.9.5", "reqwest 0.13.4", "revm", + "rstest", "serde", "serde_json", "serde_yaml", @@ -5055,7 +5116,7 @@ dependencies = [ [[package]] name = "base-metering" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5101,7 +5162,7 @@ dependencies = [ [[package]] name = "base-metrics" -version = "1.4.2" +version = "1.5.0" dependencies = [ "ctor", "metrics", @@ -5111,12 +5172,11 @@ dependencies = [ [[package]] name = "base-node-core" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", "alloy-genesis", - "alloy-network 2.4.1", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", @@ -5161,7 +5221,6 @@ dependencies = [ "reth-node-core", "reth-payload-builder", "reth-payload-primitives", - "reth-payload-util", "reth-primitives-traits", "reth-provider", "reth-prune-types", @@ -5190,7 +5249,7 @@ dependencies = [ [[package]] name = "base-node-runner" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-genesis", @@ -5200,6 +5259,7 @@ dependencies = [ "alloy-rpc-types", "alloy-rpc-types-engine", "base-common-consensus", + "base-common-evm", "base-common-network", "base-common-rpc-types", "base-common-rpc-types-engine", @@ -5220,23 +5280,16 @@ dependencies = [ "jsonrpsee", "reth-chainspec", "reth-db", - "reth-engine-primitives", - "reth-evm", "reth-exex", "reth-ipc", - "reth-node-api", "reth-node-builder", "reth-node-core", "reth-primitives-traits", "reth-provider", "reth-rpc-api", "reth-rpc-layer", - "reth-rpc-server-types", "reth-tasks", - "reth-tokio-util", "reth-tracing", - "reth-transaction-pool", - "serde", "serde_json", "tokio", "tokio-tungstenite 0.28.0", @@ -5248,7 +5301,7 @@ dependencies = [ [[package]] name = "base-observability-events" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "base-metrics", @@ -5263,12 +5316,11 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "tracing", - "tracing-appender", ] [[package]] name = "base-optimism-rpc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-network 2.4.1", @@ -5281,7 +5333,7 @@ dependencies = [ [[package]] name = "base-precompile-macros" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "proc-macro2", @@ -5291,7 +5343,7 @@ dependencies = [ [[package]] name = "base-precompile-storage" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-evm", "alloy-primitives", @@ -5307,7 +5359,7 @@ dependencies = [ [[package]] name = "base-proof" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5345,7 +5397,7 @@ dependencies = [ [[package]] name = "base-proof-client" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -5368,7 +5420,7 @@ dependencies = [ [[package]] name = "base-proof-contracts" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-contract", "alloy-primitives", @@ -5386,7 +5438,7 @@ dependencies = [ [[package]] name = "base-proof-driver" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -5406,7 +5458,7 @@ dependencies = [ [[package]] name = "base-proof-executor" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5440,7 +5492,7 @@ dependencies = [ [[package]] name = "base-proof-host" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5454,6 +5506,7 @@ dependencies = [ "alloy-transport", "ark-ff 0.6.0", "async-trait", + "base-common-chains", "base-common-consensus", "base-common-evm", "base-common-genesis", @@ -5482,7 +5535,7 @@ dependencies = [ [[package]] name = "base-proof-mpt" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5503,7 +5556,7 @@ dependencies = [ [[package]] name = "base-proof-preimage" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "async-channel", @@ -5517,7 +5570,7 @@ dependencies = [ [[package]] name = "base-proof-primitives" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-eips 2.4.1", @@ -5535,7 +5588,7 @@ dependencies = [ [[package]] name = "base-proof-rpc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-network 2.4.1", @@ -5564,7 +5617,7 @@ dependencies = [ [[package]] name = "base-proof-submission" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5580,11 +5633,11 @@ dependencies = [ [[package]] name = "base-proof-succinct-elfs" -version = "1.4.2" +version = "1.5.0" [[package]] name = "base-proof-succinct-scripts" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "anyhow", @@ -5600,7 +5653,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-enclave" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-chains", "alloy-eips 2.4.1", @@ -5633,7 +5686,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-host" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-genesis", "alloy-primitives", @@ -5665,7 +5718,7 @@ dependencies = [ [[package]] name = "base-proof-tee-nitro-verifier" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-sol-types", @@ -5682,7 +5735,7 @@ dependencies = [ [[package]] name = "base-proof-tee-registrar" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -5724,7 +5777,7 @@ dependencies = [ [[package]] name = "base-proof-tee-registrar-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-signer-local 2.4.1", @@ -5742,7 +5795,7 @@ dependencies = [ [[package]] name = "base-proof-worker" -version = "1.4.2" +version = "1.5.0" dependencies = [ "async-trait", "backon", @@ -5758,12 +5811,10 @@ dependencies = [ [[package]] name = "base-proof-zk-backend" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", - "alloy-contract", "alloy-primitives", - "alloy-sol-types", "anyhow", "async-trait", "backoff", @@ -5795,7 +5846,7 @@ dependencies = [ [[package]] name = "base-proof-zk-host" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "async-trait", @@ -5812,7 +5863,7 @@ dependencies = [ [[package]] name = "base-proof-zk-utils" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5849,7 +5900,7 @@ dependencies = [ [[package]] name = "base-proof-zk-witness" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5884,7 +5935,7 @@ dependencies = [ [[package]] name = "base-proofs-extension" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-execution-exex", "base-execution-rpc", @@ -5901,7 +5952,7 @@ dependencies = [ [[package]] name = "base-proposer" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -5943,7 +5994,7 @@ dependencies = [ [[package]] name = "base-proposer-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-cli-utils", "base-proposer", @@ -5955,17 +6006,15 @@ dependencies = [ [[package]] name = "base-protocol" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloc-no-stdlib", "alloy-consensus 2.4.1", "alloy-eips 2.4.1", - "alloy-genesis", "alloy-primitives", "alloy-rlp", "alloy-rpc-types-engine", "alloy-rpc-types-eth 2.4.1", - "ambassador", "arbitrary", "async-trait", "base-common-chains", @@ -5993,7 +6042,7 @@ dependencies = [ [[package]] name = "base-prover-nitro-enclave" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-proof-tee-nitro-enclave", "eyre", @@ -6002,7 +6051,7 @@ dependencies = [ [[package]] name = "base-prover-nitro-host" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "base-cli-utils", @@ -6024,7 +6073,7 @@ dependencies = [ [[package]] name = "base-prover-service" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "base-prover-service-db", @@ -6042,7 +6091,7 @@ dependencies = [ [[package]] name = "base-prover-service-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-cli-utils", "base-prover-service", @@ -6059,7 +6108,7 @@ dependencies = [ [[package]] name = "base-prover-service-client" -version = "1.4.2" +version = "1.5.0" dependencies = [ "async-trait", "backon", @@ -6076,7 +6125,7 @@ dependencies = [ [[package]] name = "base-prover-service-db" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "anyhow", @@ -6095,7 +6144,7 @@ dependencies = [ [[package]] name = "base-prover-service-protocol" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "base-proof-primitives", @@ -6108,7 +6157,7 @@ dependencies = [ [[package]] name = "base-prover-zk-host" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-cli-utils", "base-proof-worker", @@ -6128,7 +6177,7 @@ dependencies = [ [[package]] name = "base-reth-cli" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "base-common-chains", @@ -6147,9 +6196,8 @@ dependencies = [ [[package]] name = "base-reth-node" -version = "1.4.2" +version = "1.5.0" dependencies = [ - "alloy-primitives", "base-cli-utils", "base-execution-cli", "base-firehose-flashblocks", @@ -6170,18 +6218,18 @@ dependencies = [ [[package]] name = "base-retry" -version = "1.4.2" +version = "1.5.0" dependencies = [ "backon", ] [[package]] name = "base-ring-buffer" -version = "1.4.2" +version = "1.5.0" [[package]] name = "base-roxy" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -6195,7 +6243,7 @@ dependencies = [ [[package]] name = "base-roxy-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "base-cli-utils", @@ -6208,7 +6256,7 @@ dependencies = [ [[package]] name = "base-runtime" -version = "1.4.2" +version = "1.5.0" dependencies = [ "futures", "rand 0.9.5", @@ -6220,7 +6268,7 @@ dependencies = [ [[package]] name = "base-shadow-indexer" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6254,7 +6302,7 @@ dependencies = [ [[package]] name = "base-shadow-indexer-db" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -6271,7 +6319,7 @@ dependencies = [ [[package]] name = "base-shadow-metrics" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -6290,7 +6338,7 @@ dependencies = [ [[package]] name = "base-shadow-metrics-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -6305,7 +6353,7 @@ dependencies = [ [[package]] name = "base-sidecrush" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-provider", "alloy-rpc-types-eth 2.4.1", @@ -6318,7 +6366,7 @@ dependencies = [ [[package]] name = "base-sidecrush-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-sidecrush", "cadence", @@ -6330,7 +6378,7 @@ dependencies = [ [[package]] name = "base-snapshotter" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-provider", @@ -6357,7 +6405,7 @@ dependencies = [ [[package]] name = "base-snapshotter-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "aws-config", @@ -6374,7 +6422,7 @@ dependencies = [ [[package]] name = "base-snark-e2e" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-provider", @@ -6382,7 +6430,9 @@ dependencies = [ "anyhow", "base-common-network", "base-l1-head", + "base-proof-contracts", "base-proof-zk-backend", + "base-proof-zk-utils", "base-prover-service-client", "base-prover-service-protocol", "bincode 2.0.1", @@ -6395,7 +6445,7 @@ dependencies = [ [[package]] name = "base-snark-e2e-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-snark-e2e", "tokio", @@ -6405,7 +6455,7 @@ dependencies = [ [[package]] name = "base-system-tests" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6429,6 +6479,7 @@ dependencies = [ "base-builder-multiplex", "base-common-chains", "base-common-consensus", + "base-common-evm", "base-common-flashblocks", "base-common-genesis", "base-common-network", @@ -6494,6 +6545,7 @@ dependencies = [ "reth-tasks", "serde", "serde_json", + "serde_yaml", "sqlx", "tempfile", "testcontainers", @@ -6509,7 +6561,7 @@ dependencies = [ [[package]] name = "base-telemetry-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "base-cli-utils", @@ -6523,7 +6575,7 @@ dependencies = [ [[package]] name = "base-telemetry-service" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "anyhow", @@ -6553,7 +6605,7 @@ dependencies = [ [[package]] name = "base-test-utils" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -6573,7 +6625,7 @@ dependencies = [ [[package]] name = "base-trusted-proxy" -version = "1.4.2" +version = "1.5.0" dependencies = [ "http 1.4.2", "ipnet", @@ -6583,7 +6635,7 @@ dependencies = [ [[package]] name = "base-tx-forwarding" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6600,12 +6652,14 @@ dependencies = [ "base-observability-events", "base-test-utils", "base-txpool-rpc", + "base-txpool-tracing", "eyre", "futures", "jsonrpsee", "metrics", "reth-tasks", "reth-transaction-pool", + "rstest", "serde", "serde_json", "thiserror 2.0.19", @@ -6617,7 +6671,7 @@ dependencies = [ [[package]] name = "base-tx-manager" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6652,7 +6706,7 @@ dependencies = [ [[package]] name = "base-txpool-rpc" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6671,12 +6725,15 @@ dependencies = [ "eyre", "httpmock", "jsonrpsee", + "metrics", + "metrics-util", "reth-chainspec", "reth-provider", "reth-rpc-eth-types", "reth-rpc-server-types", "reth-storage-api", "reth-transaction-pool", + "rstest", "serde", "serde_json", "tokio", @@ -6685,7 +6742,7 @@ dependencies = [ [[package]] name = "base-txpool-tracing" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -6718,7 +6775,7 @@ dependencies = [ [[package]] name = "base-upgrade-signal" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-json-rpc 2.4.1", "alloy-primitives", @@ -6750,7 +6807,7 @@ dependencies = [ [[package]] name = "base-witness-diff" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-primitives", "alloy-provider", @@ -6776,7 +6833,7 @@ checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" [[package]] name = "base-zk-benchmarks" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -6794,7 +6851,7 @@ dependencies = [ [[package]] name = "base-zk-benchmarks-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-cli-utils", "base-load-tests", @@ -6807,7 +6864,7 @@ dependencies = [ [[package]] name = "base-zk-fork-dispute" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-node-bindings", "alloy-primitives", @@ -6835,7 +6892,7 @@ dependencies = [ [[package]] name = "base-zk-fork-dispute-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "base-cli-utils", "base-zk-fork-dispute", @@ -6902,7 +6959,7 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "basectl" -version = "1.4.2" +version = "1.5.0" dependencies = [ "anyhow", "basectl-cli", @@ -6914,7 +6971,7 @@ dependencies = [ [[package]] name = "basectl-cli" -version = "1.4.2" +version = "1.5.0" dependencies = [ "alloy-consensus 2.4.1", "alloy-contract", @@ -7178,6 +7235,7 @@ dependencies = [ "cfg-if", "constant_time_eq", "cpufeatures 0.3.0", + "rayon-core", ] [[package]] @@ -8532,6 +8590,8 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", + "group 0.13.0", + "rand_core 0.6.4", "rustc_version 0.4.1", "subtle", "zeroize", @@ -11612,64 +11672,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "ingress-rpc" -version = "1.4.2" -dependencies = [ - "alloy-provider", - "anyhow", - "audit-archiver-lib", - "base-bundles", - "base-cli-utils", - "base-common-network", - "base-observability-events", - "clap", - "dotenvy", - "ingress-rpc-lib", - "jsonrpsee", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "ingress-rpc-lib" -version = "1.4.2" -dependencies = [ - "alloy-consensus 2.4.1", - "alloy-eips 2.4.1", - "alloy-network 2.4.1", - "alloy-primitives", - "alloy-provider", - "alloy-rpc-types-eth 2.4.1", - "alloy-signer 2.4.1", - "alloy-signer-local 2.4.1", - "anyhow", - "async-trait", - "audit-archiver-lib", - "axum 0.8.9", - "base-bundles", - "base-common-consensus", - "base-common-evm", - "base-common-network", - "base-execution-evm", - "base-metrics", - "base-observability-events", - "clap", - "jsonrpsee", - "metrics", - "moka", - "reth-rpc-eth-types", - "reth-rpc-server-types", - "serde", - "serde_json", - "tokio", - "tracing", - "url", - "uuid", - "wiremock", -] - [[package]] name = "inlinable_string" version = "0.1.15" @@ -12597,20 +12599,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "libp2p-stream" -version = "0.4.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" -dependencies = [ - "futures", - "libp2p-core", - "libp2p-identity", - "libp2p-swarm", - "rand 0.8.7", - "tracing", -] - [[package]] name = "libp2p-swarm" version = "0.47.1" @@ -16325,7 +16313,7 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reth-basic-payload-builder" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16352,7 +16340,7 @@ dependencies = [ [[package]] name = "reth-chain-state" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16383,7 +16371,7 @@ dependencies = [ [[package]] name = "reth-chainspec" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -16403,7 +16391,7 @@ dependencies = [ [[package]] name = "reth-cli" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-genesis", "clap", @@ -16416,7 +16404,7 @@ dependencies = [ [[package]] name = "reth-cli-commands" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -16505,7 +16493,7 @@ dependencies = [ [[package]] name = "reth-cli-runner" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "reth-tasks", "tokio", @@ -16515,7 +16503,7 @@ dependencies = [ [[package]] name = "reth-cli-util" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -16566,7 +16554,7 @@ dependencies = [ [[package]] name = "reth-config" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "eyre", "humantime-serde", @@ -16583,7 +16571,7 @@ dependencies = [ [[package]] name = "reth-consensus" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -16597,7 +16585,7 @@ dependencies = [ [[package]] name = "reth-consensus-common" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16610,7 +16598,7 @@ dependencies = [ [[package]] name = "reth-consensus-debug-client" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16635,7 +16623,7 @@ dependencies = [ [[package]] name = "reth-db" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "derive_more 2.1.1", @@ -16664,7 +16652,7 @@ dependencies = [ [[package]] name = "reth-db-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -16690,7 +16678,7 @@ dependencies = [ [[package]] name = "reth-db-common" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-genesis", @@ -16720,7 +16708,7 @@ dependencies = [ [[package]] name = "reth-db-models" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -16735,7 +16723,7 @@ dependencies = [ [[package]] name = "reth-discv4" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -16760,7 +16748,7 @@ dependencies = [ [[package]] name = "reth-discv5" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -16784,7 +16772,7 @@ dependencies = [ [[package]] name = "reth-dns-discovery" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "dashmap", @@ -16808,7 +16796,7 @@ dependencies = [ [[package]] name = "reth-downloaders" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16843,7 +16831,7 @@ dependencies = [ [[package]] name = "reth-e2e-test-utils" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16900,7 +16888,7 @@ dependencies = [ [[package]] name = "reth-ecies" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "aes", "alloy-primitives", @@ -16927,7 +16915,7 @@ dependencies = [ [[package]] name = "reth-engine-local" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -16950,7 +16938,7 @@ dependencies = [ [[package]] name = "reth-engine-primitives" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -16977,7 +16965,7 @@ dependencies = [ [[package]] name = "reth-engine-tree" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17034,7 +17022,7 @@ dependencies = [ [[package]] name = "reth-engine-util" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17064,7 +17052,7 @@ dependencies = [ [[package]] name = "reth-era" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17082,7 +17070,7 @@ dependencies = [ [[package]] name = "reth-era-downloader" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "bytes", @@ -17098,7 +17086,7 @@ dependencies = [ [[package]] name = "reth-era-utils" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17122,7 +17110,7 @@ dependencies = [ [[package]] name = "reth-errors" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "reth-consensus", "reth-execution-errors", @@ -17133,7 +17121,7 @@ dependencies = [ [[package]] name = "reth-eth-wire" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-chains", "alloy-primitives", @@ -17162,7 +17150,7 @@ dependencies = [ [[package]] name = "reth-eth-wire-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -17188,7 +17176,7 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17204,7 +17192,7 @@ dependencies = [ [[package]] name = "reth-ethereum-engine-primitives" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -17220,7 +17208,7 @@ dependencies = [ [[package]] name = "reth-ethereum-forks" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eip2124", "alloy-hardforks 0.4.7", @@ -17234,7 +17222,7 @@ dependencies = [ [[package]] name = "reth-ethereum-payload-builder" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17264,7 +17252,7 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17278,7 +17266,7 @@ dependencies = [ [[package]] name = "reth-etl" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "rayon", "reth-db-api", @@ -17288,7 +17276,7 @@ dependencies = [ [[package]] name = "reth-evm" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17314,7 +17302,7 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17334,7 +17322,7 @@ dependencies = [ [[package]] name = "reth-execution-cache" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "fixed-cache", @@ -17352,7 +17340,7 @@ dependencies = [ [[package]] name = "reth-execution-errors" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-evm", "alloy-primitives", @@ -17365,7 +17353,7 @@ dependencies = [ [[package]] name = "reth-execution-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17384,7 +17372,7 @@ dependencies = [ [[package]] name = "reth-exex" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17422,7 +17410,7 @@ dependencies = [ [[package]] name = "reth-exex-test-utils" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "eyre", @@ -17454,7 +17442,7 @@ dependencies = [ [[package]] name = "reth-exex-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -17468,7 +17456,7 @@ dependencies = [ [[package]] name = "reth-firehose" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17497,7 +17485,7 @@ dependencies = [ [[package]] name = "reth-firehose-tests" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17524,7 +17512,7 @@ dependencies = [ [[package]] name = "reth-fs-util" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "serde", "serde_json", @@ -17534,7 +17522,7 @@ dependencies = [ [[package]] name = "reth-invalid-block-hooks" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17560,7 +17548,7 @@ dependencies = [ [[package]] name = "reth-ipc" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "bytes", "futures", @@ -17580,7 +17568,7 @@ dependencies = [ [[package]] name = "reth-libmdbx" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "bitflags 2.13.1", "byteorder", @@ -17597,7 +17585,7 @@ dependencies = [ [[package]] name = "reth-mdbx-sys" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "bindgen 0.72.1", "cc", @@ -17606,7 +17594,7 @@ dependencies = [ [[package]] name = "reth-metrics" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "futures", "libc", @@ -17620,7 +17608,7 @@ dependencies = [ [[package]] name = "reth-net-banlist" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "ipnet", @@ -17629,7 +17617,7 @@ dependencies = [ [[package]] name = "reth-net-nat" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "futures-util", "if-addrs 0.14.0", @@ -17643,7 +17631,7 @@ dependencies = [ [[package]] name = "reth-network" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17702,7 +17690,7 @@ dependencies = [ [[package]] name = "reth-network-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -17727,7 +17715,7 @@ dependencies = [ [[package]] name = "reth-network-p2p" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -17751,7 +17739,7 @@ dependencies = [ [[package]] name = "reth-network-peers" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -17766,7 +17754,7 @@ dependencies = [ [[package]] name = "reth-network-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eip2124", "humantime-serde", @@ -17780,7 +17768,7 @@ dependencies = [ [[package]] name = "reth-nippy-jar" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "anyhow", "bincode 1.3.3", @@ -17797,7 +17785,7 @@ dependencies = [ [[package]] name = "reth-node-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-rpc-types-engine", "eyre", @@ -17821,7 +17809,7 @@ dependencies = [ [[package]] name = "reth-node-builder" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17890,7 +17878,7 @@ dependencies = [ [[package]] name = "reth-node-core" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17945,7 +17933,7 @@ dependencies = [ [[package]] name = "reth-node-ethereum" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -17994,7 +17982,7 @@ dependencies = [ [[package]] name = "reth-node-ethstats" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18018,7 +18006,7 @@ dependencies = [ [[package]] name = "reth-node-events" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18042,7 +18030,7 @@ dependencies = [ [[package]] name = "reth-node-metrics" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "bytes", "eyre", @@ -18071,7 +18059,7 @@ dependencies = [ [[package]] name = "reth-node-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "reth-chainspec", "reth-db-api", @@ -18083,7 +18071,7 @@ dependencies = [ [[package]] name = "reth-payload-builder" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18107,7 +18095,7 @@ dependencies = [ [[package]] name = "reth-payload-builder-primitives" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "pin-project", "reth-payload-primitives", @@ -18119,7 +18107,7 @@ dependencies = [ [[package]] name = "reth-payload-primitives" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18142,7 +18130,7 @@ dependencies = [ [[package]] name = "reth-payload-util" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18152,7 +18140,7 @@ dependencies = [ [[package]] name = "reth-payload-validator" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-rpc-types-engine", @@ -18195,7 +18183,7 @@ dependencies = [ [[package]] name = "reth-provider" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -18245,7 +18233,7 @@ dependencies = [ [[package]] name = "reth-prune" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18272,7 +18260,7 @@ dependencies = [ [[package]] name = "reth-prune-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "arbitrary", @@ -18288,7 +18276,7 @@ dependencies = [ [[package]] name = "reth-revm" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -18303,7 +18291,7 @@ dependencies = [ [[package]] name = "reth-rpc" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-dyn-abi", @@ -18379,7 +18367,7 @@ dependencies = [ [[package]] name = "reth-rpc-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-genesis", @@ -18409,7 +18397,7 @@ dependencies = [ [[package]] name = "reth-rpc-builder" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-network 2.4.1", "alloy-provider", @@ -18452,7 +18440,7 @@ dependencies = [ [[package]] name = "reth-rpc-convert" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-evm", @@ -18472,7 +18460,7 @@ dependencies = [ [[package]] name = "reth-rpc-engine-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18503,7 +18491,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-dyn-abi", @@ -18550,7 +18538,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-chains", "alloy-consensus 2.4.1", @@ -18601,7 +18589,7 @@ dependencies = [ [[package]] name = "reth-rpc-layer" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-rpc-types-engine", "http 1.4.2", @@ -18615,7 +18603,7 @@ dependencies = [ [[package]] name = "reth-rpc-server-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18646,7 +18634,7 @@ dependencies = [ [[package]] name = "reth-stages" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -18697,7 +18685,7 @@ dependencies = [ [[package]] name = "reth-stages-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18725,7 +18713,7 @@ dependencies = [ [[package]] name = "reth-stages-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "arbitrary", @@ -18739,7 +18727,7 @@ dependencies = [ [[package]] name = "reth-static-file" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "parking_lot", @@ -18759,7 +18747,7 @@ dependencies = [ [[package]] name = "reth-static-file-types" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "clap", @@ -18774,7 +18762,7 @@ dependencies = [ [[package]] name = "reth-storage-api" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eip7928 0.4.5", @@ -18800,7 +18788,7 @@ dependencies = [ [[package]] name = "reth-storage-errors" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18817,7 +18805,7 @@ dependencies = [ [[package]] name = "reth-storage-overlay" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-eips 2.4.1", "alloy-primitives", @@ -18844,7 +18832,7 @@ dependencies = [ [[package]] name = "reth-tasks" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "crossbeam-utils", "dashmap", @@ -18865,7 +18853,7 @@ dependencies = [ [[package]] name = "reth-testing-utils" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18881,7 +18869,7 @@ dependencies = [ [[package]] name = "reth-tokio-util" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "tokio", "tokio-stream", @@ -18891,7 +18879,7 @@ dependencies = [ [[package]] name = "reth-tracing" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "clap", "eyre", @@ -18911,7 +18899,7 @@ dependencies = [ [[package]] name = "reth-tracing-otlp" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "base64 0.22.1", "clap", @@ -18930,7 +18918,7 @@ dependencies = [ [[package]] name = "reth-transaction-pool" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18973,7 +18961,7 @@ dependencies = [ [[package]] name = "reth-trie" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-eips 2.4.1", @@ -18999,7 +18987,7 @@ dependencies = [ [[package]] name = "reth-trie-common" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-consensus 2.4.1", "alloy-primitives", @@ -19026,7 +19014,7 @@ dependencies = [ [[package]] name = "reth-trie-db" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "reth-db-api", @@ -19042,7 +19030,7 @@ dependencies = [ [[package]] name = "reth-trie-parallel" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-evm", "alloy-primitives", @@ -19066,7 +19054,7 @@ dependencies = [ [[package]] name = "reth-trie-sparse" version = "2.5.2" -source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.6-fh3.1-1#6f14cf591ab0da7f4e0aaf67d870707077c6e71c" +source = "git+https://github.com/streamingfast/reth.git?tag=base-v2.5.2.8-fh3.1#3605fcb480ef42c14622e1f50c4d1d8c1bba6c6a" dependencies = [ "alloy-primitives", "alloy-trie", @@ -20647,18 +20635,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slop-air" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33360168ca7632c39d678cb58d594a3c9ee4fd4ad71b43ac330ca5a93e1e1cb2" +checksum = "c6593a86d09bddebc8a9b894b528a5762fc775d2b83034a207c97630a7306e7f" dependencies = [ "p3-air", ] [[package]] name = "slop-algebra" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "546efc4c8df95113752bce97387423a67ebe9ee5dd01170819dc1ca467d006d4" +checksum = "733d477823f02646fa8025c5e2d4227ed806ca33a469def68994f63122f9f106" dependencies = [ "itertools 0.14.0", "p3-field", @@ -20667,9 +20655,9 @@ dependencies = [ [[package]] name = "slop-alloc" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93a1b2e7fec062af53f06a387e1df418edf58fc9cb2d4104e84bcff14c25dfe6" +checksum = "140e458c2ee44d8d061f417e19715af7ba7a1e0432b09566a9ad61b57419a425" dependencies = [ "serde", "slop-algebra", @@ -20678,9 +20666,9 @@ dependencies = [ [[package]] name = "slop-baby-bear" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db42792f222f443d4f769cd62f0e730d3b4b6b3e27d560c63beed0768c2a0b36" +checksum = "b4ee5356138711b134dee8a730a7fd7514d55db4311f24c76113fd52eafb962d" dependencies = [ "lazy_static", "p3-baby-bear", @@ -20693,9 +20681,9 @@ dependencies = [ [[package]] name = "slop-basefold" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a6ae00fed8d1d93c272f482ff31f739f4d951c96205403bc1ca899b08c889b" +checksum = "c4da5c8d86bf73032dec5e9725c1e51ad03bdfa1cd3b4c5b5a66f940fe840d12" dependencies = [ "derive-where", "itertools 0.14.0", @@ -20716,9 +20704,9 @@ dependencies = [ [[package]] name = "slop-basefold-prover" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9322a50315808f06a40271123e6e587750fa2e17715a110b067575afd22ccbb" +checksum = "d4228cc21ec3ccb015bcd3d8627455a09ebecda7d934e927496ff3f239113af7" dependencies = [ "derive-where", "itertools 0.14.0", @@ -20728,7 +20716,6 @@ dependencies = [ "slop-alloc", "slop-baby-bear", "slop-basefold", - "slop-bn254", "slop-challenger", "slop-commit", "slop-dft", @@ -20743,9 +20730,9 @@ dependencies = [ [[package]] name = "slop-bn254" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44aed289b288c6f60d31b89124d97e0424cfee33a7ddcbc8c650df032594cf8a" +checksum = "0207095beb89a56e3875a0ca43e5782a2efc8637124b43ce453df42410e12a4c" dependencies = [ "ff 0.13.1", "p3-bn254-fr", @@ -20758,11 +20745,10 @@ dependencies = [ [[package]] name = "slop-challenger" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a472bd4cf17c6fdd5090955290ee0de883f34c7707871ee099a4840d8736dc71" +checksum = "cfc4121b9b84df835c8a6677a90dc9935562172a4a370758987407c793633334" dependencies = [ - "futures", "p3-challenger", "serde", "slop-algebra", @@ -20771,9 +20757,9 @@ dependencies = [ [[package]] name = "slop-commit" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062ad81f24db6d6fa4c615276efd692bf7f35aea2b47b39db7d263ba263cfbe8" +checksum = "ebb108e80118a5df3aa7fe8185d32f83bf01f9fa6493f847180b6821c84044cc" dependencies = [ "p3-commit", "serde", @@ -20782,9 +20768,9 @@ dependencies = [ [[package]] name = "slop-dft" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f508dd516fa67da538d2efd61fafc70164786953b032d0ce7558288ec8df1" +checksum = "967949400f2bc80c6d5cafe93601b4fcc018724af345a6926305172c563f5c72" dependencies = [ "p3-dft", "serde", @@ -20796,18 +20782,18 @@ dependencies = [ [[package]] name = "slop-fri" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091425a6e0332ea0ca952c922a92a5d03bdccb484f5e58cf11b435edf1a7be61" +checksum = "068417681d6f0a727248a6a4e433caa4ab37e37e7a5c8b9e492676aca7b7f514" dependencies = [ "p3-fri", ] [[package]] name = "slop-futures" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c83cf25f8c1bcc46ca7d4b39a0ca22c6f4b061ae1877ecdcf0a9fede2b74a87" +checksum = "121bb5d17da3e897dc1566e53da1f7cdbb06edda23ed70cb8a6d75ecb8e24f4a" dependencies = [ "crossbeam", "futures", @@ -20820,12 +20806,10 @@ dependencies = [ [[package]] name = "slop-jagged" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d04caa11c56e4f3cb878574fc4c41a23b09378fd3accb9818f527f1700f033d7" +checksum = "c61650d72b5489fd7f8d2a85f992e640d42be93738a1297dd395eaa1943434d4" dependencies = [ - "derive-where", - "futures", "itertools 0.14.0", "num_cpus", "rand 0.8.7", @@ -20839,7 +20823,6 @@ dependencies = [ "slop-bn254", "slop-challenger", "slop-commit", - "slop-futures", "slop-koala-bear", "slop-merkle-tree", "slop-multilinear", @@ -20854,18 +20837,18 @@ dependencies = [ [[package]] name = "slop-keccak-air" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0895b33cf38b28bdd6207d0c302df43a2964ef63ec2c731f0b614df6cacc86e" +checksum = "65f349d2b3ef84b275abc8b2729a4418a61b376deef4e7f23ec5ef5a55e0bd8f" dependencies = [ "p3-keccak-air", ] [[package]] name = "slop-koala-bear" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf01dc278c282f732ba67e8bb90a710d4779778f1a195cdae92baacae8f8d6a" +checksum = "b27fec4aae6811dbf6a384cc7993e74621aede443356ef67c301c19c1f302865" dependencies = [ "lazy_static", "p3-koala-bear", @@ -20878,29 +20861,28 @@ dependencies = [ [[package]] name = "slop-matrix" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc56360b8b5f5d6c9311336a427b0ff20c77a7cbea32390b5c0294f039544b5c" +checksum = "b668966ea5646cfc06a1eb6f637c8c6f338daca2dbb653c9a189ef5af0a831b8" dependencies = [ "p3-matrix", ] [[package]] name = "slop-maybe-rayon" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0bbd42ab0bd3f57f47ec1ff236d38d562f6d37a0726eebcfbc7ef1ee9299bc" +checksum = "0b8cf2b701f28e73aa2ebbb7a416d1e28725f58a8ad7d8b8db386dd60bd2f5aa" dependencies = [ "p3-maybe-rayon", ] [[package]] name = "slop-merkle-tree" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afe290c55814c02447b1808ef9f5a8cf9cd685d9cc32d0fa52da570a63fbe59" +checksum = "58ce5396280532acb12643e11b0f9d662c915dd9d58bf5ed4c80fdda46a2797a" dependencies = [ - "derive-where", "itertools 0.14.0", "p3-merkle-tree", "serde", @@ -20913,7 +20895,6 @@ dependencies = [ "slop-futures", "slop-koala-bear", "slop-matrix", - "slop-poseidon2", "slop-symmetric", "slop-tensor", "slop-utils", @@ -20922,12 +20903,11 @@ dependencies = [ [[package]] name = "slop-multilinear" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdacfe81725b672b35204e5a6a7a6a4d8468950eed71bef6e0c8f31431ca128" +checksum = "c973e169c7c9584ec77c0a281b4a29ec20de9c3968796bbcea6002547cedabe2" dependencies = [ "derive-where", - "futures", "num_cpus", "rand 0.8.7", "rayon", @@ -20936,37 +20916,32 @@ dependencies = [ "slop-alloc", "slop-challenger", "slop-commit", - "slop-futures", "slop-matrix", "slop-tensor", ] [[package]] name = "slop-poseidon2" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ec95c66d641b84741af6d8071b207625d191ae9026c7cedcfabf57f519a59" +checksum = "13388d3c92bac6214ef7585869b968cf895d2715ebbb8b1a0e1464dd2ada30b2" dependencies = [ "p3-poseidon2", ] [[package]] name = "slop-primitives" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dd98992a3dd8b608fd94d1b8f96d392478f6fd613cbe5db1017f8c97d6d3e13" -dependencies = [ - "slop-algebra", -] +checksum = "93f569cac42b24e672f6b06ffde272d4086c2c2620f16d31c1d1ce88cddff681" [[package]] name = "slop-stacked" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6b9e8fa66014ec878f2e42053e45215edc08cdf3d6056fe186d1f41c6c32205" +checksum = "bc9b17529d7484d1c8d7a869db83c5aa741d8bfdfa9ed7903fc9c028410f4503" dependencies = [ "derive-where", - "futures", "itertools 0.14.0", "serde", "slop-algebra", @@ -20975,7 +20950,6 @@ dependencies = [ "slop-basefold-prover", "slop-challenger", "slop-commit", - "slop-futures", "slop-merkle-tree", "slop-multilinear", "slop-tensor", @@ -20984,11 +20958,10 @@ dependencies = [ [[package]] name = "slop-sumcheck" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0c9cc5745c80b0fc4ae872b64ec44bc6046c6faa381bafdf7d46f2e76c9e51" +checksum = "9d87410940ec3d14912b44a86a420ea2973a94e805b9e318fb127cd5ee4d6de3" dependencies = [ - "futures", "itertools 0.14.0", "rayon", "serde", @@ -21002,18 +20975,18 @@ dependencies = [ [[package]] name = "slop-symmetric" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ea707679a9bee285392aa505aa353f8f473502a4f0776eec944c0b3baf4929" +checksum = "693b50b55c3f5a2bb20dd3b975da79b80e4bf15b839b90f82e5dc7ba7262d3dc" dependencies = [ "p3-symmetric", ] [[package]] name = "slop-tensor" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b43e779fcf6cabc1eeb2cf55e1a6a6d1cbe4d9b77f1fea0da74fdec025d5" +checksum = "3582c1eee176cbcc4d42dbfff1a8aea254c48ca907d0d1687729822491756a48" dependencies = [ "arrayvec", "derive-where", @@ -21023,7 +20996,6 @@ dependencies = [ "serde", "slop-algebra", "slop-alloc", - "slop-futures", "slop-matrix", "thiserror 1.0.69", "transpose", @@ -21031,18 +21003,18 @@ dependencies = [ [[package]] name = "slop-uni-stark" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e0219f62d552da26e45b0efad7ed20147ec1336f8c538c36d47aaee294c0fd" +checksum = "4afe09c7813c51c06d5a6df9a6e03590e3149db853526d3e110925a950df6232" dependencies = [ "p3-uni-stark", ] [[package]] name = "slop-utils" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcabe5063166d35dbde5383dba08444708e70162a8c7cfba0a806b2ec420ff8c" +checksum = "085f402d3f3ce1c843fdabf9d750e266e982186f3701fc56ce204fbf06bf18f0" dependencies = [ "p3-util", "tracing-forest", @@ -21051,12 +21023,11 @@ dependencies = [ [[package]] name = "slop-whir" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b777e49aff22850bc509db1e8cfc37c6bd1fcbcf7d9a18a7bdcb81b54b98e11" +checksum = "2b7f6246ea3143f45dd48c224452943a0ca992a4c4766b537522b5f1eeef07ea" dependencies = [ "derive-where", - "futures", "itertools 0.14.0", "rand 0.8.7", "rayon", @@ -21064,7 +21035,6 @@ dependencies = [ "slop-algebra", "slop-alloc", "slop-baby-bear", - "slop-basefold", "slop-challenger", "slop-commit", "slop-dft", @@ -21073,7 +21043,6 @@ dependencies = [ "slop-matrix", "slop-merkle-tree", "slop-multilinear", - "slop-stacked", "slop-tensor", "slop-utils", "thiserror 1.0.69", @@ -21178,9 +21147,9 @@ dependencies = [ [[package]] name = "sp1-build" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4767bc4addfc49402c4aa3d48424f5b6207021ccce7d90c6f2e21637f88875d" +checksum = "40672b22ed12ff30619b043c1cbb2290f51261a1862e21a5a96270efda3583bd" dependencies = [ "anyhow", "cargo_metadata", @@ -21192,8 +21161,8 @@ dependencies = [ [[package]] name = "sp1-cluster-artifact" -version = "2.7.2" -source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.7.2#e27316fa99c87ec761b8ceba1a2c7d6b55470983" +version = "2.8.4" +source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.8.4#e878e6d5d03613de39ab574d6dbeef0b76f0dfab" dependencies = [ "anyhow", "async-scoped", @@ -21218,8 +21187,8 @@ dependencies = [ [[package]] name = "sp1-cluster-common" -version = "2.7.2" -source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.7.2#e27316fa99c87ec761b8ceba1a2c7d6b55470983" +version = "2.8.4" +source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.8.4#e878e6d5d03613de39ab574d6dbeef0b76f0dfab" dependencies = [ "backoff", "chrono", @@ -21249,8 +21218,8 @@ dependencies = [ [[package]] name = "sp1-cluster-utils" -version = "2.7.2" -source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.7.2#e27316fa99c87ec761b8ceba1a2c7d6b55470983" +version = "2.8.4" +source = "git+https://github.com/succinctlabs/sp1-cluster?tag=v2.8.4#e878e6d5d03613de39ab574d6dbeef0b76f0dfab" dependencies = [ "eyre", "sp1-cluster-artifact", @@ -21263,21 +21232,18 @@ dependencies = [ [[package]] name = "sp1-core-executor" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be57804fd2b96823be15b8594de1a83bb1a52ebff3c00fadce864f60e0d4729" +checksum = "f45ca924caf27ceee7b00adfd4b3858c2cbaa1f394fb00cb4e584a62223d8d02" dependencies = [ "bincode 1.3.3", - "bytemuck", "cfg-if", - "clap", "deepsize2", "elf", "enum-map", "eyre", "gecko_profile", "hashbrown 0.14.5", - "hex", "indicatif 0.17.11", "itertools 0.14.0", "memmap2", @@ -21300,16 +21266,16 @@ dependencies = [ "subenum", "thiserror 1.0.69", "tiny-keccak", + "tokio", "tracing", "typenum", - "vec_map", ] [[package]] name = "sp1-core-executor-runner" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71cd12e2b4cf675331efc84e4dfa35291b58324830322f9f358fb0fed9377ad" +checksum = "20d74373eee66ec16ceb1e9aaa28d9d703210f13a3f1fe6b514c194df5b246f9" dependencies = [ "base64 0.22.1", "bincode 1.3.3", @@ -21329,9 +21295,9 @@ dependencies = [ [[package]] name = "sp1-core-executor-runner-binary" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cf91dece3d73534af44012320dccf58f1f31083db5bf316e9255b7efba39812" +checksum = "211f1b73314b13fe4da22fe91d9a1a77d64df6be3172132f96c94aa0019babdf" dependencies = [ "bincode 1.3.3", "crash-handler", @@ -21344,14 +21310,12 @@ dependencies = [ [[package]] name = "sp1-core-machine" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227e5bdfb4da5e08867524a153c8925521a9d9f85b420ee14900982b01e75daf" +checksum = "13e440279541a50a1e166ea008b70c0d30a64830418b829d9e36b920eea1eb7c" dependencies = [ "bincode 1.3.3", "cfg-if", - "enum-map", - "futures", "generic-array 1.1.0", "hashbrown 0.14.5", "itertools 0.14.0", @@ -21365,12 +21329,10 @@ dependencies = [ "slop-air", "slop-algebra", "slop-challenger", - "slop-futures", "slop-keccak-air", "slop-matrix", "slop-maybe-rayon", "slop-uni-stark", - "snowbridge-amcl", "sp1-core-executor", "sp1-core-executor-runner", "sp1-curves", @@ -21382,7 +21344,6 @@ dependencies = [ "struct-reflection", "strum 0.27.2", "sysinfo 0.30.13", - "tempfile", "thiserror 1.0.69", "tokio", "tracing", @@ -21393,14 +21354,16 @@ dependencies = [ [[package]] name = "sp1-curves" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d313c8619522fd363e143e6ba3bf3effdf19ca9b73abdda051313da40b7858af" +checksum = "ac61970510b3b965a58935e986e0dd04815f82247966c5823dbe3cc451db6e8f" dependencies = [ "cfg-if", + "curve25519-dalek", "dashu", "elliptic-curve 0.13.8", "generic-array 1.1.0", + "group 0.13.0", "itertools 0.14.0", "k256 0.13.4", "num", @@ -21414,9 +21377,9 @@ dependencies = [ [[package]] name = "sp1-derive" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acb95a4c9b193b05784fe07126a12e7d8bd7e13cdb943116a92bc5f03a1d4f70" +checksum = "7cbe5112430ab736c540513cf18d7e4e69d715ac347b90cefb45c4280f5da880" dependencies = [ "proc-macro2", "quote", @@ -21425,14 +21388,13 @@ dependencies = [ [[package]] name = "sp1-hypercube" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab3b0ba307c635c80725243d9a190d509ae9b4d42326de56acd4110e186671c" +checksum = "422e4ae52c18e39f07a7f38b4593c4b002339193dc6d9f51e993b5cc2a5718df" dependencies = [ "arrayref", "deepsize2", "derive-where", - "futures", "hashbrown 0.14.5", "itertools 0.14.0", "num-bigint 0.4.8", @@ -21445,7 +21407,6 @@ dependencies = [ "slop-algebra", "slop-alloc", "slop-basefold", - "slop-basefold-prover", "slop-bn254", "slop-challenger", "slop-commit", @@ -21474,9 +21435,9 @@ dependencies = [ [[package]] name = "sp1-jit" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c0b4b8415bc52929b7ea372802af4e56913227642c3bd677d58ec2492d37f4a" +checksum = "5d0fc35717922ed7133f18a48c2cf257c31a3c09190e7951239b0fad2a1f8632" dependencies = [ "dynasmrt", "hashbrown 0.14.5", @@ -21502,9 +21463,9 @@ dependencies = [ [[package]] name = "sp1-primitives" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9b6b5cc2f53c2c96b3ef611781ea151c61d541a90c467041ce3aac476001fe" +checksum = "d387e4babbee68d30774ef52aad88aad3607eda8145a0badfa823e1759187c0c" dependencies = [ "bincode 1.3.3", "blake3", @@ -21526,16 +21487,15 @@ dependencies = [ [[package]] name = "sp1-prover" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a423e8b3d8a6346ef16103e0175e35fa1ca578daa136c4ebd7051a4c9f25b4e5" +checksum = "2f0f1d58a3144090a2250f48d4743e3319e861a6537d3262d848380e2d284e59" dependencies = [ "anyhow", "bincode 1.3.3", "clap", "dirs 5.0.1", "either", - "enum-map", "eyre", "fd-lock", "futures", @@ -21547,7 +21507,6 @@ dependencies = [ "mti", "num-bigint 0.4.8", "opentelemetry 0.23.0", - "pin-project", "rand 0.8.7", "reqwest 0.12.28", "serde", @@ -21560,14 +21519,10 @@ dependencies = [ "slop-bn254", "slop-challenger", "slop-futures", - "slop-jagged", - "slop-multilinear", - "slop-stacked", "slop-symmetric", "sp1-core-executor", "sp1-core-executor-runner", "sp1-core-machine", - "sp1-derive", "sp1-hypercube", "sp1-jit", "sp1-primitives", @@ -21579,21 +21534,19 @@ dependencies = [ "sp1-recursion-machine", "sp1-verifier", "static_assertions", - "sysinfo 0.30.13", "tempfile", "thiserror 1.0.69", "tokio", "tonic 0.12.3", "tracing", - "tracing-appender", "tracing-subscriber", ] [[package]] name = "sp1-prover-types" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0761b96e7f983f77aa8957e1b6edb00b4bd8c6f7f85f3e63f7f36040bc90a352" +checksum = "9506d53b429a903c028517d325b12153a06015c7a0d698f9bde41b9de2f33072" dependencies = [ "anyhow", "async-scoped", @@ -21615,9 +21568,9 @@ dependencies = [ [[package]] name = "sp1-recursion-circuit" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58681fd1d6103ae82defd3c1a95eac0d5f54235512a2a962648fcd6d44002dd" +checksum = "691d4940e635589ef3bc77e82d883c02c93cd6361adde60f5e75715cb3e97e99" dependencies = [ "bincode 1.3.3", "itertools 0.14.0", @@ -21655,9 +21608,9 @@ dependencies = [ [[package]] name = "sp1-recursion-compiler" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708f2e05a4baccc710ffe66583c92508db647c483f2aa06d4de8d4a01dcf4de2" +checksum = "b4e7e8bd9f63df9cbf683fe148ccc1fbe645b211470e470ac7f3b93794064f4d" dependencies = [ "backtrace", "cfg-if", @@ -21676,9 +21629,9 @@ dependencies = [ [[package]] name = "sp1-recursion-executor" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311506929fc2849f71f3f414d2f5c9d791786bbf72dd19ad6bde69495a95fec6" +checksum = "115fa5ab8ff56deb955244f7525628a99b8d951883fb390678ea5bbd0c74f893" dependencies = [ "backtrace", "cfg-if", @@ -21700,15 +21653,14 @@ dependencies = [ [[package]] name = "sp1-recursion-gnark-ffi" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e470a218ab7e27fa039ce550e355855a0bad071b563403c2b87696150c2f218e" +checksum = "365c2d0b7da67108703dfd9c8f897e28ed24c6460f5af1ce5c7077222303421d" dependencies = [ "anyhow", "bincode 1.3.3", "bindgen 0.70.1", "cfg-if", - "hex", "num-bigint 0.4.8", "serde", "serde_json", @@ -21725,9 +21677,9 @@ dependencies = [ [[package]] name = "sp1-recursion-machine" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359102596c4df100156febfd2d5a6a3e72f37f2e951274d27d3523e0fd6063fd" +checksum = "2978f9522abc4e53e55e58a2bd3b5ce812e0e275dce705b55c4c451648c083ea" dependencies = [ "itertools 0.14.0", "rand 0.8.7", @@ -21747,9 +21699,9 @@ dependencies = [ [[package]] name = "sp1-sdk" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830a3aeeb1b3f2867171b8c92a3222c80a87531821017d347262163ec3cb2add" +checksum = "b2e5996edef461e0e7351ed0312423e12b78a2f68d62eeb3aae1a535dca30644" dependencies = [ "alloy-primitives", "alloy-signer 1.8.3", @@ -21762,11 +21714,9 @@ dependencies = [ "backoff", "bincode 1.3.3", "cfg-if", - "dirs 5.0.1", "eventsource-stream", "futures", "hex", - "indicatif 0.17.11", "itertools 0.14.0", "k256 0.13.4", "num-bigint 0.4.8", @@ -21775,7 +21725,6 @@ dependencies = [ "reqwest-middleware", "rustls 0.23.43", "serde", - "sha2 0.10.9", "sp1-build", "sp1-core-executor", "sp1-core-executor-runner", @@ -21787,7 +21736,6 @@ dependencies = [ "sp1-recursion-executor", "sp1-recursion-gnark-ffi", "sp1-verifier", - "strum 0.27.2", "tempfile", "thiserror 1.0.69", "tokio", @@ -21799,14 +21747,13 @@ dependencies = [ [[package]] name = "sp1-verifier" -version = "6.4.0" +version = "6.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5babf73e25052dac9de9f4d6af706b196c4cf4f441e94ef66de2bc30d4579589" +checksum = "aca7223cc7a77e42536c7229c8016672737aab07f6149097d9c7a26324b29814" dependencies = [ "bincode 1.3.3", "blake3", "cfg-if", - "dirs 5.0.1", "hex", "lazy_static", "serde", @@ -23017,6 +22964,7 @@ dependencies = [ "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] @@ -23993,9 +23941,6 @@ name = "vec_map" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" -dependencies = [ - "serde", -] [[package]] name = "vergen" @@ -24307,7 +24252,7 @@ dependencies = [ [[package]] name = "websocket-proxy" -version = "1.4.2" +version = "1.5.0" dependencies = [ "axum 0.8.9", "backoff", @@ -24329,7 +24274,7 @@ dependencies = [ [[package]] name = "websocket-proxy-bin" -version = "1.4.2" +version = "1.5.0" dependencies = [ "axum 0.8.9", "base-cli-utils", @@ -25005,29 +24950,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "wiremock" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" -dependencies = [ - "assert-json-diff", - "base64 0.22.1", - "deadpool", - "futures", - "http 1.4.2", - "http-body-util", - "hyper 1.11.0", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", -] - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 9561ee8124..612d818e59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "1.4.2" +version = "1.5.0" edition = "2024" rust-version = "1.96" license = "MIT" @@ -11,6 +11,7 @@ exclude = [".github/"] resolver = "2" exclude = ["actions/fixtures", "crates/proof/zk/programs"] members = [ + "acceptance", "actions/*", "bin/[a-o]*", "bin/[q-z]*", @@ -227,13 +228,13 @@ base-execution-eip8130-rpc-node = { path = "crates/execution/eip8130-rpc-node" } base-execution-eip8130 = { path = "crates/execution/eip8130", default-features = false } # Infra +base-acceptance = { path = "acceptance" } basectl-cli = { path = "crates/infra/basectl" } base-roxy = { path = "crates/infra/roxy" } base-sidecrush = { path = "crates/infra/sidecrush" } audit-archiver-lib = { path = "crates/infra/audit" } base-snark-e2e = { path = "crates/infra/snark-e2e" } 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" } base-telemetry-service = { path = "crates/infra/telemetry" } @@ -329,6 +330,7 @@ alloy-rlp = { version = "0.3.16", default-features = false } alloy-trie = { version = "0.9.4", default-features = false } alloy-eips = { version = "2.3.0", default-features = false } alloy-evm = { version = "0.38.0", default-features = false } +revm-inspectors = { version = "0.42.2", default-features = false } alloy-serde = { version = "2.3.0", default-features = false } alloy-chains = { version = "0.2.33", default-features = false } alloy-genesis = { version = "2.3.0", default-features = false } @@ -347,76 +349,77 @@ alloy-network-primitives = { version = "2.3.0", default-features = false } reth-zstd-compressors = { version = "0.6.0", default-features = false } reth-primitives-traits = { version = "0.6.0", default-features = false } reth-codecs = { version = "0.6.0", default-features = false, features = ["alloy"] } -reth-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-cli = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-ipc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-evm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-revm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-trie = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-exex = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-tasks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-ecies = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-db-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-discv4 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-discv5 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-trie-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-network = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-net-nat = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-tracing = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-eth-wire = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-provider = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-cli-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-node-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-engine-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-db-models = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-db-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-layer = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-node-core = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-consensus = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-chainspec = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-cli-runner = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-tokio-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-convert = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-network-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-network-p2p = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-storage-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-eth-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-chain-state = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-trie-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-engine-tree = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-payload-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-node-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-node-metrics = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-cli-commands = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-evm-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-tracing-otlp = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-testing-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-eth-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-network-peers = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-node-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-engine-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-e2e-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-eth-wire-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-execution-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-execution-cache = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-exex-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-rpc-server-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-transaction-pool = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-execution-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-payload-validator = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-payload-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-ethereum-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-basic-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-payload-builder-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } -reth-prune-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } -reth-stages-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } -reth-storage-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } -reth-ethereum-forks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } -reth-consensus-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6", default-features = false } -reth-trie-parallel = { git = "https://github.com/base/reth", tag = "base-v2.5.2.6" } +reth-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-cli = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-ipc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-evm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-revm = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-trie = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-exex = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-tasks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-ecies = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-db-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-discv4 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-discv5 = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-trie-db = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-network = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-net-nat = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-tracing = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-eth-wire = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-provider = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-cli-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-node-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-engine-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-db-models = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-db-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-layer = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-node-core = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-consensus = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-chainspec = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-cli-runner = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-tokio-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-convert = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-network-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-network-p2p = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-storage-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-eth-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-chain-state = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-trie-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-engine-tree = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-payload-util = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-node-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-node-metrics = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-cli-commands = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-evm-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-tracing-otlp = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-testing-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-eth-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-network-peers = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-node-ethereum = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-engine-api = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-e2e-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-eth-wire-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-execution-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-execution-cache = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-exex-test-utils = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-rpc-server-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-transaction-pool = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-execution-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-payload-validator = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-payload-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-ethereum-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-basic-payload-builder = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-payload-builder-primitives = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } +reth-prune-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8", default-features = false } +reth-stages-types = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8", default-features = false } +reth-storage-errors = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8", default-features = false } +reth-ethereum-forks = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8", default-features = false } +reth-consensus-common = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8", default-features = false } +reth-trie-parallel = { git = "https://github.com/base/reth", tag = "base-v2.5.2.8" } # tokio tokio = "1.53.1" @@ -445,8 +448,6 @@ arboard = "3.6.1" ratatui = "0.30.0" crossterm = "0.29" indicatif = "0.18" -human_bytes = "0.4.3" -clap_builder = "4.6.5" tracing-indicatif = "0.3" # tracing @@ -456,10 +457,8 @@ tracing = { version = "0.1.43", default-features = false } # OpenTelemetry opentelemetry = "0.32" -opentelemetry-appender-tracing = "0.32" opentelemetry_sdk = { version = "0.32", default-features = false } opentelemetry-http = { version = "0.32", default-features = false } -opentelemetry-otlp = { version = "0.32", default-features = false } # metrics cadence = "1.5" @@ -477,12 +476,12 @@ aws-sdk-ec2 = { version = "1.217.0", default-features = false } aws-credential-types = { version = "1.1.7", default-features = false } aws-sdk-elasticloadbalancingv2 = { version = "1.109.0", default-features = false } -# SP1 v6.4.0 (Hypercube) — used by crates/proof/zk -sp1-prover-types = "=6.4.0" -sp1-sdk = { version = "=6.4.0" } -sp1-cluster-utils = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.7.2" } -sp1-cluster-common = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.7.2" } -sp1-cluster-artifact = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.7.2" } +# SP1 v6.8.1 (Hypercube) — used by crates/proof/zk +sp1-prover-types = "=6.8.1" +sp1-sdk = { version = "=6.8.1" } +sp1-cluster-utils = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.8.4" } +sp1-cluster-common = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.8.4" } +sp1-cluster-artifact = { git = "https://github.com/succinctlabs/sp1-cluster", tag = "v2.8.4" } # crypto sha3 = "0.10" @@ -518,7 +517,6 @@ discv5 = "0.10" libp2p = "0.56.0" multihash = "0.19" libp2p-identity = "0.2.12" -libp2p-stream = "0.4.0-alpha" unsigned-varint = { version = "0.8", default-features = false } # serde @@ -550,7 +548,6 @@ similar-asserts = "1" pretty_assertions = "1.4" iai-callgrind = "0.16.1" arbitrary = { version = "1", default-features = false } -wiremock = { version = "0.6.2", default-features = false } testcontainers = { version = "0.25", default-features = false } testcontainers-modules = { version = "0.13", default-features = false } @@ -605,7 +602,6 @@ nanoid = "0.4" rand_08 = { package = "rand", version = "0.8" } # time -time = "0.3.55" chrono = "0.4.42" humantime = "2.3" @@ -629,7 +625,6 @@ derive_more = { version = "2.1.0", default-features = false } spin = "0.10" cfg-if = "1.0" alloc-no-stdlib = "2.0" -lazy_static = { version = "1.5.0", features = ["spin_no_std"] } # system libc = "0.2" @@ -640,14 +635,13 @@ tikv-jemallocator = "0.6" # tooling notify = "8.2" -cargo_metadata = "0.18.1" # rate limiting governor = "0.7" # Firehose tracer (defined by the streamingfast reth fork, release/base-2.x branch). -reth-firehose = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-firehose-tests = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } +reth-firehose = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-firehose-tests = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } firehose-tracer = "5.5.0" # Shared tracing-regression framework (capture / invariants / projection / golden). @@ -662,73 +656,74 @@ firehose-tracer-prestate = "5.5.0" alloy-evm = { git = "https://github.com/streamingfast/evm.git", tag = "v0.38.0-sf" } [patch."https://github.com/base/reth"] -reth-basic-payload-builder = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-chain-state = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-chainspec = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-cli = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-cli-commands = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-cli-runner = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-cli-util = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-consensus = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-consensus-common = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-db = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-db-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-db-common = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-db-models = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-discv4 = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-discv5 = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-e2e-test-utils = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-ecies = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-engine-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-engine-tree = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-eth-wire = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-eth-wire-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-ethereum-forks = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-ethereum-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-evm = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-evm-ethereum = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-execution-cache = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-execution-errors = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-execution-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-exex = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-exex-test-utils = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-ipc = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-net-nat = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-network = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-network-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-network-p2p = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-network-peers = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-node-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-node-builder = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-node-core = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-node-ethereum = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-node-metrics = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-payload-builder = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-payload-builder-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-payload-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-payload-util = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-payload-validator = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-provider = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-prune-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-revm = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-convert = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-engine-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-eth-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-eth-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-layer = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-rpc-server-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-stages-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-storage-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-storage-errors = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-tasks = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-testing-utils = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-tokio-util = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-tracing = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-tracing-otlp = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-transaction-pool = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-trie = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-trie-common = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-trie-db = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } -reth-trie-parallel = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.6-fh3.1-1" } +reth-basic-payload-builder = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-chain-state = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-chainspec = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-cli = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-cli-commands = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-cli-runner = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-cli-util = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-consensus = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-consensus-common = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-db = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-db-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-db-common = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-db-models = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-discv4 = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-discv5 = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-e2e-test-utils = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-ecies = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-engine-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-engine-tree = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-errors = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-eth-wire = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-eth-wire-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-ethereum-forks = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-ethereum-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-evm = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-evm-ethereum = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-execution-cache = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-execution-errors = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-execution-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-exex = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-exex-test-utils = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-ipc = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-net-nat = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-network = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-network-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-network-p2p = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-network-peers = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-node-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-node-builder = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-node-core = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-node-ethereum = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-node-metrics = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-payload-builder = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-payload-builder-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-payload-primitives = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-payload-util = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-payload-validator = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-provider = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-prune-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-revm = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-convert = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-engine-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-eth-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-eth-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-layer = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-rpc-server-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-stages-types = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-storage-api = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-storage-errors = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-tasks = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-testing-utils = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-tokio-util = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-tracing = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-tracing-otlp = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-transaction-pool = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-trie = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-trie-common = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-trie-db = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } +reth-trie-parallel = { git = "https://github.com/streamingfast/reth.git", tag = "base-v2.5.2.8-fh3.1" } diff --git a/Justfile b/Justfile index 4a7ce4afb8..c293bb5873 100644 --- a/Justfile +++ b/Justfile @@ -69,7 +69,7 @@ setup: ci: fix check::all test lychee zepter check::no-std check::no-std-proof # Runs ci checks with tests scoped to crates affected by changes -pr: fix check::format check::udeps check::clippy check::deny lychee zepter check::no-std check::no-std-proof test-affected +pr: fix check::format check::udeps check::clippy check::deny check::docs-index lychee zepter check::no-std check::no-std-proof test-affected # Performs lychee checks, installing the lychee command if necessary lychee: @@ -99,6 +99,14 @@ install-nextest: test: install-nextest build::contracts build::elfs cargo nextest run --workspace --all-features --exclude base-system-tests --no-fail-fast +# Checks published Base snapshot manifests without downloading snapshot archives (live network) +check-snapshot-manifests: + cargo test --locked -p base --test snapshot_defaults -- --ignored --nocapture + +# Runs one real-devnet acceptance scenario (requires Docker and no existing devnet) +acceptance scenario="smoke": + cargo run --locked -p base-acceptance-cli -- run acceptance/scenarios/{{ scenario }}.toml --output target/acceptance/{{ scenario }}-$(date +%s) + # Runs tests only for crates affected by changes vs main (excludes system tests) test-affected base="main": install-nextest build::contracts build::elfs #!/usr/bin/env bash diff --git a/acceptance/Cargo.toml b/acceptance/Cargo.toml new file mode 100644 index 0000000000..5ad7dba31b --- /dev/null +++ b/acceptance/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "base-acceptance" +description = "Configurable real-devnet acceptance scenarios and offline reports" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +eyre.workspace = true +chrono.workspace = true +humantime.workspace = true +clap = { workspace = true, features = ["derive"] } +reqwest = { workspace = true, features = ["json"] } +serde_json = { workspace = true, features = ["std"] } +serde = { workspace = true, features = ["derive", "std"] } +toml = { workspace = true, features = ["parse", "serde", "display"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "signal", "process", "io-util", "net", "sync"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/acceptance/README.md b/acceptance/README.md new file mode 100644 index 0000000000..8fc7b54b28 --- /dev/null +++ b/acceptance/README.md @@ -0,0 +1,246 @@ +# Acceptance testing + +The acceptance runner provisions the repository's real Docker Compose devnet and +checks its externally visible RPC behavior. Scenarios are strict, versioned TOML; +each run produces a portable JSON result and an offline visual report. + +The checked-in scenarios are: + +- `smoke`: chain identity, block production, and validator convergence. +- `health`: sustained sampled health across all four L2 node roles. +- `derivation`: unsafe production, safe-head derivation, and head freshness. +- `denim-transition`: progress and convergence around Denim, including the shadow validator's + safe chain derived from the base batcher's batches. + +## Requirements + +- Linux, Git, Rust/Cargo, Docker with Compose 2.24.4+ and Buildx, and enough resources to + build and run the canonical devnet. +- Acceptance publishes only execution RPCs on dynamically allocated localhost ports. +- No canonical devnet containers may already exist. + +Run commands from the repository root. `cargo run` compiles the CLI as needed: + +```console +cargo run -p base-acceptance-cli -- validate acceptance/scenarios/smoke.toml +cargo run -p base-acceptance-cli -- plan acceptance/scenarios/smoke.toml +cargo run -p base-acceptance-cli -- run acceptance/scenarios/smoke.toml \ + --output target/acceptance/smoke +``` + +The output directory must be new or empty. A managed run builds images, generates +fresh chain state, verifies genesis and the rollup schedule, waits for the required +RPCs to advance, executes checks, collects bounded logs, and removes owned Docker +resources and private chain state. + +## Scenario format: provision, then check + +A scenario has two phases in one TOML document. Top-level, `devnet`, and +`readiness` fields describe what to provision and when it is usable; `[[checks]]` +tables describe read-only observations made after readiness. + +```toml +schema_version = 1 +id = "example" +description = "Production and agreement across Denim" +timeout = "10m" + +# Phase 1: provisioning and readiness. +[devnet] +topology = "single-sequencer" + +[devnet.l1] +chain_id = 1337 +slot_duration = "12s" + +[devnet.l2] +chain_id = 84538453 +verifier_l1_confirmations = 15 + +[devnet.l2.forks] +azul = { at_block = 20 } +beryl = { at_block = 21 } +cobalt = { at_block = 22 } +denim = { at_block = 150 } +zenith = { disabled = true } + +[readiness] +timeout = "4m" +request_timeout = "2s" +poll_interval = "1s" + +# Phase 2: ordered, bounded checks. +[[checks]] +id = "before-denim" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 3 +timeout = "30s" +start = { before_fork = "denim", chain = "l2" } + +[[checks]] +id = "after-denim-agreement" +kind = "heads_converge" +endpoints = ["builder", "validator"] +head = "latest" +max_lag_blocks = 5 +timeout = "90s" +start = { after_fork = "denim", chain = "l2" } +``` + +Unknown fields are rejected. Defaults are the single-sequencer topology, chain +IDs shown above, a 12-second L1 slot, 15 confirmations, and L2 forks Azul/Beryl/ +Cobalt/Denim at blocks 20/21/22/25 with Zenith disabled. Supported endpoint roles +are `l1`, `builder`, `validator`, `rpc`, and `shadow`. Supported check kinds are: + +- `chain_id`: `endpoint`, `expected`, and `timeout`. +- `head_progress` and `safe_head_progress`: `endpoint`, `minimum_blocks`, and `timeout`. +- `heads_converge`: at least two `endpoints`, optional `head` (`latest`, `safe`, or + `finalized`), `max_lag_blocks`, and `timeout`. +- `head_fresh`: `endpoint`, `maximum_age` (whole seconds), `duration`, and `timeout`; `duration` must be + shorter than `timeout`. +- `heads_healthy`: at least two distinct L2 `endpoints`, optional `warmup` (default + `0s`), `duration`, non-zero `minimum_blocks` per node, `maximum_age` (whole seconds), + `max_lag_blocks`, and `timeout`. The timeout includes warmup and observations. + +Every check may have one L2 `start` condition with exactly one of `before_fork` or +`after_fork`. Checks run in file order. A `before_fork` check cannot target a fork active at genesis or +follow an `after_fork` check whose fork activates at or after its own. Builder RPC failures while +waiting for a fork are retried until the scenario deadline; a reachable builder that never reaches +an `after_fork` activation fails that check. `heads_converge` never accepts a common height of zero, +so `safe` and `finalized` comparisons wait for derivation to progress. With an `after_fork` start, it +also waits for a common block timestamped at or after the fork activation, so it compares a post-fork +block. See `scenarios/` for complete examples. + +## CLI workflows + +Validation and planning do not contact Docker or RPCs. `plan` prints the resolved +configuration, including defaults, as JSON: + +```console +cargo run -p base-acceptance-cli -- validate acceptance/scenarios/*.toml +cargo run -p base-acceptance-cli -- plan acceptance/scenarios/denim-transition.toml +``` + +Use `--no-build` only when the required images have already been built and loaded: + +```console +cargo run -p base-acceptance-cli -- run acceptance/scenarios/smoke.toml \ + --output target/acceptance/smoke-cached --no-build \ + --run-id local-smoke --tested-sha "$(git rev-parse HEAD)" +``` + +Attach mode performs no provisioning, log collection, or cleanup. The endpoint +file is a JSON object mapping logical roles to credential-free HTTP(S) URLs. Add +`--rollup` when a scenario has a fork-relative check (or when schedule reporting +is desired); its chain IDs and fork timestamps are verified before use. + +```json +{"l1":"http://127.0.0.1:4545","builder":"http://127.0.0.1:7545","validator":"http://127.0.0.1:8545"} +``` + +```console +cargo run -p base-acceptance-cli -- check acceptance/scenarios/smoke.toml \ + --endpoints endpoints.json --output target/acceptance/attached +``` + +Re-render a portable result and its referenced evidence into another directory: + +```console +cargo run -p base-acceptance-cli -- report \ + target/acceptance/smoke/report/result.json --output target/acceptance/rendered +``` + +Sharded CI first records exactly which scenarios and checks are expected, then +strictly aggregates one matching result for each scenario: + +```console +cargo run -p base-acceptance-cli -- manifest acceptance/scenarios/*.toml \ + --run-id run-1 --tested-sha "$GIT_SHA" --output expected.json +cargo run -p base-acceptance-cli -- aggregate --expected expected.json \ + --results downloaded-results --output target/acceptance/aggregate +``` + +The CI-only `publish` command validates aggregate artifacts and safely updates the +marker-owned PR comment. Its `GitHub` credentials and provenance inputs are supplied +by the trusted-base publication job; it is not part of the local runner workflow. +It accepts `--event`, `--result`, `--expected`, `--run-id`, `--attempt`, and +`--started-at`, with `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `TESTED_SHA`, and optional +`BOT_LOGIN` in the environment. + +If a managed process is killed before cleanup, recover only the resources named +by its private ownership manifest: + +```console +cargo run -p base-acceptance-cli -- cleanup \ + --manifest target/acceptance/smoke/private/ownership.json +``` + +Cleanup requires the local `devnet-setup:local-v2` image to remove root-owned chain +state. If that image was removed after the run, rebuild it from the same checkout +before retrying cleanup: + +```console +docker build --tag devnet-setup:local-v2 -f etc/docker/Dockerfile.devnet . +``` + +Recovery must run on the original machine and Docker daemon with the recorded +repository and output paths intact; the manifest is not a portable backup. + +## Results and status + +A run writes these user-facing files below `/report/`: + +- `report.html`: self-contained offline visual report; open it in a browser. +- `result.json`: versioned portable source of truth. +- `summary.md`: concise CI/PR summary. +- `evidence/heads.json` and, for managed runs, `evidence/compose.log`. +- `environment.json` for managed runs, containing inspected image IDs and whether + this invocation built them. + +Check and lifecycle statuses are `passed`, `failed`, `error`, `blocked`, and +`cancelled`. Exit code 0 means all checks passed, 1 means an assertion failed, 2 +means invalid invocation/configuration, and 3 means infrastructure prevented a +complete evaluation. Infrastructure takes precedence when outcomes are mixed. + +The advisory Depot workflow validates scenarios, runs one selected shard, uploads +its report bundle, and strictly aggregates expected results. Reports are artifacts; +PR publication is available only when the trusted base revision contains the +publisher. Same-repository PRs run smoke automatically; manual dispatch selects +any checked-in scenario. The publisher updates only its marker-owned +`depot-code-access[bot]` comment (override with the `BOT_LOGIN` repository variable +for a different bot). Reports remain artifact-only on the first introduction PR. + +The publisher treats artifacts as data and validates their identity, schema, and +expected check sets. Both the results and expected manifest come from PR-controlled +code: a passing advisory comment is not an independent attestation that tests ran. +Trusted rendering protects the publisher's credentials, not the truth of those +observations; review the scenario and runner changes alongside the results. + +## Testing the runner and report + +```console +cargo test --locked -p base-acceptance -p base-acceptance-cli +cargo run -p base-acceptance-cli -- report \ + acceptance/fixtures/reports/synthetic-mixed.json --output target/acceptance/preview +``` + +The synthetic fixture deliberately contains a failure and a startup error, so +the final command returns exit code 3 while still writing the visual report. +It is a rendering example, not evidence of a real devnet run. + +## Limitations and safety + +- Only the fixed `single-sequencer` topology is supported. Fixed container names + and its subnet, plus a host lock, mean acceptance runs must be serial and cannot + share a machine with the canonical developer devnet. +- Configurable L1 forks, including Glamsterdam, are unsupported: the pinned + devnet generator does not expose a compatible Glamsterdam schedule. +- `--no-build` inspects and reports cached local image IDs. It does **not** prove + that those images correspond to the current source revision. +- Attach mode validates RPC identity and behavior but does not own the target or + apply the provisioning configuration. A supplied rollup verifies its schedule. +- Results are bounded observations, not a proof of protocol correctness. A fork + crossing records the first observed active block, not proof of fork behavior. +- `heads_healthy` guarantees only the sampled windows; it cannot prove that no + node restarted between samples. diff --git a/acceptance/fixtures/reports/evidence/healthy-chain.json b/acceptance/fixtures/reports/evidence/healthy-chain.json new file mode 100644 index 0000000000..fd2b2359ec --- /dev/null +++ b/acceptance/fixtures/reports/evidence/healthy-chain.json @@ -0,0 +1 @@ +{"synthetic":true,"chain_id":84538453,"note":"Illustrative fixture, not a real RPC observation."} diff --git a/acceptance/fixtures/reports/evidence/lag-heads.json b/acceptance/fixtures/reports/evidence/lag-heads.json new file mode 100644 index 0000000000..417ac80d92 --- /dev/null +++ b/acceptance/fixtures/reports/evidence/lag-heads.json @@ -0,0 +1 @@ +{"synthetic":true,"at_elapsed_ms":50000,"builder":2025,"validator":2011,"lag_blocks":14,"maximum_lag_blocks":5} diff --git a/acceptance/fixtures/reports/synthetic-mixed.json b/acceptance/fixtures/reports/synthetic-mixed.json new file mode 100644 index 0000000000..5ade930648 --- /dev/null +++ b/acceptance/fixtures/reports/synthetic-mixed.json @@ -0,0 +1,80 @@ +{ + "schema_version": 1, + "run_id": "synthetic-report-preview-NOT-A-REAL-RUN", + "tested_sha": "synthetic-fixture", + "started_at_unix_ms": 1789785600000, + "scenarios": [ + { + "id": "healthy-synthetic", + "status": "passed", + "duration_ms": 42000, + "config": {"fixture": true, "topology": "single-sequencer", "l2_chain_id": 84538453, "generator_revision": "synthetic"}, + "stages": [ + {"id": "setup", "status": "passed", "duration_ms": 12000, "message": "Synthetic setup completed"}, + {"id": "readiness", "status": "passed", "duration_ms": 7000, "message": "Synthetic endpoints ready"}, + {"id": "checks", "status": "passed", "duration_ms": 21000, "message": "All synthetic checks passed"}, + {"id": "cleanup", "status": "passed", "duration_ms": 2000, "message": "Synthetic cleanup completed"} + ], + "checks": [{"id":"chain-identity","kind":"chain_id","status":"passed","duration_ms":900,"expected":{"chain_id":84538453},"observed":{"chain_id":84538453},"message":"Observed the configured synthetic chain identity.","next_step":"No action required.","samples":1,"rpc_errors":0,"evidence":["evidence/healthy-chain.json"]}], + "samples": [ + {"endpoint":"builder","elapsed_ms":0,"number":1000,"timestamp":1789785600,"hash":"0x1000"}, + {"endpoint":"builder","elapsed_ms":10000,"number":1005,"timestamp":1789785610,"hash":"0x1005"}, + {"endpoint":"validator","elapsed_ms":0,"number":999,"timestamp":1789785598,"hash":"0x0999"}, + {"endpoint":"validator","elapsed_ms":10000,"number":1005,"timestamp":1789785610,"hash":"0x1005"} + ], + "forks": [], + "diagnostics": ["Synthetic fixture data; not evidence of a real devnet run."], + "reproduction": "SYNTHETIC ONLY: base-acceptance run acceptance/scenarios/smoke.toml" + }, + { + "id": "lag-failure-synthetic", + "status": "passed", + "duration_ms": 76000, + "config": {"fixture": true, "max_lag_blocks": 5, "fork_schedule": {"denim": 1789785630}}, + "stages": [ + {"id":"setup","status":"passed","duration_ms":11000,"message":"Synthetic setup completed"}, + {"id":"readiness","status":"passed","duration_ms":5000,"message":"Synthetic endpoints ready"}, + {"id":"checks","status":"failed","duration_ms":58000,"message":"A synthetic assertion failed"}, + {"id":"cleanup","status":"passed","duration_ms":2000,"message":"Synthetic cleanup completed"} + ], + "checks": [{"id":"validator-catches-up","kind":"heads_converge","status":"failed","duration_ms":58000,"expected":{"max_lag_blocks":5,"common_height_hashes_equal":true},"observed":{"lag_blocks":14,"common_height_hashes_equal":true,"at_elapsed_ms":50000},"message":"Validator lag was 14 blocks at the deadline; the allowed maximum was 5.","next_step":"Inspect validator derivation and engine logs for the recorded observation window.","samples":9,"rpc_errors":1,"evidence":["evidence/lag-heads.json"]}], + "samples": [ + {"endpoint":"builder","elapsed_ms":0,"number":2000,"timestamp":1789785620,"hash":"0x2000"}, + {"endpoint":"builder","elapsed_ms":10000,"number":2005,"timestamp":1789785630,"hash":"0x2005"}, + {"endpoint":"builder","elapsed_ms":20000,"number":2010,"timestamp":1789785640,"hash":"0x2010"}, + {"endpoint":"builder","elapsed_ms":30000,"number":null,"timestamp":null,"hash":null}, + {"endpoint":"builder","elapsed_ms":40000,"number":2020,"timestamp":1789785660,"hash":"0x2020"}, + {"endpoint":"builder","elapsed_ms":50000,"number":2025,"timestamp":1789785670,"hash":"0x2025"}, + {"endpoint":"validator","elapsed_ms":0,"number":1998,"timestamp":1789785616,"hash":"0x1998"}, + {"endpoint":"validator","elapsed_ms":10000,"number":2001,"timestamp":1789785622,"hash":"0x2001"}, + {"endpoint":"validator","elapsed_ms":20000,"number":null,"timestamp":null,"hash":null}, + {"endpoint":"validator","elapsed_ms":30000,"number":2005,"timestamp":1789785630,"hash":"0x2005"}, + {"endpoint":"validator","elapsed_ms":40000,"number":2008,"timestamp":1789785636,"hash":"0x2008"}, + {"endpoint":"validator","elapsed_ms":50000,"number":2011,"timestamp":1789785642,"hash":"0x2011"} + ], + "forks": [{"name":"denim","chain":"l2","activation_timestamp":1789785630,"observed_block":2005,"observed_elapsed_ms":10000}], + "diagnostics": ["One explicit builder sampling gap and one validator sampling gap are retained.", "Observed boundary crossing is not proof of Denim behavior."], + "reproduction": "SYNTHETIC ONLY: base-acceptance run acceptance/scenarios/denim-transition.toml" + }, + { + "id": "startup-error-synthetic", + "status": "passed", + "duration_ms": 19000, + "config": {"fixture": true, "failure_injection": "synthetic startup timeout"}, + "stages": [ + {"id":"setup","status":"error","duration_ms":17000,"message":"Synthetic startup timed out before readiness"}, + {"id":"readiness","status":"blocked","duration_ms":0,"message":"Blocked by setup"}, + {"id":"checks","status":"blocked","duration_ms":0,"message":"Blocked by setup"}, + {"id":"cleanup","status":"passed","duration_ms":2000,"message":"Synthetic cleanup completed"} + ], + "checks": [ + {"id":"chain-identity","kind":"chain_id","status":"blocked","duration_ms":0,"expected":{"chain_id":84538453},"observed":{"reason":"setup prerequisite error"},"message":"Not evaluated because setup failed.","next_step":"Inspect the setup-stage diagnostic before retrying.","samples":0,"rpc_errors":0,"evidence":[]}, + {"id":"head-progress","kind":"head_progress","status":"blocked","duration_ms":0,"expected":{"minimum_blocks":3},"observed":{"reason":"setup prerequisite error"},"message":"Not evaluated because setup failed.","next_step":"Resolve startup, then rerun the complete scenario.","samples":0,"rpc_errors":0,"evidence":[]} + ], + "samples": [], + "forks": [], + "diagnostics": ["No chain observations were made; this is synthetic fixture data."], + "reproduction": "SYNTHETIC ONLY: base-acceptance run acceptance/scenarios/smoke.toml" + } + ] +} diff --git a/acceptance/report/style.css b/acceptance/report/style.css new file mode 100644 index 0000000000..ccd5689ab0 --- /dev/null +++ b/acceptance/report/style.css @@ -0,0 +1,6 @@ +:root{color-scheme:dark;--bg:#0b1020;--panel:#151c30;--line:#34415f;--text:#f3f6ff;--muted:#b8c1d9;--ok:#69d391;--bad:#ff8181;--err:#ffbd66;--block:#c4b5fd}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,sans-serif}main{max-width:1180px;margin:auto;padding:28px}.hero,.card,details{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;margin:0 0 16px}h1,h2,h3{line-height:1.2}.eyebrow,.muted{color:var(--muted)}.verdict{font-size:1.35rem;font-weight:800}.passed{color:var(--ok)}.failed{color:var(--bad)}.error{color:var(--err)}.blocked,.cancelled{color:var(--block)}.counters{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:10px}.counter{background:#0d1427;padding:12px;border-radius:8px}.counter strong{display:block;font-size:1.5rem}table{width:100%;border-collapse:collapse;margin:10px 0}th,td{text-align:left;border-bottom:1px solid var(--line);padding:9px;vertical-align:top}th{color:var(--muted)}summary{cursor:pointer;font-weight:700;padding:3px}summary:focus-visible,a:focus-visible{outline:3px solid #8ec5ff;outline-offset:3px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.kv{white-space:pre-wrap;overflow-wrap:anywhere;background:#0d1427;padding:10px;border-radius:6px}.chart{width:100%;height:auto;background:#0d1427;border-radius:8px}.chart text{fill:var(--muted);font:12px system-ui}.chart .line{fill:none;stroke-width:3}.chart .gap{stroke:#ffbd66;stroke-dasharray:4 4}.chart-table{font-size:.85rem}.pill{font-weight:700;text-transform:uppercase}.evidence{overflow-wrap:anywhere}.scenario{scroll-margin-top:1rem}@media(max-width:650px){main{padding:12px}.counters,.grid{grid-template-columns:1fr 1fr}.scroll{overflow-x:auto}th,td{padding:7px}}@media print{body{background:#fff;color:#111}.hero,.card,details{background:#fff;border-color:#777}details{display:block}details>*{display:block!important}.kv,.counter,.chart{background:#f3f3f3}.passed,.failed,.error,.blocked,.cancelled{color:#111}} +/* Reporter-specific warnings and lifecycle diagnostics. */ +.warning{color:var(--err)}.chart .gap-label{fill:var(--err)}.stage-message{color:var(--muted)} +a{color:#8ec5ff}a:visited{color:#c4b5fd}.hero p,summary{overflow-wrap:anywhere} +.chart text{paint-order:stroke;stroke:#0d1427;stroke-width:4px;stroke-linejoin:round}.card{overflow-x:auto} +@media(max-width:650px){.grid{grid-template-columns:1fr}.chart{min-width:600px}} diff --git a/acceptance/scenarios/denim-transition.toml b/acceptance/scenarios/denim-transition.toml new file mode 100644 index 0000000000..82922359d2 --- /dev/null +++ b/acceptance/scenarios/denim-transition.toml @@ -0,0 +1,56 @@ +schema_version = 1 +id = "denim-transition" +description = "Production, validator and shadow validator agreement across Denim" +timeout = "20m" + +[devnet.l2.forks] +azul = { at_block = 20 } +beryl = { at_block = 21 } +cobalt = { at_block = 22 } +denim = { at_block = 150 } +zenith = { disabled = true } + +[[checks]] +id = "before-denim" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 3 +timeout = "30s" +start = { before_fork = "denim", chain = "l2" } + +[[checks]] +id = "after-denim" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 5 +timeout = "45s" +start = { after_fork = "denim", chain = "l2" } + +[[checks]] +id = "post-denim-convergence" +kind = "heads_converge" +endpoints = ["builder", "validator"] +max_lag_blocks = 5 +timeout = "90s" +start = { after_fork = "denim", chain = "l2" } + +# The shadow validator derives its safe chain only from the base batcher's batches. Advancing +# 300 blocks takes its safe head past Denim block 150. Its safe head stays put for the 15 +# verifier confirmations, about 5.5 minutes, then catches up at the Denim block rate. The +# builder derives without those confirmations, so its safe head leads by about 1500 Denim blocks. +[[checks]] +id = "post-denim-shadow-safe-progress" +kind = "safe_head_progress" +endpoint = "shadow" +minimum_blocks = 300 +timeout = "8m" +start = { after_fork = "denim", chain = "l2" } + +[[checks]] +id = "post-denim-shadow-convergence" +kind = "heads_converge" +endpoints = ["builder", "shadow"] +head = "safe" +max_lag_blocks = 3000 +timeout = "3m" +start = { after_fork = "denim", chain = "l2" } diff --git a/acceptance/scenarios/derivation.toml b/acceptance/scenarios/derivation.toml new file mode 100644 index 0000000000..5ffb034c1c --- /dev/null +++ b/acceptance/scenarios/derivation.toml @@ -0,0 +1,26 @@ +schema_version = 1 +id = "derivation" +description = "Unsafe production is independently derived into safe heads" +timeout = "12m" + +[[checks]] +id = "unsafe-production" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 5 +timeout = "45s" + +[[checks]] +id = "safe-derivation" +kind = "safe_head_progress" +endpoint = "validator" +minimum_blocks = 2 +timeout = "5m" + +[[checks]] +id = "fresh-head" +kind = "head_fresh" +endpoint = "builder" +maximum_age = "15s" +duration = "20s" +timeout = "30s" diff --git a/acceptance/scenarios/health.toml b/acceptance/scenarios/health.toml new file mode 100644 index 0000000000..9b2aa77290 --- /dev/null +++ b/acceptance/scenarios/health.toml @@ -0,0 +1,15 @@ +schema_version = 1 +id = "health" +description = "Sustained health across builder, validator, RPC, and shadow nodes" +timeout = "8m" + +[[checks]] +id = "l2-node-health" +kind = "heads_healthy" +endpoints = ["builder", "validator", "rpc", "shadow"] +warmup = "10s" +duration = "60s" +minimum_blocks = 3 +maximum_age = "30s" +max_lag_blocks = 5 +timeout = "90s" diff --git a/acceptance/scenarios/smoke.toml b/acceptance/scenarios/smoke.toml new file mode 100644 index 0000000000..b82faafeda --- /dev/null +++ b/acceptance/scenarios/smoke.toml @@ -0,0 +1,25 @@ +schema_version = 1 +id = "smoke" +description = "Basic production and validator agreement" +timeout = "6m" + +[[checks]] +id = "chain-identity" +kind = "chain_id" +endpoint = "builder" +expected = 84538453 +timeout = "10s" + +[[checks]] +id = "production" +kind = "head_progress" +endpoint = "builder" +minimum_blocks = 3 +timeout = "30s" + +[[checks]] +id = "validator-converges" +kind = "heads_converge" +endpoints = ["builder", "validator"] +max_lag_blocks = 5 +timeout = "60s" diff --git a/acceptance/src/aggregate.rs b/acceptance/src/aggregate.rs new file mode 100644 index 0000000000..3dbdcd31c6 --- /dev/null +++ b/acceptance/src/aggregate.rs @@ -0,0 +1,629 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Component, Path, PathBuf}, +}; + +use eyre::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{CheckResult, Report, RunResult, ScenarioResult, StageResult, Status}; + +const MAX_BYTES: u64 = 20 * 1024 * 1024; +const MAX_RESULTS: usize = 200; +const MAX_DEPTH: usize = 5; + +/// Strict description of scenarios expected from a sharded run. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedManifest { + /// Manifest schema version. + pub schema_version: u32, + /// Shared run identity, including the workflow attempt. + pub run_id: String, + /// Exact tested revision. + pub tested_sha: String, + /// Expected scenarios and checks. + pub scenarios: Vec, +} + +/// One expected scenario and its complete check set. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedScenario { + /// Scenario identifier. + pub id: String, + /// Exact expected check identifiers. + pub checks: Vec, +} + +/// Strict result aggregation and bounded evidence relocation. +#[derive(Debug)] +pub struct Aggregate; + +impl Aggregate { + /// Reads one file only after enforcing the per-file 20 `MiB` limit. + pub fn bounded_read(path: &Path) -> Result> { + let metadata = + fs::symlink_metadata(path).wrap_err_with(|| format!("inspect {}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("input is not a regular file: {}", path.display()); + } + if metadata.len() > MAX_BYTES { + bail!("input exceeds 20 MiB: {}", path.display()); + } + fs::read(path).wrap_err_with(|| format!("read {}", path.display())) + } + + /// Loads and strictly validates an expected-run manifest. + pub fn load_expected(path: &Path) -> Result { + let expected: ExpectedManifest = serde_json::from_slice(&Self::bounded_read(path)?) + .wrap_err("parse expected manifest")?; + if expected.schema_version != 1 + || expected.run_id.is_empty() + || expected.tested_sha.is_empty() + { + bail!("invalid expected manifest identity or schema"); + } + if expected.scenarios.is_empty() || expected.scenarios.len() > 100 { + bail!("expected manifest must contain 1..=100 scenarios"); + } + let mut scenario_ids = BTreeSet::new(); + for scenario in &expected.scenarios { + Self::validate_id("scenario", &scenario.id)?; + if !scenario_ids.insert(&scenario.id) { + bail!("duplicate expected scenario id {}", scenario.id); + } + if scenario.checks.is_empty() || scenario.checks.len() > 1_000 { + bail!("scenario {} has an invalid check count", scenario.id); + } + let mut checks = BTreeSet::new(); + for check in &scenario.checks { + Self::validate_id("check", check)?; + if !checks.insert(check) { + bail!("scenario {} has duplicate check {check}", scenario.id); + } + } + } + Ok(expected) + } + + /// Aggregates result bundles and writes result.json, report.html, summary.md and evidence directly. + /// Invalid or missing manifests still produce an aggregate-error report. + pub fn write(expected_path: &Path, results: &Path, output: &Path) -> Result { + fs::create_dir_all(output).wrap_err("create aggregate output")?; + let expected = match Self::load_expected(expected_path) { + Ok(expected) => expected, + Err(error) => { + let run = Self::fallback(format!("invalid or missing expected manifest: {error}")); + Report::write(&run, output)?; + return Ok(run); + } + }; + + let results_root = match results.canonicalize().wrap_err("resolve results directory") { + Ok(path) => path, + Err(error) => { + let run = Self::run_error(&expected, error.to_string()); + Report::write(&run, output)?; + return Ok(run); + } + }; + let output_absolute = if output.exists() { + output.canonicalize()? + } else { + std::env::current_dir()?.join(output) + }; + if output_absolute.starts_with(&results_root) { + let run = Self::run_error(&expected, "aggregate output is inside results input".into()); + Report::write(&run, output)?; + return Ok(run); + } + + let mut paths = Vec::new(); + let mut discovery_errors = Vec::new(); + if let Err(error) = Self::discover(&results_root, 0, &mut paths, &mut discovery_errors) { + let run = Self::run_error(&expected, format!("discover result artifacts: {error}")); + Report::write(&run, output)?; + return Ok(run); + } + let mut found: BTreeMap> = BTreeMap::new(); + let mut errors = discovery_errors; + for path in paths { + match Self::bounded_read(&path).and_then(|bytes| { + let run: RunResult = serde_json::from_slice(&bytes)?; + Report::validate(&run)?; + Ok(run) + }) { + Ok(run) + if run.run_id == expected.run_id && run.tested_sha == expected.tested_sha => + { + for scenario in run.scenarios { + found + .entry(scenario.id.clone()) + .or_default() + .push((path.clone(), scenario)); + } + } + Ok(_) => errors.push(format!("stale result identity: {}", path.display())), + Err(error) => errors.push(format!("invalid result {}: {error}", path.display())), + } + } + + let mut scenarios = Vec::new(); + let mut evidence_bytes = 0; + for wanted in &expected.scenarios { + let entries = found.remove(&wanted.id).unwrap_or_default(); + if entries.len() != 1 { + scenarios.push(Self::synthetic( + wanted, + format!("expected exactly one result, found {}", entries.len()), + )); + continue; + } + let (source, mut scenario) = entries.into_iter().next().expect("checked one entry"); + let actual: BTreeSet<_> = + scenario.checks.iter().map(|check| check.id.as_str()).collect(); + let required: BTreeSet<_> = wanted.checks.iter().map(String::as_str).collect(); + if actual != required || scenario.checks.len() != wanted.checks.len() { + scenarios.push(Self::synthetic( + wanted, + "result check set does not exactly match manifest".into(), + )); + continue; + } + scenario.checks.sort_by_key(|check| { + wanted.checks.iter().position(|id| id == &check.id).expect("validated check set") + }); + Self::copy_evidence(&source, output, &mut scenario, &mut evidence_bytes); + scenario.status = scenario.outcome(); + scenarios.push(scenario); + } + for id in found.keys() { + errors.push(format!("unexpected scenario artifact: {id}")); + } + if !errors.is_empty() { + let scenario = scenarios.first_mut().expect("validated manifest is nonempty"); + scenario.diagnostics.push(errors.join("; ")); + scenario.stages.retain(|stage| stage.id != "aggregate-integrity"); + scenario.stages.push(StageResult { + id: "aggregate-integrity".into(), + status: Status::Error, + duration_ms: 0, + message: "aggregate artifacts were invalid or unexpected; inspect diagnostics" + .into(), + }); + scenario.status = scenario.outcome(); + } + let run = RunResult { + schema_version: 1, + run_id: expected.run_id, + tested_sha: expected.tested_sha, + started_at_unix_ms: 0, + scenarios, + }; + Report::write(&run, output)?; + Ok(run) + } + + /// Discovers result files with hard depth and path-count limits, rejecting symlink artifacts. + pub fn discover( + directory: &Path, + depth: usize, + paths: &mut Vec, + errors: &mut Vec, + ) -> Result<()> { + if depth > MAX_DEPTH { + errors.push(format!("result traversal exceeded depth at {}", directory.display())); + return Ok(()); + } + for entry in fs::read_dir(directory).wrap_err("read results directory")? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_symlink() { + errors.push(format!("symlink artifact rejected: {}", entry.path().display())); + } else if ty.is_dir() { + Self::discover(&entry.path(), depth + 1, paths, errors)?; + } else if entry.file_name() == "result.json" { + if paths.len() == MAX_RESULTS { + bail!("too many result artifacts"); + } + paths.push(entry.path()); + } + } + Ok(()) + } + + /// Copies evidence only when its canonical source remains in the source bundle and total bytes are bounded. + /// Source and destination bundles must not be mutated concurrently; path checks do not isolate + /// aggregation from another process changing the filesystem during a copy. + pub fn copy_evidence( + source_result: &Path, + output: &Path, + scenario: &mut ScenarioResult, + copied_bytes: &mut u64, + ) { + let Some(bundle) = source_result.parent() else { return }; + let canonical_bundle = match bundle.canonicalize() { + Ok(path) => path, + Err(_) => return, + }; + for check in &mut scenario.checks { + for evidence in &mut check.evidence { + let original = evidence.clone(); + let source = bundle.join(&original); + let safe = Report::validate_evidence_path(&original) + .and_then(|_| source.canonicalize().map_err(Into::into)) + .and_then(|canonical| { + if canonical.starts_with(&canonical_bundle) { + Ok(canonical) + } else { + bail!("evidence escapes bundle") + } + }) + .and_then(|canonical| { + let metadata = fs::symlink_metadata(&source)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + bail!("evidence is not a regular file") + } + if *copied_bytes + metadata.len() > MAX_BYTES { + bail!("aggregate evidence exceeds 20 MiB") + } + Ok((canonical, metadata.len())) + }); + let relative = + PathBuf::from("evidence").join(&scenario.id).join(&check.id).join(&original); + let copied = safe.and_then(|(canonical, size)| { + let destination = output.join(&relative); + fs::create_dir_all(destination.parent().expect("evidence has parent"))?; + fs::copy(canonical, destination)?; + *copied_bytes += size; + Ok(()) + }); + if copied.is_ok() { + *evidence = relative.to_string_lossy().into_owned(); + } else { + scenario.diagnostics.push(format!("evidence unavailable: {original}")); + check.status = Status::Error; + evidence.clear(); + } + } + check.evidence.retain(|path| !path.is_empty()); + } + } + + /// Creates a complete nonpassing result when the manifest cannot be trusted. + pub fn fallback(message: String) -> RunResult { + RunResult { + schema_version: 1, + run_id: "aggregate-error".into(), + tested_sha: "unknown".into(), + started_at_unix_ms: 0, + scenarios: vec![Self::error_scenario("aggregate-error", message)], + } + } + + /// Creates a manifest-bound aggregate error result. + pub fn run_error(expected: &ExpectedManifest, message: String) -> RunResult { + RunResult { + schema_version: 1, + run_id: expected.run_id.clone(), + tested_sha: expected.tested_sha.clone(), + started_at_unix_ms: 0, + scenarios: expected + .scenarios + .iter() + .map(|scenario| Self::synthetic(scenario, message.clone())) + .collect(), + } + } + + /// Creates a blocked representation of one missing or invalid expected scenario. + pub fn synthetic(expected: &ExpectedScenario, message: String) -> ScenarioResult { + let checks = expected + .checks + .iter() + .map(|id| CheckResult { + id: id.clone(), + kind: "expected".into(), + status: Status::Blocked, + duration_ms: 0, + expected: json!({}), + observed: json!({}), + message: message.clone(), + next_step: "inspect shard result and rerun".into(), + samples: 0, + rpc_errors: 0, + evidence: Vec::new(), + }) + .collect(); + ScenarioResult { + id: expected.id.clone(), + status: Status::Error, + duration_ms: 0, + config: json!({}), + stages: vec![StageResult { + id: "aggregate".into(), + status: Status::Error, + duration_ms: 0, + message, + }], + checks, + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + /// Creates an infrastructure-error scenario describing aggregate integrity failures. + pub fn error_scenario(id: &str, message: String) -> ScenarioResult { + ScenarioResult { + id: id.into(), + status: Status::Error, + duration_ms: 0, + config: json!({}), + stages: vec![StageResult { + id: "aggregate".into(), + status: Status::Error, + duration_ms: 0, + message, + }], + checks: vec![CheckResult { + id: "artifact-integrity".into(), + kind: "aggregate".into(), + status: Status::Error, + duration_ms: 0, + expected: json!({}), + observed: json!({}), + message: "aggregate artifacts were invalid or unexpected".into(), + next_step: "inspect aggregate diagnostics".into(), + samples: 0, + rpc_errors: 0, + evidence: Vec::new(), + }], + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + /// Validates identifiers before they can become report paths or anchors. + pub fn validate_id(kind: &str, value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 100 + || value == "." + || value == ".." + || value.chars().any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + || Path::new(value).components().any(|c| !matches!(c, Component::Normal(_))) + { + bail!("unsafe {kind} id {value:?}"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::symlink; + + use serde_json::json; + use tempfile::TempDir; + + use super::*; + + fn expected() -> ExpectedManifest { + ExpectedManifest { + schema_version: 1, + run_id: "run-1".into(), + tested_sha: "abc123".into(), + scenarios: vec![ExpectedScenario { + id: "scenario-a".into(), + checks: vec!["check-a".into(), "check-b".into()], + }], + } + } + + fn scenario(checks: &[&str]) -> ScenarioResult { + ScenarioResult { + id: "scenario-a".into(), + status: Status::Passed, + duration_ms: 1, + config: json!({}), + stages: Vec::new(), + checks: checks + .iter() + .map(|id| CheckResult { + id: (*id).into(), + kind: "test".into(), + status: Status::Passed, + duration_ms: 1, + expected: json!(true), + observed: json!(true), + message: "passed".into(), + next_step: "none".into(), + samples: 1, + rpc_errors: 0, + evidence: Vec::new(), + }) + .collect(), + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + fn run(run_id: &str, checks: &[&str]) -> RunResult { + RunResult { + schema_version: 1, + run_id: run_id.into(), + tested_sha: "abc123".into(), + started_at_unix_ms: 1, + scenarios: vec![scenario(checks)], + } + } + + fn write_json(path: &Path, value: &impl Serialize) { + fs::create_dir_all(path.parent().expect("test path has parent")).unwrap(); + fs::write(path, serde_json::to_vec(value).unwrap()).unwrap(); + } + + fn aggregate( + temp: &TempDir, + expected: &ExpectedManifest, + results: &Path, + name: &str, + ) -> RunResult { + let manifest = temp.path().join(format!("{name}-manifest.json")); + let output = temp.path().join(format!("{name}-output")); + write_json(&manifest, expected); + let result = Aggregate::write(&manifest, results, &output).unwrap(); + assert!(output.join("result.json").is_file()); + assert!(output.join("report.html").is_file()); + assert!(output.join("summary.md").is_file()); + result + } + + #[test] + fn missing_stale_duplicate_malformed_and_wrong_checkset_never_pass() { + let temp = TempDir::new().unwrap(); + let manifest = expected(); + for case in ["missing", "stale", "duplicate", "malformed", "checkset"] { + let results = temp.path().join(case); + fs::create_dir_all(&results).unwrap(); + match case { + "stale" => write_json( + &results.join("one/result.json"), + &run("old-run", &["check-a", "check-b"]), + ), + "duplicate" => { + write_json( + &results.join("one/result.json"), + &run("run-1", &["check-a", "check-b"]), + ); + write_json( + &results.join("two/result.json"), + &run("run-1", &["check-a", "check-b"]), + ); + } + "malformed" => { + fs::create_dir_all(results.join("one")).unwrap(); + fs::write(results.join("one/result.json"), b"not json").unwrap(); + } + "checkset" => write_json( + &results.join("one/result.json"), + &run("run-1", &["check-a", "extra"]), + ), + _ => {} + } + let result = aggregate(&temp, &manifest, &results, case); + assert!(!result.passed(), "{case} artifacts must not pass"); + let expected_scenario = + result.scenarios.iter().find(|scenario| scenario.id == "scenario-a").unwrap(); + assert_eq!( + expected_scenario + .checks + .iter() + .map(|check| check.id.as_str()) + .collect::>(), + BTreeSet::from(["check-a", "check-b"]), + ); + } + } + + #[test] + fn invalid_manifest_and_result_discovery_failures_still_write_reports() { + let temp = TempDir::new().unwrap(); + let bad_manifest = temp.path().join("bad-manifest.json"); + let bad_output = temp.path().join("bad-output"); + fs::write(&bad_manifest, b"{}").unwrap(); + let bad = + Aggregate::write(&bad_manifest, &temp.path().join("absent"), &bad_output).unwrap(); + assert!(!bad.passed()); + assert!(bad_output.join("report.html").is_file()); + + let manifest = expected(); + let missing_results = temp.path().join("missing-results"); + let result = aggregate(&temp, &manifest, &missing_results, "discovery"); + assert!(!result.passed()); + let scenario = + result.scenarios.iter().find(|scenario| scenario.id == "scenario-a").unwrap(); + assert_eq!(scenario.checks.len(), 2, "infrastructure failures retain expected checks"); + } + + #[test] + fn evidence_is_relocated_without_collisions_and_missing_evidence_is_an_error() { + let temp = TempDir::new().unwrap(); + let results = temp.path().join("results"); + let bundle = results.join("one"); + fs::create_dir_all(bundle.join("evidence/logs/a")).unwrap(); + fs::create_dir_all(bundle.join("evidence/logs/b")).unwrap(); + fs::write(bundle.join("evidence/logs/a/out.txt"), "a").unwrap(); + fs::write(bundle.join("evidence/logs/b/out.txt"), "b").unwrap(); + let mut shard = run("run-1", &["check-a", "check-b"]); + shard.scenarios[0].checks[0].evidence = + vec!["evidence/logs/a/out.txt".into(), "evidence/logs/b/out.txt".into()]; + shard.scenarios[0].checks[1].evidence = vec!["evidence/logs/missing.txt".into()]; + write_json(&bundle.join("result.json"), &shard); + + let result = aggregate(&temp, &expected(), &results, "evidence"); + assert!(!result.passed()); + let checks = &result.scenarios[0].checks; + assert_eq!(checks[0].evidence.len(), 2); + assert_ne!(checks[0].evidence[0], checks[0].evidence[1]); + let output = temp.path().join("evidence-output"); + assert_eq!(fs::read_to_string(output.join(&checks[0].evidence[0])).unwrap(), "a"); + assert_eq!(fs::read_to_string(output.join(&checks[0].evidence[1])).unwrap(), "b"); + assert_eq!(checks[1].status, Status::Error); + assert!(checks[1].evidence.is_empty()); + } + + #[test] + fn bounded_read_accepts_the_limit_and_rejects_larger_files() { + let file = tempfile::NamedTempFile::new().unwrap(); + file.as_file().set_len(20 * 1024 * 1024).unwrap(); + assert_eq!(Aggregate::bounded_read(file.path()).unwrap().len(), 20 * 1024 * 1024); + file.as_file().set_len(20 * 1024 * 1024 + 1).unwrap(); + assert!(Aggregate::bounded_read(file.path()).unwrap_err().to_string().contains("20 MiB")); + } + + #[cfg(unix)] + #[test] + fn evidence_symlinks_and_escaping_parent_links_are_not_copied() { + let temp = TempDir::new().unwrap(); + let bundle = temp.path().join("bundle"); + let output = temp.path().join("output"); + let outside = temp.path().join("outside"); + fs::create_dir_all(bundle.join("evidence")).unwrap(); + fs::create_dir(&outside).unwrap(); + fs::write(bundle.join("evidence/regular.txt"), "allowed evidence").unwrap(); + fs::write(outside.join("secret.txt"), "must not be copied").unwrap(); + symlink("regular.txt", bundle.join("evidence/internal-link.txt")).unwrap(); + symlink(outside.join("secret.txt"), bundle.join("evidence/external-link.txt")).unwrap(); + symlink(&outside, bundle.join("evidence/parent-link")).unwrap(); + let mut scenario = scenario(&["check-a"]); + scenario.checks[0].evidence = vec![ + "evidence/regular.txt".into(), + "evidence/internal-link.txt".into(), + "evidence/external-link.txt".into(), + "evidence/parent-link/secret.txt".into(), + ]; + let mut copied = 0; + Aggregate::copy_evidence(&bundle.join("result.json"), &output, &mut scenario, &mut copied); + assert_eq!(scenario.checks[0].status, Status::Error); + assert_eq!(scenario.checks[0].evidence.len(), 1); + assert_eq!(scenario.diagnostics.len(), 3); + assert_eq!(copied, 16); + assert_eq!( + fs::read_to_string(output.join(&scenario.checks[0].evidence[0])).unwrap(), + "allowed evidence" + ); + let directory = output.join("evidence/scenario-a/check-a/evidence"); + assert_eq!(fs::read_dir(directory).unwrap().count(), 1); + } +} diff --git a/acceptance/src/check.rs b/acceptance/src/check.rs new file mode 100644 index 0000000000..866f739d8a --- /dev/null +++ b/acceptance/src/check.rs @@ -0,0 +1,1440 @@ +use std::{ + collections::BTreeMap, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use eyre::{Result, bail, eyre}; +use reqwest::{Client, redirect::Policy}; +use serde_json::{Value, json}; +use tokio::time::Instant; + +use crate::{AcceptanceCheck, CheckResult, HeadSample, Status}; + +const RESPONSE_LIMIT: usize = 1_048_576; +const SAMPLE_LIMIT: usize = 10_000; + +/// A validated block returned by an Ethereum JSON-RPC endpoint. +#[derive(Debug, Clone)] +pub struct ObservedBlock { + /// Block height reported by the endpoint. + pub number: u64, + /// Unix timestamp in the block header. + pub timestamp: u64, + /// Canonical 32-byte block hash. + pub hash: String, +} + +/// What the sampled heads must satisfy before `heads_converge` compares their common block. +#[derive(Debug, Clone, Copy)] +pub struct ConvergenceBounds { + /// Maximum height difference between the sampled heads. + pub max_lag_blocks: u64, + /// Lowest timestamp the common block may have. + pub minimum_timestamp: u64, +} + +/// Mutable evidence and counters shared by one acceptance check. +/// +/// Samples use the scenario-wide `origin`; once the report limit is reached, +/// `samples_truncated` records that further observations were not retained. +#[derive(Debug)] +pub struct ObservationState<'a> { + /// Number of valid RPC observations. + pub count: u64, + /// Number of failed RPC observations, including recovered failures. + pub errors: u64, + /// Whether infrastructure prevented complete evaluation. + pub infrastructure_error: bool, + /// Machine-readable expected value. + pub expected: Value, + /// Machine-readable latest observation and failure thresholds. + pub observed: Value, + /// Scenario-wide head sample collection. + pub samples: &'a mut Vec, + /// Scenario-wide monotonic sample origin. + pub origin: Instant, + /// Whether the sample collection reached its fixed report limit. + pub samples_truncated: bool, +} + +impl<'a> ObservationState<'a> { + /// Creates state for a check and its scenario-wide sample stream. + pub fn new(expected: Value, samples: &'a mut Vec, origin: Instant) -> Self { + Self { + count: 0, + errors: 0, + infrastructure_error: false, + expected, + observed: json!({}), + samples, + origin, + samples_truncated: false, + } + } + + /// Records a valid block or an explicit sampling gap without storing URLs. + pub fn sample(&mut self, endpoint: &str, block: Option<&ObservedBlock>) { + if self.samples.len() >= SAMPLE_LIMIT { + self.samples_truncated = true; + return; + } + self.samples.push(HeadSample { + endpoint: endpoint.into(), + elapsed_ms: self.origin.elapsed().as_millis() as u64, + number: block.map(|value| value.number), + timestamp: block.map(|value| value.timestamp), + hash: block.map(|value| value.hash.clone()), + }); + } + + /// Records a successful block observation. + pub fn success(&mut self, endpoint: &str, block: &ObservedBlock) { + self.count += 1; + self.sample(endpoint, Some(block)); + } + + /// Records a failed observation and its sampling gap. + pub fn failure(&mut self, endpoint: &str) { + self.errors += 1; + self.sample(endpoint, None); + } + + /// Records a fatal RPC gap that prevents complete evaluation. + pub fn infrastructure_failure(&mut self, endpoint: &str) { + self.failure(endpoint); + self.infrastructure_error = true; + } +} + +/// Bounded JSON-RPC observation client used by the runner. +#[derive(Debug)] +pub struct RpcObserver { + client: Client, + poll: Duration, + request_timeout: Duration, +} + +impl RpcObserver { + /// Creates an observer with per-request and polling bounds. + pub fn new(request_timeout: Duration, poll: Duration) -> Result { + let client = + Client::builder().redirect(Policy::none()).pool_max_idle_per_host(2).build()?; + Ok(Self { client, poll, request_timeout }) + } + + /// Returns the endpoint chain identifier before `deadline`. + pub async fn chain_id(&self, url: &str, deadline: Instant) -> Result { + let value = self.rpc(url, "eth_chainId", json!([]), deadline).await?; + Self::hex(Some(&value)) + } + + /// Returns a validated block selected by tag or quantity before `deadline`. + pub async fn block(&self, url: &str, tag: &str, deadline: Instant) -> Result { + let value = self.rpc(url, "eth_getBlockByNumber", json!([tag, false]), deadline).await?; + if value.is_null() { + bail!("{tag} block is absent"); + } + let block = ObservedBlock { + number: Self::hex(value.get("number"))?, + timestamp: Self::hex(value.get("timestamp"))?, + hash: Self::hash(value.get("hash"))?, + }; + if tag.starts_with("0x") && block.number != Self::hex(Some(&Value::String(tag.into())))? { + bail!("RPC returned block {} for requested height {tag}", block.number); + } + Ok(block) + } + + /// Executes one check within both its own and the scenario deadline. + /// + /// `minimum_timestamp` is the lowest timestamp of the block `heads_converge` may compare. It + /// is the activation of the fork for a check with an `after_fork` start, and zero otherwise. + pub async fn run( + &self, + check: &AcceptanceCheck, + minimum_timestamp: u64, + endpoints: &BTreeMap, + samples: &mut Vec, + origin: Instant, + scenario_deadline: Instant, + ) -> CheckResult { + let started = Instant::now(); + let deadline = (started + check.timeout()).min(scenario_deadline); + let mut state = ObservationState::new(json!({ "check": check.kind() }), samples, origin); + let evaluation = + self.evaluate(check, minimum_timestamp, endpoints, deadline, &mut state).await; + let (status, message) = match evaluation { + Ok(message) => (Status::Passed, message), + Err(error) if state.infrastructure_error => (Status::Error, error.to_string()), + Err(error) => (Status::Failed, error.to_string()), + }; + if state.samples_truncated { + state.observed["samples_truncated_at"] = json!(SAMPLE_LIMIT); + } + CheckResult { + id: check.id().into(), + kind: check.kind().into(), + status, + duration_ms: started.elapsed().as_millis() as u64, + expected: state.expected, + observed: state.observed, + message, + next_step: if status == Status::Passed { + "no action required" + } else { + "inspect bounded Compose logs and endpoint health" + } + .into(), + samples: state.count, + rpc_errors: state.errors, + evidence: Vec::new(), + } + } + + /// Dispatches a configured check to its observation algorithm. + pub async fn evaluate( + &self, + check: &AcceptanceCheck, + minimum_timestamp: u64, + endpoints: &BTreeMap, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + match check { + AcceptanceCheck::ChainId { endpoint, expected, .. } => { + state.expected = json!(expected); + match self.chain_id(Self::endpoint(endpoints, endpoint)?, deadline).await { + Ok(actual) => { + state.count += 1; + state.observed = json!(actual); + if actual == *expected { + Ok("chain identity matched".into()) + } else { + bail!("chain id mismatch: expected {expected}, observed {actual}") + } + } + Err(error) => { + state.errors += 1; + state.infrastructure_error = true; + Err(error) + } + } + } + AcceptanceCheck::HeadProgress { endpoint, minimum_blocks, .. } + | AcceptanceCheck::SafeHeadProgress { endpoint, minimum_blocks, .. } => { + state.expected = json!({ "minimum_blocks": minimum_blocks }); + let tag = if matches!(check, AcceptanceCheck::SafeHeadProgress { .. }) { + "safe" + } else { + "latest" + }; + self.progress( + Self::endpoint(endpoints, endpoint)?, + endpoint, + tag, + *minimum_blocks, + deadline, + state, + ) + .await + } + AcceptanceCheck::HeadsConverge { endpoints: roles, head, max_lag_blocks, .. } => { + state.expected = json!({ + "max_lag_blocks": max_lag_blocks, + "common_height_hash": true, + }); + let bounds = + ConvergenceBounds { max_lag_blocks: *max_lag_blocks, minimum_timestamp }; + self.converge(endpoints, roles, head, bounds, deadline, state).await + } + AcceptanceCheck::HeadFresh { endpoint, maximum_age, duration, .. } => { + state.expected = json!({ + "maximum_age_seconds": maximum_age.0.as_secs(), + "duration_ms": duration.0.as_millis(), + }); + self.fresh( + Self::endpoint(endpoints, endpoint)?, + endpoint, + maximum_age.0, + duration.0, + deadline, + state, + ) + .await + } + AcceptanceCheck::HeadsHealthy { + endpoints: roles, + warmup, + duration, + minimum_blocks, + maximum_age, + max_lag_blocks, + .. + } => { + state.expected = json!({ + "roles": roles, + "warmup_ms": warmup.0.as_millis(), + "duration_ms": duration.0.as_millis(), + "minimum_blocks_per_node": minimum_blocks, + "maximum_age_seconds": maximum_age.0.as_secs(), + "max_lag_blocks": max_lag_blocks, + "guarantee": "sampled health window; not proof of zero restarts between samples" + }); + self.healthy( + endpoints, + roles, + (warmup.0, duration.0, *minimum_blocks, maximum_age.0, *max_lag_blocks), + deadline, + state, + ) + .await + } + } + } + + /// Samples all L2 roles for a complete shared window and requires sustained health. + pub async fn healthy( + &self, + map: &BTreeMap, + roles: &[String], + settings: (Duration, Duration, u64, Duration, u64), + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + let (warmup, duration, minimum, maximum_age, max_lag) = settings; + let observation_start = + Instant::now().checked_add(warmup).ok_or_else(|| eyre!("health warmup overflow"))?; + tokio::time::sleep_until(observation_start).await; + let mut baselines = BTreeMap::new(); + let mut latest = BTreeMap::new(); + let mut worst_ages = BTreeMap::new(); + let mut worst_lag = 0; + let mut rounds = 0_u64; + let mut until = None; + loop { + let mut heads = Vec::with_capacity(roles.len()); + for role in roles { + let block = match self.block(Self::endpoint(map, role)?, "latest", deadline).await { + Ok(block) => block, + Err(error) => { + state.infrastructure_failure(role); + state.observed = json!({ + "completed_rounds": rounds, + "partial_round_role": role, + "baselines": baselines, + "latest": latest, + "gaps": state.errors, + }); + return Err(error.wrap_err(format!("health sample unavailable for {role}"))); + } + }; + state.success(role, &block); + baselines.entry(role.clone()).or_insert(block.number); + latest.insert(role.clone(), block.number); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + // Match the L2 gossip validator's five-second future timestamp allowance. + if block.timestamp.saturating_sub(now) > 5 { + state.observed = json!({ + "role": role, + "timestamp": block.timestamp, + "host_timestamp": now, + "maximum_future_seconds": 5, + }); + bail!("{role} head timestamp exceeds the five-second future allowance"); + } + let age = now.saturating_sub(block.timestamp); + worst_ages + .entry(role.clone()) + .and_modify(|value: &mut u64| *value = (*value).max(age)) + .or_insert(age); + if age > maximum_age.as_secs() { + state.observed = json!({ + "completed_rounds": rounds, + "baselines": baselines, + "latest": latest, + "worst_age_seconds": worst_ages, + "worst_lag_blocks": worst_lag, + "gaps": state.errors, + }); + bail!("{role} head age {age}s exceeds freshness limit"); + } + heads.push((role, block)); + } + let low = heads.iter().map(|(_, block)| block.number).min().unwrap_or_default(); + let high = heads.iter().map(|(_, block)| block.number).max().unwrap_or_default(); + let lag = high.saturating_sub(low); + worst_lag = worst_lag.max(lag); + if lag > max_lag { + state.observed = json!({ + "completed_rounds": rounds, + "baselines": baselines, + "latest": latest, + "worst_age_seconds": worst_ages, + "worst_lag_blocks": worst_lag, + "gaps": state.errors, + }); + bail!("head lag {lag} blocks exceeds limit {max_lag}"); + } + let mut common_hash: Option = None; + let mut compared = Vec::with_capacity(heads.len()); + for (role, sampled) in &heads { + let historical = match self + .block(Self::endpoint(map, role)?, &format!("0x{low:x}"), deadline) + .await + { + Ok(block) => block, + Err(error) => { + state.infrastructure_failure(role); + state.observed = json!({ + "completed_rounds": rounds, + "partial_round_role": role, + "common_height": low, + "baselines": baselines, + "latest": latest, + "gaps": state.errors, + }); + return Err( + error.wrap_err(format!("canonical hash unavailable for {role}")) + ); + } + }; + state.count += 1; + let comparison = json!({ + "role": role, + "height": low, + "sampled_height": sampled.number, + "sampled_hash": sampled.hash, + "canonical_hash": historical.hash, + }); + if sampled.number == low && sampled.hash != historical.hash { + state.observed = json!({ + "completed_rounds": rounds, + "baselines": baselines, + "latest": latest, + "canonical_comparisons": [comparison], + "gaps": state.errors, + }); + bail!( + "{role} sampled hash {} disagrees with canonical hash {} at height {low}", + sampled.hash, + historical.hash + ); + } + if common_hash.as_ref().is_some_and(|hash| hash != &historical.hash) { + compared.push(comparison); + state.observed = json!({ + "completed_rounds": rounds, + "baselines": baselines, + "latest": latest, + "canonical_comparisons": compared, + "gaps": state.errors, + }); + bail!("node canonical hashes disagree at common height {low}"); + } + common_hash = Some(historical.hash.clone()); + compared.push(comparison); + } + rounds += 1; + if until.is_none() { + let end = Instant::now() + .checked_add(duration) + .ok_or_else(|| eyre!("health duration overflow"))?; + if end >= deadline { + bail!( + "health window leaves no reserved final round RPC budget before deadline" + ); + } + until = Some(end); + } + let until = until.expect("health window initialized after baseline round"); + let deltas: BTreeMap<_, _> = latest + .iter() + .map(|(role, number)| (role, number.saturating_sub(baselines[role]))) + .collect(); + state.observed = json!({ + "baselines": baselines, + "latest": latest, + "deltas": deltas, + "worst_age_seconds": worst_ages, + "worst_lag_blocks": worst_lag, + "completed_rounds": rounds, + "gaps": state.errors, + "last_common_height": low, + }); + if Instant::now() >= until { + let stalled: Vec<_> = deltas + .iter() + .filter(|(_, delta)| **delta < minimum) + .map(|(role, delta)| format!("{role} advanced {delta}")) + .collect(); + if !stalled.is_empty() { + bail!( + "insufficient independent progress: {}; required {minimum} blocks per node", + stalled.join(", ") + ); + } + return Ok("all nodes remained available, fresh, canonical, within lag, and advanced independently for the sampled window".into()); + } + tokio::time::sleep(self.poll.min(until.saturating_duration_since(Instant::now()))) + .await; + } + } + + /// Observes a head until it advances by the configured delta. + pub async fn progress( + &self, + url: &str, + role: &str, + tag: &str, + minimum: u64, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + let sample_role = if tag == "latest" { role.into() } else { format!("{role}:{tag}") }; + let first = loop { + match self.block(url, tag, deadline).await { + Ok(block) => { + state.success(&sample_role, &block); + break block; + } + Err(error) => { + state.failure(&sample_role); + if !self.wait(deadline).await { + state.infrastructure_error = true; + return Err(error); + } + } + } + }; + state.observed = json!({ + "from": first.number, + "last": first.number, + "required_delta": minimum, + }); + let mut observation_error = None; + loop { + if !self.wait(deadline).await { + if let Some(error) = observation_error { + state.infrastructure_error = true; + return Err(error); + } + let delta = state.observed["delta"].as_u64().unwrap_or_default(); + bail!("head advanced {delta} blocks; required {minimum}"); + } + match self.block(url, tag, deadline).await { + Ok(block) => { + observation_error = None; + state.success(&sample_role, &block); + let delta = block.number.saturating_sub(first.number); + state.observed = json!({ + "from": first.number, + "last": block.number, + "delta": delta, + "required_delta": minimum, + }); + if block.number < first.number { + bail!("head reorged below the initial observation"); + } + if delta >= minimum { + return Ok("head advanced".into()); + } + } + Err(error) => { + state.failure(&sample_role); + observation_error = Some(error); + } + } + } + } + + /// Observes endpoint heads and verifies lag and common-height canonicality. + /// + /// A common height of zero is not comparable: `safe` and `finalized` heads remain at genesis + /// until derivation progresses, so the check keeps polling until a non-genesis block matches. + /// It also keeps polling while the block at the common height is older than + /// `bounds.minimum_timestamp`. + pub async fn converge( + &self, + map: &BTreeMap, + roles: &[String], + tag: &str, + bounds: ConvergenceBounds, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + loop { + let mut heads = Vec::new(); + let mut observation_error = None; + for role in roles { + let sample_role = + if tag == "latest" { role.clone() } else { format!("{role}:{tag}") }; + match self.block(Self::endpoint(map, role)?, tag, deadline).await { + Ok(block) => { + state.success(&sample_role, &block); + heads.push((role, block)); + } + Err(error) => { + state.failure(&sample_role); + observation_error = Some(error); + } + } + } + if heads.len() == roles.len() { + let low = heads.iter().map(|(_, block)| block.number).min().unwrap_or_default(); + let high = heads.iter().map(|(_, block)| block.number).max().unwrap_or_default(); + let low_timestamp = heads + .iter() + .find(|(_, block)| block.number == low) + .map_or(0, |(_, block)| block.timestamp); + state.observed = json!({ + "low": low, + "low_timestamp": low_timestamp, + "high": high, + "lag": high.saturating_sub(low), + "maximum_lag": bounds.max_lag_blocks, + "minimum_timestamp": bounds.minimum_timestamp, + }); + if low > 0 + && low_timestamp >= bounds.minimum_timestamp + && high.saturating_sub(low) <= bounds.max_lag_blocks + { + let mut common: Option = None; + let mut compared = Vec::new(); + for (role, sampled) in &heads { + match self + .block(Self::endpoint(map, role)?, &format!("0x{low:x}"), deadline) + .await + { + Ok(block) => { + // Historical hash verification is not a new head observation. + state.count += 1; + if sampled.number == low && sampled.hash != block.hash { + bail!( + "endpoint {role} reorganized sampled block {low} during comparison" + ); + } + compared.push(json!({ + "endpoint": role, + "hash": block.hash, + })); + if common.as_ref().is_some_and(|hash| hash != &block.hash) { + state.observed["common_height"] = json!(low); + state.observed["compared"] = json!(compared); + bail!("heads disagree at common height {low}"); + } + common = Some(block.hash); + } + Err(error) => { + state.errors += 1; + observation_error = Some(error); + } + } + } + if compared.len() == roles.len() { + state.observed["common_height"] = json!(low); + state.observed["hash"] = json!(common); + return Ok("heads converged on a common canonical block".into()); + } + } + } + if !self.wait(deadline).await { + if let Some(error) = observation_error { + state.infrastructure_error = true; + return Err(error); + } + bail!("heads did not converge before deadline"); + } + } + } + + /// Requires every sample to be available and fresh for the complete window. + /// Block age uses the host's Unix wall clock, while the observation window uses monotonic time. + /// Keep the host clock synchronized: clock skew or backward adjustments can cause a fresh + /// head to appear stale or in the future. + pub async fn fresh( + &self, + url: &str, + role: &str, + maximum_age: Duration, + duration: Duration, + deadline: Instant, + state: &mut ObservationState<'_>, + ) -> Result { + let start = Instant::now(); + let Some(until) = start.checked_add(duration) else { + bail!("freshness duration overflow"); + }; + if until > deadline { + bail!("freshness duration exceeds remaining check deadline"); + } + let mut oldest = 0; + loop { + match self.block(url, "latest", deadline).await { + Ok(block) => { + state.success(role, &block); + let age = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() + .checked_sub(block.timestamp) + .ok_or_else(|| eyre!("head timestamp is in the future"))?; + oldest = oldest.max(age); + state.observed = json!({ + "oldest_age_seconds": oldest, + "last_block": block.number, + "maximum_age_seconds": maximum_age.as_secs(), + }); + if age > maximum_age.as_secs() { + bail!("head age {age}s exceeds freshness limit"); + } + } + Err(error) => { + state.infrastructure_failure(role); + return Err(error.wrap_err("freshness sample unavailable")); + } + } + if Instant::now() >= until { + break; + } + tokio::time::sleep(self.poll.min(until.saturating_duration_since(Instant::now()))) + .await; + } + if state.count == 0 { + bail!("freshness window produced no samples"); + } + Ok("head remained fresh for the observation window".into()) + } + + /// Performs one strictly bounded and protocol-validated JSON-RPC request. + pub async fn rpc( + &self, + url: &str, + method: &str, + params: Value, + deadline: Instant, + ) -> Result { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or_else(|| eyre!("observation deadline elapsed"))?; + let limit = self.request_timeout.min(remaining); + let operation = async { + let sent = self + .client + .post(url) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })) + .send() + .await; + let mut response = sent.map_err(|error| Self::transport(&error))?; + if !response.status().is_success() { + bail!("RPC HTTP status {}", response.status()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = + response.chunk().await.map_err(|error| Self::transport(&error))? + { + if bytes.len().saturating_add(chunk.len()) > RESPONSE_LIMIT { + bail!("RPC response exceeds 1 MiB"); + } + bytes.extend_from_slice(&chunk); + } + let value: Value = serde_json::from_slice(&bytes) + .map_err(|_| eyre!("RPC response is not valid JSON"))?; + if value.get("jsonrpc") != Some(&json!("2.0")) { + bail!("RPC response has invalid protocol version"); + } + if value.get("id") != Some(&json!(1)) { + bail!("RPC response has mismatched id"); + } + let result = value.get("result"); + let error = value.get("error"); + match (result, error) { + (Some(result), None) => Ok(result.clone()), + (None, Some(error)) => bail!("RPC {method} returned an error: {error}"), + _ => bail!("RPC response must contain exactly one of result or error"), + } + }; + tokio::time::timeout(limit, operation) + .await + .map_err(|_| eyre!("RPC request deadline elapsed"))? + } + + /// Sleeps for one polling interval, returning false at the deadline. + pub async fn wait(&self, deadline: Instant) -> bool { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return false }; + tokio::time::sleep(self.poll.min(remaining)).await; + Instant::now() < deadline + } + + /// Resolves a logical role without exposing its URL in errors. + pub fn endpoint<'a>(map: &'a BTreeMap, role: &str) -> Result<&'a str> { + map.get(role).map(String::as_str).ok_or_else(|| eyre!("endpoint {role} was not resolved")) + } + + /// Parses a canonical JSON-RPC hexadecimal quantity. + pub fn hex(value: Option<&Value>) -> Result { + let text = value.and_then(Value::as_str).ok_or_else(|| eyre!("hex quantity missing"))?; + let digits = + text.strip_prefix("0x").ok_or_else(|| eyre!("hex quantity lacks 0x prefix"))?; + if digits.is_empty() || (digits.len() > 1 && digits.starts_with('0')) { + bail!("non-canonical hex quantity"); + } + u64::from_str_radix(digits, 16).map_err(|_| eyre!("invalid hex quantity")) + } + + /// Validates and returns a lowercase-or-uppercase 32-byte hash. + pub fn hash(value: Option<&Value>) -> Result { + let hash = value.and_then(Value::as_str).ok_or_else(|| eyre!("block hash missing"))?; + if hash.len() != 66 + || !hash.starts_with("0x") + || !hash[2..].bytes().all(|byte| byte.is_ascii_hexdigit()) + { + bail!("block hash is not 0x-prefixed 32-byte hex"); + } + Ok(hash.into()) + } + + /// Converts a reqwest failure to a credential-free transport reason. + pub fn transport(error: &reqwest::Error) -> eyre::Report { + let reason = if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connection failure" + } else if error.is_body() { + "response body failure" + } else if error.is_request() { + "request failure" + } else { + "transport failure" + }; + eyre!("RPC {reason}") + } +} + +#[cfg(test)] +mod tests { + //! A hand-rolled HTTP fake is required because these tests need an ordered + //! cross-request response script, delayed bodies, and oversized wire data. + + use std::{ + collections::{BTreeMap, VecDeque}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, + }; + + use serde_json::{Value, json}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Mutex, + time::Instant, + }; + + use super::{ConvergenceBounds, ObservationState, RpcObserver}; + use crate::{AcceptanceCheck, Span, Status}; + + // Socket scheduling is real time; ordinary assertions must not benchmark the CI host. + const RPC_BUDGET: Duration = Duration::from_secs(5); + /// Deadline for cases whose expected outcome is reached only when the deadline elapses. + /// Long enough for a few local requests on a loaded runner, short enough to keep tests fast. + const DEADLINE_OUTCOME: Duration = Duration::from_millis(300); + + #[derive(Clone)] + struct Reply { + delay: Duration, + body: String, + } + + async fn server(replies: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fallback = replies + .last() + .cloned() + .unwrap_or_else(|| Reply { delay: Duration::ZERO, body: rpc(Value::Null) }); + let replies = Arc::new(Mutex::new(VecDeque::from(replies))); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { break }; + let replies = Arc::clone(&replies); + let fallback = fallback.clone(); + tokio::spawn(async move { + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + let mut reply = replies.lock().await.pop_front().unwrap_or(fallback); + tokio::time::sleep(reply.delay).await; + if let Ok(mut body) = serde_json::from_str::(&reply.body) + && let Some(offset) = body["result"]["timestamp"].as_i64() + { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + body["result"]["timestamp"] = + json!(format!("0x{:x}", now.saturating_add_signed(offset))); + reply.body = body.to_string(); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + reply.body.len(), + reply.body + ); + let _ = socket.write_all(response.as_bytes()).await; + }); + } + }); + format!("http://{address}") + } + + fn rpc(result: Value) -> String { + json!({ + "jsonrpc": "2.0", + "id": 1, + "result": result, + }) + .to_string() + } + + fn block(number: u64, hash: char) -> Value { + json!({ + "number": format!("0x{number:x}"), + "timestamp": 0, + "hash": format!("0x{}", hash.to_string().repeat(64)), + }) + } + + fn block_at(number: u64, hash: char, timestamp: u64) -> Value { + json!({ + "number": format!("0x{number:x}"), + "timestamp": format!("0x{timestamp:x}"), + "hash": format!("0x{}", hash.to_string().repeat(64)), + }) + } + + fn replies(values: &[Value]) -> Vec { + values + .iter() + .cloned() + .map(|value| Reply { delay: Duration::ZERO, body: rpc(value) }) + .collect() + } + + fn observer() -> RpcObserver { + RpcObserver::new(RPC_BUDGET, Duration::from_millis(2)).unwrap() + } + + fn progress(timeout: Duration, minimum: u64) -> AcceptanceCheck { + AcceptanceCheck::HeadProgress { + id: "p".into(), + endpoint: "rpc".into(), + minimum_blocks: minimum, + timeout: Span(timeout), + start: None, + } + } + + fn healthy(duration: Duration, maximum_age: Duration, timeout: Duration) -> AcceptanceCheck { + AcceptanceCheck::HeadsHealthy { + id: "health".into(), + endpoints: vec!["builder".into(), "validator".into()], + warmup: Span(Duration::ZERO), + duration: Span(duration), + minimum_blocks: 1, + maximum_age: Span(maximum_age), + max_lag_blocks: 1, + timeout: Span(timeout), + start: None, + } + } + + /// Runs a health check with a deadline far beyond any loaded CI runner's RPC latency. + async fn run_health( + left: Vec, + right: Vec, + duration: Duration, + maximum_age: Duration, + ) -> crate::CheckResult { + run_health_within(left, right, duration, maximum_age, RPC_BUDGET).await + } + + async fn run_health_within( + left: Vec, + right: Vec, + duration: Duration, + maximum_age: Duration, + timeout: Duration, + ) -> crate::CheckResult { + let map = BTreeMap::from([ + ("builder".into(), server(left).await), + ("validator".into(), server(right).await), + ]); + let mut samples = Vec::new(); + observer() + .run( + &healthy(duration, maximum_age, timeout), + 0, + &map, + &mut samples, + Instant::now(), + Instant::now() + timeout, + ) + .await + } + + #[tokio::test] + async fn progress_uses_delta_and_stalled_large_head_fails() { + let url = server(replies(&[block(1_000_000, 'a'), block(1_000_001, 'b')])).await; + let map = BTreeMap::from([("rpc".into(), url)]); + let mut samples = Vec::new(); + let result = RpcObserver::new(RPC_BUDGET, Duration::from_millis(50)) + .unwrap() + .run( + &progress(DEADLINE_OUTCOME, 2), + 0, + &map, + &mut samples, + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Failed); + assert_eq!(result.observed["from"], 1_000_000); + assert!(result.message.contains("head advanced 1 blocks")); + + let url = server(replies(&[block(9, 'a'), block(10, 'b'), block(11, 'c')])).await; + let mut samples = Vec::new(); + let result = observer() + .run( + &progress(RPC_BUDGET, 2), + 0, + &BTreeMap::from([("rpc".into(), url)]), + &mut samples, + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Passed); + assert_eq!(result.observed["delta"], 2); + } + + #[tokio::test] + async fn progress_distinguishes_outage_from_recovered_violation() { + let result = run_progress(replies(&[block(10, 'a'), Value::Null]), DEADLINE_OUTCOME).await; + assert_eq!(result.status, Status::Error); + assert_eq!(result.samples, 1); + + // A single request consuming the remaining deadline is also infrastructure failure. + let mut delayed = replies(&[block(10, 'a'), block(11, 'b')]); + delayed[1].delay = RPC_BUDGET; + let result = run_progress(delayed, DEADLINE_OUTCOME).await; + assert_eq!(result.status, Status::Error); + assert_eq!(result.samples, 1); + assert_eq!(result.rpc_errors, 1); + + let result = + run_progress(replies(&[block(10, 'a'), Value::Null, block(9, 'b')]), RPC_BUDGET).await; + assert_eq!(result.status, Status::Failed); + assert!(result.message.contains("reorged")); + assert!(result.rpc_errors > 0); + + let result = + run_progress(replies(&[block(10, 'a'), Value::Null, block(11, 'b')]), RPC_BUDGET).await; + assert_eq!(result.status, Status::Passed); + assert!(result.rpc_errors > 0); + } + + async fn run_progress(script: Vec, timeout: Duration) -> crate::CheckResult { + let url = server(script).await; + let mut samples = Vec::new(); + observer() + .run( + &progress(timeout, 1), + 0, + &BTreeMap::from([("rpc".into(), url)]), + &mut samples, + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await + } + + #[tokio::test] + async fn convergence_compares_common_height_and_accepts_lag() { + let left = server(replies(&[block(10, 'a'), block(10, 'a')])).await; + let right = server(replies(&[block(10, 'b'), block(10, 'b')])).await; + let roles = vec!["a".into(), "b".into()]; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + let result = observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "latest", + ConvergenceBounds { max_lag_blocks: 0, minimum_timestamp: 0 }, + Instant::now() + RPC_BUDGET, + &mut state, + ) + .await; + assert!(result.unwrap_err().to_string().contains("disagree")); + + let left = server(replies(&[block(10, 'a'), block(10, 'a')])).await; + let right = server(replies(&[block(12, 'c'), block(10, 'a')])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "latest", + ConvergenceBounds { max_lag_blocks: 2, minimum_timestamp: 0 }, + Instant::now() + RPC_BUDGET, + &mut state + ) + .await + .is_ok() + ); + assert_eq!( + samples.iter().filter_map(|sample| sample.number).collect::>(), + vec![10, 12] + ); + } + + #[tokio::test] + async fn convergence_waits_past_genesis_for_safe_heads() { + let roles = vec!["a".into(), "b".into()]; + let left = server(replies(&[block(0, 'e'), block(5, 'a'), block(5, 'a')])).await; + let right = server(replies(&[block(0, 'e'), block(5, 'a'), block(5, 'a')])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "safe", + ConvergenceBounds { max_lag_blocks: 0, minimum_timestamp: 0 }, + Instant::now() + RPC_BUDGET, + &mut state, + ) + .await + .unwrap(); + assert_eq!(state.observed["common_height"], json!(5)); + + let left = server(replies(&[block(0, 'e')])).await; + let right = server(replies(&[block(0, 'e')])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + let result = observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "safe", + ConvergenceBounds { max_lag_blocks: 0, minimum_timestamp: 0 }, + Instant::now() + Duration::from_millis(100), + &mut state, + ) + .await; + assert!(result.is_err()); + assert!(state.observed.get("common_height").is_none()); + } + + /// A common block older than `minimum_timestamp` is not compared. The check keeps polling + /// until both heads reach a block at or after it, and fails at the deadline when they never do. + #[tokio::test] + async fn convergence_waits_for_a_common_block_at_or_after_the_minimum_timestamp() { + let roles = vec!["a".into(), "b".into()]; + let script = [block_at(5, 'a', 90), block_at(10, 'c', 100), block_at(10, 'c', 100)]; + let left = server(replies(&script)).await; + let right = server(replies(&script)).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "safe", + ConvergenceBounds { max_lag_blocks: 0, minimum_timestamp: 100 }, + Instant::now() + RPC_BUDGET, + &mut state, + ) + .await + .unwrap(); + assert_eq!(state.observed["common_height"], json!(10)); + + let left = server(replies(&[block_at(5, 'a', 90)])).await; + let right = server(replies(&[block_at(5, 'a', 90)])).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + let result = observer() + .converge( + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &roles, + "safe", + ConvergenceBounds { max_lag_blocks: 0, minimum_timestamp: 100 }, + Instant::now() + Duration::from_millis(100), + &mut state, + ) + .await; + assert!(result.is_err()); + assert!(state.observed.get("common_height").is_none()); + } + + #[tokio::test] + async fn partial_convergence_and_freshness_observations_remain_infrastructure_errors() { + // Exercise both unavailable heads and unavailable historical comparison blocks. + for script in [vec![Value::Null], vec![block(10, 'a'), Value::Null]] { + let left = server(replies(&script)).await; + let right = server(replies(&[block(10, 'a')])).await; + let check = AcceptanceCheck::HeadsConverge { + id: "converge".into(), + endpoints: vec!["a".into(), "b".into()], + head: "latest".into(), + max_lag_blocks: 0, + timeout: Span(DEADLINE_OUTCOME), + start: None, + }; + let result = RpcObserver::new(RPC_BUDGET, Duration::from_secs(2)) + .unwrap() + .run( + &check, + 0, + &BTreeMap::from([("a".into(), left), ("b".into(), right)]), + &mut Vec::new(), + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Error); + assert!(result.samples > 0); + assert_eq!(result.rpc_errors, 1); + } + + let url = server(replies(&[block(10, 'a'), Value::Null])).await; + let check = AcceptanceCheck::HeadFresh { + id: "fresh".into(), + endpoint: "rpc".into(), + maximum_age: Span(RPC_BUDGET), + duration: Span(Duration::from_secs(1)), + timeout: Span(RPC_BUDGET), + start: None, + }; + let result = observer() + .run( + &check, + 0, + &BTreeMap::from([("rpc".into(), url)]), + &mut Vec::new(), + Instant::now(), + Instant::now() + RPC_BUDGET, + ) + .await; + assert_eq!(result.status, Status::Error); + assert_eq!(result.samples, 1); + assert_eq!(result.rpc_errors, 1); + } + + #[tokio::test] + async fn block_rejects_wrong_height_and_response_cap() { + let url = server(replies(&[block(8, 'a')])).await; + assert!( + observer() + .block(&url, "0x7", Instant::now() + RPC_BUDGET) + .await + .unwrap_err() + .to_string() + .contains("requested") + ); + let huge = Reply { delay: Duration::ZERO, body: "x".repeat(1_048_577) }; + let url = server(vec![huge]).await; + assert!( + observer() + .chain_id(&url, Instant::now() + RPC_BUDGET) + .await + .unwrap_err() + .to_string() + .contains("1 MiB") + ); + } + + #[tokio::test] + async fn freshness_requires_available_full_window() { + let good = Reply { delay: Duration::ZERO, body: rpc(block(1, 'a')) }; + let url = server(vec![good.clone(), good.clone(), good.clone(), good.clone(), good]).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .fresh( + &url, + "rpc", + Duration::from_secs(1), + Duration::from_millis(10), + Instant::now() + RPC_BUDGET, + &mut state + ) + .await + .is_ok() + ); + + let url = server(vec![]).await; + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .fresh( + &url, + "rpc", + Duration::from_secs(1), + Duration::from_millis(10), + Instant::now() + RPC_BUDGET, + &mut state + ) + .await + .is_err() + ); + assert_eq!(state.errors, 1); + + let mut samples = Vec::new(); + let mut state = ObservationState::new(json!({}), &mut samples, Instant::now()); + assert!( + observer() + .fresh( + "http://127.0.0.1:1", + "rpc", + Duration::from_secs(1), + Duration::from_millis(20), + Instant::now() + Duration::from_millis(5), + &mut state + ) + .await + .unwrap_err() + .to_string() + .contains("exceeds") + ); + } + + #[tokio::test] + async fn late_response_cannot_pass_and_connection_error_is_counted() { + let url = + server(vec![Reply { delay: Duration::from_millis(30), body: rpc(json!(1)) }]).await; + assert!( + observer().chain_id(&url, Instant::now() + Duration::from_millis(5)).await.is_err() + ); + + let mut samples = Vec::new(); + let result = observer() + .run( + &progress(Duration::from_millis(10), 1), + 0, + &BTreeMap::from([("rpc".into(), "http://127.0.0.1:1".into())]), + &mut samples, + Instant::now(), + Instant::now() + Duration::from_millis(20), + ) + .await; + assert_eq!(result.status, Status::Error); + assert!(result.rpc_errors > 0); + assert!(samples.iter().all(|sample| sample.number.is_none())); + } + + #[tokio::test] + async fn health_rejects_late_failure_and_stalled_fresh_heads() { + let late_left = replies(&[block(10, 'a'), block(10, 'a'), block(11, 'b'), block(11, 'b')]); + let late_right = replies(&[block(10, 'a'), block(10, 'a'), block(11, 'b'), block(11, 'c')]); + let result = + run_health(late_left, late_right, Duration::from_millis(20), Duration::from_secs(2)) + .await; + assert_eq!(result.status, Status::Failed); + assert!(result.message.contains("sampled hash") || result.message.contains("disagree")); + assert_eq!(result.observed["completed_rounds"], 1); + assert_eq!(result.observed["canonical_comparisons"][0]["role"], "validator"); + assert_eq!(result.observed["canonical_comparisons"][0]["height"], 11); + assert_eq!( + result.observed["canonical_comparisons"][0]["sampled_hash"], + format!("0x{}", "b".repeat(64)) + ); + assert_eq!( + result.observed["canonical_comparisons"][0]["canonical_hash"], + format!("0x{}", "c".repeat(64)) + ); + + let static_blocks: Vec<_> = (0..20).map(|_| block(100, 'a')).collect(); + let result = run_health( + replies(&static_blocks), + replies(&static_blocks), + Duration::from_millis(6), + Duration::from_secs(2), + ) + .await; + assert_eq!(result.status, Status::Failed); + assert!(result.message.contains("insufficient independent progress")); + } + + #[tokio::test] + async fn health_window_starts_after_the_baseline_round() { + let delayed_baseline = + |hash| Reply { delay: Duration::from_millis(6), body: rpc(block(10, hash)) }; + let mut left = + vec![delayed_baseline('a'), Reply { delay: Duration::ZERO, body: rpc(block(10, 'a')) }]; + left.extend(replies(&vec![block(11, 'b'); 20])); + let right = left.clone(); + let started = Instant::now(); + let result = + run_health(left, right, Duration::from_millis(10), Duration::from_secs(2)).await; + + assert_eq!(result.status, Status::Passed); + assert!(started.elapsed() >= Duration::from_millis(20)); + assert!(result.observed["completed_rounds"].as_u64().unwrap() >= 2); + } + + #[tokio::test] + async fn health_classifies_partial_unavailability_as_error() { + let left = replies(&[block(10, 'a'), block(10, 'a'), block(11, 'b'), block(11, 'b')]); + let right = replies(&[block(10, 'a'), block(10, 'a'), Value::Null]); + let result = + run_health(left, right, Duration::from_millis(8), Duration::from_secs(2)).await; + assert_eq!(result.status, Status::Error); + assert!(result.rpc_errors > 0); + assert_eq!(result.observed["completed_rounds"], 1); + assert_eq!(result.observed["partial_round_role"], "validator"); + } + + #[tokio::test] + async fn health_enforces_freshness_and_request_deadline_edges() { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let mut advancing = vec![block_at(10, 'a', now + 5); 2]; + advancing.extend(vec![block_at(11, 'b', now + 5); 20]); + let result = run_health( + replies(&advancing), + replies(&advancing), + Duration::from_millis(4), + Duration::from_secs(2), + ) + .await; + assert_eq!(result.status, Status::Passed); + let future = replies(&[block_at(10, 'a', now + 10)]); + let result = + run_health(future.clone(), future, Duration::from_millis(4), Duration::from_secs(2)) + .await; + assert_eq!(result.status, Status::Failed); + assert!(result.message.contains("future allowance")); + let stale = replies(&[block_at(10, 'a', now - 3)]); + let result = + run_health(stale.clone(), stale, Duration::from_millis(4), Duration::from_secs(2)) + .await; + assert_eq!(result.status, Status::Failed); + assert!(result.message.contains("freshness limit")); + + let delayed = vec![Reply { delay: Duration::from_millis(70), body: rpc(block(10, 'a')) }]; + let result = run_health_within( + delayed.clone(), + delayed, + Duration::from_millis(4), + Duration::from_secs(2), + Duration::from_millis(60), + ) + .await; + assert_eq!(result.status, Status::Error); + assert!(result.rpc_errors > 0); + } +} diff --git a/acceptance/src/cli.rs b/acceptance/src/cli.rs new file mode 100644 index 0000000000..5eef58603e --- /dev/null +++ b/acceptance/src/cli.rs @@ -0,0 +1,513 @@ +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use clap::{Parser, Subcommand}; +use eyre::{Context, Result, bail}; +use serde_json::json; + +use crate::{ + AcceptanceOptions, AcceptanceRunner, Aggregate, CheckResult, ExpectedManifest, + ExpectedScenario, Provisioner, PublishArgs, Report, RunResult, ScenarioConfig, ScenarioResult, + StageResult, Status, +}; + +/// Process exit classification used by CI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ExitCode { + /// All checks passed. + Passed = 0, + /// One or more assertions failed. + Assertion = 1, + /// Invocation, schema, or configuration was invalid. + Config = 2, + /// Infrastructure prevented evaluation. + Infrastructure = 3, +} + +/// Acceptance command-line parser and executor. +#[derive(Debug, Parser)] +#[command(name = "base-acceptance")] +pub struct AcceptanceCli { + /// Operation to perform. + #[command(subcommand)] + pub command: AcceptanceCommand, +} + +/// Supported acceptance operations. +#[derive(Debug, Subcommand)] +pub enum AcceptanceCommand { + /// Parse and validate scenario files. + Validate { + /// Scenario paths. + #[arg(required = true)] + paths: Vec, + }, + /// Emit a resolved execution plan as JSON. + Plan { + /// Scenario paths. + #[arg(required = true)] + paths: Vec, + }, + /// Build an expected-shards manifest. + Manifest { + /// Scenario paths. + #[arg(required = true)] + paths: Vec, + /// Run identity. + #[arg(long)] + run_id: String, + /// Tested revision. + #[arg(long)] + tested_sha: String, + /// Destination JSON file. + #[arg(long)] + output: PathBuf, + }, + /// Provision and execute a scenario. + Run { + /// Scenario path. + path: PathBuf, + /// Output root. + #[arg(long)] + output: PathBuf, + /// Reuse built images without claiming log reuse. + #[arg(long)] + no_build: bool, + /// Run identity. + #[arg(long)] + run_id: Option, + /// Tested revision. + #[arg(long)] + tested_sha: Option, + /// Repository root. + #[arg(long)] + repo_root: Option, + }, + /// Attach to endpoints and execute checks without mutation. + Check { + /// Scenario path. + path: PathBuf, + /// Logical endpoint JSON map. + #[arg(long)] + endpoints: PathBuf, + /// Verified rollup JSON. + #[arg(long)] + rollup: Option, + /// Output root. + #[arg(long)] + output: PathBuf, + /// Run identity. + #[arg(long)] + run_id: Option, + /// Tested revision. + #[arg(long)] + tested_sha: Option, + }, + /// Render an existing portable result. + Report { + /// Input result.json. + result: PathBuf, + /// Output report directory. + #[arg(long)] + output: PathBuf, + }, + /// Strictly aggregate sharded results. + Aggregate { + /// Expected manifest JSON. + #[arg(long)] + expected: PathBuf, + /// Directory containing result bundles. + #[arg(long)] + results: PathBuf, + /// Output root. + #[arg(long)] + output: PathBuf, + }, + /// Remove resources recorded by an owned acceptance manifest. + Cleanup { + /// Ownership manifest produced by a run. + #[arg(long)] + manifest: PathBuf, + }, + /// Publish a trusted, marker-owned `GitHub` PR summary from result artifacts. + Publish(PublishArgs), +} + +/// Inputs for one run or read-only check execution. +#[derive(Debug)] +pub struct CliRun { + /// Scenario configuration path. + pub path: PathBuf, + /// Fresh output root. + pub output: PathBuf, + /// Optional explicit run identity. + pub run_id: Option, + /// Optional explicit tested revision. + pub tested_sha: Option, + /// Repository root. + pub repo_root: PathBuf, + /// Endpoints for read-only attach mode. + pub endpoints: Option>, + /// Original endpoint-map path for an accurate read-only reproduction command. + pub endpoint_file: Option, + /// Verified rollup configuration for attach mode. + pub rollup: Option, + /// Whether provisioning may build images. + pub build: bool, +} + +impl AcceptanceCli { + /// Executes the parsed command and returns the stable process code. + pub async fn execute(self) -> Result { + match self.command { + AcceptanceCommand::Validate { paths } => { + CliRun::load_all(&paths)?; + Ok(ExitCode::Passed) + } + AcceptanceCommand::Plan { paths } => { + println!("{}", serde_json::to_string_pretty(&CliRun::load_all(&paths)?)?); + Ok(ExitCode::Passed) + } + AcceptanceCommand::Manifest { paths, run_id, tested_sha, output } => { + let scenarios = CliRun::load_all(&paths)? + .into_iter() + .map(|scenario| ExpectedScenario { + id: scenario.id, + checks: scenario.checks.iter().map(|check| check.id().into()).collect(), + }) + .collect(); + let manifest = + ExpectedManifest { schema_version: 1, run_id, tested_sha, scenarios }; + fs::write(output, format!("{}\n", serde_json::to_string_pretty(&manifest)?))?; + Ok(ExitCode::Passed) + } + AcceptanceCommand::Run { path, output, no_build, run_id, tested_sha, repo_root } => { + let root = CliRun::resolve_repo(repo_root.as_deref().or_else(|| path.parent()))?; + CliRun { + path, + output, + run_id, + tested_sha, + repo_root: root, + endpoints: None, + endpoint_file: None, + rollup: None, + build: !no_build, + } + .execute() + .await + } + AcceptanceCommand::Check { path, endpoints, rollup, output, run_id, tested_sha } => { + let endpoint_file = endpoints.clone(); + let bytes = fs::read(&endpoints).wrap_err("read endpoint map")?; + let endpoints: BTreeMap = + serde_json::from_slice(&bytes).wrap_err("parse endpoint map")?; + let root = CliRun::resolve_repo(path.parent())?; + CliRun { + path, + output, + run_id, + tested_sha, + repo_root: root, + endpoints: Some(endpoints), + endpoint_file: Some(endpoint_file), + rollup, + build: false, + } + .execute() + .await + } + AcceptanceCommand::Report { result, output } => { + let run: RunResult = serde_json::from_slice(&Aggregate::bounded_read(&result)?)?; + Report::validate(&run)?; + CliRun::copy_report_evidence(&result, &output, &run)?; + Report::write(&run, &output)?; + Ok(CliRun::verdict(&run)) + } + AcceptanceCommand::Aggregate { expected, results, output } => { + let run = Aggregate::write(&expected, &results, &output)?; + Ok(CliRun::verdict(&run)) + } + AcceptanceCommand::Cleanup { manifest } => { + Provisioner::recover(&manifest).await?; + Ok(ExitCode::Passed) + } + AcceptanceCommand::Publish(args) => { + args.execute().await?; + Ok(ExitCode::Passed) + } + } + } +} + +impl CliRun { + /// Executes one scenario while preserving its invocation identity and start time. + pub async fn execute(self) -> Result { + if self.output.exists() { + let entries = fs::read_dir(&self.output)?.collect::>>()?; + let only_empty_report = entries.len() == 1 + && entries[0].file_name() == "report" + && entries[0].file_type()?.is_dir() + && fs::read_dir(entries[0].path())?.next().is_none(); + if !entries.is_empty() && !only_empty_report { + bail!("output already exists and is not empty: {}", self.output.display()); + } + } + + let started_at = Self::now(); + let config = ScenarioConfig::load(&self.path)?; + if let Some(endpoints) = &self.endpoints { + AcceptanceRunner::validate_endpoints(&config, endpoints)?; + if self.rollup.is_none() && config.checks.iter().any(|check| check.start().is_some()) { + bail!("fork-window checks in attach mode require --rollup"); + } + } + + fs::create_dir_all(&self.output)?; + let identity = self.run_id.unwrap_or_else(|| format!("local-{started_at}")); + let sha = match self.tested_sha { + Some(sha) => sha, + None => Self::git_revision(&self.repo_root)?, + }; + let reproduction = if let Some(path) = &self.endpoint_file { + format!( + "base-acceptance check {} --endpoints {}{} --output \"$(mktemp -d)\"", + Self::shell_quote(&self.path), + Self::shell_quote(path), + self.rollup.as_ref().map_or_else(String::new, |path| format!( + " --rollup {}", + Self::shell_quote(path) + )) + ) + } else { + format!( + "base-acceptance run {} --output \"$(mktemp -d)\"{}", + Self::shell_quote(&self.path), + if self.build { "" } else { " --no-build" } + ) + }; + let options = AcceptanceOptions { + repo_root: self.repo_root, + output: self.output.clone(), + endpoints: self.endpoints, + rollup: self.rollup, + build: self.build, + }; + let mut scenario = match AcceptanceRunner::run(config.clone(), options).await { + Ok(result) => result, + Err(error) => Self::fatal(&config, error.to_string()), + }; + scenario.reproduction = reproduction; + let run = RunResult { + schema_version: 1, + run_id: identity, + tested_sha: sha, + started_at_unix_ms: started_at, + scenarios: vec![scenario], + }; + Report::write(&run, &self.output.join("report"))?; + Ok(Self::verdict(&run)) + } + + /// Loads every supplied scenario configuration. + pub fn load_all(paths: &[PathBuf]) -> Result> { + paths.iter().map(ScenarioConfig::load).collect() + } + + /// Creates a truthful blocked result after fatal setup failure. + pub fn fatal(config: &ScenarioConfig, message: String) -> ScenarioResult { + let checks = config + .checks + .iter() + .map(|check| CheckResult { + id: check.id().into(), + kind: check.kind().into(), + status: Status::Blocked, + duration_ms: 0, + expected: json!({}), + observed: json!({}), + message: "fatal setup error prevented evaluation".into(), + next_step: "inspect setup diagnostic".into(), + samples: 0, + rpc_errors: 0, + evidence: Vec::new(), + }) + .collect(); + ScenarioResult { + id: config.id.clone(), + status: Status::Error, + duration_ms: 0, + config: serde_json::to_value(config).unwrap_or_else(|_| json!({})), + stages: vec![StageResult { + id: "setup".into(), + status: Status::Error, + duration_ms: 0, + message, + }], + checks, + samples: Vec::new(), + forks: Vec::new(), + diagnostics: Vec::new(), + reproduction: String::new(), + } + } + + /// Classifies infrastructure ahead of assertions when both occur. + pub fn verdict(run: &RunResult) -> ExitCode { + let infrastructure = run.scenarios.iter().any(|scenario| { + matches!(scenario.outcome(), Status::Error | Status::Blocked | Status::Cancelled) + }); + if infrastructure { + ExitCode::Infrastructure + } else if run + .scenarios + .iter() + .flat_map(|scenario| &scenario.checks) + .any(|check| check.status == Status::Failed) + { + ExitCode::Assertion + } else if run.passed() { + ExitCode::Passed + } else { + ExitCode::Infrastructure + } + } + + /// Finds the containing repository root. + pub fn resolve_repo(start: Option<&Path>) -> Result { + let start = start + .unwrap_or_else(|| Path::new(".")) + .canonicalize() + .wrap_err("resolve repository search path")?; + for ancestor in start.ancestors() { + if ancestor.join("Cargo.toml").is_file() + && (ancestor.join("docker-compose.yml").is_file() || ancestor.join(".git").exists()) + { + return Ok(ancestor.into()); + } + } + bail!("could not locate repository root") + } + + /// Reads the current Git revision and dirty state. + pub fn git_revision(root: &Path) -> Result { + let output = Command::new("git").args(["rev-parse", "HEAD"]).current_dir(root).output()?; + if !output.status.success() { + bail!("git rev-parse failed"); + } + + let mut sha = String::from_utf8(output.stdout)?.trim().to_owned(); + let dirty = + Command::new("git").args(["status", "--porcelain"]).current_dir(root).output()?; + if !dirty.stdout.is_empty() { + sha.push_str("-dirty"); + } + Ok(sha) + } + + /// Quotes one path for inert reproduction text. + pub fn shell_quote(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")) + } + + /// Returns Unix time in milliseconds. + pub fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 + } + + /// Copies referenced evidence when rendering an existing portable result. + pub fn copy_report_evidence(result: &Path, output: &Path, run: &RunResult) -> Result<()> { + let source = result.parent().unwrap_or_else(|| Path::new(".")); + for evidence in run + .scenarios + .iter() + .flat_map(|scenario| &scenario.checks) + .flat_map(|check| &check.evidence) + { + Report::validate_evidence_path(evidence)?; + let from = source.join(evidence); + let destination = output.join(evidence); + let canonical_source = source.canonicalize()?; + let canonical_file = from + .canonicalize() + .wrap_err_with(|| format!("referenced evidence unavailable: {evidence}"))?; + if !canonical_file.starts_with(canonical_source) + || fs::symlink_metadata(&from)?.file_type().is_symlink() + { + bail!("unsafe evidence source: {evidence}"); + } + if destination.exists() && destination.canonicalize()? == canonical_file { + continue; + } + fs::create_dir_all(destination.parent().expect("evidence has parent"))?; + fs::copy(from, destination)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scenario(stage: Status, checks: &[Status]) -> ScenarioResult { + let config = ScenarioConfig::load("scenarios/smoke.toml").unwrap(); + let mut result = CliRun::fatal(&config, String::new()); + result.stages[0].status = stage; + result.checks.truncate(checks.len()); + for (check, status) in result.checks.iter_mut().zip(checks) { + check.status = *status; + } + result + } + + fn run(scenarios: Vec) -> RunResult { + RunResult { + schema_version: 1, + run_id: "run".into(), + tested_sha: "sha".into(), + started_at_unix_ms: 0, + scenarios, + } + } + + #[test] + fn verdict_reports_infrastructure_before_assertions() { + let passed = || scenario(Status::Passed, &[Status::Passed, Status::Passed]); + let failed = || scenario(Status::Passed, &[Status::Passed, Status::Failed]); + let errored = || scenario(Status::Error, &[Status::Blocked]); + for (scenarios, expected) in [ + (vec![passed()], ExitCode::Passed), + (vec![passed(), failed()], ExitCode::Assertion), + ( + vec![scenario(Status::Passed, &[Status::Failed, Status::Blocked])], + ExitCode::Infrastructure, + ), + (vec![failed(), errored()], ExitCode::Infrastructure), + (vec![scenario(Status::Passed, &[Status::Error])], ExitCode::Infrastructure), + (vec![scenario(Status::Cancelled, &[Status::Passed])], ExitCode::Infrastructure), + (vec![scenario(Status::Passed, &[])], ExitCode::Infrastructure), + (Vec::new(), ExitCode::Infrastructure), + ] { + assert_eq!(CliRun::verdict(&run(scenarios)), expected); + } + } + + #[test] + fn shell_quote_round_trips_through_a_posix_shell() { + for path in ["/tmp/plain", "/tmp/it's here", "/tmp/$(touch x) `y` \"z\""] { + let output = Command::new("/bin/sh") + .arg("-c") + .arg(format!("printf %s {}", CliRun::shell_quote(Path::new(path)))) + .output() + .unwrap(); + assert_eq!(String::from_utf8(output.stdout).unwrap(), path); + } + } +} diff --git a/acceptance/src/config.rs b/acceptance/src/config.rs new file mode 100644 index 0000000000..2304f7b166 --- /dev/null +++ b/acceptance/src/config.rs @@ -0,0 +1,971 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, + time::Duration, +}; + +use eyre::{Context, Result, bail}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::ForkBoundary; + +const FORKS: [&str; 5] = ["azul", "beryl", "cobalt", "denim", "zenith"]; +const MAX_ROLLUP_JSON_BYTES: u64 = 1024 * 1024; +const MAX_DESCRIPTION_BYTES: usize = 1024; +const MAX_CHECKS: usize = 100; + +/// A human-readable, positive duration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Span(pub Duration); + +impl Serialize for Span { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&humantime::format_duration(self.0).to_string()) + } +} + +impl<'de> Deserialize<'de> for Span { + fn deserialize>(d: D) -> Result { + let value = String::deserialize(d)?; + humantime::parse_duration(&value).map(Self).map_err(serde::de::Error::custom) + } +} + +/// Strict version-one scenario document. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScenarioConfig { + /// Configuration schema version (currently one). + pub schema_version: u32, + /// Stable scenario identifier. + pub id: String, + /// Human-readable purpose. + pub description: String, + /// Overall execution deadline. + #[serde(default = "ScenarioConfig::default_timeout")] + pub timeout: Span, + /// Devnet topology and chain settings. + #[serde(default)] + pub devnet: DevnetConfig, + /// RPC readiness settings. + #[serde(default)] + pub readiness: ReadinessConfig, + /// Assertions to execute. + pub checks: Vec, +} + +impl ScenarioConfig { + /// Returns the default overall scenario timeout. + pub const fn default_timeout() -> Span { + Span(Duration::from_secs(600)) + } + + /// Loads strict TOML and validates it without contacting Docker or the network. + pub fn load(path: impl AsRef) -> Result { + let text = fs::read_to_string(path.as_ref()) + .wrap_err_with(|| format!("read {}", path.as_ref().display()))?; + let config: Self = toml::from_str(&text).wrap_err("parse scenario TOML")?; + config.validate()?; + Ok(config) + } + + /// Validates schema, schedules, endpoints and all observation bounds. + pub fn validate(&self) -> Result<()> { + if self.schema_version != 1 { + bail!("unsupported schema_version {}; expected 1", self.schema_version); + } + Self::validate_id(&self.id, "scenario")?; + if self.description.trim().is_empty() { + bail!("description must not be empty"); + } + if self.description.len() > MAX_DESCRIPTION_BYTES { + bail!("description must not exceed {MAX_DESCRIPTION_BYTES} bytes"); + } + Self::validate_duration(self.timeout.0, "scenario timeout", 1, 3600)?; + if self.devnet.topology != "single-sequencer" { + bail!("unsupported topology {}; HA is not version-one", self.devnet.topology); + } + if self.devnet.l1.chain_id == 0 || self.devnet.l2.chain_id == 0 { + bail!("chain IDs must be non-zero"); + } + if self.devnet.l2.verifier_l1_confirmations > 10_000 { + bail!("verifier_l1_confirmations must not exceed 10000"); + } + Self::validate_duration(self.devnet.l1.slot_duration.0, "L1 slot duration", 1, 60)?; + if self.devnet.l1.slot_duration.0.subsec_nanos() != 0 { + bail!("L1 slot duration must use whole seconds"); + } + Self::validate_duration_against( + self.readiness.timeout.0, + "readiness timeout", + Duration::from_secs(1), + self.timeout.0, + )?; + Self::validate_duration(self.readiness.request_timeout.0, "request timeout", 1, 30)?; + Self::validate_duration(self.readiness.poll_interval.0, "poll interval", 1, 30)?; + if self.readiness.request_timeout.0 > self.readiness.timeout.0 { + bail!("request timeout exceeds readiness timeout"); + } + if self.checks.is_empty() { + bail!("at least one check is required"); + } + if self.checks.len() > MAX_CHECKS { + bail!("no more than {MAX_CHECKS} checks are allowed"); + } + let mut ids = BTreeSet::new(); + let mut latest_after_fork = None; + for check in &self.checks { + Self::validate_id(check.id(), "check")?; + if !ids.insert(check.id().to_ascii_lowercase()) { + bail!("duplicate check id {}", check.id()); + } + check.validate(self)?; + if let Some(start) = check.start() { + let fork = start.before_fork.as_ref().or(start.after_fork.as_ref()); + let block = fork + .and_then(|fork| self.devnet.l2.forks.get(fork)) + .and_then(ForkActivation::block); + if start.after_fork.is_some() { + latest_after_fork = latest_after_fork.max(block); + } else if let Some(block) = block { + if block == 0 { + bail!("check {}: before_fork cannot target a genesis fork", check.id()); + } + if latest_after_fork.is_some_and(|after| after >= block) { + bail!( + "check {}: before_fork window closes before an earlier after_fork check", + check.id() + ); + } + } + } + } + let mut previous = None; + let mut prerequisite_disabled = false; + for name in FORKS { + let activation = self + .devnet + .l2 + .forks + .get(name) + .ok_or_else(|| eyre::eyre!("missing L2 fork {name}"))?; + if let ForkActivation::Disabled { disabled: false } = activation { + bail!("fork {name}: disabled must be true"); + } + if let ForkActivation::AtBlock { at_block } = activation { + if prerequisite_disabled { + bail!("enabled fork {name} has a disabled prerequisite"); + } + if let Some(prior) = previous + && *at_block < prior + { + bail!("fork {name} activates before its prerequisite"); + } + if name == "zenith" { + let denim = self.devnet.l2.forks["denim"] + .block() + .ok_or_else(|| eyre::eyre!("Zenith requires Denim"))?; + if (at_block - denim) % 5 != 0 { + bail!("post-Denim fork offsets must be divisible by five"); + } + } + previous = Some(*at_block); + } else { + prerequisite_disabled = true; + } + } + for name in self.devnet.l2.forks.keys() { + if !FORKS.contains(&name.as_str()) { + bail!("unsupported L2 fork {name}"); + } + } + Ok(()) + } + + /// Resolves and verifies configured fork activation timestamps from generated rollup JSON. + pub fn verified_forks(&self, rollup_path: &Path) -> Result> { + self.validate()?; + let metadata = fs::metadata(rollup_path)?; + if metadata.len() > MAX_ROLLUP_JSON_BYTES { + bail!("rollup JSON exceeds {MAX_ROLLUP_JSON_BYTES} bytes"); + } + let value: serde_json::Value = serde_json::from_slice(&fs::read(rollup_path)?)?; + if value.get("l1_chain_id").and_then(serde_json::Value::as_u64) + != Some(self.devnet.l1.chain_id) + { + bail!("generated L1 chain ID does not match scenario"); + } + if value.get("l2_chain_id").and_then(serde_json::Value::as_u64) + != Some(self.devnet.l2.chain_id) + { + bail!("generated L2 chain ID does not match scenario"); + } + let genesis = value + .pointer("/genesis/l2_time") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| eyre::eyre!("rollup genesis.l2_time is missing"))?; + let generated = value + .get("base") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| eyre::eyre!("rollup base schedule is missing"))?; + let denim_block = self.devnet.l2.forks.get("denim").and_then(ForkActivation::block); + let mut boundaries = Vec::new(); + for name in FORKS { + let block = self.devnet.l2.forks.get(name).and_then(ForkActivation::block); + let expected = block + .map(|block| { + if let Some(denim) = denim_block.filter(|denim| block > *denim) { + genesis + .checked_add( + denim + .checked_mul(2) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow"))?, + ) + .and_then(|time| time.checked_add((block - denim) / 5)) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow")) + } else { + genesis + .checked_add( + block + .checked_mul(2) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow"))?, + ) + .ok_or_else(|| eyre::eyre!("fork timestamp overflow")) + } + }) + .transpose()?; + let actual = generated.get(name).and_then(serde_json::Value::as_u64); + if actual != expected { + bail!("generated {name} timestamp {actual:?} does not match expected {expected:?}"); + } + if let Some(activation_timestamp) = actual { + boundaries.push(ForkBoundary { + name: name.into(), + chain: "l2".into(), + activation_timestamp, + observed_block: None, + observed_elapsed_ms: None, + }); + } + } + Ok(boundaries) + } + + /// Validates a stable scenario or check identifier. + pub fn validate_id(id: &str, what: &str) -> Result<()> { + if id.is_empty() + || id.len() > 63 + || !id.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + { + bail!("invalid {what} id {id:?}"); + } + Ok(()) + } + + /// Validates a duration against inclusive whole-second bounds. + pub fn validate_duration(value: Duration, name: &str, min: u64, max: u64) -> Result<()> { + Self::validate_duration_against( + value, + name, + Duration::from_secs(min), + Duration::from_secs(max), + ) + } + + /// Validates a duration against inclusive duration bounds. + pub fn validate_duration_against( + value: Duration, + name: &str, + min: Duration, + max: Duration, + ) -> Result<()> { + if value < min || value > max { + bail!("{name} must be between {min:?} and {max:?}"); + } + Ok(()) + } +} + +/// Devnet topology and chain configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DevnetConfig { + /// Topology name. + #[serde(default = "DevnetConfig::default_topology")] + pub topology: String, + /// Layer-one settings. + #[serde(default)] + pub l1: L1Config, + /// Layer-two settings. + #[serde(default)] + pub l2: L2Config, +} + +impl DevnetConfig { + /// Returns the default topology. + pub fn default_topology() -> String { + "single-sequencer".into() + } +} + +impl Default for DevnetConfig { + fn default() -> Self { + Self { + topology: Self::default_topology(), + l1: L1Config::default(), + l2: L2Config::default(), + } + } +} + +/// Layer-one settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct L1Config { + /// Chain identifier. + #[serde(default = "L1Config::default_chain_id")] + pub chain_id: u64, + /// Slot duration. + #[serde(default = "L1Config::default_slot_duration")] + pub slot_duration: Span, +} + +impl L1Config { + /// Returns the default chain identifier. + pub const fn default_chain_id() -> u64 { + 1337 + } + + /// Returns the default slot duration. + pub const fn default_slot_duration() -> Span { + Span(Duration::from_secs(12)) + } +} + +impl Default for L1Config { + fn default() -> Self { + Self { chain_id: Self::default_chain_id(), slot_duration: Self::default_slot_duration() } + } +} + +/// Layer-two settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct L2Config { + /// Chain identifier. + #[serde(default = "L2Config::default_chain_id")] + pub chain_id: u64, + /// Required layer-one confirmations. + #[serde(default = "L2Config::default_confirmations")] + pub verifier_l1_confirmations: u64, + /// Fork activation schedule. + #[serde(default = "L2Config::default_forks", deserialize_with = "L2Config::merge_forks")] + pub forks: BTreeMap, +} + +impl L2Config { + /// Returns the default chain identifier. + pub const fn default_chain_id() -> u64 { + 84_538_453 + } + + /// Returns the default confirmation count. + pub const fn default_confirmations() -> u64 { + 15 + } + /// Merges a partial fork schedule over defaults. + pub fn merge_forks<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let supplied = BTreeMap::::deserialize(deserializer)?; + let mut forks = Self::default_forks(); + forks.extend(supplied); + Ok(forks) + } + + /// Returns the default fork schedule. + pub fn default_forks() -> BTreeMap { + [("azul", 20), ("beryl", 21), ("cobalt", 22), ("denim", 25)] + .into_iter() + .map(|(name, at_block)| (name.into(), ForkActivation::AtBlock { at_block })) + .chain([(String::from("zenith"), ForkActivation::Disabled { disabled: true })]) + .collect() + } +} + +impl Default for L2Config { + fn default() -> Self { + Self { + chain_id: Self::default_chain_id(), + verifier_l1_confirmations: Self::default_confirmations(), + forks: Self::default_forks(), + } + } +} + +/// Fork activation setting. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +pub enum ForkActivation { + /// Block activation. + AtBlock { + /// Activation block. + at_block: u64, + }, + /// Disabled activation. + Disabled { + /// Whether the fork is disabled. + disabled: bool, + }, +} + +impl ForkActivation { + /// Returns the activation block when enabled. + pub const fn block(&self) -> Option { + match self { + Self::AtBlock { at_block } => Some(*at_block), + Self::Disabled { .. } => None, + } + } +} + +/// Endpoint readiness settings. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadinessConfig { + /// Overall readiness timeout. + #[serde(default = "ReadinessConfig::default_timeout")] + pub timeout: Span, + /// Per-request timeout. + #[serde(default = "ReadinessConfig::default_request_timeout")] + pub request_timeout: Span, + /// Poll interval. + #[serde(default = "ReadinessConfig::default_poll_interval")] + pub poll_interval: Span, +} + +impl ReadinessConfig { + /// Returns the default readiness timeout. + pub const fn default_timeout() -> Span { + Span(Duration::from_secs(240)) + } + + /// Returns the default request timeout. + pub const fn default_request_timeout() -> Span { + Span(Duration::from_secs(2)) + } + + /// Returns the default polling interval. + pub const fn default_poll_interval() -> Span { + Span(Duration::from_secs(1)) + } +} + +impl Default for ReadinessConfig { + fn default() -> Self { + Self { + timeout: Self::default_timeout(), + request_timeout: Self::default_request_timeout(), + poll_interval: Self::default_poll_interval(), + } + } +} + +/// A fork-relative check start condition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CheckStart { + /// Chain containing the fork. + pub chain: String, + /// Fork immediately before which to start. + #[serde(default)] + pub before_fork: Option, + /// Fork immediately after which to start. + #[serde(default)] + pub after_fork: Option, +} + +/// An acceptance assertion. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum AcceptanceCheck { + /// Checks a chain identifier. + ChainId { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Expected identifier. + expected: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks head progress. + HeadProgress { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Minimum progress. + minimum_blocks: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks head convergence. + HeadsConverge { + /// Check identifier. + id: String, + /// Endpoint names. + endpoints: Vec, + /// Head tag. + #[serde(default = "AcceptanceCheck::default_head")] + head: String, + /// Maximum lag. + max_lag_blocks: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks safe-head progress. + SafeHeadProgress { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Minimum progress. + minimum_blocks: u64, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks head freshness. + HeadFresh { + /// Check identifier. + id: String, + /// Endpoint name. + endpoint: String, + /// Maximum head age. + maximum_age: Span, + /// Observation duration. + duration: Span, + /// Check timeout. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, + /// Checks sustained health and progress across L2 node roles. + HeadsHealthy { + /// Check identifier. + id: String, + /// Distinct L2 endpoint roles. + endpoints: Vec, + /// Delay before establishing baselines. + #[serde(default = "AcceptanceCheck::default_warmup")] + warmup: Span, + /// Complete observation window after warmup. + duration: Span, + /// Minimum independent progress required from every node. + minimum_blocks: u64, + /// Maximum permitted head age. + maximum_age: Span, + /// Maximum permitted head lag. + max_lag_blocks: u64, + /// Check timeout, including warmup and observations. + timeout: Span, + /// Optional start condition. + #[serde(default)] + start: Option, + }, +} + +impl AcceptanceCheck { + /// Returns the default head tag. + pub fn default_head() -> String { + "latest".into() + } + + /// Returns the default zero warmup. + pub const fn default_warmup() -> Span { + Span(Duration::ZERO) + } + + /// Returns the check identifier. + pub fn id(&self) -> &str { + match self { + Self::ChainId { id, .. } + | Self::HeadProgress { id, .. } + | Self::HeadsConverge { id, .. } + | Self::SafeHeadProgress { id, .. } + | Self::HeadFresh { id, .. } + | Self::HeadsHealthy { id, .. } => id, + } + } + + /// Returns the check kind. + pub const fn kind(&self) -> &'static str { + match self { + Self::ChainId { .. } => "chain_id", + Self::HeadProgress { .. } => "head_progress", + Self::HeadsConverge { .. } => "heads_converge", + Self::SafeHeadProgress { .. } => "safe_head_progress", + Self::HeadFresh { .. } => "head_fresh", + Self::HeadsHealthy { .. } => "heads_healthy", + } + } + + /// Returns the check timeout. + pub const fn timeout(&self) -> Duration { + match self { + Self::ChainId { timeout, .. } + | Self::HeadProgress { timeout, .. } + | Self::HeadsConverge { timeout, .. } + | Self::SafeHeadProgress { timeout, .. } + | Self::HeadFresh { timeout, .. } + | Self::HeadsHealthy { timeout, .. } => timeout.0, + } + } + + /// Returns the optional start condition. + pub const fn start(&self) -> Option<&CheckStart> { + match self { + Self::ChainId { start, .. } + | Self::HeadProgress { start, .. } + | Self::HeadsConverge { start, .. } + | Self::SafeHeadProgress { start, .. } + | Self::HeadFresh { start, .. } + | Self::HeadsHealthy { start, .. } => start.as_ref(), + } + } + + /// Validates the check against its containing scenario. + pub fn validate(&self, scenario: &ScenarioConfig) -> Result<()> { + ScenarioConfig::validate_duration_against( + self.timeout(), + "check timeout", + Duration::from_secs(1), + scenario.timeout.0, + )?; + let endpoints = match self { + Self::ChainId { endpoint, .. } + | Self::HeadProgress { endpoint, .. } + | Self::SafeHeadProgress { endpoint, .. } + | Self::HeadFresh { endpoint, .. } => vec![endpoint.as_str()], + Self::HeadsConverge { endpoints, head, .. } => { + if endpoints.len() < 2 { + bail!("heads_converge requires two endpoints"); + } + if !matches!(head.as_str(), "latest" | "safe" | "finalized") { + bail!("unsupported head tag {head}"); + } + let unique: BTreeSet<_> = endpoints.iter().collect(); + if unique.len() != endpoints.len() { + bail!("heads_converge endpoints must be distinct"); + } + endpoints.iter().map(String::as_str).collect() + } + Self::HeadsHealthy { endpoints, .. } => { + if endpoints.len() < 2 { + bail!("heads_healthy requires at least two endpoints"); + } + let unique: BTreeSet<_> = endpoints.iter().collect(); + if unique.len() != endpoints.len() { + bail!("heads_healthy endpoints must be distinct"); + } + if endpoints.iter().any(|endpoint| endpoint == "l1") { + bail!("heads_healthy accepts only L2 endpoint roles"); + } + endpoints.iter().map(String::as_str).collect() + } + }; + for endpoint in endpoints { + if !matches!(endpoint, "l1" | "builder" | "validator" | "rpc" | "shadow") { + bail!("unknown endpoint {endpoint}"); + } + } + if let Some(start) = self.start() { + if (start.before_fork.is_some()) == (start.after_fork.is_some()) { + bail!("start requires exactly one of before_fork/after_fork"); + } + if start.chain != "l2" { + bail!("configurable L1 forks (including Glamsterdam) are unsupported"); + } + let fork = start.before_fork.as_ref().or(start.after_fork.as_ref()).unwrap(); + if scenario.devnet.l2.forks.get(fork).and_then(ForkActivation::block).is_none() { + bail!("check references absent or disabled fork {fork}"); + } + } + if let Self::HeadFresh { maximum_age, duration, timeout, .. } = self { + ScenarioConfig::validate_duration_against( + maximum_age.0, + "freshness maximum age", + Duration::from_secs(1), + scenario.timeout.0, + )?; + if maximum_age.0.subsec_nanos() != 0 { + bail!("freshness maximum age must use whole seconds"); + } + ScenarioConfig::validate_duration_against( + duration.0, + "freshness duration", + Duration::from_secs(1), + timeout.0, + )?; + if duration.0 >= timeout.0 { + bail!("freshness duration must be shorter than check timeout"); + } + } + if let Self::HeadsHealthy { warmup, duration, maximum_age, timeout, .. } = self { + ScenarioConfig::validate_duration_against( + maximum_age.0, + "health maximum age", + Duration::from_secs(1), + scenario.timeout.0, + )?; + if maximum_age.0.subsec_nanos() != 0 { + bail!("health maximum age must use whole seconds"); + } + ScenarioConfig::validate_duration_against( + duration.0, + "health duration", + Duration::from_secs(1), + timeout.0, + )?; + if warmup.0.checked_add(duration.0).is_none_or(|total| total >= timeout.0) { + bail!("heads_healthy timeout must exceed warmup plus duration for RPC budget"); + } + } + match self { + Self::HeadProgress { minimum_blocks: 0, .. } + | Self::SafeHeadProgress { minimum_blocks: 0, .. } + | Self::HeadsHealthy { minimum_blocks: 0, .. } => { + bail!("minimum_blocks must be non-zero"); + } + _ => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL_SCENARIO: &str = r#" +schema_version = 1 +id = "smoke" +description = "x" + +[[checks]] +id = "identity" +kind = "chain_id" +endpoint = "builder" +expected = 84538453 +timeout = "5s" +"#; + + #[test] + fn rejects_unknown_and_empty() { + assert!( + toml::from_str::( + "schema_version=1\nid='x'\ndescription='x'\nchecks=[]\nextra=1" + ) + .is_err() + ); + } + + #[test] + fn validates_minimal() { + let config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + assert!(config.validate().is_ok()); + } + + #[test] + fn partial_forks_retain_devnet_defaults() { + let config: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[devnet.l2.forks]\ndenim = {{ at_block = 30 }}\n" + )) + .unwrap(); + assert_eq!(config.devnet.l2.forks["azul"].block(), Some(20)); + assert_eq!(config.devnet.l2.forks["denim"].block(), Some(30)); + } + + #[test] + fn rejects_unknown_fork_and_false_disabled() { + let unknown: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[devnet.l2.forks]\nwat = {{ at_block = 30 }}\n" + )) + .unwrap(); + assert!(unknown.validate().is_err()); + let false_disabled: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[devnet.l2.forks]\nzenith = {{ disabled = false }}\n" + )) + .unwrap(); + assert!(false_disabled.validate().is_err()); + } + + #[test] + fn zenith_requires_enabled_ordered_denim() { + let mut config = ScenarioConfig::load("scenarios/smoke.toml").unwrap(); + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block: 24 }); + assert_eq!( + config.validate().unwrap_err().to_string(), + "fork zenith activates before its prerequisite" + ); + + for at_block in [25, 30] { + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block }); + config.validate().unwrap(); + } + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block: 26 }); + assert_eq!( + config.validate().unwrap_err().to_string(), + "post-Denim fork offsets must be divisible by five" + ); + + config.devnet.l2.forks.insert("denim".into(), ForkActivation::Disabled { disabled: true }); + assert_eq!( + config.validate().unwrap_err().to_string(), + "enabled fork zenith has a disabled prerequisite" + ); + } + + #[test] + fn rejects_wrong_generated_schedule() { + let config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp.path(), + r#"{ + "l1_chain_id": 1337, + "l2_chain_id": 84538453, + "genesis": { "l2_time": 100 }, + "base": { "azul": 141, "beryl": 142, "cobalt": 144, "denim": 150 } + }"#, + ) + .unwrap(); + assert_eq!( + config.verified_forks(temp.path()).unwrap_err().to_string(), + "generated azul timestamp Some(141) does not match expected Some(140)" + ); + } + + #[test] + fn verifies_post_denim_slot_offset() { + let mut config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + config.devnet.l2.forks.insert("zenith".into(), ForkActivation::AtBlock { at_block: 30 }); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp.path(), + r#"{ + "l1_chain_id": 1337, + "l2_chain_id": 84538453, + "genesis": { "l2_time": 100 }, + "base": { + "azul": 140, + "beryl": 142, + "cobalt": 144, + "denim": 150, + "zenith": 151 + } + }"#, + ) + .unwrap(); + let forks = config.verified_forks(temp.path()).unwrap(); + assert_eq!(forks.last().unwrap().activation_timestamp, 151); + } + + #[test] + fn genesis_activation_uses_genesis_timestamp() { + let mut config: ScenarioConfig = toml::from_str(MINIMAL_SCENARIO).unwrap(); + config.devnet.l2.forks.insert("azul".into(), ForkActivation::AtBlock { at_block: 0 }); + let temp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + temp.path(), + r#"{ + "l1_chain_id": 1337, + "l2_chain_id": 84538453, + "genesis": { "l2_time": 100 }, + "base": { "azul": 100, "beryl": 142, "cobalt": 144, "denim": 150 } + }"#, + ) + .unwrap(); + let forks = config.verified_forks(temp.path()).unwrap(); + assert_eq!(forks[0].name, "azul"); + assert_eq!(forks[0].activation_timestamp, 100); + } + + #[test] + fn before_fork_windows_must_be_reachable() { + let check = |id: &str, window: &str| { + format!( + "\n[[checks]]\nid = \"{id}\"\nkind = \"chain_id\"\nendpoint = \"builder\"\nexpected = 84538453\ntimeout = \"5s\"\nstart = {{ {window} = \"denim\", chain = \"l2\" }}\n" + ) + }; + let genesis: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}{}\n[devnet.l2.forks]\nazul = {{ at_block = 0 }}\nberyl = {{ at_block = 0 }}\ncobalt = {{ at_block = 0 }}\ndenim = {{ at_block = 0 }}\n", + check("before", "before_fork") + )) + .unwrap(); + assert_eq!( + genesis.validate().unwrap_err().to_string(), + "check before: before_fork cannot target a genesis fork" + ); + + let reordered: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}{}{}", + check("after", "after_fork"), + check("before", "before_fork") + )) + .unwrap(); + assert_eq!( + reordered.validate().unwrap_err().to_string(), + "check before: before_fork window closes before an earlier after_fork check" + ); + + let ordered: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}{}{}", + check("before", "before_fork"), + check("after", "after_fork") + )) + .unwrap(); + ordered.validate().unwrap(); + } + + #[test] + fn freshness_maximum_age_uses_positive_whole_seconds() { + for (maximum_age, error) in [ + ("0s", "freshness maximum age must be between 1s and 600s"), + ("1500ms", "freshness maximum age must use whole seconds"), + ] { + let config: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[[checks]]\nid = \"fresh\"\nkind = \"head_fresh\"\nendpoint = \"builder\"\nmaximum_age = \"{maximum_age}\"\nduration = \"5s\"\ntimeout = \"10s\"\n" + )) + .unwrap(); + assert_eq!(config.validate().unwrap_err().to_string(), error); + } + } + + #[test] + fn freshness_duration_must_be_shorter_than_timeout() { + let config: ScenarioConfig = toml::from_str(&format!( + "{MINIMAL_SCENARIO}\n[[checks]]\nid = \"fresh\"\nkind = \"head_fresh\"\nendpoint = \"builder\"\nmaximum_age = \"10s\"\nduration = \"5s\"\ntimeout = \"5s\"\n" + )) + .unwrap(); + assert_eq!( + config.validate().unwrap_err().to_string(), + "freshness duration must be shorter than check timeout" + ); + } +} diff --git a/acceptance/src/lib.rs b/acceptance/src/lib.rs new file mode 100644 index 0000000000..4830f82693 --- /dev/null +++ b/acceptance/src/lib.rs @@ -0,0 +1,33 @@ +#![doc = include_str!("../README.md")] + +mod aggregate; +pub use aggregate::{Aggregate, ExpectedManifest, ExpectedScenario}; + +mod check; +pub use check::{ConvergenceBounds, ObservationState, ObservedBlock, RpcObserver}; + +mod cli; +pub use cli::{AcceptanceCli, AcceptanceCommand, CliRun, ExitCode}; + +mod config; +pub use config::{ + AcceptanceCheck, CheckStart, DevnetConfig, ForkActivation, L1Config, L2Config, ReadinessConfig, + ScenarioConfig, Span, +}; + +mod provision; +pub use provision::{EndpointMap, Ownership, Provisioner}; + +mod publish; +pub use publish::{CommentMetadata, PrPublisher, PublishArgs}; + +mod report; +pub use report::{Report, ReportCounts}; + +mod result; +pub use result::{ + CheckResult, ForkBoundary, HeadSample, RunResult, ScenarioResult, StageResult, Status, +}; + +mod runner; +pub use runner::{AcceptanceOptions, AcceptanceRunner}; diff --git a/acceptance/src/provision.rs b/acceptance/src/provision.rs new file mode 100644 index 0000000000..8128d23259 --- /dev/null +++ b/acceptance/src/provision.rs @@ -0,0 +1,561 @@ +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + process::Stdio, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use eyre::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::{io::AsyncReadExt, process::Command}; + +use crate::ScenarioConfig; + +/// Logical node roles mapped to host-accessible execution RPC endpoints. +pub type EndpointMap = BTreeMap; + +/// Recovery metadata for resources created by this runner. +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Ownership { + /// Canonical repository root supplying Compose configuration. + pub repo: PathBuf, + /// Canonical run output directory. + pub output: PathBuf, + /// Unique Compose project name. + pub project: String, +} + +/// Exclusive Compose lifecycle preserving pre-existing developer resources. +#[derive(Debug)] +pub struct Provisioner { + repo: PathBuf, + output: PathBuf, + project: String, + owned: bool, + private_owned: bool, + lock_handle: Option, +} + +impl Provisioner { + /// Creates an inert lifecycle owner; construction never touches Docker or a lock. + pub fn new(repo: PathBuf, output: PathBuf, id: &str) -> Self { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); + Self { + repo, + output, + project: format!("base-acceptance-{id}-{nonce}"), + owned: false, + private_owned: false, + lock_handle: None, + } + } + + /// Generates and verifies fresh chain state, then starts its consumers. + pub async fn setup(&mut self, config: &ScenarioConfig, build: bool) -> Result { + self.repo = self.repo.canonicalize()?; + self.output = self.output.canonicalize()?; + self.acquire_lock()?; + let private = self.output.join("private"); + fs::create_dir(&private).wrap_err("acceptance requires a fresh private state directory")?; + self.private_owned = true; + Self::restrict(&private, true)?; + fs::create_dir(private.join("devnet"))?; + self.write_environment(config)?; + self.write_ports().await?; + self.preflight().await?; + if build { + let mut command = Command::new("docker"); + command + .args(["buildx", "bake", "--load", "-f", "etc/docker/docker-bake.hcl", "devnet"]) + .env("PROFILE", "dev") + .env("BASE_SUCCINCT_ELF_REQUIRE", "0") + .current_dir(&self.repo); + Self::command(&mut command, Duration::from_secs(1800)).await?; + self.compose(&["build", "setup-devnet"], Duration::from_secs(1800)).await?; + } + self.compose(&["pull", "--ignore-buildable"], Duration::from_secs(300)).await?; + let model = self.model().await?; + let mut images = BTreeSet::new(); + for service in model["services"] + .as_object() + .ok_or_else(|| eyre::eyre!("Compose services missing"))? + .values() + { + if service.get("profiles").is_none() + && let Some(image) = service["image"].as_str() + { + images.insert(image.to_owned()); + } + } + let mut provenance = Vec::new(); + for image in images { + let inspected = Self::command( + Command::new("docker").args([ + "image", + "inspect", + "--format", + "{{json .Id}}", + &image, + ]), + Duration::from_secs(15), + ) + .await?; + provenance.push(json!({ + "image": image, + "id": serde_json::from_str::(&inspected)?, + })); + } + fs::create_dir_all(self.output.join("report"))?; + fs::write( + self.output.join("report/environment.json"), + serde_json::to_vec_pretty(&json!({ + "images": provenance, + "built_by_this_invocation": build, + "project": self.project, + }))?, + )?; + let owner = Ownership { + repo: self.repo.clone(), + output: self.output.clone(), + project: self.project.clone(), + }; + fs::write(private.join("ownership.json"), serde_json::to_vec_pretty(&owner)?)?; + self.owned = true; + self.compose( + &[ + "up", + "--no-build", + "--pull", + "never", + "--exit-code-from", + "setup-devnet", + "setup-devnet", + ], + config.readiness.timeout.0, + ) + .await?; + self.verify_artifacts(config)?; + self.compose( + &[ + "up", + "-d", + "--no-build", + "--pull", + "never", + "--wait", + "--wait-timeout", + &config.readiness.timeout.0.as_secs().to_string(), + ], + config.readiness.timeout.0 + Duration::from_secs(15), + ) + .await?; + self.endpoints().await + } + + /// Exposes only execution RPCs on daemon-allocated localhost ports. + pub async fn write_ports(&self) -> Result<()> { + let model = self.model().await?; + let mut overlay = String::from("services:\n"); + for name in model["services"] + .as_object() + .ok_or_else(|| eyre::eyre!("Compose services missing"))? + .keys() + { + let port = match name.as_str() { + "l1-el" => Some(4545), + "base-builder" => Some(7545), + "base-client" => Some(8545), + "base-rpc" => Some(8645), + "base-shadow-validator" => Some(8845), + _ => None, + }; + overlay.push_str(&format!(" {}:\n ports: !override", serde_json::to_string(name)?)); + if let Some(port) = port { + overlay.push_str(&format!("\n - \"127.0.0.1::{port}\"\n")); + } else { + overlay.push_str(" []\n"); + } + } + fs::write(self.output.join("private/ports.yml"), overlay)?; + Ok(()) + } + + /// Resolves the published host endpoint for each logical role after startup. + pub async fn endpoints(&self) -> Result { + let mut endpoints = BTreeMap::new(); + for (role, service, port) in [ + ("l1", "l1-el", "4545"), + ("builder", "base-builder", "7545"), + ("validator", "base-client", "8545"), + ("rpc", "base-rpc", "8645"), + ("shadow", "base-shadow-validator", "8845"), + ] { + let address = self.compose(&["port", service, port], Duration::from_secs(10)).await?; + endpoints.insert(role.into(), format!("http://{}", address.trim())); + } + Ok(endpoints) + } + + /// Acquires a kernel lock; the stable lock file is never unlinked by any runner. + pub fn acquire_lock(&mut self) -> Result<()> { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + options.mode(0o600); + let mut lock = options.open(std::env::temp_dir().join("base-acceptance-docker.lock"))?; + lock.try_lock().wrap_err("another acceptance run owns the local Docker daemon")?; + lock.set_len(0)?; + writeln!(lock, "{}", std::process::id())?; + self.lock_handle = Some(lock); + Ok(()) + } + + /// Writes a private Compose environment with explicit scenario overrides. + pub fn write_environment(&self, config: &ScenarioConfig) -> Result<()> { + let root = self.output.join("private/devnet").canonicalize()?; + if root.to_string_lossy().contains(['\n', '\r', '$', '#', '"', '\'', ',']) { + bail!("unsupported characters in output directory"); + } + let mut env = fs::read_to_string(self.repo.join("etc/docker/devnet-env"))?; + env.push_str(&format!( + "\nDEVNET_ROOT={}\nPROFILE=dev\nBASE_SUCCINCT_ELF_REQUIRE=0\nL1_CHAIN_ID={}\nL2_CHAIN_ID={}\nL1_SLOT_DURATION_OVERRIDE={}\nBASE_NODE_VERIFIER_L1_CONFS={}\n", + root.display(), + config.devnet.l1.chain_id, + config.devnet.l2.chain_id, + config.devnet.l1.slot_duration.0.as_secs(), + config.devnet.l2.verifier_l1_confirmations, + )); + for (name, activation) in &config.devnet.l2.forks { + env.push_str(&format!( + "L2_BASE_{}_BLOCK={}\n", + name.to_ascii_uppercase(), + activation.block().map_or_else(String::new, |block| block.to_string()) + )); + } + let path = self.output.join("private/compose.env"); + fs::write(&path, env)?; + Self::restrict(&path, false) + } + + /// Rejects container name collisions without adopting existing resources. + /// Docker assigns and binds the dynamically allocated host ports at startup. + pub async fn preflight(&self) -> Result<()> { + let model = self.model().await?; + let existing = Self::command( + Command::new("docker").args(["ps", "-a", "--format", "{{.Names}}"]), + Duration::from_secs(15), + ) + .await?; + for service in model["services"] + .as_object() + .ok_or_else(|| eyre::eyre!("Compose services missing"))? + .values() + { + if service.get("profiles").is_some() { + continue; + } + if let Some(name) = service["container_name"].as_str() + && existing.lines().any(|line| line == name) + { + bail!( + "container {name} already exists; stop the developer devnet explicitly before acceptance" + ); + } + } + Ok(()) + } + + /// Resolves Compose configuration without persisting credential-bearing fields. + pub async fn model(&self) -> Result { + Ok(serde_json::from_str( + &self.compose(&["config", "--format", "json"], Duration::from_secs(15)).await?, + )?) + } + + /// Verifies generated identity and both representations of every L2 schedule. + pub fn verify_artifacts(&self, config: &ScenarioConfig) -> Result<()> { + let root = self.output.join("private/devnet"); + let rollup = root.join("l2/configs/rollup.json"); + let forks = config.verified_forks(&rollup)?; + let genesis: Value = + serde_json::from_slice(&fs::read(root.join("l2/configs/genesis.json"))?)?; + if genesis.pointer("/config/chainId").and_then(Value::as_u64) + != Some(config.devnet.l2.chain_id) + { + bail!("L2 genesis chain ID differs from scenario"); + } + let l1: Value = + serde_json::from_slice(&fs::read(root.join("l1/configs/el/genesis.json"))?)?; + if l1.pointer("/config/chainId").and_then(Value::as_u64) != Some(config.devnet.l1.chain_id) + { + bail!("L1 genesis chain ID differs from scenario"); + } + for name in config.devnet.l2.forks.keys() { + let actual = genesis.pointer(&format!("/config/base/{name}")).and_then(Value::as_u64); + let expected = + forks.iter().find(|fork| &fork.name == name).map(|fork| fork.activation_timestamp); + if actual != expected { + bail!("L2 genesis {name} schedule differs from verified rollup"); + } + } + Ok(()) + } + + /// Captures bounded sanitized logs, returning collection failures truthfully. + pub async fn collect_logs(&self) -> Result<()> { + if !self.owned { + return Ok(()); + } + let logs = self + .compose( + &["logs", "--no-color", "--timestamps", "--tail", "1000"], + Duration::from_secs(30), + ) + .await?; + let clean = self.sanitize(&logs); + let evidence = self.output.join("report/evidence"); + fs::create_dir_all(&evidence)?; + fs::write(evidence.join("compose.log"), clean)?; + Ok(()) + } + + /// Redacts configured secrets and strips control characters from diagnostics. + pub fn sanitize(&self, text: &str) -> String { + let mut clean = text.replace(&self.output.to_string_lossy().to_string(), ""); + if let Ok(env) = fs::read_to_string(self.output.join("private/compose.env")) { + for (key, value) in env.lines().filter_map(|line| line.split_once('=')) { + if (key.contains("KEY") + || key.contains("TOKEN") + || key.contains("SECRET") + || key.contains("PASSWORD")) + && value.len() > 8 + { + clean = clean.replace(value.trim_matches(['\'', '"']), "[REDACTED]"); + } + } + } + let mut characters = clean.chars().peekable(); + let mut output = String::new(); + while let Some(character) = characters.next() { + if character == '\u{1b}' && characters.peek() == Some(&'[') { + characters.next(); + for part in characters.by_ref() { + if ('@'..='~').contains(&part) { + break; + } + } + } else if !character.is_control() || matches!(character, '\n' | '\t') { + output.push(character); + } + if output.len() >= 2_000_000 { + break; + } + } + output + } + + /// Stops owned resources and deletes only this run's freshly generated state. + /// Removing root-owned state requires the local `devnet-setup:local-v2` image. + pub async fn cleanup(&mut self) -> Result<()> { + if self.owned { + self.compose( + &["down", "--volumes", "--remove-orphans", "--timeout", "10"], + Duration::from_secs(40), + ) + .await?; + let data = self.output.join("private/devnet"); + if data.exists() { + Self::command( + Command::new("docker").args([ + "run", + "--rm", + "--network", + "none", + "--entrypoint", + "/bin/sh", + "--mount", + &format!("type=bind,source={},target=/owned", data.display()), + "devnet-setup:local-v2", + "-c", + "find /owned -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +", + ]), + Duration::from_secs(30), + ) + .await?; + } + self.owned = false; + } + if self.private_owned { + fs::remove_dir_all(self.output.join("private"))?; + self.private_owned = false; + } + self.lock_handle.take(); + Ok(()) + } + + /// Recovers an interrupted local run using its private ownership manifest. + pub async fn recover(path: &Path) -> Result<()> { + let canonical = path.canonicalize()?; + let owner: Ownership = serde_json::from_slice(&fs::read(&canonical)?)?; + if canonical != owner.output.join("private/ownership.json") + || !owner.project.starts_with("base-acceptance-") + || !owner + .project + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + bail!("invalid ownership manifest"); + } + let mut provisioner = Self { + repo: owner.repo, + output: owner.output, + project: owner.project, + owned: true, + private_owned: true, + lock_handle: None, + }; + provisioner.acquire_lock()?; + provisioner.cleanup().await + } + + /// Invokes canonical Compose with scenario-sensitive ambient variables removed. + pub async fn compose(&self, args: &[&str], timeout: Duration) -> Result { + let env_file = self.output.join("private/compose.env"); + let mut command = Command::new("docker"); + command + .args(["compose", "--project-name", &self.project, "--env-file"]) + .arg(&env_file) + .arg("--file") + .arg(self.repo.join("etc/docker/docker-compose.yml")); + let ports = self.output.join("private/ports.yml"); + if ports.exists() { + command.arg("--file").arg(ports); + } + command.args(args).current_dir(&self.repo); + for line in fs::read_to_string(&env_file)?.lines() { + if let Some((key, _)) = line.split_once('=') { + command.env_remove(key.trim()); + } + } + for (key, _) in std::env::vars().filter(|(key, _)| { + key.starts_with("COMPOSE_") + || key.starts_with("L2_BASE_") + || key.starts_with("UPGRADE_SIGNAL_") + }) { + command.env_remove(key); + } + Self::command(&mut command, timeout) + .await + .map_err(|error| eyre::eyre!("{}", self.sanitize(&error.to_string()))) + } + + /// Executes a child with bounded retained output while draining both pipes. + pub async fn command(command: &mut Command, timeout: Duration) -> Result { + command.stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let mut child = command.spawn()?; + let stdout = child.stdout.take().ok_or_else(|| eyre::eyre!("stdout unavailable"))?; + let stderr = child.stderr.take().ok_or_else(|| eyre::eyre!("stderr unavailable"))?; + let (status, out, err) = tokio::time::timeout(timeout, async { + let (out, err, status) = + tokio::try_join!(Self::drain(stdout), Self::drain(stderr), child.wait())?; + Ok::<_, std::io::Error>((status, out, err)) + }) + .await + .wrap_err("command deadline elapsed")??; + if !status.success() { + bail!( + "command failed ({status}): {}", + String::from_utf8_lossy(&err).chars().take(4000).collect::() + ); + } + Ok(String::from_utf8_lossy(&out).into_owned()) + } + + /// Retains a bounded prefix and drains the remainder to avoid deadlocking a child. + pub async fn drain(mut input: impl tokio::io::AsyncRead + Unpin) -> std::io::Result> { + const LIMIT: usize = 2_000_000; + let mut output = Vec::new(); + let mut buffer = [0; 8192]; + loop { + let read = input.read(&mut buffer).await?; + if read == 0 { + break; + } + output.extend_from_slice(&buffer[..read.min(LIMIT.saturating_sub(output.len()))]); + } + Ok(output) + } + + /// Restricts private state on Unix, where the Compose devnet is supported. + pub fn restrict(path: &Path, directory: bool) -> Result<()> { + #[cfg(unix)] + fs::set_permissions( + path, + fs::Permissions::from_mode(if directory { 0o700 } else { 0o600 }), + )?; + Ok(()) + } + + /// Host endpoints of the canonical single-sequencer topology. + pub fn default_endpoints() -> EndpointMap { + [("l1", 4545), ("builder", 7545), ("validator", 8545), ("rpc", 8645), ("shadow", 8845)] + .into_iter() + .map(|(role, port)| (role.into(), format!("http://127.0.0.1:{port}"))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contender_cannot_release_owner_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + let owner = File::create(&path).unwrap(); + owner.try_lock().unwrap(); + let contender = File::open(&path).unwrap(); + assert!(contender.try_lock().is_err()); + drop(contender); + assert!(File::open(&path).unwrap().try_lock().is_err()); + drop(owner); + File::open(&path).unwrap().try_lock().unwrap(); + } + + #[tokio::test] + async fn output_is_bounded() { + let data = vec![b'a'; 2_100_000]; + assert_eq!(Provisioner::drain(data.as_slice()).await.unwrap().len(), 2_000_000); + } + + #[tokio::test] + async fn inert_cleanup_preserves_existing_private_data() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("private")).unwrap(); + let sentinel = dir.path().join("private/sentinel"); + fs::write(&sentinel, "developer data").unwrap(); + let mut provisioner = Provisioner::new(PathBuf::new(), dir.path().into(), "test"); + provisioner.cleanup().await.unwrap(); + assert_eq!(fs::read_to_string(sentinel).unwrap(), "developer data"); + } + + #[test] + fn diagnostic_removes_ansi_and_configured_credentials() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("private")).unwrap(); + fs::write(dir.path().join("private/compose.env"), "PRIVATE_KEY=never-publish-this\n") + .unwrap(); + let provisioner = Provisioner::new(PathBuf::new(), dir.path().into(), "test"); + assert_eq!( + provisioner.sanitize("\x1b[31mERROR\x1b[0m never-publish-this\n"), + "ERROR [REDACTED]\n" + ); + } +} diff --git a/acceptance/src/publish.rs b/acceptance/src/publish.rs new file mode 100644 index 0000000000..61c1e271c3 --- /dev/null +++ b/acceptance/src/publish.rs @@ -0,0 +1,927 @@ +use std::{env, fmt::Write as _, path::PathBuf, time::Duration}; + +use chrono::DateTime; +use clap::Args; +use eyre::{Context, Result, ensure}; +use reqwest::{Client, Method, header, redirect::Policy}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{Aggregate, Report, ReportCounts, RunResult, Status}; + +const MARKER: &str = ""; +const META_PREFIX: &str = "").map(|(metadata, _)| metadata)) + .ok_or_else(|| eyre::eyre!("owned marker has invalid metadata"))?; + let metadata: Self = serde_json::from_str(encoded)?; + metadata.validate()?; + Ok(metadata) + } +} + +/// `GitHub` comment publisher with bounded requests and narrow comment ownership. +#[derive(Debug)] +pub struct PrPublisher { + /// Authenticated client; authorization headers must be marked sensitive. + pub client: Client, + /// Repository API URL (production always uses api.github.com). + pub api_url: String, + /// Pull-request number. + pub pr: u64, + /// Expected pull-request head revision. + pub head: String, + /// Expected pull-request base revision. + pub base: String, + /// Exact bot login allowed to own the comment. + pub bot_login: String, +} + +impl PublishArgs { + /// Publishes from trusted CI environment and artifact data, never executing artifact code. + pub async fn execute(self) -> Result<()> { + let metadata = CommentMetadata { + run_id: self.run_id.clone(), + attempt: self.attempt, + started_at: self.started_at.clone(), + }; + metadata.validate()?; + let repo = env::var("GITHUB_REPOSITORY")?; + let parts: Vec<_> = repo.split('/').collect(); + ensure!( + parts.len() == 2 + && parts.iter().all(|part| !part.is_empty() + && *part != "." + && *part != ".." + && part.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))), + "invalid repository" + ); + let tested_sha = env::var("TESTED_SHA")?; + PrPublisher::validate_sha(&tested_sha)?; + let event: Value = serde_json::from_slice(&Aggregate::bounded_read(&self.event)?)?; + let pr = event["pull_request"]["number"] + .as_u64() + .filter(|number| *number > 0) + .ok_or_else(|| eyre::eyre!("missing PR number"))?; + let head = event["pull_request"]["head"]["sha"].as_str().unwrap_or_default().to_owned(); + let base = event["pull_request"]["base"]["sha"].as_str().unwrap_or_default().to_owned(); + PrPublisher::validate_sha(&head)?; + PrPublisher::validate_sha(&base)?; + let run = self.load_result(&tested_sha).unwrap_or_else(|_| { + let mut run = Aggregate::fallback( + "Missing or invalid acceptance artifacts; inspect PR checks and report artifacts." + .into(), + ); + run.tested_sha.clone_from(&tested_sha); + run.run_id.clone_from(&self.run_id); + run + }); + let body = PrPublisher::body( + &run, + &format!("https://github.com/{repo}/pull/{pr}/checks"), + &metadata, + )?; + let mut authorization = + header::HeaderValue::from_str(&format!("Bearer {}", env::var("GITHUB_TOKEN")?))?; + authorization.set_sensitive(true); + let publisher = PrPublisher { + client: Client::builder() + .redirect(Policy::none()) + .timeout(Duration::from_secs(20)) + .user_agent("base-acceptance") + .default_headers(header::HeaderMap::from_iter([ + (header::AUTHORIZATION, authorization), + ( + header::ACCEPT, + header::HeaderValue::from_static("application/vnd.github+json"), + ), + ( + header::HeaderName::from_static("x-github-api-version"), + header::HeaderValue::from_static("2022-11-28"), + ), + ])) + .build()?, + api_url: format!("https://api.github.com/repos/{repo}"), + pr, + head, + base, + bot_login: env::var("BOT_LOGIN") + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "depot-code-access[bot]".into()), + }; + println!( + "{}", + if publisher.publish(&metadata, &body).await? { + "published" + } else { + "stale run; publication skipped" + } + ); + Ok(()) + } + + /// Reuses portable schema validation and enforces exact artifact provenance and check sets. + pub fn load_result(&self, tested_sha: &str) -> Result { + PrPublisher::validate_sha(tested_sha)?; + let expected = Aggregate::load_expected(&self.expected)?; + let run: RunResult = serde_json::from_slice(&Aggregate::bounded_read(&self.result)?)?; + Report::validate(&run)?; + ensure!( + expected.run_id == self.run_id + && expected.tested_sha == tested_sha + && run.run_id == self.run_id + && run.tested_sha == tested_sha, + "artifact provenance mismatch" + ); + ensure!(expected.scenarios.len() == run.scenarios.len(), "scenario set mismatch"); + for (wanted, actual) in expected.scenarios.iter().zip(&run.scenarios) { + ensure!( + wanted.id == actual.id + && wanted + .checks + .iter() + .map(String::as_str) + .eq(actual.checks.iter().map(|check| check.id.as_str())), + "scenario/check set mismatch" + ); + } + Ok(run) + } +} + +impl PrPublisher { + /// Validates a full `GitHub` commit identity. + pub fn validate_sha(sha: &str) -> Result<()> { + ensure!( + sha.len() == 40 + && sha.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "invalid tested revision" + ); + Ok(()) + } + + /// Performs one bounded `GitHub` request without printing credentials or response bodies. + pub async fn request(&self, method: Method, path: &str, body: Option) -> Result { + let mut request = self.client.request(method, format!("{}{path}", self.api_url)); + if let Some(body) = body { + request = request.json(&body); + } + let mut response = request.send().await.wrap_err("GitHub request failed")?; + ensure!(response.status().is_success(), "GitHub HTTP status {}", response.status()); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + ensure!( + bytes.len().saturating_add(chunk.len()) <= 20 * 1024 * 1024, + "GitHub response exceeds 20 MiB" + ); + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).wrap_err("invalid GitHub JSON response") + } + + /// Rechecks both branch identities, including immediately before a write. + pub async fn current(&self) -> Result { + let item = self.request(Method::GET, &format!("/pulls/{}", self.pr), None).await?; + Ok(item["head"]["sha"].as_str() == Some(&self.head) + && item["base"]["sha"].as_str() == Some(&self.base)) + } + + /// Upserts exactly one marker-owned bot comment, refusing stale or ambiguous writes. + pub async fn publish(&self, metadata: &CommentMetadata, body: &str) -> Result { + metadata.validate()?; + Report::bounded("comment body", body, 60_000)?; + ensure!(body.starts_with(MARKER), "missing comment marker"); + if !self.current().await? { + return Ok(false); + } + let mut owned = None; + for page in 1..=MAX_PAGES { + let response = self + .request( + Method::GET, + &format!("/issues/{}/comments?per_page=100&page={page}", self.pr), + None, + ) + .await?; + let comments = + response.as_array().ok_or_else(|| eyre::eyre!("invalid comment page"))?; + ensure!(comments.len() <= 100, "oversized comment page"); + for comment in comments { + if comment["user"]["type"] == "Bot" + && comment["user"]["login"].as_str() == Some(&self.bot_login) + && comment["body"].as_str().is_some_and(|body| body.starts_with(MARKER)) + { + ensure!(owned.is_none(), "duplicate owned comments"); + owned = Some(comment.clone()); + } + } + if comments.len() < 100 { + break; + } + ensure!(page < MAX_PAGES, "comment pagination cap reached"); + } + if let Some(comment) = &owned { + let old = CommentMetadata::parse(comment["body"].as_str().unwrap_or_default())?; + if (old.run_id == metadata.run_id && old.attempt > metadata.attempt) + || (old.run_id != metadata.run_id && old.started_at >= metadata.started_at) + { + return Ok(false); + } + } + let (method, path) = if let Some(comment) = owned { + let id = comment["id"] + .as_u64() + .filter(|id| *id > 0) + .ok_or_else(|| eyre::eyre!("invalid owned comment id"))?; + (Method::PATCH, format!("/issues/comments/{id}")) + } else { + (Method::POST, format!("/issues/{}/comments", self.pr)) + }; + if !self.current().await? { + return Ok(false); + } + self.request(method, &path, Some(json!({ "body": body }))).await?; + Ok(true) + } + + /// Escapes untrusted content for both Markdown and embedded HTML summary text. + pub fn text(value: &str) -> String { + let mut out = String::new(); + for word in value.split_whitespace() { + if !out.is_empty() { + out.push(' '); + } + if word.to_ascii_lowercase().contains("http://") + || word.to_ascii_lowercase().contains("https://") + { + out.push_str("[link removed]"); + continue; + } + out.push_str(word); + } + let out: String = out.chars().take(2_000).collect(); + out.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('@', "@") + .replace('`', "`") + .replace('|', "|") + .replace('[', "[") + .replace(']', "]") + .replace('(', "(") + .replace(')', ")") + } + + /// Summarizes check counts with an inline, collapsed report of checks and lifecycle errors. + pub fn body(run: &RunResult, checks_url: &str, metadata: &CommentMetadata) -> Result { + Report::validate(run)?; + metadata.validate()?; + let counts = ReportCounts::from_run(run); + let total = + counts.passed + counts.failed + counts.error + counts.blocked + counts.cancelled; + let mut out = format!( + "{MARKER}\n{META_PREFIX}{} -->\n## Acceptance Tests: {}\n\n{}/{total} checks passed", + serde_json::to_string(metadata)?, + if run.passed() { "✅ Passed" } else { "❌ Not passed" }, + counts.passed, + ); + for (count, label) in [ + (counts.failed, "failed"), + (counts.error, "errored"), + (counts.blocked, "blocked"), + (counts.cancelled, "cancelled"), + ] { + if count > 0 { + write!(out, " · {count} {label}")?; + } + } + writeln!(out, " · [Checks & reports]({checks_url})\n")?; + + out.push_str("
\nAcceptance report\n\n"); + let mut omitted = 0; + for scenario in &run.scenarios { + let records = scenario + .stages + .iter() + .filter(|stage| stage.status != Status::Passed) + .map(|stage| (&stage.id, stage.status, &stage.message, None)) + .chain( + scenario + .checks + .iter() + .map(|check| (&check.id, check.status, &check.message, Some(check))), + ); + for (id, status, message, check) in records { + let mut summary: String = message.chars().take(300).collect(); + if summary.len() < message.len() { + summary.push('…'); + } + let mut record = format!( + "- **{} / {}** — {}: {}\n", + Self::text(&scenario.id), + Self::text(id), + status.label(), + Self::text(&summary), + ); + if let Some(check) = check { + writeln!( + record, + " - Expected: {}\n - Observed: {}", + Self::text(&Report::compact_json(&check.expected)), + Self::text(&Report::compact_json(&check.observed)), + )?; + if check.status != Status::Passed { + writeln!(record, " - Next step: {}", Self::text(&check.next_step))?; + } + } + // Leave room for the omission notice and closing tag below GitHub's limit. + if out.len() + record.len() > 50_000 { + omitted += 1; + } else { + out.push_str(&record); + } + } + } + if omitted > 0 { + writeln!( + out, + "\n{omitted} more results omitted due to comment size; see Checks & reports for the complete report." + )?; + } + out.push_str("\n
\n"); + Report::bounded("comment body", &out, 60_000)?; + Ok(out) + } +} + +#[cfg(test)] +mod tests { + //! HTTP fixtures exercise reqwest itself, including ordered reads before a write; + //! no internal trait is replaced by a hand-written mock. + + use std::fs; + + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + task::JoinHandle, + }; + + use super::*; + use crate::{ExpectedManifest, ExpectedScenario}; + + fn metadata() -> CommentMetadata { + CommentMetadata { + run_id: "run/attempt-2".into(), + attempt: 2, + started_at: "2026-01-02T00:00:00Z".into(), + } + } + + fn run() -> RunResult { + let mut run: RunResult = + serde_json::from_str(include_str!("../fixtures/reports/synthetic-mixed.json")).unwrap(); + run.run_id = metadata().run_id; + run.tested_sha = "a".repeat(40); + run + } + + fn current() -> Value { + json!({ + "head": { + "sha": "head", + }, + "base": { + "sha": "base", + }, + }) + } + + fn owned(metadata: &CommentMetadata) -> Value { + json!({ + "id": 777, + "user": { + "login": "depot-code-access[bot]", + "type": "Bot", + }, + "body": format!( + "{MARKER}\n{META_PREFIX}{} -->\nprior result", + serde_json::to_string(metadata).unwrap() + ), + }) + } + + async fn server(steps: Vec<(String, Value)>) -> (PrPublisher, JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let mut bodies = Vec::new(); + for (expected, reply) in steps { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let (end, length) = loop { + let mut buffer = [0; 4096]; + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + assert_eq!(headers.lines().next().unwrap(), format!("{expected} HTTP/1.1")); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + break (end + 4, length); + } + }; + while bytes.len() < end + length { + let mut buffer = [0; 4096]; + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0); + bytes.extend_from_slice(&buffer[..read]); + } + if length > 0 { + bodies.push(serde_json::from_slice(&bytes[end..end + length]).unwrap()); + } + let body = reply.to_string(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + } + bodies + }); + ( + PrPublisher { + client: Client::builder().timeout(Duration::from_secs(2)).build().unwrap(), + api_url: format!("http://{address}"), + pr: 1, + head: "head".into(), + base: "base".into(), + bot_login: "depot-code-access[bot]".into(), + }, + handle, + ) + } + + #[test] + fn timestamp_and_metadata_validation_preserve_trusted_ordering() { + metadata().validate().unwrap(); + for invalid in [ + "", + "invalid", + "2026-02-30T00:00:00Z", + "2026-01-02T00:00:00", + "2026-1-2T00:00:00Z", + "2026-01-02T01:00:00+01:00", + "2026-01-02T00:00:00.123Z", + ] { + assert!( + CommentMetadata { started_at: invalid.into(), ..metadata() }.validate().is_err(), + "{invalid}" + ); + } + assert!(CommentMetadata { run_id: "-->@team".into(), ..metadata() }.validate().is_err()); + assert!(CommentMetadata { attempt: 0, ..metadata() }.validate().is_err()); + assert!(CommentMetadata::parse(MARKER).is_err()); + let parsed = CommentMetadata::parse(owned(&metadata())["body"].as_str().unwrap()).unwrap(); + assert_eq!(parsed.started_at, "2026-01-02T00:00:00Z"); + } + + #[tokio::test] + async fn invalid_timestamp_is_rejected_before_environment_or_artifact_access() { + let args = PublishArgs { + event: "missing".into(), + result: "missing".into(), + expected: "missing".into(), + run_id: "run".into(), + attempt: 1, + started_at: "invalid".into(), + }; + assert!(args.execute().await.unwrap_err().to_string().contains("started-at")); + } + + #[test] + fn artifact_schema_provenance_and_exact_check_sets_are_required() { + let directory = tempfile::tempdir().unwrap(); + let original = run(); + let args = PublishArgs { + event: PathBuf::new(), + result: directory.path().join("result.json"), + expected: directory.path().join("expected.json"), + run_id: original.run_id.clone(), + attempt: 2, + started_at: metadata().started_at, + }; + let expected = ExpectedManifest { + schema_version: 1, + run_id: original.run_id.clone(), + tested_sha: original.tested_sha.clone(), + scenarios: original + .scenarios + .iter() + .map(|scenario| ExpectedScenario { + id: scenario.id.clone(), + checks: scenario.checks.iter().map(|check| check.id.clone()).collect(), + }) + .collect(), + }; + fs::write(&args.expected, serde_json::to_vec(&expected).unwrap()).unwrap(); + assert!(args.load_result(&original.tested_sha).is_err()); + fs::write(&args.result, serde_json::to_vec(&original).unwrap()).unwrap(); + assert!(!args.load_result(&original.tested_sha).unwrap().passed()); + let mut variants = Vec::new(); + let mut stale = original.clone(); + stale.run_id = "other".into(); + variants.push(stale); + let mut stale = original.clone(); + stale.tested_sha = "b".repeat(40); + variants.push(stale); + let mut missing = original.clone(); + missing.scenarios.clear(); + variants.push(missing); + let mut duplicate = original.clone(); + duplicate.scenarios.push(original.scenarios[0].clone()); + variants.push(duplicate); + let mut wrong_check = original.clone(); + wrong_check.scenarios[0].checks[0].id = "unexpected".into(); + variants.push(wrong_check); + let mut bad_path = original.clone(); + bad_path.scenarios[0].checks[0].evidence = vec!["../secret".into()]; + variants.push(bad_path); + for invalid in variants { + fs::write(&args.result, serde_json::to_vec(&invalid).unwrap()).unwrap(); + assert!(args.load_result(&original.tested_sha).is_err()); + } + fs::write(&args.result, b"not json").unwrap(); + assert!(args.load_result(&original.tested_sha).is_err()); + } + + #[test] + fn aggregate_normalizes_reordered_shard_checks_for_publication() { + let directory = tempfile::tempdir().unwrap(); + let mut shard = run(); + let expected = ExpectedManifest { + schema_version: 1, + run_id: shard.run_id.clone(), + tested_sha: shard.tested_sha.clone(), + scenarios: shard + .scenarios + .iter() + .map(|scenario| ExpectedScenario { + id: scenario.id.clone(), + checks: scenario.checks.iter().map(|check| check.id.clone()).collect(), + }) + .collect(), + }; + let reordered = shard + .scenarios + .iter_mut() + .find(|scenario| scenario.checks.len() > 1) + .expect("fixture has a multi-check scenario"); + reordered.checks.reverse(); + + let expected_path = directory.path().join("expected.json"); + let results = directory.path().join("results"); + let output = directory.path().join("aggregate"); + fs::create_dir_all(results.join("shard")).unwrap(); + fs::write(&expected_path, serde_json::to_vec(&expected).unwrap()).unwrap(); + fs::write(results.join("shard/result.json"), serde_json::to_vec(&shard).unwrap()).unwrap(); + + Aggregate::write(&expected_path, &results, &output).unwrap(); + let args = PublishArgs { + event: PathBuf::new(), + result: output.join("result.json"), + expected: expected_path, + run_id: shard.run_id.clone(), + attempt: 2, + started_at: metadata().started_at, + }; + let loaded = args.load_result(&shard.tested_sha).unwrap(); + for (actual, wanted) in loaded.scenarios.iter().zip(&expected.scenarios) { + assert_eq!( + actual.checks.iter().map(|check| check.id.as_str()).collect::>(), + wanted.checks.iter().map(String::as_str).collect::>() + ); + } + } + + #[test] + fn passing_comment_keeps_summary_short_and_includes_a_collapsed_report() { + let mut run = run(); + run.scenarios.truncate(1); + let body = + PrPublisher::body(&run, "https://github.com/base/base/pull/1/checks", &metadata()) + .unwrap(); + let (summary, report) = body.split_once("
\n").unwrap(); + let visible: Vec<_> = summary.lines().filter(|line| !line.starts_with("\n## Acceptance: {}\n\nRun {} · revision {}\n\n| Passed | Failed | Error | Blocked | Cancelled |\n|---:|---:|---:|---:|---:|\n| {} | {} | {} | {} | {} |\n\n", + Self::markdown_text(ReportCounts::status_phrase(run)), + Self::markdown_code(&run.run_id), + Self::markdown_code(&run.tested_sha), + counts.passed, + counts.failed, + counts.error, + counts.blocked, + counts.cancelled + ); + let total = run.scenarios.iter().map(|s| s.checks.len()).sum::(); + let mut shown = 0; + for scenario in &run.scenarios { + let mut section = format!( + "{} — {}\n\n", + if scenario.outcome() == Status::Passed { "" } else { " open" }, + Self::escape_html(&scenario.id), + scenario.outcome().label() + ); + for check in &scenario.checks { + let line = format!( + "- **{}** ({}): {} — expected {}, observed {}. {}\n - Next step: {}\n - Reproduce: {}\n - Evidence: {}\n", + Self::markdown_text(&check.id), + Self::markdown_code(&check.kind), + check.status.label(), + Self::markdown_code(&Self::compact_json(&check.expected)), + Self::markdown_code(&Self::compact_json(&check.observed)), + Self::markdown_text(&check.message), + Self::markdown_text(&check.next_step), + Self::markdown_code(&scenario.reproduction), + if check.evidence.is_empty() { + "⚠ none recorded".into() + } else { + check + .evidence + .iter() + .map(|path| Self::markdown_code(path)) + .collect::>() + .join(", ") + } + ); + if out.len() + section.len() + line.len() + RESERVE > LIMIT { + break; + } + section.push_str(&line); + shown += 1; + } + section.push_str("\n
\n\n"); + if out.len() + section.len() + RESERVE > LIMIT { + break; + } + out.push_str(§ion); + } + if shown < total { + writeln!( + out, + "_{} check(s) omitted to keep this comment below 50 KiB; download the report bundle for complete results._", + total - shown + )?; + } + Ok(out) + } + + /// Writes the portable report bundle, leaving an existing evidence directory intact. + pub fn write(run: &RunResult, directory: &Path) -> Result<()> { + Self::validate(run)?; + fs::create_dir_all(directory).wrap_err("create report directory")?; + Self::validate_existing_evidence(run, directory)?; + for name in ["result.json", "report.html", "summary.md"] { + let destination = directory.join(name); + if destination + .symlink_metadata() + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + bail!("refusing to overwrite symlink report file {name}"); + } + } + let json = serde_json::to_string_pretty(run).wrap_err("serialize result")?; + let temporary = directory.join(format!(".result.json.{}.tmp", std::process::id())); + if temporary.symlink_metadata().is_ok() { + bail!("temporary result path already exists"); + } + fs::write(&temporary, format!("{json}\n")).wrap_err("write temporary result.json")?; + fs::rename(&temporary, directory.join("result.json")).wrap_err("commit result.json")?; + fs::write(directory.join("report.html"), Self::html(run)?).wrap_err("write report.html")?; + fs::write(directory.join("summary.md"), Self::markdown(run)?) + .wrap_err("write summary.md")?; + Ok(()) + } + + /// Validates reporting schema, resource bounds, identifiers, and artifact paths. + pub fn validate(run: &RunResult) -> Result<()> { + if run.schema_version != 1 { + bail!("unsupported result schema {}", run.schema_version); + } + Self::bounded("run_id", &run.run_id, 200)?; + Self::bounded("tested_sha", &run.tested_sha, 200)?; + if run.scenarios.is_empty() || run.scenarios.len() > 100 { + bail!("result must contain 1..=100 scenarios"); + } + let mut scenario_ids = HashSet::new(); + let mut total_checks = 0usize; + let mut total_samples = 0usize; + for scenario in &run.scenarios { + Self::valid_id("scenario", &scenario.id)?; + if !scenario_ids.insert(&scenario.id) { + bail!("duplicate scenario id {}", scenario.id); + } + if scenario.checks.len() > 1_000 || scenario.stages.len() > 100 { + bail!("scenario {} exceeds record bounds", scenario.id); + } + total_checks += scenario.checks.len(); + total_samples += scenario.samples.len(); + if total_checks > 5_000 || total_samples > 50_000 { + bail!("result exceeds reporting bounds"); + } + let mut stage_ids = HashSet::new(); + for stage in &scenario.stages { + Self::valid_id("stage", &stage.id)?; + Self::bounded("stage message", &stage.message, 65_536)?; + Self::reject_secret("stage message", &stage.message)?; + if !stage_ids.insert(&stage.id) { + bail!("duplicate stage id {}", stage.id); + } + } + let mut check_ids = HashSet::new(); + for check in &scenario.checks { + Self::valid_id("check", &check.id)?; + Self::bounded("check kind", &check.kind, 100)?; + for (name, value) in [("message", &check.message), ("next_step", &check.next_step)] + { + Self::bounded(name, value, 65_536)?; + Self::reject_secret(name, value)?; + } + if !check_ids.insert(&check.id) { + bail!("duplicate check id {}", check.id); + } + for path in &check.evidence { + Self::validate_evidence_path(path)?; + } + Self::validate_value(&check.expected, 0)?; + Self::validate_value(&check.observed, 0)?; + } + Self::validate_value(&scenario.config, 0)?; + Self::bounded("reproduction", &scenario.reproduction, 4_096)?; + Self::reject_secret("reproduction", &scenario.reproduction)?; + for diagnostic in &scenario.diagnostics { + Self::bounded("diagnostic", diagnostic, 65_536)?; + Self::reject_secret("diagnostic", diagnostic)?; + } + for fork in &scenario.forks { + Self::bounded("fork name", &fork.name, 200)?; + Self::bounded("fork chain", &fork.chain, 100)?; + if fork.observed_block.is_some() != fork.observed_elapsed_ms.is_some() { + bail!("fork {} has a partial observed boundary", fork.name); + } + } + for sample in &scenario.samples { + Self::bounded("endpoint", &sample.endpoint, 100)?; + if let Some(hash) = &sample.hash { + Self::bounded("sample hash", hash, 200)?; + } + if sample.number.is_none() && (sample.timestamp.is_some() || sample.hash.is_some()) + { + bail!("gap sample for {} contains block data", sample.endpoint); + } + } + } + Ok(()) + } + + /// Appends one semantic scenario section to an HTML document. + pub fn render_scenario(out: &mut String, scenario: &ScenarioResult) -> Result<()> { + let open = scenario.outcome() != Status::Passed; + write!( + out, + "
{} — {}

{} ms · recorded aggregate: {} · derived result: {}

", + Self::anchor(&scenario.id), + if open { " open" } else { "" }, + Self::escape_html(&scenario.id), + scenario.outcome().label(), + scenario.duration_ms, + scenario.status.label(), + scenario.outcome().label() + )?; + for check in &scenario.checks { + write!( + out, + "

{} {}

{}

Expected
{}
Observed
{}

Next step: {}

{} samples · {} RPC errors · {} ms

", + Self::anchor(&scenario.id), + Self::anchor(&check.id), + Self::escape_html(&check.id), + check.status.label(), + check.status.label(), + Self::escape_html(&check.message), + Self::escape_html(&Self::pretty_json(&check.expected)), + Self::escape_html(&Self::pretty_json(&check.observed)), + Self::escape_html(&check.next_step), + check.samples, + check.rpc_errors, + check.duration_ms + )?; + if !check.evidence.is_empty() { + out.push_str("

Evidence: "); + for (i, path) in check.evidence.iter().enumerate() { + if i > 0 { + out.push_str(", "); + } + write!( + out, + "{}", + Self::escape_html(path), + Self::escape_html(path) + )?; + } + out.push_str("

"); + } else { + out.push_str( + "

Evidence: none recorded

", + ); + } + out.push_str("
"); + } + if !scenario.samples.is_empty() { + out.push_str(&Self::head_chart(scenario)?); + } + out.push_str("
Accessible head sample data
"); + for sample in &scenario.samples { + write!( + out, + "", + Self::escape_html(&sample.endpoint), + sample.elapsed_ms, + Self::option_u64(sample.number), + Self::option_u64(sample.timestamp), + Self::escape_html(sample.hash.as_deref().unwrap_or("gap")) + )?; + } + out.push_str("
EndpointElapsed msHeightTimestampHash
{}{}{}{}{}
"); + write!( + out, + "
Configuration, provenance, diagnostics, and reproduction

Resolved configuration

{}
", + Self::escape_html(&Self::pretty_json(&scenario.config)) + )?; + if !scenario.forks.is_empty() { + out.push_str("

Fork boundaries

    "); + for fork in &scenario.forks { + write!( + out, + "
  • {} on {}: configured activation timestamp {}; {}.
  • ", + Self::escape_html(&fork.name), + Self::escape_html(&fork.chain), + fork.activation_timestamp, + match (fork.observed_block, fork.observed_elapsed_ms) { + (Some(block), Some(ms)) => format!( + "boundary crossing observed at block {block}, {ms} ms (not proof of fork behavior)" + ), + _ => "boundary crossing not observed".into(), + } + )?; + } + out.push_str("
"); + } + if !scenario.diagnostics.is_empty() { + out.push_str("

Collection limitations

    "); + for diagnostic in &scenario.diagnostics { + write!(out, "
  • {}
  • ", Self::escape_html(diagnostic))?; + } + out.push_str("
"); + } + write!( + out, + "

Inert reproduction command

{}
", + Self::escape_html(&scenario.reproduction) + )?; + Ok(()) + } + + /// Renders a fixed head-progress SVG with explicit sample gaps. + pub fn head_chart(scenario: &ScenarioResult) -> Result { + let numbered: Vec<_> = + scenario.samples.iter().filter_map(|s| s.number.map(|n| (s.elapsed_ms, n))).collect(); + if numbered.is_empty() { + return Ok("

Head progress

No successful head observations; all samples are explicit gaps.

".into()); + } + let min_x = 0; + let max_x = + scenario.samples.iter().map(|s| s.elapsed_ms).max().unwrap_or(min_x + 1).max(min_x + 1); + let min_y = numbered.iter().map(|(_, n)| *n).min().unwrap_or(0); + let max_y = + numbered.iter().map(|(_, n)| *n).max().unwrap_or(min_y).max(min_y.saturating_add(1)); + let mut endpoints: Vec<_> = scenario.samples.iter().map(|s| &s.endpoint).collect(); + endpoints.sort(); + endpoints.dedup(); + let colors = ["#69d391", "#8ec5ff", "#ffbd66", "#c4b5fd"]; + let mut svg = String::from( + "

Head progress

Height over elapsed time; lines stop at failed samples. Vertical separation at the same time is lag.

", + ); + write!( + svg, + "{min_x} ms{max_x} msblock {max_y}block {min_y}" + )?; + for (index, endpoint) in endpoints.iter().enumerate() { + let samples: Vec<_> = + scenario.samples.iter().filter(|s| &s.endpoint == *endpoint).collect(); + let mut segment = String::new(); + for sample in samples { + if let Some(number) = sample.number { + let x = Self::scale(sample.elapsed_ms, min_x, max_x, 60, 980); + let y = 300 - Self::scale(number, min_y, max_y, 0, 280); + if segment.is_empty() { + write!(segment, "M{x} {y}")?; + } else { + write!(segment, " L{x} {y}")?; + } + } else { + if !segment.is_empty() { + write!( + svg, + "", + colors[index % colors.len()], + segment + )?; + segment.clear(); + } + let x = Self::scale(sample.elapsed_ms, min_x, max_x, 60, 980); + write!( + svg, + "gap", + x + 3 + )?; + } + } + if !segment.is_empty() { + write!( + svg, + "", + colors[index % colors.len()], + segment + )?; + } + write!( + svg, + "{}", + 650 + (index % 2) * 170, + 25 + (index / 2) * 18, + Self::escape_html(endpoint) + )?; + } + for fork in &scenario.forks { + if let Some(ms) = fork.observed_elapsed_ms { + let x = Self::scale(ms, min_x, max_x, 60, 980); + write!( + svg, + "{} boundary observed", + x + 4, + Self::escape_html(&fork.name) + )?; + } + } + svg.push_str("
"); + Ok(svg) + } + + /// Renders a lifecycle table cell for matching stage records. + pub fn stage_cell(scenario: &ScenarioResult, needle: &str) -> String { + let stages: Vec<_> = scenario + .stages + .iter() + .filter(|stage| stage.id.to_ascii_lowercase().contains(needle)) + .collect(); + if stages.is_empty() { + return "— not recorded".into(); + } + let worst = stages + .iter() + .map(|stage| stage.status) + .find(|s| *s != Status::Passed) + .unwrap_or(Status::Passed); + let duration: u64 = stages.iter().map(|stage| stage.duration_ms).sum(); + let messages = stages + .iter() + .filter(|stage| !stage.message.is_empty()) + .map(|stage| Self::escape_html(&stage.message)) + .collect::>() + .join("; "); + format!( + "{} · {} ms{}", + worst.label(), + worst.label(), + duration, + if messages.is_empty() { + String::new() + } else { + format!("
{messages}") + } + ) + } + + /// Renders an accessible status table cell. + pub fn status_html(status: Status) -> String { + format!("{}", status.label(), status.label()) + } + + /// Serializes a JSON value for human-readable display. + pub fn pretty_json(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".into()) + } + + /// Serializes a JSON value on one line. + pub fn compact_json(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "null".into()) + } + + /// Formats an optional observation as a value or explicit gap. + pub fn option_u64(value: Option) -> String { + value.map_or_else(|| "gap".into(), |v| v.to_string()) + } + + /// Scales an integer into a bounded chart coordinate range. + pub fn scale(value: u64, min: u64, max: u64, low: u64, high: u64) -> u64 { + low + value.saturating_sub(min).saturating_mul(high - low) / max.saturating_sub(min).max(1) + } + + /// Converts a validated identifier to a stable HTML anchor fragment. + pub fn anchor(value: &str) -> String { + value + .bytes() + .map(|b| if b.is_ascii_alphanumeric() { (b as char).to_ascii_lowercase() } else { '-' }) + .collect() + } + + /// Escapes untrusted text for HTML text and quoted-attribute contexts. + pub fn escape_html(value: &str) -> String { + value + .chars() + .map(|c| match c { + '&' => "&".into(), + '<' => "<".into(), + '>' => ">".into(), + '"' => """.into(), + '\'' => "'".into(), + c if c.is_control() && !matches!(c, '\n' | '\t') => "�".into(), + c => c.to_string(), + }) + .collect() + } + + /// Escapes untrusted text used in Markdown content. + pub fn markdown_text(value: &str) -> String { + value + .chars() + .map(|c| match c { + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '|' | '#' => format!("\\{c}"), + c if c.is_control() && !matches!(c, '\n' | '\t') => "�".into(), + c => c.to_string(), + }) + .collect() + } + + /// Renders untrusted text as portable inline code without Markdown delimiter ambiguity. + pub fn markdown_code(value: &str) -> String { + let delimiter = "`".repeat(value.split(|c| c != '`').map(str::len).max().unwrap_or(0) + 1); + let text: String = value + .chars() + .map(|c| { + if c.is_whitespace() { + ' ' + } else if c.is_control() { + '�' + } else { + c + } + }) + .collect(); + format!("{delimiter} {text} {delimiter}") + } + + /// Validates a bounded portable record identifier. + pub fn valid_id(kind: &str, value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 200 + || !value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + { + bail!("invalid {kind} id {value:?}"); + } + Ok(()) + } + + /// Rejects a string exceeding its byte budget. + pub fn bounded(name: &str, value: &str, maximum: usize) -> Result<()> { + if value.len() > maximum { + bail!("{name} exceeds {maximum} bytes"); + } + Ok(()) + } + + /// Validates a contained, URL-safe relative evidence path. + pub fn validate_evidence_path(value: &str) -> Result<()> { + Self::bounded("evidence path", value, 500)?; + let path = Path::new(value); + if value.is_empty() + || value.contains('\\') + || value.contains(['?', '#', '%']) + || path.is_absolute() + || !value.starts_with("evidence/") + || path.components().any(|c| !matches!(c, Component::Normal(_))) + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')) + { + bail!("unsafe evidence path {value:?}"); + } + Ok(()) + } + + /// Rejects existing evidence links which resolve outside the report bundle. + pub fn validate_existing_evidence(run: &RunResult, directory: &Path) -> Result<()> { + let root = directory.canonicalize().wrap_err("canonicalize report directory")?; + for path in run + .scenarios + .iter() + .flat_map(|scenario| &scenario.checks) + .flat_map(|check| &check.evidence) + { + let candidate = directory.join(path); + if candidate.symlink_metadata().is_ok() { + let resolved = candidate + .canonicalize() + .wrap_err_with(|| format!("resolve evidence path {path}"))?; + if !resolved.starts_with(&root) { + bail!("evidence path escapes report directory: {path}"); + } + } + } + Ok(()) + } + + /// Rejects common credential forms; collection must still use allowlists. + pub fn reject_secret(name: &str, value: &str) -> Result<()> { + let lower = value.to_ascii_lowercase(); + let bearer_token = lower.find("bearer ").is_some_and(|start| { + value[start + 7..].split_whitespace().next().is_some_and(|token| { + token.len() >= 16 + && token.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + }) + }); + if lower.contains("begin private key") + || bearer_token + || lower.contains("://") + && value.split("://").nth(1).is_some_and(|tail| { + tail.split('/').next().is_some_and(|authority| authority.contains('@')) + }) + { + bail!("possible credential in {name}"); + } + Ok(()) + } + + /// Validates bounds and credential patterns recursively in JSON display data. + pub fn validate_value(value: &Value, depth: usize) -> Result<()> { + if depth > 20 { + bail!("JSON value nesting exceeds 20"); + } + match value { + Value::String(v) => { + Self::bounded("JSON string", v, 65_536)?; + Self::reject_secret("JSON string", v)?; + } + Value::Array(values) => { + if values.len() > 10_000 { + bail!("JSON array too large"); + } + for v in values { + Self::validate_value(v, depth + 1)?; + } + } + Value::Object(values) => { + if values.len() > 1_000 { + bail!("JSON object too large"); + } + for (k, v) in values { + Self::bounded("JSON key", k, 200)?; + Self::validate_value(v, depth + 1)?; + } + } + _ => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use std::os::unix::fs::symlink; + + use serde_json::json; + + use super::*; + use crate::{CheckResult, ForkBoundary, HeadSample, ScenarioResult, StageResult}; + + fn run(status: Status) -> RunResult { + RunResult { + schema_version: 1, + run_id: "synthetic-test".into(), + tested_sha: "abc123".into(), + started_at_unix_ms: 1, + scenarios: vec![ScenarioResult { + id: "smoke".into(), + status: Status::Passed, + duration_ms: 10, + config: json!({ "network": "synthetic" }), + stages: vec![StageResult { + id: "setup".into(), + status: Status::Passed, + duration_ms: 2, + message: String::new(), + }], + checks: vec![CheckResult { + id: "heads".into(), + kind: "heads_converge".into(), + status, + duration_ms: 5, + expected: json!({ "max_lag": 5 }), + observed: json!({ "lag": 14 }), + message: "result".into(), + next_step: "inspect logs".into(), + samples: 2, + rpc_errors: 0, + evidence: vec!["evidence/heads.json".into()], + }], + samples: vec![ + HeadSample { + endpoint: "builder".into(), + elapsed_ms: 0, + number: Some(100), + timestamp: Some(1), + hash: Some("0xaa".into()), + }, + HeadSample { + endpoint: "builder".into(), + elapsed_ms: 10, + number: None, + timestamp: None, + hash: None, + }, + HeadSample { + endpoint: "builder".into(), + elapsed_ms: 20, + number: Some(102), + timestamp: Some(2), + hash: Some("0xbb".into()), + }, + ], + forks: vec![ForkBoundary { + name: "denim".into(), + chain: "l2".into(), + activation_timestamp: 2, + observed_block: Some(102), + observed_elapsed_ms: Some(20), + }], + diagnostics: vec![], + reproduction: "base-acceptance run synthetic.toml".into(), + }], + } + } + + #[test] + fn derives_counts_and_ignores_supplied_status() { + let mut value = run(Status::Failed); + value.scenarios[0].status = Status::Passed; + assert_eq!( + ReportCounts::from_run(&value), + ReportCounts { failed: 1, ..Default::default() } + ); + assert!(Report::html(&value).unwrap().contains("FAILED")); + } + + #[test] + fn escapes_hostile_content_in_both_formats() { + let mut value = run(Status::Failed); + value.scenarios[0].checks[0].message = "