From 512c16f6e63d6f8d7c7f1935273009ff7d203ad9 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:26:06 +0200 Subject: [PATCH 01/26] feat: add phylax docker workflow --- .github/workflows/phylax-docker.yml | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/phylax-docker.yml diff --git a/.github/workflows/phylax-docker.yml b/.github/workflows/phylax-docker.yml new file mode 100644 index 00000000000..fc39edfc583 --- /dev/null +++ b/.github/workflows/phylax-docker.yml @@ -0,0 +1,67 @@ +# Builds and pushes the Phylax credible-reth Docker image to GHCR. +# +# Kept separate from docker.yml to avoid future conflicts +# with upstream on every reth rebase. + +name: phylax-docker + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + dry_run: + description: "Skip pushing the image (dry run)" + required: false + type: boolean + default: false + + +permissions: {} + +jobs: + build: + name: Build and push phylax reth image + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.4 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: x-access-token + password: ${{ steps.app-token.outputs.token }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Get short SHA + id: git + run: echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT" + + - name: Build and push image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64 + push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + build-args: | + BINARY=reth + MANIFEST_PATH=bin/reth + tags: ghcr.io/${{ github.repository }}/reth:sha-${{ steps.git.outputs.short_sha }} From 8b78fef0db901a4d6d05dbb4641200eab00521b2 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:31:24 +0200 Subject: [PATCH 02/26] chore: remove useless write perm --- .github/workflows/phylax-docker.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/phylax-docker.yml b/.github/workflows/phylax-docker.yml index fc39edfc583..796472be9a5 100644 --- a/.github/workflows/phylax-docker.yml +++ b/.github/workflows/phylax-docker.yml @@ -25,7 +25,6 @@ jobs: name: Build and push phylax reth image runs-on: ubuntu-latest permissions: - packages: write contents: read steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 From 84cecb27a44af3838bebd4c1df9bef2703fb8ff3 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:52:42 +0200 Subject: [PATCH 03/26] fix: use GITHUB_TOKEN for GHCR push --- .github/workflows/phylax-docker.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/phylax-docker.yml b/.github/workflows/phylax-docker.yml index 796472be9a5..ab57a1cea3a 100644 --- a/.github/workflows/phylax-docker.yml +++ b/.github/workflows/phylax-docker.yml @@ -26,25 +26,18 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + packages: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.4 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - name: Log in to GHCR uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io - username: x-access-token - password: ${{ steps.app-token.outputs.token }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 From 56396da305a5a8a6f9a5c17614530f7e7cf28869 Mon Sep 17 00:00:00 2001 From: penumbra23 Date: Mon, 6 Jul 2026 18:18:45 +0200 Subject: [PATCH 04/26] fix: install ca-certificates in phylax docker image reth creates HTTPS clients at startup (ERA history import) and reqwest 0.13 panics when the system CA store is empty, crash-looping the node. The ubuntu:24.04 base image ships no ca-certificates, and upstream's published images avoid this via Dockerfile.depot, not ./Dockerfile. Adds a fork-owned Dockerfile.phylax (copy of ./Dockerfile plus ca-certificates in the runtime stage) so upstream rebases don't conflict, and points phylax-docker.yml at it. Co-Authored-By: Claude Fable 5 --- .github/workflows/phylax-docker.yml | 5 +- Dockerfile.phylax | 78 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.phylax diff --git a/.github/workflows/phylax-docker.yml b/.github/workflows/phylax-docker.yml index ab57a1cea3a..97d6d719f46 100644 --- a/.github/workflows/phylax-docker.yml +++ b/.github/workflows/phylax-docker.yml @@ -50,10 +50,7 @@ jobs: uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - file: ./Dockerfile + file: ./Dockerfile.phylax platforms: linux/amd64 push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} - build-args: | - BINARY=reth - MANIFEST_PATH=bin/reth tags: ghcr.io/${{ github.repository }}/reth:sha-${{ steps.git.outputs.short_sha }} diff --git a/Dockerfile.phylax b/Dockerfile.phylax new file mode 100644 index 00000000000..0b9021010a9 --- /dev/null +++ b/Dockerfile.phylax @@ -0,0 +1,78 @@ +# syntax=docker.io/docker/dockerfile:1.7-labs + +# Phylax credible-reth image, built by .github/workflows/phylax-docker.yml. +# Fork-owned copy of ./Dockerfile so upstream rebases don't conflict. +# Unlike ./Dockerfile, the runtime stage installs ca-certificates: reth +# creates HTTPS clients at startup (e.g. ERA history import) and reqwest +# panics if the system CA store is empty. + +FROM lukemathwalker/cargo-chef:latest-rust-1.95-trixie AS chef +WORKDIR /app + +LABEL org.opencontainers.image.source=https://github.com/phylaxsystems/credible-reth +LABEL org.opencontainers.image.licenses="MIT OR Apache-2.0" + +# Install system dependencies +COPY .github/scripts/install_llvm_ubuntu.sh /tmp/install_llvm.sh +RUN /tmp/install_llvm.sh && rm /tmp/install_llvm.sh && \ + apt-get install -y --no-install-recommends libclang-dev m4 pkg-config + +# Builds a cargo-chef plan +FROM chef AS planner +COPY --exclude=.git --exclude=dist . . +RUN cargo chef prepare --recipe-path recipe.json + +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json + +# Build profile, maxperf by default +ARG BUILD_PROFILE=maxperf +ENV BUILD_PROFILE=$BUILD_PROFILE + +# Extra Cargo flags +ARG RUSTFLAGS="" +ENV RUSTFLAGS="$RUSTFLAGS" + +# Extra Cargo features +ARG FEATURES="" +ENV FEATURES=$FEATURES + +# Builds dependencies +RUN cargo chef cook --profile $BUILD_PROFILE --features "$FEATURES" --recipe-path recipe.json + +# Build application +# Platform-specific RUSTFLAGS: amd64 uses x86-64-v3 (Haswell+) with pclmulqdq for rocksdb +# +# TARGETPLATFORM is set by BuildKit: https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope +ARG TARGETPLATFORM +COPY --exclude=dist . . +RUN if [ -n "$RUSTFLAGS" ]; then \ + export RUSTFLAGS="$RUSTFLAGS"; \ + elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then \ + export RUSTFLAGS="-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq"; \ + fi && \ + cargo build --profile $BUILD_PROFILE --features "$FEATURES" --locked --bin reth + +# ARG is not resolved in COPY so we have to hack around it by copying the +# binary to a temporary location +RUN cp /app/target/$BUILD_PROFILE/reth /app/reth + +# Use Ubuntu as the release image +FROM ubuntu:24.04 AS runtime +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Copy reth over from the build stage +COPY --from=builder /app/reth /usr/local/bin + +# Copy licenses +COPY LICENSE-* ./ +COPY LICENSES ./LICENSES +COPY README.md ./README.md + +EXPOSE 30303 30303/udp 9001 8545 8546 +ENTRYPOINT ["/usr/local/bin/reth"] From 8c7d22fd7af6a9dced21f68de43f378a35e83f0f Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:05:07 +0200 Subject: [PATCH 05/26] ci: remove unused upstream workflows Removes book.yml, hive.yml, and bench-scheduled.yml. These fire automatically but depend on upstream-only infra (self-hosted runners, GitHub Pages publishing) this fork doesn't operate. --- .github/workflows/bench-scheduled.yml | 1125 ------------------------- .github/workflows/book.yml | 89 -- .github/workflows/hive.yml | 465 ---------- 3 files changed, 1679 deletions(-) delete mode 100644 .github/workflows/bench-scheduled.yml delete mode 100644 .github/workflows/book.yml delete mode 100644 .github/workflows/hive.yml diff --git a/.github/workflows/bench-scheduled.yml b/.github/workflows/bench-scheduled.yml deleted file mode 100644 index 37da77bf4e5..00000000000 --- a/.github/workflows/bench-scheduled.yml +++ /dev/null @@ -1,1125 +0,0 @@ -# Scheduled regression benchmarks (nightly + hourly + release). -# -# Three modes: -# nightly — Compares the previous nightly Docker build against the current one. -# Runs daily after docker.yml produces a new nightly image. -# hourly — Compares main HEAD against the last benchmarked commit to catch -# regressions quickly. Falls back to HEAD~1 on first run. -# Skips if no new commits or if a previous run is still in progress. -# release — Compares the latest GitHub release tag against the current nightly -# Docker build. Runs daily to track nightly vs release performance. -# -# State is persisted between runs via the decofe/reth-bench-charts repo: each -# successful run saves the feature commit SHA so the next run knows what to -# compare against. - -on: - schedule: - # Nightly: compares previous vs current nightly Docker build - - cron: "30 5 * * *" - # Hourly: compares main HEAD vs last benchmarked commit, skips if no new commits - - cron: "0 * * * *" - # Release: compares latest GitHub release tag vs current nightly Docker build - - cron: "0 9 * * *" - workflow_dispatch: - inputs: - force: - description: "Force run even if no new commit (bypass skip logic)" - required: false - default: false - type: boolean - slack: - description: "Slack notification policy" - required: false - default: "never" - type: choice - options: - - always - - on-win - - on-error - - never - mode: - description: "Benchmark mode" - required: false - default: "nightly" - type: choice - options: - - nightly - - hourly - - release - blocks: - description: "Number of blocks to benchmark" - required: false - default: "2000" - type: string - warmup: - description: "Number of warmup blocks (default: one-quarter of blocks)" - required: false - default: "" - type: string - -env: - CARGO_TERM_COLOR: always - RUSTC_WRAPPER: "sccache" - -name: bench-scheduled - -permissions: {} - -jobs: - # --------------------------------------------------------------------------- - # Job 1: Resolve refs, check staleness, manage state - # --------------------------------------------------------------------------- - resolve-refs: - name: resolve-refs - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - outputs: - mode: ${{ steps.mode.outputs.mode }} - baseline-ref: ${{ steps.refs.outputs.baseline-ref }} - feature-ref: ${{ steps.refs.outputs.feature-ref }} - should-skip: ${{ steps.refs.outputs.should-skip }} - is-stale: ${{ steps.refs.outputs.is-stale }} - stale-age-hours: ${{ steps.refs.outputs.stale-age-hours }} - nightly-created: ${{ steps.refs.outputs.nightly-created }} - long-running: ${{ steps.refs.outputs.long-running }} - release-tag: ${{ steps.refs.outputs.release-tag }} - blocks: ${{ steps.bench-config.outputs.blocks }} - warmup: ${{ steps.bench-config.outputs.warmup }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true - fetch-depth: 2 - - - name: Detect mode - id: mode - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_MODE: ${{ inputs.mode }} - SCHEDULE: ${{ github.event.schedule }} - run: | - # Maps cron schedules to modes (must match the schedule entries above) - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - MODE="${INPUT_MODE:-nightly}" - elif [ "$SCHEDULE" = "30 5 * * *" ]; then - MODE="nightly" - elif [ "$SCHEDULE" = "0 9 * * *" ]; then - MODE="release" - else - MODE="hourly" - fi - echo "mode=$MODE" >> "$GITHUB_OUTPUT" - echo "Detected mode: $MODE" - - - name: Resolve benchmark config - id: bench-config - env: - INPUT_BLOCKS: ${{ inputs.blocks || '2000' }} - INPUT_WARMUP: ${{ inputs.warmup || '' }} - run: | - if ! [[ "$INPUT_BLOCKS" =~ ^[0-9]+$ ]]; then - echo "::error::blocks must be a non-negative integer" - exit 1 - fi - if [ -n "$INPUT_WARMUP" ] && ! [[ "$INPUT_WARMUP" =~ ^[0-9]+$ ]]; then - echo "::error::warmup must be a non-negative integer" - exit 1 - fi - - if [ -z "$INPUT_WARMUP" ]; then - INPUT_WARMUP=$(( INPUT_BLOCKS / 4 )) - fi - - { - echo "blocks=$INPUT_BLOCKS" - echo "warmup=$INPUT_WARMUP" - } >> "$GITHUB_OUTPUT" - - - name: Resolve refs - id: refs - env: - GH_TOKEN: ${{ github.token }} - DEREK_TOKEN: ${{ secrets.DEREK_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_RUN_ID: ${{ github.run_id }} - INPUT_FORCE: ${{ inputs.force || 'false' }} - run: | - FORCE="${INPUT_FORCE:-false}" - MODE="${{ steps.mode.outputs.mode }}" - .github/scripts/bench-scheduled-refs.sh "$FORCE" "$MODE" - - - name: Alert on long-running hourly - if: steps.mode.outputs.mode == 'hourly' && steps.refs.outputs.long-running == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.slack == 'never') - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }} - SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }} - with: - script: | - const token = process.env.SLACK_BENCH_BOT_TOKEN; - const channel = process.env.SLACK_BENCH_CHANNEL; - if (!token || !channel) return; - - const repo = '${{ github.repository }}'; - const runUrl = `${context.serverUrl}/${repo}/actions/runs/${context.runId}`; - const blocks = [ - { - type: 'header', - text: { type: 'plain_text', text: ':warning: Hourly Bench: previous run still in progress', emoji: true }, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: 'A previous hourly benchmark run is still in progress. This invocation will be skipped.\nThis may indicate a long-running or stuck job.', - }, - }, - { - type: 'actions', - elements: [{ - type: 'button', - text: { type: 'plain_text', text: 'View Run :github:', emoji: true }, - url: runUrl, - action_id: 'ci_button', - }], - }, - ]; - await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ channel, blocks, text: 'Hourly bench: previous run still in progress', unfurl_links: false }), - }); - - - name: Alert on stale nightly - if: steps.mode.outputs.mode == 'nightly' && steps.refs.outputs.is-stale == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.slack == 'never') - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }} - SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }} - with: - script: | - const token = process.env.SLACK_BENCH_BOT_TOKEN; - const channel = process.env.SLACK_BENCH_CHANNEL; - if (!token || !channel) { - core.warning('Slack credentials not set, skipping stale nightly alert'); - return; - } - - const ageHours = '${{ steps.refs.outputs.stale-age-hours }}'; - const created = '${{ steps.refs.outputs.nightly-created }}'; - const featureRef = '${{ steps.refs.outputs.feature-ref }}'; - const shortSha = featureRef.slice(0, 8); - const repo = '${{ github.repository }}'; - const runUrl = `${context.serverUrl}/${repo}/actions/runs/${context.runId}`; - - const blocks = [ - { - type: 'header', - text: { type: 'plain_text', text: ':rotating_light: Nightly Regression: nightly build is stale', emoji: true }, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: [ - '*Nightly regression did not run* — nightly build is stale', - '', - `The latest nightly image was built from a commit that is *${ageHours}h old* (threshold: 24h).`, - `This means today's nightly docker build likely failed and no new image was produced.`, - '', - `Stale commit: \`${shortSha}\` (built at ${created})`, - '', - '*Action required:* Check the workflow for failures.', - ].join('\n'), - }, - }, - { - type: 'actions', - elements: [ - { - type: 'button', - text: { type: 'plain_text', text: 'View Run :github:', emoji: true }, - url: runUrl, - action_id: 'ci_button', - }, - ], - }, - ]; - - const resp = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channel, - blocks, - text: 'Nightly regression: nightly build is stale', - unfurl_links: false, - }), - }); - const data = await resp.json(); - if (!data.ok) { - core.warning(`Slack API error: ${JSON.stringify(data)}`); - } - - - name: Fail on stale nightly - if: steps.mode.outputs.mode == 'nightly' && steps.refs.outputs.is-stale == 'true' - run: | - echo "::error::Nightly build is stale (>24h old). Aborting." - exit 1 - - # --------------------------------------------------------------------------- - # Job 2: Run the benchmark - # --------------------------------------------------------------------------- - bench-scheduled: - needs: resolve-refs - if: | - needs.resolve-refs.outputs.should-skip != 'true' && - needs.resolve-refs.outputs.is-stale != 'true' - name: bench-scheduled - runs-on: [self-hosted, Linux, X64, available] - permissions: - contents: read - actions: read - timeout-minutes: 120 - env: - BENCH_RPC_URL: http://ethereum-mainnet-stable-reth-greedy-goose:8545 - SCHELK_MOUNT: /reth-bench - RETH_SCOPE: reth-bench.scope - BENCH_WORK_DIR: ${{ github.workspace }}/bench-work - BENCH_PR: "" - BENCH_MODE: ${{ needs.resolve-refs.outputs.mode }} - BENCH_ACTOR: "${{ needs.resolve-refs.outputs.mode }}-regression" - BENCH_BLOCKS: ${{ needs.resolve-refs.outputs.blocks }} - BENCH_WARMUP_BLOCKS: ${{ needs.resolve-refs.outputs.warmup }} - BENCH_SAMPLY: "false" - BENCH_CORES: "0" - BENCH_BIG_BLOCKS: "false" - BENCH_WAIT_TIME: "" - BENCH_BASELINE_ARGS: "" - BENCH_FEATURE_ARGS: "" - BENCH_RUN_PAIRS: "2" - BENCH_COMMENT_ID: "" - BENCH_SLACK: ${{ github.event_name == 'workflow_dispatch' && inputs.slack || 'always' }} - BENCH_TARGET_METRICS_CONFIG: .github/config/bench-metrics-targets.json - BENCH_NODE_BIN: reth - BENCH_SNAPSHOT_MANIFEST_URL: ${{ secrets.BENCH_SNAPSHOT_MANIFEST_URL }} - BENCH_METRICS_ADDR: "127.0.0.1:9001" - BENCH_TARGET_METRICS_SCRAPE_INTERVAL_MS: "200" - BENCH_OTLP_DISABLED: ${{ needs.resolve-refs.outputs.mode == 'release' && 'true' || 'false' }} - BENCH_OTLP_TRACES_ENDPOINT: ${{ needs.resolve-refs.outputs.mode != 'release' && secrets.BENCH_OTLP_TRACES_ENDPOINT || '' }} - BENCH_OTLP_LOGS_ENDPOINT: ${{ needs.resolve-refs.outputs.mode != 'release' && secrets.BENCH_OTLP_LOGS_ENDPOINT || '' }} - BASELINE_REF: ${{ needs.resolve-refs.outputs.baseline-ref }} - FEATURE_REF: ${{ needs.resolve-refs.outputs.feature-ref }} - CLICKHOUSE_URL: ${{ secrets.CLICKHOUSE_URL }} - CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }} - CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }} - BENCH_VICTORIAMETRICS_URL: ${{ secrets.BENCH_VICTORIAMETRICS_URL }} - steps: - - name: Clean up previous bench-work - run: sudo rm -rf "$BENCH_WORK_DIR" 2>/dev/null || true - - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - submodules: true - fetch-depth: 0 - ref: ${{ needs.resolve-refs.outputs.feature-ref }} - - - name: Resolve job URL - id: job-url - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.runId, - }); - const job = jobs.jobs.find(j => j.name === 'bench-scheduled'); - const jobUrl = job ? job.html_url : `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - core.exportVariable('BENCH_JOB_URL', jobUrl); - - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - uses: dtolnay/rust-toolchain@stable - - uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10 - continue-on-error: true - - uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 - - - name: Install dependencies - env: - DEREK_TOKEN: ${{ secrets.DEREK_TOKEN }} - run: | - mkdir -p "$HOME/.local/bin" - - # apt packages - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends \ - python3 make jq zstd curl dmsetup m4 \ - linux-tools-"$(uname -r)" || \ - sudo apt-get install -y --no-install-recommends linux-tools-generic - - # mc (MinIO client) - if ! command -v mc &>/dev/null; then - curl -sSfL https://dl.min.io/client/mc/release/linux-amd64/mc -o "$HOME/.local/bin/mc" - chmod +x "$HOME/.local/bin/mc" - fi - - # llvm - .github/scripts/install_llvm.sh ubuntu - - # uv (Python package manager) - if ! command -v uv &>/dev/null; then - curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="$HOME/.local/bin" sh - fi - - # Configure git auth for private repos - git config --global url."https://x-access-token:${DEREK_TOKEN}@github.com/".insteadOf "https://github.com/" - - # thin-provisioning-tools (era_invalidate, required by schelk) - if ! command -v era_invalidate &>/dev/null; then - git clone --depth 1 https://github.com/jthornber/thin-provisioning-tools /tmp/tpt - sudo make -C /tmp/tpt install - rm -rf /tmp/tpt - fi - - # schelk (snapshot rollback tool, invoked via sudo) - if ! sudo sh -c 'command -v schelk' &>/dev/null; then - cargo install --git https://github.com/tempoxyz/schelk --locked - sudo install "$HOME/.cargo/bin/schelk" /usr/local/bin/ - fi - - - name: Check dependencies - run: | - export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - missing=() - for cmd in mc schelk cpupower taskset stdbuf python3 curl make uv jq m4; do - command -v "$cmd" &>/dev/null || missing+=("$cmd") - done - if [ ${#missing[@]} -gt 0 ]; then - echo "::error::Missing required tools: ${missing[*]}" - exit 1 - fi - echo "All dependencies found" - - - name: Resolve display names - id: refs - env: - RELEASE_TAG: ${{ needs.resolve-refs.outputs.release-tag }} - run: | - FEATURE_SHORT=$(echo "$FEATURE_REF" | cut -c1-8) - if [ "$BENCH_MODE" = "release" ] && [ -n "$RELEASE_TAG" ]; then - echo "baseline-name=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - echo "baseline-ref=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - else - BASELINE_SHORT=$(echo "$BASELINE_REF" | cut -c1-8) - echo "baseline-name=${BENCH_MODE}-${BASELINE_SHORT}" >> "$GITHUB_OUTPUT" - echo "baseline-ref=$BASELINE_REF" >> "$GITHUB_OUTPUT" - fi - echo "feature-name=${BENCH_MODE}-${FEATURE_SHORT}" >> "$GITHUB_OUTPUT" - echo "feature-ref=$FEATURE_REF" >> "$GITHUB_OUTPUT" - - - name: Prepare source dirs - run: | - prepare_source_dir() { - local dir="$1" - local ref="$2" - - if [ -d "$dir" ]; then - git -C "$dir" reset --hard HEAD - git -C "$dir" clean -fdx - git -C "$dir" fetch origin "$ref" - else - git clone . "$dir" - fi - - git -C "$dir" checkout --force "$ref" - } - - prepare_source_dir ../reth-baseline "$BASELINE_REF" - - prepare_source_dir ../reth-feature "$FEATURE_REF" - - - name: Build binaries - id: build - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TXGEN_DEPLOY_KEY: ${{ secrets.TXGEN_DEPLOY_KEY }} - TXGEN_TOKEN: ${{ secrets.TXGEN_TOKEN }} - GH_PROJECT_TOKEN: ${{ secrets.GH_PROJECT_TOKEN }} - DEREK_PAT: ${{ secrets.DEREK_PAT }} - DEREK_TOKEN: ${{ secrets.DEREK_TOKEN }} - BENCH_REPO: ${{ github.repository }} - run: | - BASELINE_DIR="$(cd ../reth-baseline && pwd)" - FEATURE_DIR="$(cd ../reth-feature && pwd)" - - .github/scripts/bench-txgen-install.sh - BUILD_SCRIPT=.github/scripts/bench-txgen-build.sh - - "$BUILD_SCRIPT" baseline "${BASELINE_DIR}" "$BASELINE_REF" & - PID_BASELINE=$! - "$BUILD_SCRIPT" feature "${FEATURE_DIR}" "$FEATURE_REF" & - PID_FEATURE=$! - - FAIL=0 - wait $PID_BASELINE || FAIL=1 - wait $PID_FEATURE || FAIL=1 - if [ $FAIL -ne 0 ]; then - echo "::error::One or more build tasks failed" - exit 1 - fi - - - name: Sync snapshot - id: snapshot-check - run: | - BENCH_RETH_BINARY="$(pwd)/../reth-feature/target/profiling/${BENCH_NODE_BIN}" \ - .github/scripts/bench-reth-snapshot.sh - - # System tuning for reproducible benchmarks - - name: System setup - run: | - sudo cpupower frequency-set -g performance || true - # Disable turbo boost (Intel and AMD paths) - echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo 2>/dev/null || true - echo 0 | sudo tee /sys/devices/system/cpu/cpufreq/boost 2>/dev/null || true - sudo swapoff -a || true - echo 0 | sudo tee /proc/sys/kernel/randomize_va_space || true - # Disable SMT (hyperthreading) - for cpu in /sys/devices/system/cpu/cpu*/topology/thread_siblings_list; do - first=$(cut -d, -f1 < "$cpu" | cut -d- -f1) - current=$(echo "$cpu" | grep -o 'cpu[0-9]*' | grep -o '[0-9]*') - if [ "$current" != "$first" ]; then - echo 0 | sudo tee "/sys/devices/system/cpu/cpu${current}/online" || true - fi - done - echo "Online CPUs: $(nproc)" - # Disable transparent huge pages - for p in /sys/kernel/mm/transparent_hugepage /sys/kernel/mm/transparent_hugepages; do - [ -d "$p" ] && echo never | sudo tee "$p/enabled" && echo never | sudo tee "$p/defrag" && break - done || true - # Replace any stale PM QoS holders left behind by earlier benchmark jobs. - sudo pkill -f '^bench-cpu-dma-latency' 2>/dev/null || true - # Prevent deep C-states - sudo bash -c 'exec 3<>/dev/cpu_dma_latency; printf "\0\0\0\0" >&3; exec -a bench-cpu-dma-latency sleep infinity' & - echo "BENCH_CPU_DMA_LATENCY_PID=$!" >> "$GITHUB_ENV" - # Move all IRQs to core 0 - for irq in /proc/irq/*/smp_affinity_list; do - echo 0 | sudo tee "$irq" 2>/dev/null || true - done - # Stop noisy background services - sudo systemctl stop \ - irqbalance cron atd unattended-upgrades snapd \ - prometheus-node-exporter-apt.timer prometheus-node-exporter-apt.service \ - prometheus-node-exporter-nvme.timer prometheus-node-exporter-nvme.service \ - prometheus-node-exporter-ipmitool-sensor.timer prometheus-node-exporter-ipmitool-sensor.service \ - sysstat-collect.timer sysstat-collect.service \ - sysstat-summary.timer sysstat-summary.service \ - 2>/dev/null || true - echo "=== Benchmark environment ===" - uname -r - lscpu | grep -E 'Model name|CPU\(s\)|MHz|NUMA' - cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor - cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq - cat /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null || cat /sys/kernel/mm/transparent_hugepages/enabled 2>/dev/null || echo "THP: unknown" - free -h - - - name: Pre-flight cleanup - run: | - sudo systemctl stop "$RETH_SCOPE" 2>/dev/null || true - sudo systemctl reset-failed "$RETH_SCOPE" 2>/dev/null || true - sudo schelk recover -y --kill || sudo schelk full-recover -y || true - rm -rf "$BENCH_WORK_DIR" - mkdir -p "$BENCH_WORK_DIR" - - - name: Initialize benchmark metrics labels - run: | - if [ -n "${BENCH_VICTORIAMETRICS_URL:-}" ]; then - echo "::add-mask::$BENCH_VICTORIAMETRICS_URL" - fi - - BENCH_ID="${BENCH_MODE}-${{ github.run_id }}" - BENCH_REFERENCE_EPOCH=$(date +%s) - echo "BENCH_ID=${BENCH_ID}" >> "$GITHUB_ENV" - echo "BENCH_REFERENCE_EPOCH=${BENCH_REFERENCE_EPOCH}" >> "$GITHUB_ENV" - - LABELS_FILE="$(mktemp "${RUNNER_TEMP:-/tmp}/bench-metrics-labels.XXXXXX")" - echo '{}' > "$LABELS_FILE" - echo "BENCH_LABELS_FILE=${LABELS_FILE}" >> "$GITHUB_ENV" - - # Extract txgen payloads once so they are reused across all benchmark - # runs instead of re-fetching from the remote RPC for every run. - - name: Extract txgen payloads - run: | - PAYLOADS_DIR="$BENCH_WORK_DIR/txgen-payloads" - .github/scripts/bench-txgen-extract.sh \ - "../reth-feature/target/profiling/${BENCH_NODE_BIN}" \ - "$PAYLOADS_DIR" - echo "TXGEN_PAYLOADS_DIR=${PAYLOADS_DIR}" >> "$GITHUB_ENV" - - # Interleaved run order (B-F-F-B) to reduce systematic bias - - name: "Run benchmark: baseline (1/2)" - id: run-baseline-1 - run: | - cat > "$BENCH_LABELS_FILE" < "$BENCH_LABELS_FILE" < "$BENCH_LABELS_FILE" <> "$GITHUB_ENV" - cat > "$BENCH_LABELS_FILE" <> "$GITHUB_OUTPUT" - echo "Grafana URL: ${GRAFANA_URL}" - - - name: Scan logs for errors - if: "!cancelled()" - run: | - ERRORS_FILE="$BENCH_WORK_DIR/errors.md" - found=false - for run_dir in baseline-1 feature-1 feature-2 baseline-2; do - LOG="$BENCH_WORK_DIR/$run_dir/node.log" - if [ ! -f "$LOG" ]; then continue; fi - - panics=$(grep -c -E 'panicked at' "$LOG" || true) - errors=$(grep -c ' ERROR ' "$LOG" || true) - - if [ "$panics" -gt 0 ] || [ "$errors" -gt 0 ]; then - if [ "$found" = false ]; then - printf '### ⚠️ Node Errors\n\n' >> "$ERRORS_FILE" - found=true - fi - printf '
%s: %d panic(s), %d error(s)\n\n' "$run_dir" "$panics" "$errors" >> "$ERRORS_FILE" - if [ "$panics" -gt 0 ]; then - printf '**Panics:**\n```\n' >> "$ERRORS_FILE" - grep -E 'panicked at' "$LOG" | head -10 >> "$ERRORS_FILE" - printf '```\n' >> "$ERRORS_FILE" - fi - if [ "$errors" -gt 0 ]; then - printf '**Errors (first 20):**\n```\n' >> "$ERRORS_FILE" - grep ' ERROR ' "$LOG" | head -20 >> "$ERRORS_FILE" - printf '```\n' >> "$ERRORS_FILE" - fi - printf '\n
\n\n' >> "$ERRORS_FILE" - fi - done - - - name: Parse results - id: results - if: success() - env: - BASELINE_NAME: ${{ steps.refs.outputs.baseline-name }} - FEATURE_NAME: ${{ steps.refs.outputs.feature-name }} - BASELINE_REF_DISPLAY: ${{ steps.refs.outputs.baseline-ref }} - run: | - SUMMARY_ARGS="--output-summary $BENCH_WORK_DIR/summary.json" - SUMMARY_ARGS="$SUMMARY_ARGS --output-markdown $BENCH_WORK_DIR/comment.md" - SUMMARY_ARGS="$SUMMARY_ARGS --repo ${{ github.repository }}" - SUMMARY_ARGS="$SUMMARY_ARGS --baseline-ref ${BASELINE_REF_DISPLAY}" - SUMMARY_ARGS="$SUMMARY_ARGS --baseline-name ${BASELINE_NAME}" - SUMMARY_ARGS="$SUMMARY_ARGS --feature-name ${FEATURE_NAME}" - SUMMARY_ARGS="$SUMMARY_ARGS --feature-ref ${FEATURE_REF}" - - BASELINE_CSVS="$BENCH_WORK_DIR/baseline-1/combined_latency.csv" - FEATURE_CSVS="$BENCH_WORK_DIR/feature-1/combined_latency.csv" - BASELINE_CSVS="$BASELINE_CSVS $BENCH_WORK_DIR/baseline-2/combined_latency.csv" - FEATURE_CSVS="$FEATURE_CSVS $BENCH_WORK_DIR/feature-2/combined_latency.csv" - SUMMARY_ARGS="$SUMMARY_ARGS --baseline-csv $BASELINE_CSVS" - SUMMARY_ARGS="$SUMMARY_ARGS --feature-csv $FEATURE_CSVS" - SUMMARY_ARGS="$SUMMARY_ARGS --run-pairs $BENCH_RUN_PAIRS" - - GRAFANA_URL='${{ steps.metrics.outputs.grafana-url }}' - if [ -n "$GRAFANA_URL" ]; then - SUMMARY_ARGS="$SUMMARY_ARGS --grafana-url $GRAFANA_URL" - fi - if [ -n "${BENCH_TARGET_METRICS_CONFIG:-}" ]; then - SUMMARY_ARGS="$SUMMARY_ARGS --target-metrics-config $BENCH_TARGET_METRICS_CONFIG" - fi - # shellcheck disable=SC2086 - python3 .github/scripts/bench-reth-summary.py $SUMMARY_ARGS - - - name: Upload to ClickHouse - if: success() - env: - CLICKHOUSE_HOST: ${{ secrets.CLICKHOUSE_HOST }} - CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }} - CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }} - run: | - if [ "$BENCH_MODE" = "release" ]; then - WORKFLOW_NAME="workflows-release-regression-${{ github.run_id }}" - else - WORKFLOW_NAME="workflows-nightly-regression-${{ github.run_id }}" - fi - DIFF_URL="https://github.com/${{ github.repository }}/compare/${BASELINE_REF}...${FEATURE_REF}" - GRAFANA_URL='${{ steps.metrics.outputs.grafana-url }}' - JOB_URL="${BENCH_JOB_URL:-${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}}" - - python3 .github/scripts/bench-upload-clickhouse.py \ - --summary "$BENCH_WORK_DIR/summary.json" \ - --workflow-name "$WORKFLOW_NAME" \ - --chain mainnet \ - --grafana-url "${GRAFANA_URL:-}" \ - --github-diff-url "$DIFF_URL" \ - --job-url "$JOB_URL" - - - name: Generate charts - if: success() && env.BENCH_MODE != 'hourly' - env: - BASELINE_NAME: ${{ steps.refs.outputs.baseline-name }} - FEATURE_NAME: ${{ steps.refs.outputs.feature-name }} - run: | - CHART_ARGS="--output-dir $BENCH_WORK_DIR/charts" - FEATURE_CSVS="$BENCH_WORK_DIR/feature-1/combined_latency.csv" - BASELINE_CSVS="$BENCH_WORK_DIR/baseline-1/combined_latency.csv" - FEATURE_CSVS="$FEATURE_CSVS $BENCH_WORK_DIR/feature-2/combined_latency.csv" - BASELINE_CSVS="$BASELINE_CSVS $BENCH_WORK_DIR/baseline-2/combined_latency.csv" - CHART_ARGS="$CHART_ARGS --feature $FEATURE_CSVS" - CHART_ARGS="$CHART_ARGS --baseline $BASELINE_CSVS" - CHART_ARGS="$CHART_ARGS --baseline-name ${BASELINE_NAME}" - CHART_ARGS="$CHART_ARGS --feature-name ${FEATURE_NAME}" - # shellcheck disable=SC2086 - uv run --with matplotlib python3 .github/scripts/bench-reth-charts.py $CHART_ARGS - - - name: Upload results - if: "!cancelled()" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: bench-scheduled-results - path: ${{ env.BENCH_WORK_DIR }} - - - name: Push charts - id: push-charts - if: success() && env.BENCH_MODE != 'hourly' - env: - DEREK_TOKEN: ${{ secrets.DEREK_TOKEN }} - RUN_ID: ${{ github.run_id }} - run: | - CHART_DIR="${BENCH_MODE}/${RUN_ID}" - CHARTS_REPO="https://x-access-token:${DEREK_TOKEN}@github.com/decofe/reth-bench-charts.git" - - TMP_DIR="" - prepare_charts() { - if [ -n "${TMP_DIR}" ]; then - rm -rf "${TMP_DIR}" - fi - - TMP_DIR=$(mktemp -d) - if git clone --depth 1 "${CHARTS_REPO}" "${TMP_DIR}" 2>/dev/null; then - true - else - git init "${TMP_DIR}" - git -C "${TMP_DIR}" remote add origin "${CHARTS_REPO}" - fi - .github/scripts/configure-git-token-user.sh "${TMP_DIR}" "${DEREK_TOKEN}" - - mkdir -p "${TMP_DIR}/${CHART_DIR}" - cp "$BENCH_WORK_DIR"/charts/*.png "${TMP_DIR}/${CHART_DIR}/" - git -C "${TMP_DIR}" add "${CHART_DIR}" - } - - for attempt in 1 2 3 4 5; do - prepare_charts - git -C "${TMP_DIR}" commit -m "nightly bench charts for run ${RUN_ID}" - if git -C "${TMP_DIR}" push origin HEAD:main; then - break - fi - if [ "$attempt" -eq 5 ]; then - echo "::error::Failed to push charts after ${attempt} attempts" - rm -rf "${TMP_DIR}" - exit 1 - fi - sleep "$attempt" - done - echo "sha=$(git -C "${TMP_DIR}" rev-parse HEAD)" >> "$GITHUB_OUTPUT" - rm -rf "${TMP_DIR}" - - - name: Write job summary - if: success() - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const fs = require('fs'); - const { verdict, metricRows, waitTimeRows, blocksLabel } = require('./.github/scripts/bench-utils'); - - let summary; - try { - summary = JSON.parse(fs.readFileSync(process.env.BENCH_WORK_DIR + '/summary.json', 'utf8')); - } catch (e) { - await core.summary.addRaw('⚠️ Benchmark completed but failed to load summary.').write(); - return; - } - - const repo = `${context.repo.owner}/${context.repo.repo}`; - const commitUrl = `https://github.com/${repo}/commit`; - const { emoji, label } = verdict(summary.changes); - const baselineLink = `[\`${summary.baseline.name}\`](${commitUrl}/${summary.baseline.ref})`; - const featureLink = `[\`${summary.feature.name}\`](${commitUrl}/${summary.feature.ref})`; - const diffUrl = `https://github.com/${repo}/compare/${summary.baseline.ref}...${summary.feature.ref}`; - - const mode = process.env.BENCH_MODE || 'nightly'; - const modeLabel = mode === 'hourly' ? 'Hourly Regression' : 'Nightly Regression'; - let md = `# ${emoji} ${modeLabel}: ${label}\n\n`; - md += `**Baseline:** ${baselineLink}\n`; - md += `**Feature:** ${featureLink} ([diff](${diffUrl}))\n`; - md += blocksLabel(summary).map(p => `**${p.key}:** ${p.value}`).join(' · ') + '\n\n'; - - const rows = metricRows(summary); - md += `| Metric | Baseline | Feature | Change |\n`; - md += `|--------|----------|---------|--------|\n`; - for (const r of rows) { - md += `| ${r.label} | ${r.baseline} | ${r.feature} | ${r.change} |\n`; - } - md += '\n'; - - const wtRows = waitTimeRows(summary); - if (wtRows.length > 0) { - md += `### Wait Time Breakdown\n\n`; - md += `| Metric | Baseline | Feature |\n`; - md += `|--------|----------|--------|\n`; - for (const r of wtRows) { - md += `| ${r.title} | ${r.baseline} | ${r.feature} |\n`; - } - md += '\n'; - } - - // Charts - const chartSha = '${{ steps.push-charts.outputs.sha }}'; - if (chartSha) { - const runId = '${{ github.run_id }}'; - const baseUrl = `https://raw.githubusercontent.com/decofe/reth-bench-charts/${chartSha}/nightly/${runId}`; - const charts = [ - { file: 'latency_throughput.png', label: 'Latency, Throughput & Diff' }, - { file: 'wait_breakdown.png', label: 'Wait Time Breakdown' }, - { file: 'gas_vs_latency.png', label: 'Gas vs Latency' }, - ]; - md += `### Charts\n\n`; - for (const chart of charts) { - md += `
${chart.label}\n\n`; - md += `![${chart.label}](${baseUrl}/${chart.file})\n\n`; - md += `
\n\n`; - } - } - - const grafanaUrl = '${{ steps.metrics.outputs.grafana-url }}'; - if (grafanaUrl) { - md += `### Grafana Dashboard\n\n[View real-time metrics](${grafanaUrl})\n\n`; - } - - try { - const errors = fs.readFileSync(process.env.BENCH_WORK_DIR + '/errors.md', 'utf8'); - if (errors.trim()) md += '\n' + errors + '\n'; - } catch {} - - await core.summary.addRaw(md).write(); - - - name: Send Slack notification (success) - if: success() && (env.BENCH_SLACK == 'always' || env.BENCH_SLACK == 'on-win') - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }} - SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }} - with: - script: | - const fs = require('fs'); - const { verdict, fmtChange, fmtMs, metricRows, waitTimeRows, blocksLabel, isWin } = require('./.github/scripts/bench-utils'); - - const token = process.env.SLACK_BENCH_BOT_TOKEN; - const channel = process.env.SLACK_BENCH_CHANNEL; - if (!token || !channel) { - core.info('Slack credentials not set, skipping notification'); - return; - } - - let summary; - try { - summary = JSON.parse(fs.readFileSync(process.env.BENCH_WORK_DIR + '/summary.json', 'utf8')); - } catch (e) { - core.warning('Could not read summary.json for Slack notification'); - return; - } - - // Filter notifications based on mode - const changes = summary.changes || {}; - const mode = process.env.BENCH_MODE || 'nightly'; - const slackMode = process.env.BENCH_SLACK || 'always'; - const hasRegression = Object.values(changes).some(c => c.sig === 'bad'); - - // on-win mode: only notify on unambiguous improvements. Mixed results are not wins. - if (slackMode === 'on-win' && !isWin(changes)) { - core.info('on-win mode: no unambiguous improvement detected, skipping Slack notification'); - return; - } - - // Hourly mode: only notify on regressions - if (mode === 'hourly' && !hasRegression) { - core.info('Hourly mode: no regression detected, skipping Slack notification'); - return; - } - - // Nightly mode: always notify (report every run regardless of significance) - - const SLACK_VERDICT = { - '⚠️': ':warning:', - '❌': ':x:', - '✅': ':white_check_mark:', - '⚪': ':white_circle:', - }; - - const repo = `${context.repo.owner}/${context.repo.repo}`; - const { emoji, label } = verdict(changes); - const headerEmoji = SLACK_VERDICT[emoji] || emoji; - const commitUrl = `https://github.com/${repo}/commit`; - const repoLink = ``; - const baselineLink = `<${commitUrl}/${summary.baseline.ref}|${summary.baseline.name}>`; - const featureLink = `<${commitUrl}/${summary.feature.ref}|${summary.feature.name}>`; - const diffUrl = `https://github.com/${repo}/compare/${summary.baseline.ref}...${summary.feature.ref}`; - const jobUrl = process.env.BENCH_JOB_URL || `${context.serverUrl}/${repo}/actions/runs/${context.runId}`; - - function cell(text) { return { type: 'raw_text', text: String(text) || ' ' }; } - - const modeLabel = mode === 'release' ? 'Release Regression' : mode === 'hourly' ? 'Hourly Regression' : 'Nightly Regression'; - const sectionText = [ - `*${modeLabel}*`, - `*Repo:* ${repoLink}`, - '', - `*Baseline:* ${baselineLink}`, - `*Feature:* ${featureLink}`, - blocksLabel(summary).map(p => `*${p.key}:* ${p.value}`).join(' | '), - ].join('\n'); - - const rows = metricRows(summary); - const tableRows = [ - [cell('Metric'), cell('Baseline'), cell('Feature'), cell('Change')], - ...rows.map(r => [cell(r.label), cell(r.baseline), cell(r.feature), cell(r.change || ' ')]), - ]; - - const blocks = [ - { - type: 'header', - text: { type: 'plain_text', text: `${headerEmoji} ${modeLabel}: ${label}`, emoji: true }, - }, - { - type: 'section', - text: { type: 'mrkdwn', text: sectionText }, - }, - { - type: 'table', - column_settings: [{ align: 'left' }, { align: 'right' }, { align: 'right' }, { align: 'right' }], - rows: tableRows, - }, - { - type: 'actions', - elements: [ - { - type: 'button', - text: { type: 'plain_text', text: 'CI :github:', emoji: true }, - url: jobUrl, - action_id: 'ci_button', - }, - { - type: 'button', - text: { type: 'plain_text', text: 'Diff :github:', emoji: true }, - url: diffUrl, - action_id: 'diff_button', - }, - ], - }, - ]; - - const text = `${modeLabel}: ${summary.baseline.name} vs ${summary.feature.name}`; - const resp = await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ channel, blocks, text, unfurl_links: false }), - }); - const data = await resp.json(); - if (!data.ok) { - core.warning(`Slack API error: ${JSON.stringify(data)}`); - return; - } - - // Post wait time breakdown as threaded reply - const wtRows = waitTimeRows(summary); - if (data.ts && wtRows.length > 0) { - const waitTableRows = [ - [cell('Wait Time'), cell('Baseline'), cell('Feature')], - ...wtRows.map(r => [cell(r.title), cell(r.baseline), cell(r.feature)]), - ]; - await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channel, - thread_ts: data.ts, - blocks: [{ - type: 'table', - column_settings: [{ align: 'left' }, { align: 'right' }, { align: 'right' }], - rows: waitTableRows, - }], - text: 'Wait time breakdown', - unfurl_links: false, - }), - }); - } - - - name: Send Slack notification (failure) - if: failure() && env.BENCH_SLACK != 'never' && env.BENCH_SLACK != 'on-win' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - SLACK_BENCH_BOT_TOKEN: ${{ secrets.SLACK_BENCH_BOT_TOKEN }} - SLACK_BENCH_CHANNEL: ${{ secrets.SLACK_BENCH_CHANNEL }} - with: - script: | - const token = process.env.SLACK_BENCH_BOT_TOKEN; - const channel = process.env.SLACK_BENCH_CHANNEL; - if (!token || !channel) return; - - const steps_status = [ - ['building binaries', '${{ steps.build.outcome }}'], - ['syncing snapshot', '${{ steps.snapshot-check.outcome }}'], - ['running baseline benchmark (1/2)', '${{ steps.run-baseline-1.outcome }}'], - ['running feature benchmark (1/2)', '${{ steps.run-feature-1.outcome }}'], - ['running feature benchmark (2/2)', '${{ steps.run-feature-2.outcome }}'], - ['running baseline benchmark (2/2)', '${{ steps.run-baseline-2.outcome }}'], - ]; - const failed = steps_status.find(([, o]) => o === 'failure'); - const failedStep = failed ? failed[0] : 'unknown step'; - - const repo = `${context.repo.owner}/${context.repo.repo}`; - const jobUrl = process.env.BENCH_JOB_URL || `${context.serverUrl}/${repo}/actions/runs/${context.runId}`; - - const mode = process.env.BENCH_MODE || 'nightly'; - const modeLabel = mode === 'release' ? 'Release' : mode === 'hourly' ? 'Hourly' : 'Nightly'; - - const blocks = [ - { - type: 'header', - text: { type: 'plain_text', text: `:rotating_light: ${modeLabel} Bench Failed`, emoji: true }, - }, - { - type: 'section', - text: { type: 'mrkdwn', text: `*${modeLabel} regression* failed while *${failedStep}*\ncc <@U09FARE0B9Q> <@U09FAL2UMLJ>\n<@U0ANX3AM5RR> investigate this` }, - }, - { - type: 'actions', - elements: [{ - type: 'button', - text: { type: 'plain_text', text: 'View Logs :github:', emoji: true }, - url: jobUrl, - action_id: 'ci_button', - }], - }, - ]; - - await fetch('https://slack.com/api/chat.postMessage', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - channel, - blocks, - text: `${modeLabel} bench failed while ${failedStep}`, - unfurl_links: false, - }), - }); - - - name: Clean build outputs - if: always() - run: | - sudo rm -rf ../reth-baseline/target ../reth-feature/target "$BENCH_WORK_DIR" 2>/dev/null || true - - - name: Restore system settings - if: always() - run: | - if [ -n "${BENCH_CPU_DMA_LATENCY_PID:-}" ]; then - sudo kill "$BENCH_CPU_DMA_LATENCY_PID" 2>/dev/null || true - fi - sudo pkill -f '^bench-cpu-dma-latency' 2>/dev/null || true - sudo systemctl start irqbalance cron atd 2>/dev/null || true - - # --------------------------------------------------------------------------- - # Job 3: Save state on success - # --------------------------------------------------------------------------- - save-state: - needs: [resolve-refs, bench-scheduled] - if: success() - name: save-state - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - sparse-checkout: .github/scripts - sparse-checkout-cone-mode: true - - - name: Push state to charts repo - env: - DEREK_TOKEN: ${{ secrets.DEREK_TOKEN }} - run: | - MODE="${{ needs.resolve-refs.outputs.mode }}" - FEATURE_REF="${{ needs.resolve-refs.outputs.feature-ref }}" - CHARTS_REPO="https://x-access-token:${DEREK_TOKEN}@github.com/decofe/reth-bench-charts.git" - - TMP_DIR=$(mktemp -d) - if git clone --depth 1 --branch state "${CHARTS_REPO}" "${TMP_DIR}" 2>/dev/null; then - true - else - git init "${TMP_DIR}" - git -C "${TMP_DIR}" remote add origin "${CHARTS_REPO}" - fi - .github/scripts/configure-git-token-user.sh "${TMP_DIR}" "${DEREK_TOKEN}" - - mkdir -p "${TMP_DIR}/state" - echo "${FEATURE_REF}" > "${TMP_DIR}/state/${MODE}-last-feature-ref" - git -C "${TMP_DIR}" add state/ - git -C "${TMP_DIR}" diff --cached --quiet && echo "No state change" && exit 0 - git -C "${TMP_DIR}" commit -m "bench: update ${MODE} state to ${FEATURE_REF}" - git -C "${TMP_DIR}" push origin HEAD:state - rm -rf "${TMP_DIR}" diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml deleted file mode 100644 index 8e6f64469d4..00000000000 --- a/.github/workflows/book.yml +++ /dev/null @@ -1,89 +0,0 @@ -# Documentation and mdbook related jobs. - -name: book - -on: - push: - branches: [main] - pull_request: - branches: [main] - types: [opened, reopened, synchronize, closed] - merge_group: - -permissions: {} - -jobs: - build: - runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-8' || 'ubuntu-latest' }} - permissions: - contents: read - timeout-minutes: 90 - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - run: .github/scripts/install_llvm.sh ubuntu - - - name: Install bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: v1.2.23 - - - name: Install Playwright browsers - # Required for rehype-mermaid to render Mermaid diagrams during build - run: | - cd docs/vocs/ - bun i - npx playwright install --with-deps chromium - - - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly - - - name: Build docs - run: cd docs/vocs && bash scripts/build-cargo-docs.sh - - - name: Build Vocs - run: | - cd docs/vocs/ && bun run build - test -f docs/dist/public/index.html - test -f docs/dist/public/overview/index.html - test -f docs/dist/public/sdk/index.html - test -f docs/dist/public/logo.png - test -f docs/dist/public/reth-prod.png - test -f docs/dist/public/docs/index.html - test -f docs/dist/public/docs/reth/index.html - test -d docs/dist/public/docs/static.files - grep -q 'content="rustdoc"' docs/dist/public/docs/index.html - echo "Vocs Build Complete" - - - name: Setup Pages - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - name: Upload artifact - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: "./docs/vocs/docs/dist/public" - - deploy: - # Only deploy if a push to main - if: github.ref_name == 'main' && github.event_name == 'push' - runs-on: ubuntu-latest - needs: [build] - - # Grant GITHUB_TOKEN the permissions required to make a Pages deployment - permissions: - pages: write - id-token: write - - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - timeout-minutes: 60 - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/hive.yml b/.github/workflows/hive.yml deleted file mode 100644 index 9bef4675d95..00000000000 --- a/.github/workflows/hive.yml +++ /dev/null @@ -1,465 +0,0 @@ -# Runs `ethereum/hive` tests. - -name: hive - -on: - workflow_dispatch: - schedule: - - cron: "0 0 * * *" - -env: - CARGO_TERM_COLOR: always - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -permissions: {} - -jobs: - build-reth: - permissions: - contents: read - id-token: write - uses: ./.github/workflows/docker-test.yml - with: - hive_target: hive - artifact_name: "reth" - secrets: inherit - - prepare-hive: - if: github.repository == 'paradigmxyz/reth' - timeout-minutes: 45 - runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-16' || 'ubuntu-latest' }} - permissions: - contents: read - strategy: - fail-fast: false - matrix: - variant: - - amsterdam - - osaka - name: Prepare Hive - ${{ matrix.variant == 'amsterdam' && 'Amsterdam' || 'Osaka' }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - name: Checkout hive tests - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: ethereum/hive - path: hivetests - persist-credentials: false - - - name: Get hive commit hash - id: hive-commit - run: echo "hash=$(cd hivetests && git rev-parse HEAD)" >> $GITHUB_OUTPUT - - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 - with: - go-version: "^1.13.1" - - run: go version - - - name: Restore hive assets cache - id: cache-hive - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ./hive_assets - key: hive-assets-${{ matrix.variant }}-${{ steps.hive-commit.outputs.hash }}-${{ hashFiles('.github/scripts/hive/build_simulators.sh') }} - - - name: Build hive assets - if: steps.cache-hive.outputs.cache-hit != 'true' - run: .github/scripts/hive/build_simulators.sh ${{ matrix.variant }} - - - name: Load cached Docker images - if: steps.cache-hive.outputs.cache-hit == 'true' - run: | - cd hive_assets - for tar_file in *.tar; do - if [ -f "$tar_file" ]; then - echo "Loading $tar_file..." - docker load -i "$tar_file" - fi - done - # Make hive binary executable - chmod +x hive - - - name: Upload hive assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: hive_assets_${{ matrix.variant }} - path: ./hive_assets - test-amsterdam: - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - # ethereum/rpc to be deprecated: - # https://github.com/ethereum/hive/pull/1117 - scenario: - - sim: smoke/genesis - - sim: smoke/network - - sim: ethereum/sync - - sim: devp2p - limit: discv4 - # started failing after https://github.com/ethereum/go-ethereum/pull/31843, no - # action on our side, remove from here when we get unexpected passes on these tests - # - sim: devp2p - # limit: eth - # include: - # - MaliciousHandshake - # # failures tracked in https://github.com/paradigmxyz/reth/issues/14825 - # - Status - # - GetBlockHeaders - # - ZeroRequestID - # - GetBlockBodies - # - Transaction - # - NewPooledTxs - - sim: devp2p - limit: discv5 - include: - # failures tracked at https://github.com/paradigmxyz/reth/issues/14825 - - PingLargeRequestID - - sim: ethereum/engine - limit: engine-exchange-capabilities - - sim: ethereum/engine - limit: engine-withdrawals - - sim: ethereum/engine - limit: engine-auth - - sim: ethereum/engine - limit: engine-api - - sim: ethereum/engine - limit: cancun - # eth_ rpc methods - - sim: ethereum/rpc-compat - include: - - eth_blockNumber - - eth_call - - eth_chainId - - eth_createAccessList - - eth_estimateGas - - eth_feeHistory - - eth_getBalance - - eth_getBlockBy - - eth_getBlockTransactionCountBy - - eth_getCode - - eth_getProof - - eth_getStorage - - eth_getTransactionBy - - eth_getTransactionCount - - eth_getTransactionReceipt - - eth_sendRawTransaction - - eth_syncing - # debug_ rpc methods - - debug_ - - # consume-engine - - sim: ethereum/eels/consume-engine - limit: .*tests/amsterdam.* - - sim: ethereum/eels/consume-engine - limit: .*tests/osaka.* - - sim: ethereum/eels/consume-engine - limit: .*tests/prague.* - - sim: ethereum/eels/consume-engine - limit: .*tests/cancun.* - - sim: ethereum/eels/consume-engine - limit: .*tests/shanghai.* - - sim: ethereum/eels/consume-engine - limit: .*tests/berlin.* - - sim: ethereum/eels/consume-engine - limit: .*tests/istanbul.* - - sim: ethereum/eels/consume-engine - limit: .*tests/homestead.* - - sim: ethereum/eels/consume-engine - limit: .*tests/frontier.* - - sim: ethereum/eels/consume-engine - limit: .*tests/paris.* - - # consume-rlp - - sim: ethereum/eels/consume-rlp - limit: .*tests/amsterdam.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/osaka.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/prague.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/cancun.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/shanghai.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/berlin.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/istanbul.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/homestead.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/frontier.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/paris.* - needs: - - build-reth - - prepare-hive - name: Hive-Amsterdam / ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }} - # Use larger runners for eels tests to avoid OOM runner crashes - runs-on: ${{ github.repository == 'paradigmxyz/reth' && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }} - permissions: - contents: read - issues: write - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Download hive assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: hive_assets_amsterdam - path: /tmp - - - name: Download reth image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: reth - path: /tmp - - - name: Load Docker images - run: .github/scripts/hive/load_images.sh - - - name: Move hive binary - run: | - mv /tmp/hive /usr/local/bin - chmod +x /usr/local/bin/hive - - - name: Checkout hive tests - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: ethereum/hive - ref: master - path: hivetests - persist-credentials: false - - - name: Run simulator - env: - SCENARIO_SIM: ${{ matrix.scenario.sim }} - SCENARIO_LIMIT: ${{ matrix.scenario.limit }} - SCENARIO_TESTS: ${{ join(matrix.scenario.include, '|') }} - run: | - LIMIT="$SCENARIO_LIMIT" - TESTS="$SCENARIO_TESTS" - if [ -n "$LIMIT" ] && [ -n "$TESTS" ]; then - FILTER="$LIMIT/$TESTS" - elif [ -n "$LIMIT" ]; then - FILTER="$LIMIT" - elif [ -n "$TESTS" ]; then - FILTER="/$TESTS" - else - FILTER="/" - fi - echo "filter: $FILTER" - .github/scripts/hive/run_simulator.sh "$SCENARIO_SIM" "$FILTER" "amsterdam" - - - name: Parse hive output - run: | - find hivetests/workspace/logs -type f -name "*.json" ! -name "hive.json" | xargs -I {} python .github/scripts/hive/parse.py {} --exclusion .github/scripts/hive/expected_failures.yaml --ignored .github/scripts/hive/ignored_tests.yaml - - - name: Print simulator output - if: ${{ failure() }} - run: | - cat hivetests/workspace/logs/*simulator*.log - - - name: Print reth client logs - if: ${{ failure() }} - run: .github/scripts/hive/print_client_logs.sh - - test-osaka: - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - # ethereum/rpc to be deprecated: - # https://github.com/ethereum/hive/pull/1117 - scenario: - - sim: smoke/genesis - - sim: smoke/network - - sim: ethereum/sync - - sim: devp2p - limit: discv4 - # started failing after https://github.com/ethereum/go-ethereum/pull/31843, no - # action on our side, remove from here when we get unexpected passes on these tests - # - sim: devp2p - # limit: eth - # include: - # - MaliciousHandshake - # # failures tracked in https://github.com/paradigmxyz/reth/issues/14825 - # - Status - # - GetBlockHeaders - # - ZeroRequestID - # - GetBlockBodies - # - Transaction - # - NewPooledTxs - - sim: devp2p - limit: discv5 - include: - # failures tracked at https://github.com/paradigmxyz/reth/issues/14825 - - PingLargeRequestID - - sim: ethereum/engine - limit: engine-exchange-capabilities - - sim: ethereum/engine - limit: engine-withdrawals - - sim: ethereum/engine - limit: engine-auth - - sim: ethereum/engine - limit: engine-api - - sim: ethereum/engine - limit: cancun - # eth_ rpc methods - - sim: ethereum/rpc-compat - include: - - eth_blockNumber - - eth_call - - eth_chainId - - eth_createAccessList - - eth_estimateGas - - eth_feeHistory - - eth_getBalance - - eth_getBlockBy - - eth_getBlockTransactionCountBy - - eth_getCode - - eth_getProof - - eth_getStorage - - eth_getTransactionBy - - eth_getTransactionCount - - eth_getTransactionReceipt - - eth_sendRawTransaction - - eth_syncing - # debug_ rpc methods - - debug_ - - # consume-engine - - sim: ethereum/eels/consume-engine - limit: .*tests/osaka.* - - sim: ethereum/eels/consume-engine - limit: .*tests/prague.* - - sim: ethereum/eels/consume-engine - limit: .*tests/cancun.* - - sim: ethereum/eels/consume-engine - limit: .*tests/shanghai.* - - sim: ethereum/eels/consume-engine - limit: .*tests/berlin.* - - sim: ethereum/eels/consume-engine - limit: .*tests/istanbul.* - - sim: ethereum/eels/consume-engine - limit: .*tests/homestead.* - - sim: ethereum/eels/consume-engine - limit: .*tests/frontier.* - - sim: ethereum/eels/consume-engine - limit: .*tests/paris.* - - # consume-rlp - - sim: ethereum/eels/consume-rlp - limit: .*tests/osaka.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/prague.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/cancun.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/shanghai.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/berlin.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/istanbul.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/homestead.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/frontier.* - - sim: ethereum/eels/consume-rlp - limit: .*tests/paris.* - needs: - - build-reth - - prepare-hive - name: Hive-Osaka / ${{ matrix.scenario.sim }}${{ matrix.scenario.limit && format(' - {0}', matrix.scenario.limit) }} - # Use larger runners for eels tests to avoid OOM runner crashes - runs-on: ${{ github.repository == 'paradigmxyz/reth' && (contains(matrix.scenario.sim, 'eels') && 'depot-ubuntu-latest-8' || 'depot-ubuntu-latest-4') || 'ubuntu-latest' }} - permissions: - contents: read - issues: write - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Download hive assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: hive_assets_osaka - path: /tmp - - - name: Download reth image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: reth - path: /tmp - - - name: Load Docker images - run: .github/scripts/hive/load_images.sh - - - name: Move hive binary - run: | - mv /tmp/hive /usr/local/bin - chmod +x /usr/local/bin/hive - - - name: Checkout hive tests - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: ethereum/hive - ref: master - path: hivetests - persist-credentials: false - - - name: Run simulator - env: - SCENARIO_SIM: ${{ matrix.scenario.sim }} - SCENARIO_LIMIT: ${{ matrix.scenario.limit }} - SCENARIO_TESTS: ${{ join(matrix.scenario.include, '|') }} - run: | - LIMIT="$SCENARIO_LIMIT" - TESTS="$SCENARIO_TESTS" - if [ -n "$LIMIT" ] && [ -n "$TESTS" ]; then - FILTER="$LIMIT/$TESTS" - elif [ -n "$LIMIT" ]; then - FILTER="$LIMIT" - elif [ -n "$TESTS" ]; then - FILTER="/$TESTS" - else - FILTER="/" - fi - echo "filter: $FILTER" - .github/scripts/hive/run_simulator.sh "$SCENARIO_SIM" "$FILTER" "osaka" - - - name: Parse hive output - run: | - find hivetests/workspace/logs -type f -name "*.json" ! -name "hive.json" | xargs -I {} python .github/scripts/hive/parse.py {} --exclusion .github/scripts/hive/expected_failures.yaml --ignored .github/scripts/hive/ignored_tests.yaml - - - name: Print simulator output - if: ${{ failure() }} - run: | - cat hivetests/workspace/logs/*simulator*.log - - - name: Print reth client logs - if: ${{ failure() }} - run: .github/scripts/hive/print_client_logs.sh - notify-on-error: - needs: - - test-amsterdam - - test-osaka - if: failure() - runs-on: ubuntu-latest - steps: - - name: Slack Webhook Action - uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 - env: - SLACK_COLOR: ${{ job.status }} - SLACK_MESSAGE: "Failed run: https://github.com/paradigmxyz/reth/actions/runs/${{ github.run_id }}" - SLACK_WEBHOOK: ${{ secrets.SLACK_HIVE_WEBHOOK_URL }} From 4f09359dda60e6ac8a50bb05840392848a6cf74e Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:31:23 +0200 Subject: [PATCH 06/26] update doc --- crates/node/core/src/args/credible.rs | 4 ++-- docs/vocs/docs/pages/cli/reth/node.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/node/core/src/args/credible.rs b/crates/node/core/src/args/credible.rs index 35489adf07c..5b2b3098726 100644 --- a/crates/node/core/src/args/credible.rs +++ b/crates/node/core/src/args/credible.rs @@ -8,8 +8,8 @@ use reth_rpc_eth_types::CredibleRpcConfig; pub struct CredibleArgs { /// Address of the on-chain `CredibleRegistry` contract. /// - /// When set, the marker override for `eth_call` / `eth_estimateGas` is derived per-request - /// from the registry's `_credibleBlocks` mapping instead of a static override. + /// When set, the marker override for call-like RPC methods is derived per-request from the + /// registry's `_credibleBlocks` mapping instead of a static override. #[arg(long = "rpc.credible-registry-address", value_name = "ADDRESS")] pub registry_address: Option
, diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index b43a1c7d027..50d482270c1 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -584,7 +584,7 @@ Credible Layer: --rpc.credible-registry-address
Address of the on-chain `CredibleRegistry` contract. - When set, the marker override for `eth_call` / `eth_estimateGas` is derived per-request from the registry's `_credibleBlocks` mapping instead of a static override. + When set, the marker override for call-like RPC methods is derived per-request from the registry's `_credibleBlocks` mapping instead of a static override. --rpc.credible-retain-forwarded-private Retains transactions accepted by `--rpc.forwarder` as private pool transactions From a0bf749bd01aa72252c78dc723f0ab0b5c07b23c Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:49:15 +0200 Subject: [PATCH 07/26] fix(rpc): pin credible marker block to the executed block credible_call_overrides resolved the block tag to a concrete number for the marker, but the original tag was re-resolved independently by the EVM call. If the chain tip advanced in between, the marker targeted block N while the call executed at N+1, intermittently reverting marker-aware calls. Resolve the tag once and pass the pinned block to both the override and the call. --- crates/rpc/rpc-eth-api/src/core.rs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index bf4a7d807ff..86de5b6b692 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -766,9 +766,9 @@ where ) -> RpcResult { trace!(target: "rpc::eth", ?request, ?block_number, ?state_overrides, ?block_overrides, "Serving eth_call"); let at = block_number.unwrap_or_default(); - let overrides = + let (at, overrides) = credible_call_overrides(self, at, EvmOverrides::new(state_overrides, block_overrides))?; - Ok(EthCall::call(self, request, block_number, overrides).await?) + Ok(EthCall::call(self, request, Some(at), overrides).await?) } /// Handler for: `eth_fillTransaction` @@ -802,8 +802,9 @@ where let at = block_number.unwrap_or_default(); // createAccessList takes no block overrides, so the credible marker derives from the // request's block tag. The override is a registry state-diff, so it rides on `state`. - let overrides = credible_call_overrides(self, at, EvmOverrides::new(state_override, None))?; - Ok(EthCall::create_access_list_at(self, request, block_number, overrides.state).await?) + let (at, overrides) = + credible_call_overrides(self, at, EvmOverrides::new(state_override, None))?; + Ok(EthCall::create_access_list_at(self, request, Some(at), overrides.state).await?) } /// Handler for: `eth_estimateGas` @@ -816,7 +817,7 @@ where ) -> RpcResult { trace!(target: "rpc::eth", ?request, ?block_number, "Serving eth_estimateGas"); let at = block_number.unwrap_or_default(); - let overrides = + let (at, overrides) = credible_call_overrides(self, at, EvmOverrides::new(state_override, block_overrides))?; Ok(EthCall::estimate_gas_at(self, request, at, overrides).await?) } @@ -1009,22 +1010,28 @@ where } } -/// Applies the credible block override to `overrides` for a call simulated against `at`. -/// -/// A no-op if no credible registry is configured, skipping block number resolution entirely. +/// Applies the credible block override and pins the block the call executes at, so the marker and +/// the EVM call resolve the same block even if the tip advances mid-request. A no-op returning +/// `at` unchanged when no registry is configured; `pending` and an explicit +/// `block_overrides.number` are left unpinned. fn credible_call_overrides( eth_api: &T, at: BlockId, overrides: EvmOverrides, -) -> Result { +) -> Result<(BlockId, EvmOverrides), T::Error> { let credible_config = eth_api.credible_config(); if credible_config.registry_address.is_none() { - return Ok(overrides); + return Ok((at, overrides)); } let credible_block_number = resolve_credible_block_number(eth_api, at, overrides.block.as_deref())?; - Ok(credible_config.apply_credible_block_override(credible_block_number, overrides)) + let pin_block = + !at.is_pending() && credible_block_number_override(overrides.block.as_deref()).is_none(); + let overrides = credible_config.apply_credible_block_override(credible_block_number, overrides); + let at = if pin_block { BlockId::from(credible_block_number) } else { at }; + + Ok((at, overrides)) } /// Resolves the block number the EVM will actually see, for deriving the credible block From 96ba3c775a61ad9bdd51b80d40e6df5171e78f4b Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:52:13 +0200 Subject: [PATCH 08/26] fix(rpc): derive credible marker slot from full U256 block number credible_block_number_override truncated block_overrides.number from U256 to u64 while the EVM uses the full width, so a block number above u64::MAX would inject the marker into the wrong mapping slot. Thread U256 through slot derivation so the marker matches the block number the registry lookup sees. --- crates/rpc/rpc-eth-api/src/core.rs | 8 +++-- crates/rpc/rpc-eth-api/src/helpers/call.rs | 2 +- crates/rpc/rpc-eth-types/src/credible.rs | 34 +++++++++++++--------- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index 86de5b6b692..7b1323c5e69 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -1029,7 +1029,9 @@ fn credible_call_overrides( let pin_block = !at.is_pending() && credible_block_number_override(overrides.block.as_deref()).is_none(); let overrides = credible_config.apply_credible_block_override(credible_block_number, overrides); - let at = if pin_block { BlockId::from(credible_block_number) } else { at }; + // A pinned block is a committed provider block number, so it always fits `u64`. + let at = + if pin_block { BlockId::from(credible_block_number.saturating_to::()) } else { at }; Ok((at, overrides)) } @@ -1045,7 +1047,7 @@ fn resolve_credible_block_number( eth_api: &T, at: BlockId, block_overrides: Option<&BlockOverrides>, -) -> Result { +) -> Result { if let Some(number) = credible_block_number_override(block_overrides) { return Ok(number); } @@ -1057,5 +1059,5 @@ fn resolve_credible_block_number( .map_err(T::Error::from_eth_err)? .ok_or_else(|| T::Error::from_eth_err(EthApiError::HeaderNotFound(at)))?; - Ok(if is_pending { number.saturating_add(1) } else { number }) + Ok(U256::from(if is_pending { number.saturating_add(1) } else { number })) } diff --git a/crates/rpc/rpc-eth-api/src/helpers/call.rs b/crates/rpc/rpc-eth-api/src/helpers/call.rs index b6d7c85b04c..97826254886 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/call.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/call.rs @@ -378,7 +378,7 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA // Block number each bundle executes at (its own `block_override.number`, or the // target block) — this drives the credible marker slot, just like a single call. - let base_block_number = evm_env.block_env.number().saturating_to::(); + let base_block_number = evm_env.block_env.number(); // transact all bundles for (bundle_index, bundle) in bundles.into_iter().enumerate() { diff --git a/crates/rpc/rpc-eth-types/src/credible.rs b/crates/rpc/rpc-eth-types/src/credible.rs index 945608f9ea5..6a01222d354 100644 --- a/crates/rpc/rpc-eth-types/src/credible.rs +++ b/crates/rpc/rpc-eth-types/src/credible.rs @@ -30,7 +30,7 @@ impl CredibleRpcConfig { /// computation — no call into the registry contract, no EVM execution, no async lookup. pub fn apply_credible_block_override( &self, - block_number: u64, + block_number: U256, overrides: EvmOverrides, ) -> EvmOverrides { let Some(registry) = self.registry_address else { return overrides }; @@ -65,15 +65,18 @@ impl CredibleRpcConfig { /// Computes the storage slot of `_credibleBlocks[block_number]`, matching Solidity's mapping /// slot derivation: `keccak256(abi.encode(block_number, base_slot))`. -fn credible_block_slot(block_number: u64, base_slot: U256) -> B256 { - keccak256((U256::from(block_number), base_slot).abi_encode()) +fn credible_block_slot(block_number: U256, base_slot: U256) -> B256 { + keccak256((block_number, base_slot).abi_encode()) } /// Extracts the block number the EVM will use from `block_overrides.number`, if the caller set /// one. This must take priority over resolving the request's block tag, since the override is /// applied after tag resolution. -pub fn credible_block_number_override(block_overrides: Option<&BlockOverrides>) -> Option { - block_overrides.and_then(|overrides| overrides.number).map(|number| number.saturating_to()) +/// +/// Kept as `U256` (the EVM's native block-number width) so the marker slot matches the number the +/// registry lookup sees, with no truncation. +pub fn credible_block_number_override(block_overrides: Option<&BlockOverrides>) -> Option { + block_overrides.and_then(|overrides| overrides.number) } /// The storage override that makes `_credibleBlocks[blockNumber]` read as `true` for one @@ -159,13 +162,14 @@ mod tests { // `cast index uint256 12345 1`. let expected: B256 = "0x24689f9b6ba9bad3c49d2b1293bf33fa38d0c418c093b2b4bc23f5d18e11355e".parse().unwrap(); - assert_eq!(credible_block_slot(12345, U256::from(1)), expected); + assert_eq!(credible_block_slot(U256::from(12345), U256::from(1)), expected); } #[test] fn no_override_without_registry() { let config = CredibleRpcConfig::default(); - let overrides = config.apply_credible_block_override(100, EvmOverrides::default()); + let overrides = + config.apply_credible_block_override(U256::from(100), EvmOverrides::default()); assert_eq!(overrides.state, None); } @@ -174,7 +178,8 @@ mod tests { let registry = Address::repeat_byte(0xaa); let config = CredibleRpcConfig { registry_address: Some(registry), ..Default::default() }; - let overrides = config.apply_credible_block_override(12345, EvmOverrides::default()); + let overrides = + config.apply_credible_block_override(U256::from(12345), EvmOverrides::default()); let state = overrides.state.expect("registry override should add state overrides"); let account = state.get(®istry).expect("registry account should be present"); let expected_slot: B256 = @@ -188,7 +193,7 @@ mod tests { #[test] fn block_overrides_number_takes_priority() { let overrides = BlockOverrides { number: Some(U256::from(42)), ..Default::default() }; - assert_eq!(credible_block_number_override(Some(&overrides)), Some(42)); + assert_eq!(credible_block_number_override(Some(&overrides)), Some(U256::from(42))); } #[test] @@ -205,16 +210,16 @@ mod tests { // target block, so the marker is derived for the block that bundle executes at. let registry = Address::repeat_byte(0xaa); let config = CredibleRpcConfig { registry_address: Some(registry), ..Default::default() }; - let base_block = 100; + let base_block = U256::from(100); let bundle = BlockOverrides { number: Some(U256::from(250)), ..Default::default() }; let number = credible_block_number_override(Some(&bundle)).unwrap_or(base_block); - assert_eq!(number, 250); + assert_eq!(number, U256::from(250)); let overrides = config.apply_credible_block_override(number, EvmOverrides::default()); let state = overrides.state.expect("registry override should add state overrides"); let account = state.get(®istry).expect("registry account should be present"); - let expected_slot = credible_block_slot(250, U256::from(1)); + let expected_slot = credible_block_slot(U256::from(250), U256::from(1)); assert_eq!( account.state_diff.as_ref().and_then(|diff| diff.get(&expected_slot)), Some(&B256::with_last_byte(1)) @@ -224,7 +229,10 @@ mod tests { #[test] fn call_many_bundle_marker_falls_back_to_base_block() { // Without a bundle block override, the marker derives for the target/base block. - assert_eq!(credible_block_number_override(None).unwrap_or(100), 100); + assert_eq!( + credible_block_number_override(None).unwrap_or(U256::from(100)), + U256::from(100) + ); } #[test] From b3de14bd31be2949bb8da0f168b18b22e23988e8 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:54:47 +0200 Subject: [PATCH 09/26] docs(rpc): document pending marker limitation --- crates/rpc/rpc-eth-api/src/core.rs | 3 +++ crates/rpc/rpc-eth-types/src/credible.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index 7b1323c5e69..369f6ded335 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -1014,6 +1014,9 @@ where /// the EVM call resolve the same block even if the tip advances mid-request. A no-op returning /// `at` unchanged when no registry is configured; `pending` and an explicit /// `block_overrides.number` are left unpinned. +/// +/// `pending` has no stable block identifier: it is resolved as `latest + 1`, so a tip advance can +/// make the marker target the previous pending height. fn credible_call_overrides( eth_api: &T, at: BlockId, diff --git a/crates/rpc/rpc-eth-types/src/credible.rs b/crates/rpc/rpc-eth-types/src/credible.rs index 6a01222d354..ba6adff3328 100644 --- a/crates/rpc/rpc-eth-types/src/credible.rs +++ b/crates/rpc/rpc-eth-types/src/credible.rs @@ -230,7 +230,7 @@ mod tests { fn call_many_bundle_marker_falls_back_to_base_block() { // Without a bundle block override, the marker derives for the target/base block. assert_eq!( - credible_block_number_override(None).unwrap_or(U256::from(100)), + credible_block_number_override(None).unwrap_or_else(|| U256::from(100)), U256::from(100) ); } From 1c77288f491fde58e29c8ea2fae413c857284a2c Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:02:26 +0200 Subject: [PATCH 10/26] fix(rpc): keep exact-block semantics for credible block-hash requests The marker-block pinning replaced every non-pending block id with a resolved number, including an explicit block hash. That dropped reorg safety: a non-canonical or reorged hash could resolve to a different block at the same height. Pin only moving tags (latest, safe, finalized) and leave an exact hash or number unchanged. --- crates/rpc/rpc-eth-api/src/core.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index 369f6ded335..57dfd8ed326 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -1012,8 +1012,12 @@ where /// Applies the credible block override and pins the block the call executes at, so the marker and /// the EVM call resolve the same block even if the tip advances mid-request. A no-op returning -/// `at` unchanged when no registry is configured; `pending` and an explicit -/// `block_overrides.number` are left unpinned. +/// `at` unchanged when no registry is configured. Only moving tags (`latest`, `safe`, `finalized`) +/// are pinned; an exact hash or number, `pending`, and an explicit `block_overrides.number` are +/// left unchanged. +/// +/// An exact block hash is deliberately not pinned to a number: that would drop reorg safety, since +/// a non-canonical or reorged hash could otherwise resolve to a different block at the same height. /// /// `pending` has no stable block identifier: it is resolved as `latest + 1`, so a tip advance can /// make the marker target the previous pending height. @@ -1029,8 +1033,12 @@ fn credible_call_overrides( let credible_block_number = resolve_credible_block_number(eth_api, at, overrides.block.as_deref())?; - let pin_block = - !at.is_pending() && credible_block_number_override(overrides.block.as_deref()).is_none(); + let pin_block = matches!( + at, + BlockId::Number( + BlockNumberOrTag::Latest | BlockNumberOrTag::Safe | BlockNumberOrTag::Finalized + ) + ) && credible_block_number_override(overrides.block.as_deref()).is_none(); let overrides = credible_config.apply_credible_block_override(credible_block_number, overrides); // A pinned block is a committed provider block number, so it always fits `u64`. let at = From 36928d646519184864ed2da2e328f09cc424294a Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:18:29 +0200 Subject: [PATCH 11/26] fix(rpc): don't broadcast retained-private forwarded txs The raw-transaction broadcast ran before a forwarded tx was marked private, so with --rpc.credible-retain-forwarded-private the tx was still exposed through the raw-transaction subscription channel despite being filtered from the pool-read RPCs. Resolve the retained origin first and skip the broadcast when it is private. --- crates/rpc/rpc/src/eth/helpers/transaction.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/rpc/rpc/src/eth/helpers/transaction.rs b/crates/rpc/rpc/src/eth/helpers/transaction.rs index 70dca40b7aa..5a6ecf43ebd 100644 --- a/crates/rpc/rpc/src/eth/helpers/transaction.rs +++ b/crates/rpc/rpc/src/eth/helpers/transaction.rs @@ -97,8 +97,12 @@ where tracing::debug!(target: "rpc::eth", %hash, "forwarding raw transaction to forwarder"); let rlp_hex = hex::encode_prefixed(&tx); - // broadcast raw transaction to subscribers if there is any. - self.broadcast_raw_transaction(tx); + let retained_origin = self.credible_config().resolve_forwarded_origin(origin); + // Skip the public raw-transaction broadcast for retained-private forwarded txs, so + // they aren't exposed to subscribers. + if !retained_origin.is_private() { + self.broadcast_raw_transaction(tx); + } // The forwarder's response isn't guaranteed to be a tx hash, so only errors are // checked; the locally-computed hash is always returned to the caller. @@ -111,7 +115,6 @@ where .map_err(EthApiError::other)?; // Retain tx in local tx pool after forwarding, for local RPC usage. - let retained_origin = self.credible_config().resolve_forwarded_origin(origin); let _ = self.inner.add_pool_transaction(retained_origin, pool_transaction).await; return Ok(hash); From 93b134a6f157646283a4ad710b139c49268b7c33 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:36:48 +0200 Subject: [PATCH 12/26] fix(rpc): derive credible marker from resolved call env The single-call marker paths (eth_call, eth_estimateGas, eth_createAccessList) resolved the target block number with a separate provider read in the RPC handler, then let the execution path resolve the block again. For `pending` the two reads could straddle a new block, so the marker was written for `latest + 1` while the call executed at `latest + 2`, leaving `_credibleBlocks` unset for the executed block. Move the marker injection down to where the resolved EVM env already exists (`prepare_call_env`, `estimate_gas_with`, `create_access_list_with`), deriving the block number from `evm_env.block_env.number()` after any block override is applied. Marker slot and execution now resolve the same block by construction. This also folds in eth_callMany, which previously carried a bespoke per-bundle injection, and covers the trace/debug call paths that share `prepare_call_env`. All remain no-ops without a registry. `apply_credible_block_override` now takes and returns the call's `Option` directly instead of round-tripping through `EvmOverrides`, so every injection site is a single call. --- crates/rpc/rpc-eth-api/src/core.rs | 99 ++++--------------- crates/rpc/rpc-eth-api/src/helpers/call.rs | 49 ++++----- .../rpc/rpc-eth-api/src/helpers/estimate.rs | 9 +- crates/rpc/rpc-eth-types/src/credible.rs | 57 +++++------ 4 files changed, 72 insertions(+), 142 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index 57dfd8ed326..7f55f338339 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -2,7 +2,7 @@ //! the `eth_` namespace. use crate::{ helpers::{EthApiSpec, EthBlocks, EthCall, EthFees, EthState, EthTransactions, FullEthApi}, - FromEthApiError, RpcBlock, RpcHeader, RpcReceipt, RpcTransaction, + RpcBlock, RpcHeader, RpcReceipt, RpcTransaction, }; use alloy_dyn_abi::TypedData; use alloy_eips::{eip2930::AccessListResult, BlockId, BlockNumberOrTag}; @@ -18,11 +18,8 @@ use alloy_serde::JsonStorageKey; use jsonrpsee::{core::RpcResult, proc_macros::rpc}; use reth_primitives_traits::TxTy; use reth_rpc_convert::RpcTxReq; -use reth_rpc_eth_types::{ - credible::credible_block_number_override, EthApiError, EthCapabilities, FillTransaction, -}; +use reth_rpc_eth_types::{EthApiError, EthCapabilities, FillTransaction}; use reth_rpc_server_types::{result::internal_rpc_err, ToRpcResult}; -use reth_storage_api::BlockIdReader; use serde_json::Value; use std::collections::HashMap; use tracing::trace; @@ -765,10 +762,13 @@ where block_overrides: Option>, ) -> RpcResult { trace!(target: "rpc::eth", ?request, ?block_number, ?state_overrides, ?block_overrides, "Serving eth_call"); - let at = block_number.unwrap_or_default(); - let (at, overrides) = - credible_call_overrides(self, at, EvmOverrides::new(state_overrides, block_overrides))?; - Ok(EthCall::call(self, request, Some(at), overrides).await?) + Ok(EthCall::call( + self, + request, + block_number, + EvmOverrides::new(state_overrides, block_overrides), + ) + .await?) } /// Handler for: `eth_fillTransaction` @@ -799,12 +799,7 @@ where state_override: Option, ) -> RpcResult { trace!(target: "rpc::eth", ?request, ?block_number, ?state_override, "Serving eth_createAccessList"); - let at = block_number.unwrap_or_default(); - // createAccessList takes no block overrides, so the credible marker derives from the - // request's block tag. The override is a registry state-diff, so it rides on `state`. - let (at, overrides) = - credible_call_overrides(self, at, EvmOverrides::new(state_override, None))?; - Ok(EthCall::create_access_list_at(self, request, Some(at), overrides.state).await?) + Ok(EthCall::create_access_list_at(self, request, block_number, state_override).await?) } /// Handler for: `eth_estimateGas` @@ -816,10 +811,13 @@ where block_overrides: Option>, ) -> RpcResult { trace!(target: "rpc::eth", ?request, ?block_number, "Serving eth_estimateGas"); - let at = block_number.unwrap_or_default(); - let (at, overrides) = - credible_call_overrides(self, at, EvmOverrides::new(state_override, block_overrides))?; - Ok(EthCall::estimate_gas_at(self, request, at, overrides).await?) + Ok(EthCall::estimate_gas_at( + self, + request, + block_number.unwrap_or_default(), + EvmOverrides::new(state_override, block_overrides), + ) + .await?) } /// Handler for: `eth_gasPrice` @@ -1009,66 +1007,3 @@ where Ok(self.get_raw_block_access_list(block).await?) } } - -/// Applies the credible block override and pins the block the call executes at, so the marker and -/// the EVM call resolve the same block even if the tip advances mid-request. A no-op returning -/// `at` unchanged when no registry is configured. Only moving tags (`latest`, `safe`, `finalized`) -/// are pinned; an exact hash or number, `pending`, and an explicit `block_overrides.number` are -/// left unchanged. -/// -/// An exact block hash is deliberately not pinned to a number: that would drop reorg safety, since -/// a non-canonical or reorged hash could otherwise resolve to a different block at the same height. -/// -/// `pending` has no stable block identifier: it is resolved as `latest + 1`, so a tip advance can -/// make the marker target the previous pending height. -fn credible_call_overrides( - eth_api: &T, - at: BlockId, - overrides: EvmOverrides, -) -> Result<(BlockId, EvmOverrides), T::Error> { - let credible_config = eth_api.credible_config(); - if credible_config.registry_address.is_none() { - return Ok((at, overrides)); - } - - let credible_block_number = - resolve_credible_block_number(eth_api, at, overrides.block.as_deref())?; - let pin_block = matches!( - at, - BlockId::Number( - BlockNumberOrTag::Latest | BlockNumberOrTag::Safe | BlockNumberOrTag::Finalized - ) - ) && credible_block_number_override(overrides.block.as_deref()).is_none(); - let overrides = credible_config.apply_credible_block_override(credible_block_number, overrides); - // A pinned block is a committed provider block number, so it always fits `u64`. - let at = - if pin_block { BlockId::from(credible_block_number.saturating_to::()) } else { at }; - - Ok((at, overrides)) -} - -/// Resolves the block number the EVM will actually see, for deriving the credible block -/// override. Prefers `block_overrides.number` when set; otherwise resolves `at`, erroring -/// instead of silently defaulting to block `0` if it can't be resolved. -/// -/// The provider only resolves committed blocks, so `at.is_pending()` can't be looked up -/// directly; a pending call/estimate executes as if mined on top of the current chain tip, -/// so that case resolves the latest block instead and adds one. -fn resolve_credible_block_number( - eth_api: &T, - at: BlockId, - block_overrides: Option<&BlockOverrides>, -) -> Result { - if let Some(number) = credible_block_number_override(block_overrides) { - return Ok(number); - } - - let is_pending = at.is_pending(); - let number = eth_api - .provider() - .block_number_for_id(if is_pending { BlockId::latest() } else { at }) - .map_err(T::Error::from_eth_err)? - .ok_or_else(|| T::Error::from_eth_err(EthApiError::HeaderNotFound(at)))?; - - Ok(U256::from(if is_pending { number.saturating_add(1) } else { number })) -} diff --git a/crates/rpc/rpc-eth-api/src/helpers/call.rs b/crates/rpc/rpc-eth-api/src/helpers/call.rs index 97826254886..56703f37e64 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/call.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/call.rs @@ -136,14 +136,9 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA // (block 0) slot. let state_overrides = match credible_block_number_override(block_overrides.as_ref()) { - Some(target_number) => { - this.credible_config() - .apply_credible_block_override( - target_number, - EvmOverrides::state(state_overrides), - ) - .state - } + Some(target_number) => this + .credible_config() + .apply_credible_block_override(target_number, state_overrides), None => state_overrides, }; @@ -376,10 +371,6 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA } } - // Block number each bundle executes at (its own `block_override.number`, or the - // target block) — this drives the credible marker slot, just like a single call. - let base_block_number = evm_env.block_env.number(); - // transact all bundles for (bundle_index, bundle) in bundles.into_iter().enumerate() { let Bundle { transactions, block_override } = bundle; @@ -390,26 +381,13 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA let mut bundle_results = Vec::with_capacity(transactions.len()); let block_overrides = block_override.map(Box::new); - let bundle_block_number = - credible_block_number_override(block_overrides.as_deref()) - .unwrap_or(base_block_number); // transact all transactions in the bundle for (tx_index, tx) in transactions.into_iter().enumerate() { - // State overrides apply only on the first tx of each bundle; inject the - // credible marker per bundle for that bundle's block (no-op with no - // registry). - let state = if tx_index == 0 { - this.credible_config() - .apply_credible_block_override( - bundle_block_number, - EvmOverrides::state(state_override.take()), - ) - .state - } else { - None - }; - let overrides = EvmOverrides::new(state, block_overrides.clone()); + // Apply overrides, state overrides are only applied for the first tx in the + // request + let overrides = + EvmOverrides::new(state_override.take(), block_overrides.clone()); let (current_evm_env, prepared_tx) = this .prepare_call_env(evm_env.clone(), tx, &mut db, overrides) @@ -495,6 +473,11 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA let state = this.state_at_block_id(at).await?; let mut db = State::builder().with_database(StateProviderDatabase::new(state)).build(); + // Inject the credible Layer marker for the block the EVM will execute at. No-op unless + // a registry is configured. + let state_override = this + .credible_config() + .apply_credible_block_override(evm_env.block_env.number(), state_override); if let Some(state_overrides) = state_override { apply_state_overrides(state_overrides, &mut db) .map_err(Self::Error::from_eth_err)?; @@ -928,7 +911,13 @@ pub trait Call: if let Some(block_overrides) = overrides.block { apply_block_overrides(*block_overrides, db, evm_env.block_env.inner_mut()); } - if let Some(state_overrides) = overrides.state { + // Inject the credible Layer marker for the block the EVM will execute at (after any block + // override is applied), so the marker slot and the execution resolve the same block. No-op + // unless a registry is configured. + let state_overrides = self + .credible_config() + .apply_credible_block_override(evm_env.block_env.number(), overrides.state); + if let Some(state_overrides) = state_overrides { apply_state_overrides(state_overrides, db) .map_err(EthApiError::from_state_overrides_err)?; } diff --git a/crates/rpc/rpc-eth-api/src/helpers/estimate.rs b/crates/rpc/rpc-eth-api/src/helpers/estimate.rs index 0990ab2c52a..4c572ea2217 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/estimate.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/estimate.rs @@ -88,8 +88,13 @@ pub trait EstimateCall: Call { apply_block_overrides(*block_overrides, &mut db, evm_env.block_env.inner_mut()); } - // Apply any state overrides if specified. - if let Some(state_override) = overrides.state { + // Inject the Credible Layer marker for the block the EVM will execute at (after any block + // override is applied), so the marker slot and the execution resolve the same block. No-op + // unless a registry is configured. + let state_override = self + .credible_config() + .apply_credible_block_override(evm_env.block_env.number(), overrides.state); + if let Some(state_override) = state_override { apply_state_overrides(state_override, &mut db).map_err(Self::Error::from_eth_err)?; } diff --git a/crates/rpc/rpc-eth-types/src/credible.rs b/crates/rpc/rpc-eth-types/src/credible.rs index ba6adff3328..06f77a00687 100644 --- a/crates/rpc/rpc-eth-types/src/credible.rs +++ b/crates/rpc/rpc-eth-types/src/credible.rs @@ -1,7 +1,7 @@ //! Credible Layer RPC integration hooks. use alloy_primitives::{keccak256, Address, B256, U256}; -use alloy_rpc_types_eth::{state::EvmOverrides, BlockOverrides}; +use alloy_rpc_types_eth::{state::StateOverride, BlockOverrides}; use alloy_sol_types::SolValue; use reth_transaction_pool::TransactionOrigin; use serde::{Deserialize, Serialize}; @@ -23,24 +23,25 @@ pub struct CredibleRpcConfig { } impl CredibleRpcConfig { - /// Applies the credible block override for a call simulated against `block_number` to - /// call-like EVM overrides. + /// Merges the credible block marker for a call simulated against `block_number` into the + /// call's state overrides. /// - /// A no-op if no registry is configured. Registry-backed resolution is a pure hash - /// computation — no call into the registry contract, no EVM execution, no async lookup. + /// A no-op returning `state` unchanged if no registry is configured. Registry-backed + /// resolution is a pure hash computation — no call into the registry contract, no EVM + /// execution, no async lookup. pub fn apply_credible_block_override( &self, block_number: U256, - overrides: EvmOverrides, - ) -> EvmOverrides { - let Some(registry) = self.registry_address else { return overrides }; + state: Option, + ) -> Option { + let Some(registry) = self.registry_address else { return state }; let block_override = CredibleBlockOverride { address: registry, slot: credible_block_slot(block_number, U256::from(DEFAULT_CREDIBLE_BLOCKS_BASE_SLOT)), value: B256::with_last_byte(1), }; - block_override.apply_to(overrides) + block_override.apply_to(state) } /// Returns the origin a successfully forwarded transaction should be retained under. @@ -92,25 +93,25 @@ struct CredibleBlockOverride { } impl CredibleBlockOverride { - /// Merges this override into existing EVM overrides. - fn apply_to(self, mut overrides: EvmOverrides) -> EvmOverrides { - let state = overrides.state.get_or_insert_with(Default::default); + /// Merges this override into the call's state overrides. + fn apply_to(self, state: Option) -> Option { + let mut state = state.unwrap_or_default(); let account = state.entry(self.address).or_default(); - if let Some(state) = account.state.as_mut() { - state.insert(self.slot, self.value); + if let Some(slots) = account.state.as_mut() { + slots.insert(self.slot, self.value); } else { account.state_diff.get_or_insert_with(Default::default).insert(self.slot, self.value); } - overrides + Some(state) } } #[cfg(test)] mod tests { use super::*; - use alloy_rpc_types_eth::state::{AccountOverride, StateOverride}; + use alloy_rpc_types_eth::state::AccountOverride; #[test] fn credible_block_override_adds_state_diff() { @@ -119,8 +120,7 @@ mod tests { let value = B256::repeat_byte(0x33); let block_override = CredibleBlockOverride { address, slot, value }; - let overrides = block_override.apply_to(EvmOverrides::default()); - let state = overrides.state.expect("override should add state overrides"); + let state = block_override.apply_to(None).expect("override should add state overrides"); let account = state.get(&address).expect("override account should be present"); assert_eq!(account.state, None); @@ -146,8 +146,9 @@ mod tests { let block_override = CredibleBlockOverride { address, slot: override_slot, value: override_value }; - let overrides = block_override.apply_to(EvmOverrides::state(Some(state_override))); - let state = overrides.state.expect("override should keep state overrides"); + let state = block_override + .apply_to(Some(state_override)) + .expect("override should keep state overrides"); let account = state.get(&address).expect("override account should be present"); let state = account.state.as_ref().expect("full state override should be preserved"); @@ -168,9 +169,8 @@ mod tests { #[test] fn no_override_without_registry() { let config = CredibleRpcConfig::default(); - let overrides = - config.apply_credible_block_override(U256::from(100), EvmOverrides::default()); - assert_eq!(overrides.state, None); + let state = config.apply_credible_block_override(U256::from(100), None); + assert_eq!(state, None); } #[test] @@ -178,9 +178,9 @@ mod tests { let registry = Address::repeat_byte(0xaa); let config = CredibleRpcConfig { registry_address: Some(registry), ..Default::default() }; - let overrides = - config.apply_credible_block_override(U256::from(12345), EvmOverrides::default()); - let state = overrides.state.expect("registry override should add state overrides"); + let state = config + .apply_credible_block_override(U256::from(12345), None) + .expect("registry override should add state overrides"); let account = state.get(®istry).expect("registry account should be present"); let expected_slot: B256 = "0x24689f9b6ba9bad3c49d2b1293bf33fa38d0c418c093b2b4bc23f5d18e11355e".parse().unwrap(); @@ -216,8 +216,9 @@ mod tests { let number = credible_block_number_override(Some(&bundle)).unwrap_or(base_block); assert_eq!(number, U256::from(250)); - let overrides = config.apply_credible_block_override(number, EvmOverrides::default()); - let state = overrides.state.expect("registry override should add state overrides"); + let state = config + .apply_credible_block_override(number, None) + .expect("registry override should add state overrides"); let account = state.get(®istry).expect("registry account should be present"); let expected_slot = credible_block_slot(U256::from(250), U256::from(1)); assert_eq!( From 524b542c4e4d2a9267839aa5f50f4e7605ca8321 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:38:13 +0200 Subject: [PATCH 13/26] fix(rpc): keep credible-retained txs visible by hash Retaining forwarded transactions as private must be overlay-only: it may hide a retained transaction from pool enumeration and its raw bytes, but a lookup by hash still needs to resolve it so callers can track it before inclusion. The pool branch of the by-hash lookup was suppressing the retained transaction, which broke that tracking path. Restore the upstream pool lookup so `eth_getTransactionByHash` returns the retained transaction. Filtering stays on the enumeration surface (`eth_pendingTransactions`) and the raw-byte surface (`eth_getRawTransactionByHash`). --- crates/rpc/rpc-eth-api/src/helpers/transaction.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs index afe4dbdff80..fe5d72058f7 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs @@ -746,15 +746,9 @@ pub trait LoadTransaction: SpawnBlocking + FullEthApiTypes + RpcNodeCoreExt { } // tx not found on disk, check pool - if let Some(tx) = self.pool().get(&hash) { - // Suppress the pooled copy of a retained-private tx so it doesn't leak before - // inclusion; once mined, the on-disk lookup above returns it. - let hide_private = - self.credible_config().hide_private_pool_txs() && tx.origin.is_private(); - if !hide_private { - let tx = tx.transaction.clone_into_consensus(); - return Ok(Some(TransactionSource::Pool(tx.into()))); - } + if let Some(tx) = self.pool().get(&hash).map(|tx| tx.transaction.clone_into_consensus()) + { + return Ok(Some(TransactionSource::Pool(tx.into()))); } Ok(None) From 1d3a48257ef0b2421314e37d265a9ed3ae1e38aa Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:17:49 +0200 Subject: [PATCH 14/26] fix(rpc): read retained-private tx from single pool snapshot eth_getRawTransactionByHash checked the pool entry's origin and fetched its raw bytes through two independent pool reads. A private transaction inserted between the two could be returned, because the origin check ran before it was present and so saw it as non-private, leaking its bytes before inclusion. Gate the raw-byte fetch on a single origin check of the same pool entry. The fetch runs only once the entry is seen as non-private, and a hash maps to one immutable-origin transaction, so it can only return that authorized entry; a mined transaction is still returned by the provider lookup. --- .../rpc-eth-api/src/helpers/transaction.rs | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs index fe5d72058f7..3e757d5c1cb 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs @@ -229,18 +229,19 @@ pub trait EthTransactions: LoadTransaction { hash: B256, ) -> impl Future, Self::Error>> + Send { async move { - // Note: this is mostly used to fetch pooled transactions so we check the pool first. - // Suppress the pooled copy of a retained-private tx so its raw bytes don't leak before - // inclusion; once mined, the provider lookup below still returns it. - let private_in_pool = self.credible_config().hide_private_pool_txs() && - self.pool().get(&hash).is_some_and(|tx| tx.origin.is_private()); - if let Some(tx) = self - .pool() - .get_pooled_transaction_element(hash) - .filter(|_| !private_in_pool) - .map(|tx| tx.encoded_2718().into()) - { - return Ok(Some(tx)) + // Check the pool first. Gate the raw-byte fetch on the entry's own origin so a + // retained-private tx can't leak its bytes before inclusion. + if let Some(pool_tx) = self.pool().get(&hash) { + let hide_private = + self.credible_config().hide_private_pool_txs() && pool_tx.origin.is_private(); + if !hide_private && + let Some(tx) = self + .pool() + .get_pooled_transaction_element(hash) + .map(|tx| tx.encoded_2718().into()) + { + return Ok(Some(tx)) + } } self.spawn_blocking_io(move |ref this| { From 060aada405806e7477ddaabf03c54759e74b6a8a Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:22:10 +0200 Subject: [PATCH 15/26] docs(rpc): note private-origin retention assumption Hiding retained-private transactions keys off `Private` transaction origin, which is exact only because forwarder retention is the sole path assigning that origin on this node. Record the assumption so a future `Private` source doesn't silently widen what gets hidden. --- crates/rpc/rpc-eth-types/src/credible.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/rpc/rpc-eth-types/src/credible.rs b/crates/rpc/rpc-eth-types/src/credible.rs index 06f77a00687..13063e23654 100644 --- a/crates/rpc/rpc-eth-types/src/credible.rs +++ b/crates/rpc/rpc-eth-types/src/credible.rs @@ -58,7 +58,10 @@ impl CredibleRpcConfig { /// Whether retained-private transactions must be hidden from public pool-reading RPCs. /// - /// Enabled exactly when the node retains forwarded transactions as private. + /// Enabled exactly when the node retains forwarded transactions as private. Callers hide every + /// `Private`-origin pool transaction, which is exact here because + /// [`Self::resolve_forwarded_origin`] is the only path that assigns `Private` origin on + /// this node. pub const fn hide_private_pool_txs(&self) -> bool { self.retain_forwarded_txs_as_private } From 130735b658f735979f4be06e059ec4b58aea4c83 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:23:21 +0200 Subject: [PATCH 16/26] test(rpc): cover credible marker across call handlers Add RPC handler-level tests that execute eth_call, eth_estimateGas and eth_createAccessList through a mock provider with a registry contract, and assert the credible marker reaches the EVM: disabled-config parity, an explicit block number, a block-number override, and the pending path (marker derived from the resolved env at latest + 1). eth_callMany shares the same injection point as eth_call, and eth_simulateV1 requires block recovery the mock provider does not support, so those remain covered by the external node test suite. --- crates/rpc/rpc/src/eth/core.rs | 200 ++++++++++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 4 deletions(-) diff --git a/crates/rpc/rpc/src/eth/core.rs b/crates/rpc/rpc/src/eth/core.rs index e08291afa06..f169f6a3c0d 100644 --- a/crates/rpc/rpc/src/eth/core.rs +++ b/crates/rpc/rpc/src/eth/core.rs @@ -583,24 +583,28 @@ mod tests { use crate::{eth::helpers::types::EthRpcConverter, EthApi, EthApiBuilder}; use alloy_consensus::{Block, BlockBody, Header}; use alloy_eips::BlockNumberOrTag; - use alloy_primitives::{Signature, B256, U64}; + use alloy_primitives::{Address, Bytes, Signature, TxKind, B256, U256, U64}; use alloy_rpc_types::FeeHistory; - use alloy_rpc_types_eth::{Bundle, TransactionRequest}; + use alloy_rpc_types_eth::{BlockOverrides, Bundle, TransactionRequest}; use jsonrpsee_types::error::INVALID_PARAMS_CODE; use rand::Rng; use reth_chain_state::CanonStateSubscriptions; - use reth_chainspec::{ChainSpec, ChainSpecProvider, EthChainSpec}; + use reth_chainspec::{ChainSpec, ChainSpecProvider, EthChainSpec, DEV}; use reth_ethereum_primitives::TransactionSigned; use reth_evm_ethereum::EthEvmConfig; use reth_network_api::noop::NoopNetwork; use reth_provider::{ - test_utils::{MockEthProvider, NoopProvider}, + test_utils::{ExtendedAccount, MockEthProvider, NoopProvider}, PruneCheckpointReader, StageCheckpointReader, }; use reth_rpc_eth_api::{node::RpcNodeCoreAdapter, EthApiServer}; + use reth_rpc_eth_types::CredibleRpcConfig; use reth_storage_api::{BalProvider, BlockReader, BlockReaderIdExt, StateProviderFactory}; use reth_testing_utils::generators; use reth_transaction_pool::test_utils::{testing_pool, TestPool}; + use revm::bytecode::opcode::{ + JUMPDEST, JUMPI, KECCAK256, MSTORE, NUMBER, PUSH1, RETURN, REVERT, SLOAD, + }; type FakeEthApi

= EthApi< RpcNodeCoreAdapter, @@ -927,4 +931,192 @@ mod tests { "all: no percentiles were requested, so there should be no rewards result" ); } + + // Returns `_credibleBlocks[block.number]` (slot = keccak256(abi.encode(block.number, 1))). + fn credible_readback_bytecode() -> Bytes { + Bytes::from(vec![ + NUMBER, PUSH1, 0x00, MSTORE, PUSH1, 0x01, PUSH1, 0x20, MSTORE, PUSH1, 0x40, PUSH1, + 0x00, KECCAK256, // slot = keccak256(mem[0x00..0x40]) + SLOAD, PUSH1, 0x00, MSTORE, PUSH1, 0x20, PUSH1, 0x00, RETURN, + ]) + } + + // Reverts unless the marker slot is set; for methods whose output isn't the slot value. + fn credible_revert_unless_marker_bytecode() -> Bytes { + Bytes::from(vec![ + NUMBER, PUSH1, 0x00, MSTORE, PUSH1, 0x01, PUSH1, 0x20, MSTORE, PUSH1, 0x40, PUSH1, + 0x00, KECCAK256, SLOAD, PUSH1, 0x17, + JUMPI, // marker set -> jump to the JUMPDEST at byte 0x17 + PUSH1, 0x00, PUSH1, 0x00, REVERT, JUMPDEST, PUSH1, 0x00, PUSH1, 0x00, RETURN, + ]) + } + + /// Provider seeded with a single block whose `registry` account runs `bytecode`. + fn credible_mock_provider(registry: Address, bytecode: Bytes) -> MockEthProvider { + // DEV enables all forks (typed txs for createAccessList); Cancun needs the blob fields. + let provider = MockEthProvider::default().with_chain_spec((**DEV).clone()); + let hash = B256::repeat_byte(0xbb); + let header = Header { + number: 1, + gas_limit: 30_000_000, + excess_blob_gas: Some(0), + blob_gas_used: Some(0), + parent_beacon_block_root: Some(B256::ZERO), + ..Default::default() + }; + provider.add_block(hash, Block { header: header.clone(), body: Default::default() }); + provider.add_header(hash, header); + provider.add_account(registry, ExtendedAccount::new(0, U256::ZERO).with_bytecode(bytecode)); + provider + } + + /// Builds an `EthApi`, enabling the credible registry override when `registry` is set. + fn build_credible_eth_api(provider: MockEthProvider, registry: Option

) -> FakeEthApi { + let builder = EthApiBuilder::new( + provider.clone(), + testing_pool(), + NoopNetwork::default(), + EthEvmConfig::new(provider.chain_spec()), + ); + match registry { + Some(registry_address) => builder + .credible_config(CredibleRpcConfig { + registry_address: Some(registry_address), + ..Default::default() + }) + .build(), + None => builder.build(), + } + } + + /// A call request targeting the registry contract. + fn credible_call_request(registry: Address) -> TransactionRequest { + TransactionRequest { to: Some(TxKind::Call(registry)), ..Default::default() } + } + + /// A 32-byte value with `byte` in the last position, as returned by the readback contract. + fn u256_bytes(byte: u8) -> Bytes { + Bytes::from(B256::with_last_byte(byte)) + } + + // eth_call: disabled-config parity, explicit number, and block-number override. + #[tokio::test] + async fn credible_marker_readback_via_eth_call() { + let registry = Address::repeat_byte(0xcc); + let provider = credible_mock_provider(registry, credible_readback_bytecode()); + let request = credible_call_request(registry); + + // Disabled config: marker absent, slot reads 0. + let eth_api = build_credible_eth_api(provider.clone(), None); + let out = as EthApiServer<_, _, _, _, _, _>>::call( + ð_api, + request.clone(), + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(out, u256_bytes(0)); + + // Registry configured: marker injected at latest and at an explicit number. + let eth_api = build_credible_eth_api(provider, Some(registry)); + for block in [None, Some(BlockNumberOrTag::Number(1).into())] { + let out = as EthApiServer<_, _, _, _, _, _>>::call( + ð_api, + request.clone(), + block, + None, + None, + ) + .await + .unwrap(); + assert_eq!(out, u256_bytes(1)); + } + + // A block-number override selects the slot for the overridden block. + let overrides = BlockOverrides { number: Some(U256::from(1)), ..Default::default() }; + let out = as EthApiServer<_, _, _, _, _, _>>::call( + ð_api, + request, + None, + None, + Some(Box::new(overrides)), + ) + .await + .unwrap(); + assert_eq!(out, u256_bytes(1)); + } + + // eth_call `pending`: marker derives from the resolved env (latest + 1). + #[tokio::test] + async fn credible_marker_readback_via_eth_call_pending() { + let registry = Address::repeat_byte(0xcc); + let provider = credible_mock_provider(registry, credible_readback_bytecode()); + let eth_api = build_credible_eth_api(provider, Some(registry)); + + let out = as EthApiServer<_, _, _, _, _, _>>::call( + ð_api, + credible_call_request(registry), + Some(BlockNumberOrTag::Pending.into()), + None, + None, + ) + .await + .unwrap(); + assert_eq!(out, u256_bytes(1)); + } + + // eth_estimateGas reaches the marker (reverting probe only succeeds when set). + #[tokio::test] + async fn credible_marker_reaches_estimate_gas() { + let registry = Address::repeat_byte(0xcc); + let provider = credible_mock_provider(registry, credible_revert_unless_marker_bytecode()); + let request = credible_call_request(registry); + + let disabled = build_credible_eth_api(provider.clone(), None); + assert!( as EthApiServer<_, _, _, _, _, _>>::estimate_gas( + &disabled, + request.clone(), + None, + None, + None, + ) + .await + .is_err()); + + let enabled = build_credible_eth_api(provider, Some(registry)); + assert!( as EthApiServer<_, _, _, _, _, _>>::estimate_gas( + &enabled, request, None, None, None, + ) + .await + .is_ok()); + } + + // eth_createAccessList reaches the marker. + #[tokio::test] + async fn credible_marker_reaches_create_access_list() { + let registry = Address::repeat_byte(0xcc); + let provider = credible_mock_provider(registry, credible_revert_unless_marker_bytecode()); + let request = credible_call_request(registry); + + let disabled = build_credible_eth_api(provider.clone(), None); + let res = as EthApiServer<_, _, _, _, _, _>>::create_access_list( + &disabled, + request.clone(), + None, + None, + ) + .await + .unwrap(); + assert!(res.error.is_some()); + + let enabled = build_credible_eth_api(provider, Some(registry)); + let res = as EthApiServer<_, _, _, _, _, _>>::create_access_list( + &enabled, request, None, None, + ) + .await + .unwrap(); + assert!(res.error.is_none()); + } } From a5dc8495d53a42a56a727880472a9ff6b519234f Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:00:20 +0200 Subject: [PATCH 17/26] fix(rpc): hide retained-private txs from txpool namespace Retention only filtered eth_pendingTransactions; the txpool namespace read the pool directly and still exposed private-origin transactions through txpool_content, txpool_contentFrom and txpool_inspect, and counted them in txpool_status. Thread the Credible Layer config into TxPoolApi and drop private-origin transactions from those methods when retention is enabled, keeping the cheap count path when it is not. --- crates/rpc/rpc-builder/src/lib.rs | 1 + crates/rpc/rpc/src/txpool.rs | 45 ++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/rpc/rpc-builder/src/lib.rs b/crates/rpc/rpc-builder/src/lib.rs index c3dd3392168..07051932da8 100644 --- a/crates/rpc/rpc-builder/src/lib.rs +++ b/crates/rpc/rpc-builder/src/lib.rs @@ -1005,6 +1005,7 @@ where RethRpcModule::Txpool => TxPoolApi::new( self.eth.api.pool().clone(), dyn_clone::clone(self.eth.api.converter()), + self.eth.api.credible_config(), ) .into_rpc() .into(), diff --git a/crates/rpc/rpc/src/txpool.rs b/crates/rpc/rpc/src/txpool.rs index 51355dc1c12..a86055b21b5 100644 --- a/crates/rpc/rpc/src/txpool.rs +++ b/crates/rpc/rpc/src/txpool.rs @@ -12,6 +12,7 @@ use reth_primitives_traits::NodePrimitives; use reth_rpc_api::TxPoolApiServer; use reth_rpc_convert::{RpcConvert, RpcTypes}; use reth_rpc_eth_api::RpcTransaction; +use reth_rpc_eth_types::CredibleRpcConfig; use reth_transaction_pool::{ AllPoolTransactions, PoolConsensusTx, PoolTransaction, TransactionPool, }; @@ -25,12 +26,13 @@ pub struct TxPoolApi { /// An interface to interact with the pool pool: Pool, converter: Eth, + credible_config: CredibleRpcConfig, } impl TxPoolApi { /// Creates a new instance of `TxpoolApi`. - pub const fn new(pool: Pool, converter: Eth) -> Self { - Self { pool, converter } + pub const fn new(pool: Pool, converter: Eth, credible_config: CredibleRpcConfig) -> Self { + Self { pool, converter, credible_config } } } @@ -61,13 +63,21 @@ where Ok(()) } + // With Credible Layer retention, private-origin pool txs must not leak before inclusion. + let hide_private = self.credible_config.hide_private_pool_txs(); let AllPoolTransactions { pending, queued } = self.pool.all_transactions(); let mut content = TxpoolContent::default(); for pending in pending { + if hide_private && pending.origin.is_private() { + continue; + } insert::<_, Eth>(&pending.transaction, &mut content.pending, &self.converter)?; } for queued in queued { + if hide_private && queued.origin.is_private() { + continue; + } insert::<_, Eth>(&queued.transaction, &mut content.queued, &self.converter)?; } @@ -88,6 +98,13 @@ where /// Handler for `txpool_status` async fn txpool_status(&self) -> RpcResult { trace!(target: "rpc::eth", "Serving txpool_status"); + // With Credible Layer retention, exclude private-origin txs from the public counts. + if self.credible_config.hide_private_pool_txs() { + let AllPoolTransactions { pending, queued } = self.pool.all_transactions(); + let pending = pending.iter().filter(|tx| !tx.origin.is_private()).count(); + let queued = queued.iter().filter(|tx| !tx.origin.is_private()).count(); + return Ok(TxpoolStatus { pending: pending as u64, queued: queued as u64 }); + } let (pending, queued) = self.pool.pending_and_queued_txn_count(); Ok(TxpoolStatus { pending: pending as u64, queued: queued as u64 }) } @@ -111,17 +128,25 @@ where entry.insert(tx.nonce().to_string(), tx.into_inner().into()); } + // With Credible Layer retention, private-origin pool txs must not leak before inclusion. + let hide_private = self.credible_config.hide_private_pool_txs(); let AllPoolTransactions { pending, queued } = self.pool.all_transactions(); Ok(TxpoolInspect { - pending: pending.iter().fold(Default::default(), |mut acc, tx| { - insert(&tx.transaction, &mut acc); - acc - }), - queued: queued.iter().fold(Default::default(), |mut acc, tx| { - insert(&tx.transaction, &mut acc); - acc - }), + pending: pending.iter().filter(|tx| !hide_private || !tx.origin.is_private()).fold( + Default::default(), + |mut acc, tx| { + insert(&tx.transaction, &mut acc); + acc + }, + ), + queued: queued.iter().filter(|tx| !hide_private || !tx.origin.is_private()).fold( + Default::default(), + |mut acc, tx| { + insert(&tx.transaction, &mut acc); + acc + }, + ), }) } From a07d6c45db0541d4eaf4ec650b79e5eb5ca3fda6 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:16:42 +0200 Subject: [PATCH 18/26] test(rpc): cover txpool private-tx hiding Assert the txpool namespace hides retained-private transactions when retention is enabled and exposes them when it is not. --- crates/rpc/rpc/src/txpool.rs | 57 ++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/rpc/rpc/src/txpool.rs b/crates/rpc/rpc/src/txpool.rs index a86055b21b5..decd6a32100 100644 --- a/crates/rpc/rpc/src/txpool.rs +++ b/crates/rpc/rpc/src/txpool.rs @@ -179,3 +179,60 @@ impl fmt::Debug for TxPoolApi { f.debug_struct("TxpoolApi").finish_non_exhaustive() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::eth::helpers::types::EthRpcConverter; + use reth_chainspec::MAINNET; + use reth_rpc_eth_types::receipt::EthReceiptConverter; + use reth_transaction_pool::{ + test_utils::{testing_pool, MockTransaction}, + TransactionOrigin, + }; + + #[tokio::test] + async fn txpool_hides_retained_private_txs() { + let public_sender = Address::repeat_byte(0x11); + let private_sender = Address::repeat_byte(0x22); + + let pool = testing_pool(); + pool.add_transaction( + TransactionOrigin::External, + MockTransaction::eip1559().with_nonce(0).with_sender(public_sender), + ) + .await + .unwrap(); + pool.add_transaction( + TransactionOrigin::Private, + MockTransaction::eip1559().with_nonce(0).with_sender(private_sender), + ) + .await + .unwrap(); + + let converter = EthRpcConverter::new(EthReceiptConverter::new(MAINNET.clone())); + + // Retention enabled: the private-origin tx is hidden, the public one remains. + let api = TxPoolApi::new( + pool.clone(), + converter.clone(), + CredibleRpcConfig { retain_forwarded_txs_as_private: true, ..Default::default() }, + ); + let content = api.txpool_content().await.unwrap(); + assert!( + content.pending.contains_key(&public_sender) || + content.queued.contains_key(&public_sender) + ); + assert!( + !content.pending.contains_key(&private_sender) && + !content.queued.contains_key(&private_sender) + ); + let status = api.txpool_status().await.unwrap(); + assert_eq!(status.pending + status.queued, 1); + + // Retention disabled: both transactions are visible. + let api = TxPoolApi::new(pool, converter, CredibleRpcConfig::default()); + let status = api.txpool_status().await.unwrap(); + assert_eq!(status.pending + status.queued, 2); + } +} From 0173e9532d17f6c5a42593bf6b8f96cb1ac230cc Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:20:14 +0200 Subject: [PATCH 19/26] perf(txpool): maintain private-tx counts for O(1) txpool_status txpool_status walked the whole pool and cloned every entry via all_transactions() to exclude private-origin txs under Credible Layer retention, turning a cheap, frequently-polled endpoint into O(n) work with two allocations per call. The pending and parked sub-pools now maintain a running private-origin count alongside their size tracker, so private_pending_and_queued_txn_count is O(1) and txpool_status reads it instead of scanning. Also wires the count through the TransactionPool trait, which previously fell back to the (0, 0) default on the concrete pool. --- crates/rpc/rpc/src/txpool.rs | 12 +++--- crates/transaction-pool/src/lib.rs | 4 ++ crates/transaction-pool/src/pool/parked.rs | 17 +++++++++ crates/transaction-pool/src/pool/pending.rs | 24 ++++++++++++ crates/transaction-pool/src/pool/txpool.rs | 41 +++++++++++++++++++++ crates/transaction-pool/src/traits.rs | 9 +++++ 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/crates/rpc/rpc/src/txpool.rs b/crates/rpc/rpc/src/txpool.rs index decd6a32100..b0c86cb9823 100644 --- a/crates/rpc/rpc/src/txpool.rs +++ b/crates/rpc/rpc/src/txpool.rs @@ -98,14 +98,16 @@ where /// Handler for `txpool_status` async fn txpool_status(&self) -> RpcResult { trace!(target: "rpc::eth", "Serving txpool_status"); + let (pending, queued) = self.pool.pending_and_queued_txn_count(); // With Credible Layer retention, exclude private-origin txs from the public counts. if self.credible_config.hide_private_pool_txs() { - let AllPoolTransactions { pending, queued } = self.pool.all_transactions(); - let pending = pending.iter().filter(|tx| !tx.origin.is_private()).count(); - let queued = queued.iter().filter(|tx| !tx.origin.is_private()).count(); - return Ok(TxpoolStatus { pending: pending as u64, queued: queued as u64 }); + let (private_pending, private_queued) = + self.pool.private_pending_and_queued_txn_count(); + return Ok(TxpoolStatus { + pending: pending.saturating_sub(private_pending) as u64, + queued: queued.saturating_sub(private_queued) as u64, + }); } - let (pending, queued) = self.pool.pending_and_queued_txn_count(); Ok(TxpoolStatus { pending: pending as u64, queued: queued as u64 }) } diff --git a/crates/transaction-pool/src/lib.rs b/crates/transaction-pool/src/lib.rs index f61f3895c56..cf2201e68a9 100644 --- a/crates/transaction-pool/src/lib.rs +++ b/crates/transaction-pool/src/lib.rs @@ -637,6 +637,10 @@ where (pending, queued) } + fn private_pending_and_queued_txn_count(&self) -> (usize, usize) { + self.pool.get_pool_data().private_pending_and_queued_txn_count() + } + fn all_transactions(&self) -> AllPoolTransactions { self.pool.all_transactions() } diff --git a/crates/transaction-pool/src/pool/parked.rs b/crates/transaction-pool/src/pool/parked.rs index e3efd30967f..1dd337e344d 100644 --- a/crates/transaction-pool/src/pool/parked.rs +++ b/crates/transaction-pool/src/pool/parked.rs @@ -38,6 +38,11 @@ pub struct ParkedPool { /// /// See also [`reth_primitives_traits::InMemorySize::size`]. size_of: SizeTracker, + /// Running count of [`crate::TransactionOrigin::Private`] transactions in the pool. + /// + /// Maintained alongside `size_of` so callers can read the private count without walking the + /// pool. See [`Self::private_pool_count()`]. + private_pool_count: usize, } // === impl ParkedPool === @@ -55,6 +60,9 @@ impl ParkedPool { // keep track of size self.size_of += tx.size(); + if tx.origin.is_private() { + self.private_pool_count += 1; + } // update or create sender entry self.add_sender_count(tx.sender_id(), submission_id); @@ -134,6 +142,9 @@ impl ParkedPool { // keep track of size self.size_of -= tx.transaction.size(); + if tx.transaction.origin.is_private() { + self.private_pool_count -= 1; + } Some(tx.transaction.into()) } @@ -231,6 +242,11 @@ impl ParkedPool { self.by_id.len() } + /// Number of [`crate::TransactionOrigin::Private`] transactions in the pool. + pub(crate) const fn private_pool_count(&self) -> usize { + self.private_pool_count + } + /// Returns true if the pool exceeds the given limit #[inline] pub(crate) fn exceeds(&self, limit: &SubPoolLimit) -> bool { @@ -353,6 +369,7 @@ impl Default for ParkedPool { last_sender_submission: Default::default(), sender_transaction_count: Default::default(), size_of: Default::default(), + private_pool_count: 0, } } } diff --git a/crates/transaction-pool/src/pool/pending.rs b/crates/transaction-pool/src/pool/pending.rs index 57a70c90c91..a897bc92fd5 100644 --- a/crates/transaction-pool/src/pool/pending.rs +++ b/crates/transaction-pool/src/pool/pending.rs @@ -43,6 +43,11 @@ pub struct PendingPool { /// /// See also [`reth_primitives_traits::InMemorySize::size`]. size_of: SizeTracker, + /// Running count of [`crate::TransactionOrigin::Private`] transactions in the pool. + /// + /// Maintained alongside `size_of` so callers can read the private count without walking the + /// pool. See [`Self::private_pool_count()`]. + private_pool_count: usize, /// Used to broadcast new transactions that have been added to the `PendingPool` to existing /// `static_files` of this pool. new_transaction_notifier: broadcast::Sender>, @@ -66,6 +71,7 @@ impl PendingPool { independent_transactions: Default::default(), highest_nonces: Default::default(), size_of: Default::default(), + private_pool_count: 0, new_transaction_notifier, } } @@ -80,6 +86,7 @@ impl PendingPool { self.independent_transactions.clear(); self.highest_nonces.clear(); self.size_of.reset(); + self.private_pool_count = 0; std::mem::take(&mut self.by_id) } @@ -194,6 +201,9 @@ impl PendingPool { } } else { self.size_of += tx.transaction.size(); + if tx.transaction.origin.is_private() { + self.private_pool_count += 1; + } self.update_independents_and_highest_nonces(&tx); self.by_id.insert(id, tx); } @@ -239,6 +249,9 @@ impl PendingPool { tx.priority = self.ordering.priority(&tx.transaction.transaction, base_fee); self.size_of += tx.transaction.size(); + if tx.transaction.origin.is_private() { + self.private_pool_count += 1; + } self.update_independents_and_highest_nonces(&tx); self.by_id.insert(id, tx); } @@ -290,6 +303,9 @@ impl PendingPool { // keep track of size self.size_of += tx.size(); + if tx.origin.is_private() { + self.private_pool_count += 1; + } let tx_id = *tx.id(); @@ -327,6 +343,9 @@ impl PendingPool { let tx = self.by_id.remove(id)?; self.size_of -= tx.transaction.size(); + if tx.transaction.origin.is_private() { + self.private_pool_count -= 1; + } match self.highest_nonces.entry(id.sender) { Entry::Occupied(mut entry) => { @@ -540,6 +559,11 @@ impl PendingPool { self.by_id.len() } + /// Number of [`crate::TransactionOrigin::Private`] transactions in the pool. + pub(crate) const fn private_pool_count(&self) -> usize { + self.private_pool_count + } + /// All transactions grouped by id pub const fn by_id(&self) -> &OrdMap> { &self.by_id diff --git a/crates/transaction-pool/src/pool/txpool.rs b/crates/transaction-pool/src/pool/txpool.rs index a0e6782e5c8..d89f94fd1f0 100644 --- a/crates/transaction-pool/src/pool/txpool.rs +++ b/crates/transaction-pool/src/pool/txpool.rs @@ -507,6 +507,15 @@ impl TxPool { self.basefee_pool.len() + self.queued_pool.len() } + /// Returns the number of [`TransactionOrigin::Private`] transactions in the pending and queued + /// sub-pools, using the same sub-pool grouping as + /// [`Self::pending_transactions_count`]/[`Self::queued_transactions_count`]. + pub(crate) const fn private_pending_and_queued_txn_count(&self) -> (usize, usize) { + let pending = self.pending_pool.private_pool_count(); + let queued = self.basefee_pool.private_pool_count() + self.queued_pool.private_pool_count(); + (pending, queued) + } + /// Returns queued and pending transactions for the specified sender pub fn queued_and_pending_txs_by_sender( &self, @@ -2475,6 +2484,38 @@ mod tests { assert!(pool.pending_pool.is_empty()); } + #[test] + fn private_pool_count_tracks_pending_and_queued() { + let on_chain_balance = U256::MAX; + let on_chain_nonce = 0; + let mut f = MockTransactionFactory::default(); + let mut pool = TxPool::new(MockOrdering::default(), Default::default()); + + // Private tx at the on-chain nonce lands in the pending sub-pool. + let private_pending = + f.validated_with_origin(TransactionOrigin::Private, MockTransaction::eip1559()); + let private_pending_id = *private_pending.id(); + pool.add_transaction(private_pending, on_chain_balance, on_chain_nonce, None).unwrap(); + + // Public tx at the on-chain nonce is also pending but must not be counted. + let public_pending = + f.validated_with_origin(TransactionOrigin::External, MockTransaction::eip1559()); + pool.add_transaction(public_pending, on_chain_balance, on_chain_nonce, None).unwrap(); + + // Private tx with a nonce gap parks in the queued sub-pool. + let private_queued = f.validated_with_origin( + TransactionOrigin::Private, + MockTransaction::eip1559().with_nonce(1), + ); + pool.add_transaction(private_queued, on_chain_balance, on_chain_nonce, None).unwrap(); + + assert_eq!(pool.private_pending_and_queued_txn_count(), (1, 1)); + + // Removing the pending private tx decrements only the pending count. + pool.remove_transaction(&private_pending_id); + assert_eq!(pool.private_pending_and_queued_txn_count(), (0, 1)); + } + #[test] fn test_promote_valid_tx_with_decreasing_blob_fee() { let on_chain_balance = U256::MAX; diff --git a/crates/transaction-pool/src/traits.rs b/crates/transaction-pool/src/traits.rs index 654ef7e5a87..f589f4aeea5 100644 --- a/crates/transaction-pool/src/traits.rs +++ b/crates/transaction-pool/src/traits.rs @@ -450,6 +450,15 @@ pub trait TransactionPool: Clone + Debug + Send + Sync { /// number of transactions that are ready for inclusion in future blocks: `(pending, queued)`. fn pending_and_queued_txn_count(&self) -> (usize, usize); + /// Returns the number of [`TransactionOrigin::Private`] transactions. + /// + /// Lets callers exclude private transactions from the pool counts without materializing the + /// pool. Defaults to `(0, 0)`; pool implementations that hold private transactions should + /// override this. + fn private_pending_and_queued_txn_count(&self) -> (usize, usize) { + (0, 0) + } + /// Returns all transactions that are currently in the pool grouped by whether they are ready /// for inclusion in the next block or not. /// From 67dad48761f040e85432b224732ba1fca5c72626 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:13:28 +0200 Subject: [PATCH 20/26] refactor(rpc): drop misleading DEFAULT_ prefix from credible base-slot const The credible-blocks base slot is fixed to the CredibleRegistry storage layout, not configurable. The DEFAULT_ prefix implied a configurability that does not exist. --- crates/rpc/rpc-eth-types/src/credible.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/rpc/rpc-eth-types/src/credible.rs b/crates/rpc/rpc-eth-types/src/credible.rs index 13063e23654..f622446ba7a 100644 --- a/crates/rpc/rpc-eth-types/src/credible.rs +++ b/crates/rpc/rpc-eth-types/src/credible.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; /// Base storage slot of `CredibleRegistry`'s `_credibleBlocks` mapping, per its current /// storage layout (`forge inspect CredibleRegistry storage-layout`). -const DEFAULT_CREDIBLE_BLOCKS_BASE_SLOT: u64 = 1; +const CREDIBLE_BLOCKS_BASE_SLOT: u64 = 1; /// Credible Layer behavior toggles for the `eth` RPC namespace. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -38,7 +38,7 @@ impl CredibleRpcConfig { let block_override = CredibleBlockOverride { address: registry, - slot: credible_block_slot(block_number, U256::from(DEFAULT_CREDIBLE_BLOCKS_BASE_SLOT)), + slot: credible_block_slot(block_number, U256::from(CREDIBLE_BLOCKS_BASE_SLOT)), value: B256::with_last_byte(1), }; block_override.apply_to(state) From 0b735ae26fa0f3d95163729b758f7f0225d42542 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:14:04 +0200 Subject: [PATCH 21/26] fix(txpool): guard private-tx count decrement against underflow The maintained private-origin count is decremented on removal. If a future removal path ever skips the paired increment, a plain subtraction would panic in debug and wrap in release, permanently corrupting the count. Assert the count is positive in debug builds and saturate in release. --- crates/transaction-pool/src/pool/parked.rs | 3 ++- crates/transaction-pool/src/pool/pending.rs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/transaction-pool/src/pool/parked.rs b/crates/transaction-pool/src/pool/parked.rs index 1dd337e344d..219a6dbe145 100644 --- a/crates/transaction-pool/src/pool/parked.rs +++ b/crates/transaction-pool/src/pool/parked.rs @@ -143,7 +143,8 @@ impl ParkedPool { // keep track of size self.size_of -= tx.transaction.size(); if tx.transaction.origin.is_private() { - self.private_pool_count -= 1; + debug_assert!(self.private_pool_count > 0, "private_pool_count underflow"); + self.private_pool_count = self.private_pool_count.saturating_sub(1); } Some(tx.transaction.into()) diff --git a/crates/transaction-pool/src/pool/pending.rs b/crates/transaction-pool/src/pool/pending.rs index a897bc92fd5..e19fa8b1647 100644 --- a/crates/transaction-pool/src/pool/pending.rs +++ b/crates/transaction-pool/src/pool/pending.rs @@ -344,7 +344,8 @@ impl PendingPool { let tx = self.by_id.remove(id)?; self.size_of -= tx.transaction.size(); if tx.transaction.origin.is_private() { - self.private_pool_count -= 1; + debug_assert!(self.private_pool_count > 0, "private_pool_count underflow"); + self.private_pool_count = self.private_pool_count.saturating_sub(1); } match self.highest_nonces.entry(id.sender) { From 6b43319c64c2af54138167fcf308f637b2c77b46 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:32:15 +0200 Subject: [PATCH 22/26] fix: fix doc --- crates/transaction-pool/src/pool/txpool.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/transaction-pool/src/pool/txpool.rs b/crates/transaction-pool/src/pool/txpool.rs index d89f94fd1f0..3587a4110ec 100644 --- a/crates/transaction-pool/src/pool/txpool.rs +++ b/crates/transaction-pool/src/pool/txpool.rs @@ -507,8 +507,8 @@ impl TxPool { self.basefee_pool.len() + self.queued_pool.len() } - /// Returns the number of [`TransactionOrigin::Private`] transactions in the pending and queued - /// sub-pools, using the same sub-pool grouping as + /// Returns the number of [`crate::TransactionOrigin::Private`] transactions in the pending and + /// queued sub-pools, using the same sub-pool grouping as /// [`Self::pending_transactions_count`]/[`Self::queued_transactions_count`]. pub(crate) const fn private_pending_and_queued_txn_count(&self) -> (usize, usize) { let pending = self.pending_pool.private_pool_count(); From 8ffa5ca70828c360f1b67c65105020e2b32db25a Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:49:39 +0200 Subject: [PATCH 23/26] fix(txpool): serve txpool_status counts from a single snapshot txpool_status read the pool totals and the private counts through two separate pool snapshots. A private transaction moving between the pending and queued sub-pools between the reads could be counted in one snapshot but subtracted from the other, leaving a private transaction in the public count under Credible Layer retention. Add a combined accessor that returns totals and private counts from one pool view and use it in the handler so the subtraction is consistent. --- crates/rpc/rpc/src/txpool.rs | 7 ++++--- crates/transaction-pool/src/lib.rs | 4 ++++ crates/transaction-pool/src/pool/txpool.rs | 13 +++++++++++++ crates/transaction-pool/src/traits.rs | 7 +++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/rpc/rpc/src/txpool.rs b/crates/rpc/rpc/src/txpool.rs index b0c86cb9823..fcdd32aad79 100644 --- a/crates/rpc/rpc/src/txpool.rs +++ b/crates/rpc/rpc/src/txpool.rs @@ -98,11 +98,12 @@ where /// Handler for `txpool_status` async fn txpool_status(&self) -> RpcResult { trace!(target: "rpc::eth", "Serving txpool_status"); - let (pending, queued) = self.pool.pending_and_queued_txn_count(); + // Read totals and private counts from a single snapshot: reading them separately lets a tx + // moving between sub-pools leave a private tx in the public count under retention. + let ((pending, queued), (private_pending, private_queued)) = + self.pool.pending_and_queued_txn_count_with_private(); // With Credible Layer retention, exclude private-origin txs from the public counts. if self.credible_config.hide_private_pool_txs() { - let (private_pending, private_queued) = - self.pool.private_pending_and_queued_txn_count(); return Ok(TxpoolStatus { pending: pending.saturating_sub(private_pending) as u64, queued: queued.saturating_sub(private_queued) as u64, diff --git a/crates/transaction-pool/src/lib.rs b/crates/transaction-pool/src/lib.rs index cf2201e68a9..e9d4b554ccc 100644 --- a/crates/transaction-pool/src/lib.rs +++ b/crates/transaction-pool/src/lib.rs @@ -641,6 +641,10 @@ where self.pool.get_pool_data().private_pending_and_queued_txn_count() } + fn pending_and_queued_txn_count_with_private(&self) -> ((usize, usize), (usize, usize)) { + self.pool.get_pool_data().pending_and_queued_txn_count_with_private() + } + fn all_transactions(&self) -> AllPoolTransactions { self.pool.all_transactions() } diff --git a/crates/transaction-pool/src/pool/txpool.rs b/crates/transaction-pool/src/pool/txpool.rs index 3587a4110ec..973cdb63444 100644 --- a/crates/transaction-pool/src/pool/txpool.rs +++ b/crates/transaction-pool/src/pool/txpool.rs @@ -516,6 +516,16 @@ impl TxPool { (pending, queued) } + /// Returns `((pending, queued), (private_pending, private_queued))` from this single pool view, + /// so callers can subtract the private counts without a cross-snapshot race. + pub(crate) fn pending_and_queued_txn_count_with_private( + &self, + ) -> ((usize, usize), (usize, usize)) { + let pending = self.pending_transactions_count(); + let queued = self.queued_transactions_count(); + ((pending, queued), self.private_pending_and_queued_txn_count()) + } + /// Returns queued and pending transactions for the specified sender pub fn queued_and_pending_txs_by_sender( &self, @@ -2510,10 +2520,13 @@ mod tests { pool.add_transaction(private_queued, on_chain_balance, on_chain_nonce, None).unwrap(); assert_eq!(pool.private_pending_and_queued_txn_count(), (1, 1)); + // The combined accessor reports totals (2 pending, 1 queued) and private counts together. + assert_eq!(pool.pending_and_queued_txn_count_with_private(), ((2, 1), (1, 1))); // Removing the pending private tx decrements only the pending count. pool.remove_transaction(&private_pending_id); assert_eq!(pool.private_pending_and_queued_txn_count(), (0, 1)); + assert_eq!(pool.pending_and_queued_txn_count_with_private(), ((1, 1), (0, 1))); } #[test] diff --git a/crates/transaction-pool/src/traits.rs b/crates/transaction-pool/src/traits.rs index f589f4aeea5..ccb5d375f57 100644 --- a/crates/transaction-pool/src/traits.rs +++ b/crates/transaction-pool/src/traits.rs @@ -459,6 +459,13 @@ pub trait TransactionPool: Clone + Debug + Send + Sync { (0, 0) } + /// Returns `((pending, queued), (private_pending, private_queued))` from a single snapshot, so + /// callers can subtract the private counts consistently. The default delegates to the two + /// separate accessors; pools holding private transactions should override it. + fn pending_and_queued_txn_count_with_private(&self) -> ((usize, usize), (usize, usize)) { + (self.pending_and_queued_txn_count(), self.private_pending_and_queued_txn_count()) + } + /// Returns all transactions that are currently in the pool grouped by whether they are ready /// for inclusion in the next block or not. /// From 69f9f69020673a3f5f2d88238cce162887f09e52 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:54:47 +0200 Subject: [PATCH 24/26] refactor(txpool): rename combined count accessor to total_and_private_txn_counts Clearer than pending_and_queued_txn_count_with_private for a method returning ((pending, queued), (private_pending, private_queued)). --- crates/rpc/rpc/src/txpool.rs | 2 +- crates/transaction-pool/src/lib.rs | 4 ++-- crates/transaction-pool/src/pool/txpool.rs | 8 +++----- crates/transaction-pool/src/traits.rs | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/crates/rpc/rpc/src/txpool.rs b/crates/rpc/rpc/src/txpool.rs index fcdd32aad79..89e3004b2e6 100644 --- a/crates/rpc/rpc/src/txpool.rs +++ b/crates/rpc/rpc/src/txpool.rs @@ -101,7 +101,7 @@ where // Read totals and private counts from a single snapshot: reading them separately lets a tx // moving between sub-pools leave a private tx in the public count under retention. let ((pending, queued), (private_pending, private_queued)) = - self.pool.pending_and_queued_txn_count_with_private(); + self.pool.total_and_private_txn_counts(); // With Credible Layer retention, exclude private-origin txs from the public counts. if self.credible_config.hide_private_pool_txs() { return Ok(TxpoolStatus { diff --git a/crates/transaction-pool/src/lib.rs b/crates/transaction-pool/src/lib.rs index e9d4b554ccc..b43e666f90f 100644 --- a/crates/transaction-pool/src/lib.rs +++ b/crates/transaction-pool/src/lib.rs @@ -641,8 +641,8 @@ where self.pool.get_pool_data().private_pending_and_queued_txn_count() } - fn pending_and_queued_txn_count_with_private(&self) -> ((usize, usize), (usize, usize)) { - self.pool.get_pool_data().pending_and_queued_txn_count_with_private() + fn total_and_private_txn_counts(&self) -> ((usize, usize), (usize, usize)) { + self.pool.get_pool_data().total_and_private_txn_counts() } fn all_transactions(&self) -> AllPoolTransactions { diff --git a/crates/transaction-pool/src/pool/txpool.rs b/crates/transaction-pool/src/pool/txpool.rs index 973cdb63444..af9eae1a850 100644 --- a/crates/transaction-pool/src/pool/txpool.rs +++ b/crates/transaction-pool/src/pool/txpool.rs @@ -518,9 +518,7 @@ impl TxPool { /// Returns `((pending, queued), (private_pending, private_queued))` from this single pool view, /// so callers can subtract the private counts without a cross-snapshot race. - pub(crate) fn pending_and_queued_txn_count_with_private( - &self, - ) -> ((usize, usize), (usize, usize)) { + pub(crate) fn total_and_private_txn_counts(&self) -> ((usize, usize), (usize, usize)) { let pending = self.pending_transactions_count(); let queued = self.queued_transactions_count(); ((pending, queued), self.private_pending_and_queued_txn_count()) @@ -2521,12 +2519,12 @@ mod tests { assert_eq!(pool.private_pending_and_queued_txn_count(), (1, 1)); // The combined accessor reports totals (2 pending, 1 queued) and private counts together. - assert_eq!(pool.pending_and_queued_txn_count_with_private(), ((2, 1), (1, 1))); + assert_eq!(pool.total_and_private_txn_counts(), ((2, 1), (1, 1))); // Removing the pending private tx decrements only the pending count. pool.remove_transaction(&private_pending_id); assert_eq!(pool.private_pending_and_queued_txn_count(), (0, 1)); - assert_eq!(pool.pending_and_queued_txn_count_with_private(), ((1, 1), (0, 1))); + assert_eq!(pool.total_and_private_txn_counts(), ((1, 1), (0, 1))); } #[test] diff --git a/crates/transaction-pool/src/traits.rs b/crates/transaction-pool/src/traits.rs index ccb5d375f57..ac00efb1b96 100644 --- a/crates/transaction-pool/src/traits.rs +++ b/crates/transaction-pool/src/traits.rs @@ -462,7 +462,7 @@ pub trait TransactionPool: Clone + Debug + Send + Sync { /// Returns `((pending, queued), (private_pending, private_queued))` from a single snapshot, so /// callers can subtract the private counts consistently. The default delegates to the two /// separate accessors; pools holding private transactions should override it. - fn pending_and_queued_txn_count_with_private(&self) -> ((usize, usize), (usize, usize)) { + fn total_and_private_txn_counts(&self) -> ((usize, usize), (usize, usize)) { (self.pending_and_queued_txn_count(), self.private_pending_and_queued_txn_count()) } From 768e670513fdafc34a8224d9fc04486e2a1c4b48 Mon Sep 17 00:00:00 2001 From: Lea Na <78718413+lean-apple@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:38:59 +0200 Subject: [PATCH 25/26] ci: cache docker builds via GHCR registry buildcache --- .github/workflows/phylax-docker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/phylax-docker.yml b/.github/workflows/phylax-docker.yml index 97d6d719f46..69d02b2e548 100644 --- a/.github/workflows/phylax-docker.yml +++ b/.github/workflows/phylax-docker.yml @@ -54,3 +54,5 @@ jobs: platforms: linux/amd64 push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} tags: ghcr.io/${{ github.repository }}/reth:sha-${{ steps.git.outputs.short_sha }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/reth:buildcache + cache-to: type=registry,ref=ghcr.io/${{ github.repository }}/reth:buildcache,mode=max From f2da67993ffb51c5e36f03914cdaff6c27f92184 Mon Sep 17 00:00:00 2001 From: "Odysseas.eth" Date: Tue, 21 Jul 2026 18:14:13 +0200 Subject: [PATCH 26/26] ci: reject fork refs on self-hosted benchmarks --- .github/workflows/bench-benchmarkoor.yml | 57 ++++++++++++++++++++---- .github/workflows/bench.yml | 27 ++++++++++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/.github/workflows/bench-benchmarkoor.yml b/.github/workflows/bench-benchmarkoor.yml index 17c70c3682b..f0552a30977 100644 --- a/.github/workflows/bench-benchmarkoor.yml +++ b/.github/workflows/bench-benchmarkoor.yml @@ -26,7 +26,7 @@ on: download_ref: description: "Reth git ref used only for snapshot download" required: false - default: "pull/24027/head" + default: "main" type: string suite: description: "benchmarkoor-replay suite" @@ -149,7 +149,53 @@ name: bench-benchmarkoor permissions: {} jobs: + authorize: + name: authorize self-hosted benchmark refs + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Reject fork and hidden pull refs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_PR: ${{ inputs.pr || '' }} + INPUT_FEATURE: ${{ inputs.feature || '' }} + INPUT_BASELINE: ${{ inputs.baseline || 'main' }} + INPUT_DOWNLOAD: ${{ inputs.download_ref || 'main' }} + with: + script: | + const trustedRepository = `${context.repo.owner}/${context.repo.repo}`; + const pullNumber = process.env.INPUT_PR; + if (pullNumber) { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(pullNumber), + }); + if (pr.head.repo.full_name !== trustedRepository) { + core.setFailed(`Refusing to run fork PR code from ${pr.head.repo.full_name} on a self-hosted runner`); + return; + } + } + + const unsafeRef = (ref) => { + const value = String(ref || ''); + return /^(refs\/)?pull\//.test(value) || /^[0-9a-f]{40}$/i.test(value); + }; + for (const [name, value] of [ + ['feature', process.env.INPUT_FEATURE], + ['baseline', process.env.INPUT_BASELINE], + ['download_ref', process.env.INPUT_DOWNLOAD], + ]) { + if (unsafeRef(value)) { + core.setFailed(`${name} must be a branch or tag in ${trustedRepository}; pull refs and raw SHAs are not allowed on self-hosted runners`); + return; + } + } + benchmarkoor: + needs: authorize name: bench-benchmarkoor runs-on: [self-hosted, Linux, X64, available] permissions: @@ -330,18 +376,13 @@ jobs: INPUT_PR: ${{ inputs.pr || '' }} INPUT_FEATURE: ${{ inputs.feature || '' }} INPUT_BASELINE: ${{ inputs.baseline || 'main' }} - INPUT_DOWNLOAD: ${{ inputs.download_ref || 'pull/24027/head' }} + INPUT_DOWNLOAD: ${{ inputs.download_ref || 'main' }} run: | set -euo pipefail git fetch origin main --quiet resolve_ref() { local ref="$1" - if [[ "$ref" =~ ^pull/[0-9]+/(head|merge)$ ]]; then - git fetch origin "$ref" --quiet - git rev-parse --verify "FETCH_HEAD^{commit}" - return 0 - fi git fetch origin "$ref" --quiet 2>/dev/null || true if git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then git rev-parse --verify "${ref}^{commit}" @@ -364,7 +405,7 @@ jobs: BASELINE_NAME="${INPUT_BASELINE:-main}" BASELINE_REF="$(resolve_ref "$BASELINE_NAME")" - DOWNLOAD_NAME="${INPUT_DOWNLOAD:-pull/24027/head}" + DOWNLOAD_NAME="${INPUT_DOWNLOAD:-main}" DOWNLOAD_REF="$(resolve_ref "$DOWNLOAD_NAME")" { diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 8100c8deeb9..9d12d7c74fd 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -178,7 +178,8 @@ jobs: with: github-token: ${{ secrets.DEREK_BENCH_ACK_TOKEN }} script: | - const org = 'paradigmxyz'; + const org = context.repo.owner; + const trustedRepository = `${context.repo.owner}/${context.repo.repo}`; const checkMembership = async (username) => { try { const { status } = await github.rest.orgs.checkMembershipForUser({ org, username }); @@ -199,6 +200,10 @@ jobs: repo: context.repo.repo, pull_number: context.issue.number, }); + if (pr.head.repo.full_name !== trustedRepository) { + core.setFailed(`Refusing to run fork PR code from ${pr.head.repo.full_name} on a self-hosted runner`); + return; + } const prAuthor = pr.user.login; if (!await checkMembership(prAuthor)) { core.setFailed(`PR author @${prAuthor} is not a member of ${org}`); @@ -322,6 +327,17 @@ jobs: var featureNodeArgs = '${{ github.event.inputs.feature_args }}' || ''; var skipStateRoot = 'false'; + const unsafeRef = (ref) => { + const value = String(ref || ''); + return /^(refs\/)?pull\//.test(value) || /^[0-9a-f]{40}$/i.test(value); + }; + for (const [name, value] of [['baseline', baseline], ['feature', feature]]) { + if (unsafeRef(value)) { + core.setFailed(`${name} must be a branch or tag in ${context.repo.owner}/${context.repo.repo}; pull refs and raw SHAs are not allowed on self-hosted runners`); + return; + } + } + // Find PR for the selected branch const branch = '${{ github.ref_name }}'; const { data: prs } = await github.rest.pulls.list({ @@ -512,6 +528,11 @@ jobs: repo: context.repo.repo, pull_number: parseInt(pr), }); + const trustedRepository = `${context.repo.owner}/${context.repo.repo}`; + if (prData.head.repo.full_name !== trustedRepository) { + core.setFailed(`Refusing to run fork PR code from ${prData.head.repo.full_name} on a self-hosted runner`); + return; + } core.setOutput('pr-head-sha', prData.head.sha); core.setOutput('pr-head-ref', prData.head.ref); core.setOutput('pr-head-repo', prData.head.repo.full_name); @@ -728,7 +749,9 @@ jobs: bench-txgen: needs: bench-ack - if: needs.bench-ack.outputs.command == 'bench' + if: >- + needs.bench-ack.outputs.command == 'bench' && + (needs.bench-ack.outputs.pr == '' || needs.bench-ack.outputs.pr-head-repo == github.repository) name: bench-txgen runs-on: [self-hosted, Linux, X64, available] permissions: