diff --git a/.agents/skills/reth-rust-style/SKILL.md b/.agents/skills/reth-rust-style/SKILL.md new file mode 100644 index 00000000000..041d75dccde --- /dev/null +++ b/.agents/skills/reth-rust-style/SKILL.md @@ -0,0 +1,102 @@ +--- +name: reth-rust-style +description: Review rules reth reviewers enforce beyond AGENTS.md. Use when writing, refactoring, or reviewing Rust in reth, before opening or updating a PR. +--- + +# Reth Rust Style + +These are review comments reth reviewers leave often that AGENTS.md doesn't spell out. AGENTS.md still applies; this file only adds to it. + +## Scope + +- Before pushing, read the full diff and revert every hunk the PR description doesn't explain. +- Refactors keep behavior identical. If behavior must change, say so in the PR description. +- When wrapping or moving a type, keep existing call sites unchanged (`Deref` or delegating methods). + +## Reuse before writing + +- Prefer upstream APIs to manual byte handling: + - `B256::from(U256)`, `U256::from(B256)`, `Address::into_word()` + - `Log::topic0()`, `Signature::as_bytes()`, `Bytes::from([u8; N])` + - `PayloadId::from_str`, alloy `From` impls between RPC and consensus types + - `alloy_rlp::decode_exact`, `abi_decode_validate`, `sol!` for ABI types +- Use alloy's fixed-bytes maps (`B256Map`, `AddressMap`) for maps keyed by hashes or addresses. +- Use the constructors a type already has (`ExecOutput::done`, `ExecOutput::in_progress`, `StorageEntry::new`) instead of struct literals. +- When the same logic appears twice, extract one helper. + +## Keep it small + +- No wrapper, trait, enum, or type alias with a single use. The one exception is a type alias that replaces `#[allow(clippy::type_complexity)]`. +- No caches, interior mutability, or format versioning without a measured need. +- Don't re-check an invariant that is already enforced upstream. If correctness depends on it, say where it's enforced in a one-line comment. +- Remove unused parameters instead of `_`-prefixing them. + +## Public API + +- reth is a library. Don't remove or rename public items without a reason in the PR description. +- A new trait method needs a default or no-op impl unless every implementor must provide it. +- Public types need public fields or accessors for the data callers need. +- Destructive or write operations belong on the mutable transaction (`DbTxMut`), not on `DbTx`. Say in their docs that they are destructive. +- Re-export types that appear in public signatures from the crate that exposes the signature. + +## Comments and docs + +- A comment states one non-obvious fact. Don't restate the code or narrate the change. +- Keep existing comments and TODOs when moving code. +- When behavior changes, update everything that describes it in the same PR: doc comments, numbered step lists, `expect` and `unreachable!` messages, metric names. + +## Naming + +- Names say what a thing does or holds, including side effects (`*_blocking`). +- Match sibling names (`*_with_nodes` next to the trait that uses it). +- Rename an item when its meaning changes. Leave unrelated names alone. +- Don't reuse names of alloy types for different things (prefix with `Pooled*` or similar). +- Don't alias imports with `use X as Y`; it breaks grep. + +## Errors + +- Carry the source error in the variant, and don't report provider errors as consensus errors. +- Never discard a fallible result with `.ok()` or `let _ =`. Ignore only the specific variant you expect. +- Don't turn values a spec leaves open into hard decode errors. Decode, then truncate or reject at the layer that owns the limit. +- Use `checked_*` and return an error for gas and index math. Use `try_from` for conversions; `as` only when it can't truncate. + +## Types + +- Make invalid states unrepresentable. Put variant-specific data inside enum variants, not in correlated `bool`/`Option` fields. +- Use `usize` for limits that size collections, so they need no casts. +- Check serde compatibility of changed public types. +- A function that doesn't use `self` is a free function. Logic that belongs to a type goes in its `impl`. +- Reject mutually exclusive CLI flags at parse time with clap's `conflicts_with`, and update the parse-all test. + +## Tests + +- Every new branch, guard, error path, and unwind or reopen path gets a test in the same PR. +- Assert the exact error or message, not just `is_err()`. +- Fixtures must be coherent: request the same root the fixture verifies, and cover every match arm the loop claims to. +- Don't commit `#[ignore]` manual perf tests. Write a bench. + +## Async and observability + +- Every wait is bounded and observes shutdown. Every queue and retained map has a bound. +- A spawned task enters the caller's span for the whole closure, not only as the parent of a `DEBUG` child; check it survives an `INFO` filter. +- Log `debug!` or `trace!` on state transitions in background tasks. +- Count skips with a dedicated metric, not by recording zeros. + +## Layout and visibility + +- When dropping an import, remove the whole line and any blank line it leaves. +- Keep items `pub(crate)` or private unless another crate uses them. +- Use `#[expect(lint, reason = "...")]` instead of `#[allow]`. + +## Performance + +- Don't clone large values to read them. Borrow, `take`, or share through `Arc`. Getters return references. +- Don't read the same DB value twice, and build optional data lazily (`Option`) when only some paths need it. +- Cap any length that comes from untrusted input before `Vec::with_capacity` or a loop. + +## Dependencies and CI + +- Workspace dependencies have default features off. Check that each touched crate builds on its own, with and without its features. +- Keep `no_std` crates free of `std`; use `core` and `alloc`. +- Dependency bumps respect the MSRV. +- Pin reusable workflows by SHA only after the 7-day soak, with a trailing `# repo#PR` comment. Never reference `@main` or expose secrets to cargo builds. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000000..2b7a412b8fa --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.config/zepter.yaml b/.config/zepter.yaml index d2d5dfb5255..c2673c05f0d 100644 --- a/.config/zepter.yaml +++ b/.config/zepter.yaml @@ -12,7 +12,7 @@ workflows: # Check that `A` activates the features of `B`. "propagate-feature", # These are the features to check: - "--features=std,dev,asm-keccak,jemalloc,jemalloc-prof,tracy-allocator,tracy,serde-bincode-compat,serde,test-utils,arbitrary,min-error-logs,min-warn-logs,min-info-logs,min-debug-logs,min-trace-logs,otlp,otlp-logs,js-tracer,portable,keccak-cache-global,trie-debug,secp256k1", + "--features=std,dev,account-ext,asm-keccak,jemalloc,jemalloc-prof,tracy-allocator,tracy,serde-bincode-compat,serde,test-utils,arbitrary,min-error-logs,min-warn-logs,min-info-logs,min-debug-logs,min-trace-logs,otlp,otlp-logs,js-tracer,portable,keccak-cache-global,trie-debug,secp256k1", # Do not try to add a new section to `[features]` of `A` only because `B` exposes that feature. There are edge-cases where this is still needed, but we can add them manually. "--left-side-feature-missing=ignore", # Ignore the case that `A` it outside of the workspace. Otherwise it will report errors in external dependencies that we have no influence on. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cde8861896c..e6d8de73f36 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,13 +8,16 @@ updates: time: "09:00" timezone: UTC cooldown: - default-days: 7 + # Buffer the scanner's seven-day minimum: Dependabot reads date-only tag metadata. + default-days: 8 groups: ci-weekly: patterns: - '*' - package-ecosystem: cargo directory: / + allow: + - dependency-type: all schedule: interval: weekly day: monday diff --git a/.github/scripts/bench-args-test.js b/.github/scripts/bench-args-test.js new file mode 100644 index 00000000000..3f540c62b2f --- /dev/null +++ b/.github/scripts/bench-args-test.js @@ -0,0 +1,90 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { test } = require('node:test'); + +const workflow = fs.readFileSync(path.join(__dirname, '../workflows/bench.yml'), 'utf8'); +const source = workflow.match(/ const validBalModes = [\s\S]*?(?=\n - name: Acknowledge request)/)[0]; + +async function parse(args = {}, eventName = 'workflow_dispatch') { + const outputs = {}; + const failures = []; + await vm.runInNewContext(`(async () => {${source}\n})()`, { + process: { env: Object.fromEntries(Object.entries(args).map(([key, value]) => [ + `INPUT_${key.toUpperCase().replaceAll('-', '_')}`, value, + ])) }, + context: { + eventName, + repo: { owner: 'paradigmxyz', repo: 'reth' }, + issue: { number: 27928 }, + payload: { comment: { user: { login: 'tester' }, body: `derek bench ${Object.entries(args).map(([key, value]) => `${key}=${value}`).join(' ')}` } }, + }, + github: { rest: { pulls: { + list: async () => ({ data: [] }), + get: async () => ({ data: { head: { sha: 'test-sha', ref: 'test-ref', repo: { full_name: 'paradigmxyz/reth' } } } }), + } } }, + core: { + setOutput: (key, value) => { outputs[key] = value; }, + setFailed: message => failures.push(message), + info: () => {}, + }, + }); + return { outputs, failures }; +} + +for (const event of ['workflow_dispatch', 'issue_comment']) { + for (const bal of ['true', 'baseline', 'feature']) { + test(`${event}: bal=${bal} uses the existing big-block format in both arms`, async () => { + const { outputs, failures } = await parse({ bal }, event); + assert.deepEqual(failures, []); + assert.equal(outputs['big-blocks'], 'true'); + assert.equal(outputs['big-blocks-target-gas'], '1'); + assert.equal(outputs['node-bin'], 'reth-bb'); + assert.equal(outputs.bal, bal); + assert.equal(outputs.blocks, '500'); + assert.equal(outputs.warmup, '125'); + assert.equal(outputs['run-pairs'], '6'); + }); + for (const extra of [{ reorg: '5' }, { mempool: 'true' }]) { + test(`${event}: rejects bal=${bal} with ${Object.keys(extra)[0]}`, async () => { + const { outputs, failures } = await parse({ bal, ...extra }, event); + assert.deepEqual(outputs, {}); + assert.deepEqual(failures, [extra.reorg + ? 'reorg is not supported with big-blocks or BAL (reth-bb cannot build reorg blocks)' + : 'mempool is only supported in Engine mode without big-blocks or reorg']); + }); + } + } + + test(`${event}: retains explicit big-block targets and run sizes`, async () => { + const { outputs, failures } = await parse({ bal: 'true', 'big-blocks': '2G', blocks: '17', warmup: '3', 'run-pairs': '2' }, event); + assert.deepEqual(failures, []); + assert.equal(outputs['big-blocks-target-gas'], '2G'); + assert.equal(outputs.blocks, '17'); + assert.equal(outputs.warmup, '3'); + assert.equal(outputs['run-pairs'], '2'); + }); + + test(`${event}: non-BAL reorg replay continues using reth`, async () => { + const { outputs, failures } = await parse({ reorg: '5' }, event); + assert.deepEqual(failures, []); + assert.equal(outputs['node-bin'], 'reth'); + assert.equal(outputs['big-blocks'], 'false'); + assert.equal(outputs.reorg, '5'); + }); + + test(`${event}: rejects big-block reorgs even without BAL`, async () => { + const { outputs, failures } = await parse({ 'big-blocks': '1', reorg: '5' }, event); + assert.deepEqual(outputs, {}); + assert.deepEqual(failures, ['reorg is not supported with big-blocks or BAL (reth-bb cannot build reorg blocks)']); + }); + + for (const mode of ['rpc', 'call']) { + test(`${event}: rejects BAL in ${mode} mode`, async () => { + const { outputs, failures } = await parse({ mode, bal: 'true' }, event); + assert.deepEqual(outputs, {}); + assert.deepEqual(failures, [`${mode} mode does not support big-blocks, reorg, or BAL`]); + }); + } +} diff --git a/.github/scripts/bench-scheduled-refs.sh b/.github/scripts/bench-scheduled-refs.sh index 44122a0be47..0607682d54d 100755 --- a/.github/scripts/bench-scheduled-refs.sh +++ b/.github/scripts/bench-scheduled-refs.sh @@ -123,17 +123,16 @@ if [ "$MODE" = "release" ]; then # --- Step 1: Resolve feature ref from latest nightly Docker build --- echo "::group::Querying latest nightly docker build" + # Filter client-side: the API's event/status filters can return stale runs. RUNS_JSON=$(gh run list \ -R "$REPO" \ --workflow=docker.yml \ - --event=schedule \ - --status=completed \ - --limit 5 \ - --json headSha,createdAt,conclusion) + --limit 200 \ + --json headSha,createdAt,conclusion,event) - LATEST=$(echo "$RUNS_JSON" | jq -r '[.[] | select(.conclusion == "success")] | first // empty') + LATEST=$(echo "$RUNS_JSON" | jq -r '[.[] | select(.event == "schedule" and .conclusion == "success")] | first // empty') if [ -z "$LATEST" ]; then - echo "::error::No successful scheduled docker.yml run found in the last 5 runs" + echo "::error::No successful scheduled docker.yml run found in the last 200 runs" exit 1 fi @@ -217,19 +216,18 @@ fi # --- Step 1: Query latest successful scheduled docker.yml run --- echo "::group::Querying latest nightly docker build" +# Filter client-side: the API's event/status filters can return stale runs. RUNS_JSON=$(gh run list \ -R "$REPO" \ --workflow=docker.yml \ - --event=schedule \ - --status=completed \ - --limit 5 \ - --json headSha,createdAt,conclusion) + --limit 200 \ + --json headSha,createdAt,conclusion,event) # Find the most recent successful run -LATEST=$(echo "$RUNS_JSON" | jq -r '[.[] | select(.conclusion == "success")] | first // empty') +LATEST=$(echo "$RUNS_JSON" | jq -r '[.[] | select(.event == "schedule" and .conclusion == "success")] | first // empty') if [ -z "$LATEST" ]; then - echo "::error::No successful scheduled docker.yml run found in the last 5 runs" + echo "::error::No successful scheduled docker.yml run found in the last 200 runs" echo "Runs found: $RUNS_JSON" exit 1 fi diff --git a/.github/scripts/bench-state-test.sh b/.github/scripts/bench-state-test.sh index f72e998c8cc..0f69f53e569 100644 --- a/.github/scripts/bench-state-test.sh +++ b/.github/scripts/bench-state-test.sh @@ -57,7 +57,7 @@ gh() { if [[ "$*" == *'--status=in_progress'* ]]; then printf '0\n' else - printf '[{"headSha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","createdAt":"%s","conclusion":"success"}]\n' "$STATE_TEST_DATE" + printf '[{"headSha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","createdAt":"%s","conclusion":"success","event":"schedule"}]\n' "$STATE_TEST_DATE" fi ;; *) return 1 ;; diff --git a/.github/scripts/bench-txgen-build-test.sh b/.github/scripts/bench-txgen-build-test.sh new file mode 100644 index 00000000000..2221e40809f --- /dev/null +++ b/.github/scripts/bench-txgen-build-test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/bench-txgen-build.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +export BUILD_TEST_LOG="$scratch/build-args" + +cargo() { + printf '%s\n' "$*" > "$BUILD_TEST_LOG" +} +rustup() { :; } +export -f cargo rustup + +for arm in baseline feature; do + for bal in false true baseline feature; do + for big_blocks in false true; do + BENCH_BAL="$bal" BENCH_BIG_BLOCKS="$big_blocks" BENCH_TRACY=off \ + bash "$script" "$arm" "$scratch" test-ref > /dev/null 2>&1 + if [ "$big_blocks" = true ]; then + expected='build --locked --profile profiling -p reth-bb' + else + expected='build --locked --profile profiling --bin reth' + fi + [[ "$(cat "$BUILD_TEST_LOG")" == "$expected" ]] + done + done +done +echo 'PASS: both arms build the selected node for all BAL modes' diff --git a/.github/scripts/bench-txgen-run.sh b/.github/scripts/bench-txgen-run.sh index 902f4ebb174..500c3a97a69 100755 --- a/.github/scripts/bench-txgen-run.sh +++ b/.github/scripts/bench-txgen-run.sh @@ -14,7 +14,7 @@ # Optional env: BENCH_EXECUTION_MODE, BENCH_BIG_BLOCKS, BENCH_BIG_BLOCKS_TARGET_GAS, # BENCH_CALL_CLASS, BENCH_CALL_METHODS, BENCH_CALL_RPS, # BENCH_CALL_DURATION, BENCH_CALL_PASSES, BENCH_CALL_CONCURRENCY, -# BENCH_REORG, BENCH_BAL, +# BENCH_REORG, BENCH_BAL, BENCH_MEMPOOL, # BENCH_WORK_DIR, BENCH_WAIT_TIME, BENCH_BLOCK_TIME, BENCH_BASELINE_ARGS, # BENCH_FEATURE_ARGS, BENCH_OTLP_TRACES_ENDPOINT, # BENCH_OTLP_LOGS_ENDPOINT, BENCH_OTLP_DISABLED, @@ -516,6 +516,19 @@ fi if [ -n "${BENCH_REORG:-}" ]; then TXGEN_SEND_ARGS+=(--reorg "$BENCH_REORG") fi +# Submits each block's transactions to the txpool before its payload, so +# that features acting on pool transactions see them. BENCH_MEMPOOL is +# `true` for txgen's defaults, DELAY, or DELAY,FRACTION. +if [ "$EXECUTION_MODE" = "engine" ] && [ -n "${BENCH_MEMPOOL:-}" ]; then + TXGEN_SEND_ARGS+=(--mempool) + if [ "$BENCH_MEMPOOL" != "true" ]; then + IFS=, read -r MEMPOOL_DELAY MEMPOOL_FRACTION <<< "$BENCH_MEMPOOL" + TXGEN_SEND_ARGS+=(--mempool-delay "$MEMPOOL_DELAY") + if [ -n "$MEMPOOL_FRACTION" ]; then + TXGEN_SEND_ARGS+=(--mempool-fraction "$MEMPOOL_FRACTION") + fi + fi +fi WARMUP="${BENCH_WARMUP_BLOCKS:-0}" BLOCKS="${BENCH_BLOCKS:?BENCH_BLOCKS must be set}" diff --git a/.github/scripts/hive/no_sim_build.diff b/.github/scripts/hive/no_sim_build.diff index 1df59805c45..d2546bcd881 100644 --- a/.github/scripts/hive/no_sim_build.diff +++ b/.github/scripts/hive/no_sim_build.diff @@ -1,5 +1,5 @@ diff --git a/internal/libdocker/builder.go b/internal/libdocker/builder.go -index 1c9714df..2aa493ca 100644 +index 20f5060..50a9194 100644 --- a/internal/libdocker/builder.go +++ b/internal/libdocker/builder.go @@ -8,7 +8,6 @@ import ( @@ -13,10 +13,10 @@ index 1c9714df..2aa493ca 100644 @@ -50,25 +49,8 @@ func (b *Builder) BuildClientImage(ctx context.Context, client libhive.ClientDes // BuildSimulatorImage builds a docker image of a simulator. - func (b *Builder) BuildSimulatorImage(ctx context.Context, name string, buildArgs map[string]string) (string, error) { -- dir := b.config.Inventory.SimulatorDirectory(name) + func (b *Builder) BuildSimulatorImage(ctx context.Context, sim libhive.SimulatorDesignator) (string, error) { +- dir := b.config.Inventory.SimulatorDirectory(sim.Simulator) - buildContextPath := dir -- buildDockerfile := "Dockerfile" +- buildDockerfile := sim.Dockerfile() - - // build context dir of simulator can be overridden with "hive_context.txt" file containing the desired build path - if contextPathBytes, err := os.ReadFile(filepath.Join(filepath.FromSlash(dir), "hive_context.txt")); err == nil { @@ -24,21 +24,21 @@ index 1c9714df..2aa493ca 100644 - if strings.HasPrefix(buildContextPath, "../") { - return "", fmt.Errorf("cannot access build directory outside of Hive root: %q", buildContextPath) - } -- if p, err := filepath.Rel(buildContextPath, filepath.Join(filepath.FromSlash(dir), "Dockerfile")); err != nil { +- if p, err := filepath.Rel(buildContextPath, filepath.Join(filepath.FromSlash(dir), sim.Dockerfile())); err != nil { - return "", fmt.Errorf("failed to derive relative simulator Dockerfile path: %v", err) - } else { - buildDockerfile = p - } - } - tag := fmt.Sprintf("hive/simulators/%s:latest", name) -- err := b.buildImage(ctx, buildContextPath, buildDockerfile, tag, buildArgs) + tag := fmt.Sprintf("hive/simulators/%s:latest", sim.Simulator) +- err := b.buildImage(ctx, buildContextPath, buildDockerfile, tag, sim.BuildArgs) - return tag, err + return tag, nil } // BuildImage creates a container by archiving the given file system, diff --git a/internal/libdocker/proxy.go b/internal/libdocker/proxy.go -index 386b4224..7545fda0 100644 +index 386b422..7545fda 100644 --- a/internal/libdocker/proxy.go +++ b/internal/libdocker/proxy.go @@ -16,10 +16,6 @@ const hiveproxyTag = "hive/hiveproxy" diff --git a/.github/scripts/verify_image_arch.sh b/.github/scripts/verify_image_arch.sh index 9746b97edd2..a54128bfb15 100755 --- a/.github/scripts/verify_image_arch.sh +++ b/.github/scripts/verify_image_arch.sh @@ -43,7 +43,6 @@ verify_image() { if [[ "$TARGETS" == *"nightly"* ]]; then verify_image "${REGISTRY}/reth:nightly" amd64 arm64 verify_image "${REGISTRY}/reth:nightly-profiling" amd64 - verify_image "${REGISTRY}/reth:nightly-edge-profiling" amd64 else for tag in $(echo "$ETHEREUM_TAGS" | tr ',' ' '); do verify_image "$tag" amd64 arm64 diff --git a/.github/sts/cargo-update-pr.sts.yaml b/.github/sts/cargo-update-pr.sts.yaml index 75ec78bd80f..ac47cf05871 100644 --- a/.github/sts/cargo-update-pr.sts.yaml +++ b/.github/sts/cargo-update-pr.sts.yaml @@ -7,7 +7,7 @@ claim_pattern: repository_id: '537233603' event_name: '(schedule|workflow_dispatch)' workflow_ref: 'paradigmxyz/reth/\.github/workflows/dependencies\.yml@refs/heads/main' - job_workflow_ref: 'tempoxyz/gh-actions/\.github/workflows/cargo-update-pr\.yml@ee2960a6cc25d99bda45614135bfe1197d879bac' + job_workflow_ref: 'tempoxyz/gh-actions/\.github/workflows/cargo-update-pr\.yml@d5402286a7abdfb7232173b3228d56473feb8a66' permissions: contents: write diff --git a/.github/workflows/bench-benchmarkoor.yml b/.github/workflows/bench-benchmarkoor.yml index e2b4c03eee6..0e79b7ede2c 100644 --- a/.github/workflows/bench-benchmarkoor.yml +++ b/.github/workflows/bench-benchmarkoor.yml @@ -177,7 +177,7 @@ jobs: BENCHMARKOOR_CACHE: ${{ github.workspace }}/bench-work/benchmarkoor-cache steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Clean up previous bench-work run: sudo rm -rf "$BENCH_WORK_DIR" 2>/dev/null || true @@ -199,7 +199,7 @@ jobs: submodules: true fetch-depth: 0 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable @@ -722,7 +722,7 @@ jobs: - name: Upload results if: "!cancelled()" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: benchmarkoor-results path: ${{ env.BENCH_WORK_DIR }} diff --git a/.github/workflows/bench-scheduled.yml b/.github/workflows/bench-scheduled.yml index 2cb2e34498b..aec1c9d18dc 100644 --- a/.github/workflows/bench-scheduled.yml +++ b/.github/workflows/bench-scheduled.yml @@ -328,7 +328,7 @@ jobs: BENCH_VICTORIAMETRICS_URL: ${{ secrets.BENCH_VICTORIAMETRICS_URL }} steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Clean up previous bench-work run: sudo rm -rf "$BENCH_WORK_DIR" 2>/dev/null || true @@ -353,13 +353,13 @@ jobs: 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@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 continue-on-error: true - - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - name: Install dependencies env: @@ -738,11 +738,11 @@ jobs: 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 + uv run --with matplotlib==3.10.8 --with pillow==12.3.0 python3 .github/scripts/bench-reth-charts.py $CHART_ARGS - name: Upload results if: "!cancelled()" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bench-scheduled-results path: ${{ env.BENCH_WORK_DIR }} @@ -1073,7 +1073,7 @@ jobs: printf '%s\n' "$FEATURE_REF" > "$RUNNER_TEMP/bench-state/feature-ref" - name: Save benchmark state - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bench-state-${{ needs.resolve-refs.outputs.mode }} path: ${{ runner.temp }}/bench-state/feature-ref diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index afddfab67ac..a64d3d55e50 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -66,6 +66,11 @@ on: required: false default: "" type: string + mempool: + description: "Engine mode: submit each block's transactions to the txpool before its payload: true, DELAY, or DELAY,FRACTION (e.g. 200ms,0.5)" + required: false + default: "" + type: string block_time: description: "RPC mode local block interval (default: 1s)" required: false @@ -177,6 +182,7 @@ jobs: reorg: ${{ steps.args.outputs.reorg }} bal: ${{ steps.args.outputs.bal }} wait-time: ${{ steps.args.outputs.wait-time }} + mempool: ${{ steps.args.outputs.mempool }} block-time: ${{ steps.args.outputs.block-time }} baseline-args: ${{ steps.args.outputs.baseline-args }} feature-args: ${{ steps.args.outputs.feature-args }} @@ -301,6 +307,7 @@ jobs: INPUT_CORES: ${{ github.event.inputs.cores }} INPUT_FEATURE: ${{ github.event.inputs.feature }} INPUT_FEATURE_ARGS: ${{ github.event.inputs.feature_args }} + INPUT_MEMPOOL: ${{ github.event.inputs.mempool }} INPUT_METRICS: ${{ github.event.inputs.metrics }} INPUT_MODE: ${{ github.event.inputs.mode }} INPUT_OTLP: ${{ github.event.inputs.otlp }} @@ -342,12 +349,13 @@ jobs: const jsonObjectValid = (value) => { try { const parsed = JSON.parse(value); return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed); } catch { return false; } }; - const usage = '`@decofe bench [mode=engine|rpc|call] [blocks=N] [big-blocks[=true|false|100M|2G]] [reorg[=N]] [bal=true|false|feature|baseline] [warmup=N] [baseline=REF] [feature=REF] [samply] [tracing-chrome] [skip-state-root] [slack=always|on-win|on-error|never] [cores=N] [run-pairs=N] [otlp=true|false] [metrics[=true|false]] [wait-time=DURATION] [block-time=DURATION] [baseline-args="..."] [feature-args="..."] [corpus=static|NAME] [class=call|tracecall|tracetx|traceblock] [namespace=debug|trace] [methods=a,b] [rps=N] [duration=DUR] [passes=N] [concurrency=N] [top-gas=N] [tracer=SPEC[,SPEC]] [tracer-config=JSON] [trace-options=JSON] [txgen-ref=REF]`'; + const usage = '`@decofe bench [mode=engine|rpc|call] [blocks=N] [big-blocks[=true|false|100M|2G]] [reorg[=N]] [bal=true|false|feature|baseline] [warmup=N] [baseline=REF] [feature=REF] [samply] [tracing-chrome] [skip-state-root] [slack=always|on-win|on-error|never] [cores=N] [run-pairs=N] [otlp=true|false] [metrics[=true|false]] [wait-time=DURATION] [mempool[=DELAY[,FRACTION]]] [block-time=DURATION] [baseline-args="..."] [feature-args="..."] [corpus=static|NAME] [class=call|tracecall|tracetx|traceblock] [namespace=debug|trace] [methods=a,b] [rps=N] [duration=DUR] [passes=N] [concurrency=N] [top-gas=N] [tracer=SPEC[,SPEC]] [tracer-config=JSON] [trace-options=JSON] [txgen-ref=REF]`'; let pr, actor, blocks, warmup, baseline, feature, samply, tracingChrome, cores, bigBlocks, bal; let corpus, callClass, callMethods, callRps, callDuration, callPasses, callConcurrency, callTopGas; let callTracer, callTracerConfig, callTraceOptions, callNamespace; let bigBlocksTargetGas = ''; let reorg = ''; + let mempool = ''; let explicitBlocks = false; let explicitWarmup = false; let explicitRunPairs = false; @@ -368,6 +376,13 @@ jobs: if (/^[1-9]\d*$/.test(value)) return value; return null; }; + // `true` uses txgen's defaults; otherwise a delay, optionally followed by a fraction. + const parseMempool = (value) => { + value = String(value || ''); + if (value === '' || value === 'false') return ''; + if (value === 'true' || /^\d+(ms|s)(,(0?\.\d*[1-9]\d*|1(\.0*)?))?$/.test(value)) return value; + return null; + }; const benchRunOrder = (runPairs) => { const n = Number(runPairs); if (!Number.isInteger(n) || n < 1) { @@ -401,6 +416,11 @@ jobs: core.setFailed(`Invalid reorg value: ${process.env.INPUT_REORG || ''} (must be a positive integer)`); return; } + mempool = parseMempool((process.env.INPUT_MEMPOOL || '')); + if (mempool === null) { + core.setFailed(`Invalid mempool value: ${process.env.INPUT_MEMPOOL || ''} (must be true, a delay like 200ms, or a delay and fraction like 200ms,0.5)`); + return; + } bal = (process.env.INPUT_BAL || '') || 'false'; var runPairs = (process.env.INPUT_RUN_PAIRS || '') || ''; explicitRunPairs = runPairs !== ''; @@ -489,7 +509,7 @@ jobs: const enumArgs = new Map([['bal', validBalModes], ['slack', validSlackModes], ['mode', validModes], ['class', validCallClasses], ['namespace', validNamespaces]]); const durationArgs = new Set(['wait-time', 'block-time', 'duration']); const stringArgs = new Set(['baseline-args', 'feature-args', 'txgen-ref']); - const defaults = { mode: 'engine', blocks: '', warmup: '', baseline: '', feature: '', samply: 'false', 'tracing-chrome': 'false', slack: 'always', 'big-blocks': 'false', reorg: '', bal: 'false', cores: '0', 'run-pairs': '', otlp: 'true', metrics: 'false', 'skip-state-root': 'false', 'wait-time': '', 'block-time': '', 'baseline-args': '', 'feature-args': '', 'txgen-ref': '', corpus: '', class: '', methods: '', rps: '', duration: '', passes: '', concurrency: '', 'top-gas': '', tracer: '', 'tracer-config': '', 'trace-options': '', namespace: '' }; + const defaults = { mode: 'engine', blocks: '', warmup: '', baseline: '', feature: '', samply: 'false', 'tracing-chrome': 'false', slack: 'always', 'big-blocks': 'false', reorg: '', mempool: '', bal: 'false', cores: '0', 'run-pairs': '', otlp: 'true', metrics: 'false', 'skip-state-root': 'false', 'wait-time': '', 'block-time': '', 'baseline-args': '', 'feature-args': '', 'txgen-ref': '', corpus: '', class: '', methods: '', rps: '', duration: '', passes: '', concurrency: '', 'top-gas': '', tracer: '', 'tracer-config': '', 'trace-options': '', namespace: '' }; const unknown = []; const invalid = []; const args = body.replace(/^(?:@decofe|derek) bench\s*/, ''); @@ -505,6 +525,8 @@ jobs: defaults[part] = 'true'; } else if (part === 'reorg') { defaults[part] = '5'; + } else if (part === 'mempool') { + defaults[part] = 'true'; } else if (boolArgs.has(part)) { defaults[part] = 'true'; } else { @@ -532,6 +554,13 @@ jobs: } else { invalid.push(`\`${key}=${value}\` (must be a positive integer)`); } + } else if (key === 'mempool') { + const parsed = parseMempool(value); + if (parsed !== null) { + defaults[key] = parsed; + } else { + invalid.push(`\`${key}=${value}\` (must be true, false, a delay like 200ms, or a delay and fraction like 200ms,0.5)`); + } } else if (key === 'corpus') { if (/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) { defaults[key] = value; @@ -626,6 +655,7 @@ jobs: bigBlocks = parsedBigBlocks.enabled; bigBlocksTargetGas = parsedBigBlocks.targetGas; reorg = defaults.reorg; + mempool = defaults.mempool; bal = defaults.bal; var runPairs = defaults['run-pairs']; var otlp = defaults.otlp; @@ -655,15 +685,15 @@ jobs: featureNodeArgs = [featureNodeArgs, '--debug.skip-state-root'].filter(Boolean).join(' '); } - const isBigBlocks = bigBlocks === 'true'; + const useBigBlockDefaults = bigBlocks === 'true'; // In call mode `blocks` counts the chain blocks a static corpus is generated // from and `warmup` is a warmup duration in seconds. const isCall = mode === 'call'; if (!explicitBlocks) { - blocks = isCall ? '20' : defaultBlocks(isBigBlocks); + blocks = isCall ? '20' : defaultBlocks(useBigBlockDefaults); } if (!explicitRunPairs) { - runPairs = defaultRunPairs(isBigBlocks); + runPairs = defaultRunPairs(useBigBlockDefaults); } if (!/^\d+$/.test(blocks)) { core.setFailed(`Invalid blocks value: ${blocks} (must be a positive integer)`); @@ -682,6 +712,15 @@ jobs: } const runOrder = benchRunOrder(runPairs); + // Historical block headers lack a BAL hash. Use the existing big-block + // payload format to retain BALs, including when replaying older baseline refs. + if (mode === 'engine' && bal !== 'false' && bigBlocks !== 'true') { + bigBlocks = 'true'; + bigBlocksTargetGas = '1'; + } + + const isBigBlocks = bigBlocks === 'true'; + if (!validBalModes.has(bal)) { core.setFailed(`Invalid bal mode: ${bal}`); return; @@ -694,6 +733,14 @@ jobs: core.setFailed(`${mode} mode does not support big-blocks, reorg, or BAL`); return; } + if (isBigBlocks && reorg) { + core.setFailed('reorg is not supported with big-blocks or BAL (reth-bb cannot build reorg blocks)'); + return; + } + if (mempool && (mode !== 'engine' || isBigBlocks || reorg)) { + core.setFailed('mempool is only supported in Engine mode without big-blocks or reorg'); + return; + } if (mode !== 'rpc' && blockTime) { core.setFailed('block-time is only supported in RPC mode'); return; @@ -786,6 +833,7 @@ jobs: core.setOutput('reorg', reorg); core.setOutput('bal', bal); core.setOutput('wait-time', waitTime); + core.setOutput('mempool', mempool); core.setOutput('block-time', blockTime); core.setOutput('baseline-args', baselineNodeArgs); core.setOutput('feature-args', featureNodeArgs); @@ -845,6 +893,7 @@ jobs: INPUT_TRACER: ${{ steps.args.outputs.tracer }} INPUT_TRACING_CHROME: ${{ steps.args.outputs.tracing-chrome }} INPUT_WAIT_TIME: ${{ steps.args.outputs.wait-time }} + INPUT_MEMPOOL: ${{ steps.args.outputs.mempool }} INPUT_WARMUP: ${{ steps.args.outputs.warmup }} with: github-token: ${{ secrets.DEREK_PAT }} @@ -940,6 +989,8 @@ jobs: const metricsNote = metricsEnabled ? ', metrics: `enabled`' : ''; const waitTimeVal = process.env.INPUT_WAIT_TIME; const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : ''; + const mempoolVal = process.env.INPUT_MEMPOOL; + const mempoolNote = mempoolVal ? `, mempool: \`${mempoolVal}\`` : ''; const blockTimeVal = process.env.INPUT_BLOCK_TIME; const blockTimeNote = blockTimeVal ? `, block-time: \`${blockTimeVal}\`` : ''; const baselineArgsVal = process.env.INPUT_BASELINE_ARGS; @@ -949,7 +1000,7 @@ jobs: const blocksDesc = mode === 'call' ? `${blocks} corpus blocks, ${warmup}s warmup` : (bigBlocks ? `${blocks} big blocks, ${warmup} warmup blocks` : `${blocks} blocks, ${warmup} warmup blocks`); - const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${modeNote}${callNote}${samplyNote}${tracingChromeNote}${slackNote}${reorgNote}${balNote}${coresNote}${runPairsNote}${otlpNote}${metricsNote}${waitTimeNote}${blockTimeNote}${baselineArgsNote}${featureArgsNote}`; + const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${modeNote}${callNote}${samplyNote}${tracingChromeNote}${slackNote}${reorgNote}${balNote}${coresNote}${runPairsNote}${otlpNote}${metricsNote}${waitTimeNote}${mempoolNote}${blockTimeNote}${baselineArgsNote}${featureArgsNote}`; const { data: comment } = await github.rest.issues.createComment({ owner: context.repo.owner, @@ -997,6 +1048,7 @@ jobs: INPUT_TRACER: ${{ steps.args.outputs.tracer }} INPUT_TRACING_CHROME: ${{ steps.args.outputs.tracing-chrome }} INPUT_WAIT_TIME: ${{ steps.args.outputs.wait-time }} + INPUT_MEMPOOL: ${{ steps.args.outputs.mempool }} INPUT_WARMUP: ${{ steps.args.outputs.warmup }} with: github-token: ${{ secrets.DEREK_PAT }} @@ -1050,6 +1102,8 @@ jobs: const metricsNote = metricsEnabled ? ', metrics: `enabled`' : ''; const waitTimeVal = process.env.INPUT_WAIT_TIME; const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : ''; + const mempoolVal = process.env.INPUT_MEMPOOL; + const mempoolNote = mempoolVal ? `, mempool: \`${mempoolVal}\`` : ''; const blockTimeVal = process.env.INPUT_BLOCK_TIME; const blockTimeNote = blockTimeVal ? `, block-time: \`${blockTimeVal}\`` : ''; const baselineArgsVal = process.env.INPUT_BASELINE_ARGS; @@ -1059,7 +1113,7 @@ jobs: const blocksDesc = mode === 'call' ? `${blocks} corpus blocks, ${warmup}s warmup` : (bigBlocks ? `${blocks} big blocks, ${warmup} warmup blocks` : `${blocks} blocks, ${warmup} warmup blocks`); - const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${modeNote}${callNote}${samplyNote}${tracingChromeNote}${slackNote}${reorgNote}${balNote}${coresNote}${runPairsNote}${otlpNote}${metricsNote}${waitTimeNote}${blockTimeNote}${baselineArgsNote}${featureArgsNote}`; + const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${modeNote}${callNote}${samplyNote}${tracingChromeNote}${slackNote}${reorgNote}${balNote}${coresNote}${runPairsNote}${otlpNote}${metricsNote}${waitTimeNote}${mempoolNote}${blockTimeNote}${baselineArgsNote}${featureArgsNote}`; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const numRunners = parseInt(process.env.BENCH_RUNNERS) || 1; @@ -1138,6 +1192,7 @@ jobs: BENCH_REORG: ${{ needs.bench-ack.outputs.reorg }} BENCH_BAL: ${{ needs.bench-ack.outputs.bal }} BENCH_WAIT_TIME: ${{ needs.bench-ack.outputs.wait-time }} + BENCH_MEMPOOL: ${{ needs.bench-ack.outputs.mempool }} BENCH_BLOCK_TIME: ${{ needs.bench-ack.outputs.block-time }} BENCH_BASELINE_ARGS: ${{ needs.bench-ack.outputs.baseline-args }} BENCH_FEATURE_ARGS: ${{ needs.bench-ack.outputs.feature-args }} @@ -1171,7 +1226,7 @@ jobs: BENCH_VICTORIAMETRICS_URL: ${{ needs.bench-ack.outputs.metrics == 'true' && secrets.BENCH_VICTORIAMETRICS_URL || '' }} steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Clean up previous bench-work run: sudo rm -rf "$BENCH_WORK_DIR" 2>/dev/null || true @@ -1271,6 +1326,8 @@ jobs: const metricsNote = metricsEnabled ? ', metrics: `enabled`' : ''; const waitTimeVal = process.env.BENCH_WAIT_TIME || ''; const waitTimeNote = waitTimeVal ? `, wait-time: \`${waitTimeVal}\`` : ''; + const mempoolVal = process.env.BENCH_MEMPOOL || ''; + const mempoolNote = mempoolVal ? `, mempool: \`${mempoolVal}\`` : ''; const blockTimeVal = process.env.BENCH_BLOCK_TIME || ''; const blockTimeNote = blockTimeVal ? `, block-time: \`${blockTimeVal}\`` : ''; const baselineArgsVal = process.env.BENCH_BASELINE_ARGS || ''; @@ -1280,7 +1337,7 @@ jobs: const blocksDesc = mode === 'call' ? `${blocks} corpus blocks, ${warmup}s warmup` : (bigBlocks ? `${blocks} big blocks, ${warmup} warmup blocks` : `${blocks} blocks, ${warmup} warmup blocks`); - const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${modeNote}${callNote}${samplyNote}${tracingChromeNote}${slackNote}${reorgNote}${balNote}${coresNote}${runPairsNote}${otlpNote}${metricsNote}${waitTimeNote}${blockTimeNote}${baselineArgsNote}${featureArgsNote}`; + const config = `**Config:** ${blocksDesc}, baseline: \`${baseline}\`, feature: \`${feature}\`${modeNote}${callNote}${samplyNote}${tracingChromeNote}${slackNote}${reorgNote}${balNote}${coresNote}${runPairsNote}${otlpNote}${metricsNote}${waitTimeNote}${mempoolNote}${blockTimeNote}${baselineArgsNote}${featureArgsNote}`; core.exportVariable('BENCH_CONFIG', config); const pr = process.env.BENCH_PR; @@ -1302,13 +1359,13 @@ jobs: }); } - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 continue-on-error: true - - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 - name: Mask OTLP endpoints uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -1620,7 +1677,7 @@ jobs: quote_arg() { local value="$1" - if [[ "$value" =~ ^[A-Za-z0-9._/:=@+-]+$ ]]; then + if [[ "$value" =~ ^[A-Za-z0-9._/:=@+,-]+$ ]]; then printf '%s' "$value" else printf "'%s'" "${value//\'/\'\\\'\'}" @@ -1662,6 +1719,11 @@ jobs: [ "$BENCH_SAMPLY" = "true" ] && derek_cmd+=(samply) [ "$BENCH_TRACING_CHROME" = "true" ] && derek_cmd+=(tracing-chrome) [ -n "$BENCH_WAIT_TIME" ] && derek_cmd+=(wait-time="$BENCH_WAIT_TIME") + if [ "$BENCH_MEMPOOL" = "true" ]; then + derek_cmd+=(mempool) + elif [ -n "$BENCH_MEMPOOL" ]; then + derek_cmd+=(mempool="$BENCH_MEMPOOL") + fi [ -n "$BENCH_BLOCK_TIME" ] && derek_cmd+=(block-time="$BENCH_BLOCK_TIME") [ -n "$BENCH_BASELINE_ARGS" ] && derek_cmd+=(baseline-args="$BENCH_BASELINE_ARGS") [ -n "$BENCH_FEATURE_ARGS" ] && derek_cmd+=(feature-args="$BENCH_FEATURE_ARGS") @@ -1941,7 +2003,7 @@ jobs: # Results & charts - name: Upload raw results if: success() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bench-raw-results # Replayed corpora can contain third-party traffic, so only their @@ -2096,7 +2158,7 @@ jobs: 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 + uv run --with matplotlib==3.10.8 --with pillow==12.3.0 python3 .github/scripts/bench-reth-charts.py $CHART_ARGS - name: Upload chart attachments id: upload-charts @@ -2108,7 +2170,7 @@ jobs: - name: Upload results id: upload-results if: "!cancelled()" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: bench-results # Replayed corpora can contain third-party traffic, so only their diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml index ffdb189146f..b7e712761ec 100644 --- a/.github/workflows/book.yml +++ b/.github/workflows/book.yml @@ -24,7 +24,7 @@ jobs: NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -45,7 +45,7 @@ jobs: npx playwright install --with-deps chromium - name: Install Rust nightly - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly diff --git a/.github/workflows/check-alloy.yml b/.github/workflows/check-alloy.yml index 3f15cf387d1..79f39dd1486 100644 --- a/.github/workflows/check-alloy.yml +++ b/.github/workflows/check-alloy.yml @@ -34,15 +34,15 @@ jobs: timeout-minutes: 60 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable diff --git a/.github/workflows/compact.yml b/.github/workflows/compact.yml index edfa4252704..60c5044b539 100644 --- a/.github/workflows/compact.yml +++ b/.github/workflows/compact.yml @@ -14,6 +14,12 @@ on: env: CARGO_TERM_COLOR: always RUSTC_WRAPPER: "sccache" + # Pin vergen output so reth-node-core compiles identically across commits; otherwise it and + # every crate depending on it miss sccache/rust-cache on every run. Test binaries report a fake SHA. + SOURCE_DATE_EPOCH: "1700000000" + VERGEN_GIT_SHA: "0000000000000000000000000000000000000000" + VERGEN_GIT_DESCRIBE: "v0.0.0-g0000000" + VERGEN_GIT_DIRTY: "false" name: compact-codec permissions: {} @@ -30,14 +36,14 @@ jobs: - cargo run --bin reth --features "dev" steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Checkout PR uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 @@ -48,6 +54,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.base_ref || 'main' }} + # The default `git clean -ffdx` would delete the `target/` restored by rust-cache. + clean: false persist-credentials: false # On `main` branch, generates test vectors and serializes them to disk using `Compact`. - name: Generate compact vectors diff --git a/.github/workflows/cyclops-audit.yml b/.github/workflows/cyclops-audit.yml index d468bfaba3d..dcf16b9cd37 100644 --- a/.github/workflows/cyclops-audit.yml +++ b/.github/workflows/cyclops-audit.yml @@ -1,8 +1,10 @@ name: Cyclops audit on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] pull_request_target: # zizmor: ignore[dangerous-triggers] Cyclops needs privileged PR-label triggers and does not check out PR code. - types: [labeled] + types: [labeled, opened, synchronize, reopened, ready_for_review] issue_comment: # zizmor: ignore[dangerous-triggers] Cyclops gates comment callers before using secrets. types: [created] @@ -42,6 +44,26 @@ jobs: } }' + # Push reviews for same-repo PRs. Labels stay on publish-label so fork PRs can still be audited. + audit: + if: >- + contains(fromJSON('["pull_request","pull_request_target"]'), github.event_name) && + github.event.action != 'labeled' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + id-token: write + pull-requests: read + statuses: write + uses: tempoxyz/gh-actions/.github/workflows/pr-audit.yml@252d87d9636b94551796b4b8a5cb4e27b8916cf6 # 2026-10-12T22-06-06Z-252d87d9 + with: + audit-on-push: true + audit-on-command: true + secrets: + EVENTS_KEY: ${{ secrets.EVENTS_KEY }} + EVENTS_CERT: ${{ secrets.EVENTS_CERT }} + EVENTS_ARGS: ${{ secrets.EVENTS_ARGS }} + publish-comment: if: >- github.event_name == 'issue_comment' && @@ -60,7 +82,7 @@ jobs: pull-requests: write steps: - name: Handle audit command - uses: tempoxyz/gh-actions/actions/pr-audit-comment@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/pr-audit-comment@252d87d9636b94551796b4b8a5cb4e27b8916cf6 # 2026-10-12T22-06-06Z-252d87d9 with: command-regex: '^(?:@decofe\s+)?(?:cyclops\s+(?:private\s+)?audit|derek\s+audit)\b' permission-check-mode: association diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index fab87788aa1..5a6559e75a8 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -17,4 +17,4 @@ jobs: permissions: contents: read id-token: write - uses: tempoxyz/gh-actions/.github/workflows/cargo-update-pr.yml@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/.github/workflows/cargo-update-pr.yml@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml index 246f9cc5c46..fbda5938e01 100644 --- a/.github/workflows/docker-test.yml +++ b/.github/workflows/docker-test.yml @@ -27,7 +27,7 @@ jobs: contents: read steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -54,7 +54,7 @@ jobs: echo "is_fork=false" >> "$GITHUB_OUTPUT" fi - - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 if: steps.fork.outputs.is_fork == 'false' with: toolchain: "1.95.0" @@ -84,11 +84,11 @@ jobs: # Docker build (forks) - name: Set up Docker Buildx if: steps.fork.outputs.is_fork == 'true' - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build reth image (Docker) if: steps.fork.outputs.is_fork == 'true' - uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0 + uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 env: VERGEN_GIT_SHA: ${{ steps.git.outputs.sha }} VERGEN_GIT_DESCRIBE: ${{ steps.git.outputs.describe }} @@ -100,7 +100,7 @@ jobs: *.dockerfile=Dockerfile - name: Upload reth image - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact_name }} path: ./artifacts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1e40ad717b4..ae325d2885e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,12 +42,12 @@ jobs: id-token: write steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: "1.95.0" @@ -110,6 +110,8 @@ jobs: echo "ethereum_set<> "$GITHUB_OUTPUT" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 74739c415e3..6b44ae9b9e7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,6 +12,12 @@ env: CARGO_TERM_COLOR: always SEED: rustethereumethereumrust RUSTC_WRAPPER: "sccache" + # Pin vergen output so reth-node-core compiles identically across commits; otherwise it and + # every crate depending on it miss sccache/rust-cache on every run. Test binaries report a fake SHA. + SOURCE_DATE_EPOCH: "1700000000" + VERGEN_GIT_SHA: "0000000000000000000000000000000000000000" + VERGEN_GIT_DESCRIBE: "v0.0.0-g0000000" + VERGEN_GIT_DIRTY: "false" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -31,18 +37,18 @@ jobs: timeout-minutes: 90 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 - name: Install nextest - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: nextest@0.9.140 checksum: true @@ -63,47 +69,3 @@ jobs: --exclude 'ef-tests' \ --exclude 'reth' \ -E 'binary(e2e_testsuite)' - - rocksdb: - name: e2e-rocksdb - runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }} - permissions: - id-token: write - contents: read - env: - RUST_BACKTRACE: 1 - timeout-minutes: 60 - steps: - - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 - with: - toolchain: stable - - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 - - name: Install nextest - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 - with: - tool: nextest@0.9.140 - checksum: true - fallback: none - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - cache-on-failure: true - - name: Run RocksDB e2e tests - run: | - cargo nextest run \ - --no-fail-fast \ - --locked \ - -p reth-e2e-test-utils \ - -E 'binary(rocksdb)' - - name: Free disk before post steps - if: always() - run: | - sccache --stop-server || true - rm -rf target ~/.cache/sccache - df -h diff --git a/.github/workflows/hive.yml b/.github/workflows/hive.yml index 428ab9d65ed..c34595812ad 100644 --- a/.github/workflows/hive.yml +++ b/.github/workflows/hive.yml @@ -44,7 +44,7 @@ jobs: name: Prepare Hive - ${{ matrix.variant == 'amsterdam' && 'Amsterdam' || 'Osaka' }} steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -89,7 +89,7 @@ jobs: chmod +x hive - name: Upload hive assets - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: hive_assets_${{ matrix.variant }} path: ./hive_assets @@ -218,7 +218,7 @@ jobs: issues: write steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -228,13 +228,13 @@ jobs: run: sudo apt-get update && sudo apt-get install -y ripgrep - name: Download hive assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: hive_assets_amsterdam path: /tmp - name: Download reth image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: reth path: /tmp @@ -407,7 +407,7 @@ jobs: issues: write steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -417,13 +417,13 @@ jobs: run: sudo apt-get update && sudo apt-get install -y ripgrep - name: Download hive assets - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: hive_assets_osaka path: /tmp - name: Download reth image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: reth path: /tmp diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 412d6db76c5..16fc0d8790d 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -15,6 +15,12 @@ env: CARGO_TERM_COLOR: always SEED: rustethereumethereumrust RUSTC_WRAPPER: "sccache" + # Pin vergen output so reth-node-core compiles identically across commits; otherwise it and + # every crate depending on it miss sccache/rust-cache on every run. Test binaries report a fake SHA. + SOURCE_DATE_EPOCH: "1700000000" + VERGEN_GIT_SHA: "0000000000000000000000000000000000000000" + VERGEN_GIT_DESCRIBE: "v0.0.0-g0000000" + VERGEN_GIT_DIRTY: "false" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -32,25 +38,27 @@ jobs: contents: read env: RUST_BACKTRACE: 1 + # Test builds don't need debuginfo; dropping it shrinks link output ~3.5x. + CARGO_PROFILE_DEV_DEBUG: 0 strategy: matrix: network: ["ethereum"] timeout-minutes: 60 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - name: Install Geth run: .github/scripts/install_geth.sh - name: Install nextest - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: nextest@0.9.140 checksum: true @@ -90,17 +98,17 @@ jobs: contents: read steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - name: Install nextest - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: nextest@0.9.140 checksum: true diff --git a/.github/workflows/kurtosis.yml b/.github/workflows/kurtosis.yml index f2a0f4c84fe..6639cac4445 100644 --- a/.github/workflows/kurtosis.yml +++ b/.github/workflows/kurtosis.yml @@ -45,14 +45,14 @@ jobs: - build-reth steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 - name: Download reth image - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: artifacts path: /tmp diff --git a/.github/workflows/lint-actions.yml b/.github/workflows/lint-actions.yml index e9950d740c9..554ee6a0d33 100644 --- a/.github/workflows/lint-actions.yml +++ b/.github/workflows/lint-actions.yml @@ -18,7 +18,7 @@ jobs: contents: read steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -35,4 +35,5 @@ jobs: run: | bash .github/scripts/bench-state-test.sh bash .github/scripts/bench-upload-charts-test.sh - node --test .github/scripts/bench-job-summary-test.js + bash .github/scripts/bench-txgen-build-test.sh + node --test .github/scripts/bench-job-summary-test.js .github/scripts/bench-args-test.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6cd6ab01ae0..fa8d67be976 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,6 +9,12 @@ on: env: CARGO_TERM_COLOR: always RUSTC_WRAPPER: "sccache" + # Pin vergen output so reth-node-core compiles identically across commits; otherwise it and + # every crate depending on it miss sccache/rust-cache on every run. Test binaries report a fake SHA. + SOURCE_DATE_EPOCH: "1700000000" + VERGEN_GIT_SHA: "0000000000000000000000000000000000000000" + VERGEN_GIT_DESCRIBE: "v0.0.0-g0000000" + VERGEN_GIT_DIRTY: "false" # Supersede PR runs without cancelling independent main or merge-queue runs. concurrency: @@ -33,13 +39,13 @@ jobs: features: "asm-keccak jemalloc jemalloc-prof min-error-logs min-warn-logs min-info-logs min-debug-logs min-trace-logs" steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly components: clippy @@ -61,13 +67,13 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly components: clippy @@ -78,6 +84,10 @@ jobs: - run: cargo clippy --workspace --lib --examples --tests --benches --all-features --locked env: RUSTFLAGS: -D warnings + # `--all-features` enables `account-ext`, so also lint the default account layout. + - run: cargo clippy --workspace --lib --examples --tests --benches --locked + env: + RUSTFLAGS: -D warnings wasm: runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest' || 'ubuntu-latest' }} @@ -87,17 +97,17 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable target: wasm32-wasip1 - name: Install cargo-hack - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: cargo-hack@0.6.45 checksum: true @@ -120,17 +130,17 @@ jobs: timeout-minutes: 60 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable target: riscv32imac-unknown-none-elf - name: Install cargo-hack - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: cargo-hack@0.6.45 checksum: true @@ -146,6 +156,8 @@ jobs: crate-checks: name: crate-checks (${{ matrix.partition }}/${{ matrix.total_partitions }}) runs-on: ${{ github.repository == 'paradigmxyz/reth' && 'depot-ubuntu-latest-4' || 'ubuntu-latest' }} + env: + CARGO_INCREMENTAL: 0 permissions: id-token: write contents: read @@ -156,25 +168,22 @@ jobs: timeout-minutes: 60 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - name: Install cargo-hack - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: cargo-hack@0.6.45 checksum: true fallback: none - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - cache-on-failure: true - run: cargo hack check --workspace --partition ${{ matrix.partition }}/${{ matrix.total_partitions }} msrv: @@ -186,13 +195,13 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: "1.95" # MSRV - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 @@ -212,13 +221,13 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 @@ -240,12 +249,12 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly components: rustfmt @@ -262,13 +271,13 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 @@ -276,7 +285,7 @@ jobs: with: cache-on-failure: true - name: Install cargo-udeps - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: cargo-udeps@0.1.61 checksum: true @@ -292,13 +301,13 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: nightly - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 @@ -320,7 +329,7 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -334,7 +343,7 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -352,7 +361,7 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -367,12 +376,12 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - name: Ensure no arbitrary or proptest dependency on default build @@ -387,16 +396,16 @@ jobs: timeout-minutes: 20 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - uses: taiki-e/cache-cargo-install-action@9ee83daaa7b96a6fab930949ecf1122bba04a389 # v3.0.8 with: tool: zepter @@ -408,7 +417,7 @@ jobs: permissions: contents: read id-token: write - uses: tempoxyz/gh-actions/.github/workflows/rust-deny.yml@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/.github/workflows/rust-deny.yml@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: rust-toolchain: nightly diff --git a/.github/workflows/release-dist.yml b/.github/workflows/release-dist.yml index 4595a4db18f..8c209bcc2f5 100644 --- a/.github/workflows/release-dist.yml +++ b/.github/workflows/release-dist.yml @@ -22,7 +22,7 @@ jobs: id-token: write steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - name: Update Homebrew formula uses: dawidd6/action-homebrew-bump-formula@b12d2bc99c46444125e606142f28200ec71a1214 # v10 with: diff --git a/.github/workflows/release-reproducible.yml b/.github/workflows/release-reproducible.yml index 66b0b485b23..7c2d1f77882 100644 --- a/.github/workflows/release-reproducible.yml +++ b/.github/workflows/release-reproducible.yml @@ -55,14 +55,14 @@ jobs: contents: write steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ github.event.workflow_run.head_sha }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 @@ -78,7 +78,7 @@ jobs: echo "RUST_TOOLCHAIN=$RUST_TOOLCHAIN" >> "$GITHUB_OUTPUT" - name: Build reproducible artifacts - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 id: docker_build with: context: . @@ -92,7 +92,7 @@ jobs: DOCKER_BUILD_RECORD_UPLOAD: false - name: Build and push final image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./Dockerfile.reproducible diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 859178ba70c..0b63705f426 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,12 +64,12 @@ jobs: if: ${{ github.event.inputs.dry_run != 'true' }} steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ env.RELEASE_REF }} - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - name: Verify crate version matches tag @@ -117,13 +117,13 @@ jobs: binary: reth steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ env.RELEASE_REF }} - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable target: ${{ matrix.configs.target }} @@ -169,8 +169,6 @@ jobs: GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | - GPG_TTY=$(tty) - export GPG_TTY echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --batch --import cd artifacts tar -czf ${{ matrix.build.binary }}-"${INPUT_VERSION}"-${{ matrix.configs.target }}.tar.gz ${{ matrix.build.binary }}* LICENSES README.md @@ -180,14 +178,14 @@ jobs: - name: Upload artifact if: ${{ github.event.inputs.dry_run != 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz path: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz - name: Upload signature if: ${{ github.event.inputs.dry_run != 'true' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz.asc path: ${{ matrix.build.binary }}-${{ needs.extract-version.outputs.VERSION }}-${{ matrix.configs.target }}.tar.gz.asc @@ -212,7 +210,7 @@ jobs: fetch-depth: 0 ref: ${{ env.RELEASE_REF }} - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - name: Generate full changelog run: | previous_tag=$(git describe --tags --abbrev=0 "${VERSION}^") diff --git a/.github/workflows/reproducible-build.yml b/.github/workflows/reproducible-build.yml index 950a0dc54a2..b192b807f97 100644 --- a/.github/workflows/reproducible-build.yml +++ b/.github/workflows/reproducible-build.yml @@ -24,18 +24,18 @@ jobs: machine: machine-2 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable target: x86_64-unknown-linux-gnu - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build reproducible binary with Docker run: | @@ -53,7 +53,7 @@ jobs: echo "Binaries SHA256 on ${{ matrix.machine }}: $(cat checksum.sha256)" - name: Upload the hash - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: checksum-${{ matrix.machine }} path: | @@ -66,12 +66,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Download artifacts from machine-1 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: checksum-machine-1 path: machine-1/ - name: Download artifacts from machine-2 - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: checksum-machine-2 path: machine-2/ diff --git a/.github/workflows/scan-github-actions.yml b/.github/workflows/scan-github-actions.yml index 4bbf84d159c..3039bdbbe7e 100644 --- a/.github/workflows/scan-github-actions.yml +++ b/.github/workflows/scan-github-actions.yml @@ -10,7 +10,7 @@ permissions: {} jobs: scan: - uses: tempoxyz/gh-actions/.github/workflows/scan-github-actions.yml@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/.github/workflows/scan-github-actions.yml@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 permissions: actions: read contents: read diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index 0b8af9b96e1..8645fe1f390 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -13,6 +13,12 @@ env: FROM_BLOCK: 0 TO_BLOCK: 50000 RUSTC_WRAPPER: "sccache" + # Pin vergen output so reth-node-core compiles identically across commits; otherwise it and + # every crate depending on it miss sccache/rust-cache on every run. The installed binary reports a fake SHA. + SOURCE_DATE_EPOCH: "1700000000" + VERGEN_GIT_SHA: "0000000000000000000000000000000000000000" + VERGEN_GIT_DESCRIBE: "v0.0.0-g0000000" + VERGEN_GIT_DIRTY: "false" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -32,16 +38,18 @@ jobs: env: RUST_LOG: info,sync=error RUST_BACKTRACE: 1 + # This is a smoke test of `reth stage run`, so LTO only adds link time. + CARGO_PROFILE_RELEASE_LTO: "off" timeout-minutes: 60 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 diff --git a/.github/workflows/sync-era.yml b/.github/workflows/sync-era.yml index 2bd45132f01..2ca18233663 100644 --- a/.github/workflows/sync-era.yml +++ b/.github/workflows/sync-era.yml @@ -40,13 +40,13 @@ jobs: unwind-target: "0x52e0509d33a988ef807058e2980099ee3070187f7333aae12b64d4d675f34c5a" steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index a6748b5372e..9f50133df06 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -40,13 +40,13 @@ jobs: unwind-target: "0x52e0509d33a988ef807058e2980099ee3070187f7333aae12b64d4d675f34c5a" steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index cf325c776c9..10268dc907f 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -12,6 +12,12 @@ env: CARGO_TERM_COLOR: always SEED: rustethereumethereumrust RUSTC_WRAPPER: "sccache" + # Pin vergen output so reth-node-core compiles identically across commits; otherwise it and + # every crate depending on it miss sccache/rust-cache on every run. Test binaries report a fake SHA. + SOURCE_DATE_EPOCH: "1700000000" + VERGEN_GIT_SHA: "0000000000000000000000000000000000000000" + VERGEN_GIT_DESCRIBE: "v0.0.0-g0000000" + VERGEN_GIT_DIRTY: "false" concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -28,6 +34,8 @@ jobs: contents: read env: RUST_BACKTRACE: 1 + # Test builds don't need debuginfo; dropping it shrinks link output ~3.5x. + CARGO_PROFILE_DEV_DEBUG: 0 strategy: matrix: type: [ethereum] @@ -38,13 +46,13 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 @@ -54,7 +62,7 @@ jobs: cache-bin: false cache-on-failure: true - name: Install nextest - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: nextest@0.9.140 checksum: true @@ -67,6 +75,8 @@ jobs: ${{ matrix.exclude_args }} --workspace \ --exclude ef-tests --no-tests=warn \ -E "!kind(test) and not binary(e2e_testsuite)" + - name: Run standalone EthStats tests + run: cargo nextest run --no-fail-fast --locked -p reth-node-ethstats state: name: Ethereum state tests @@ -80,7 +90,7 @@ jobs: timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -102,13 +112,13 @@ jobs: mkdir -p testing/ef-tests/execution-spec-tests URL="https://github.com/ethereum/execution-spec-tests/releases/download/${EEST_TESTS_TAG}/fixtures_stable.tar.gz" curl -L "$URL" | tar -xz --strip-components=1 -C testing/ef-tests/execution-spec-tests - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - name: Install nextest - uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/vendor/taiki-e/install-action@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: tool: nextest@0.9.140 checksum: true @@ -117,7 +127,16 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: cache-on-failure: true - - run: cargo nextest run --no-fail-fast --cargo-profile hivetests -p ef-tests --features "asm-keccak ef-tests" + - name: Prepare tmpfs temp dir + run: | + df -h /dev/shm + mkdir -p /dev/shm/reth-tmp + - name: Run ef-tests + env: + # Every case creates and deletes a fresh test database, thousands of times per run. + # tmpfs avoids the disk filesystem cost of those create/delete cycles. + TMPDIR: /dev/shm/reth-tmp + run: cargo nextest run --no-fail-fast --cargo-profile hivetests -p ef-tests --features "asm-keccak ef-tests" doc: name: doc tests @@ -127,21 +146,29 @@ jobs: contents: read env: RUST_BACKTRACE: 1 + # Doctests don't need debuginfo, and dropping it keeps the cached target dir small. + CARGO_PROFILE_DEV_DEBUG: 0 timeout-minutes: 30 steps: - name: Secure runner - uses: tempoxyz/gh-actions/actions/secure-runner@ee2960a6cc25d99bda45614135bfe1197d879bac # 2026-09-26T06-46-03Z-ee2960a6 + uses: tempoxyz/gh-actions/actions/secure-runner@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: rui314/setup-mold@339e96b4ff1f3bd865856ba4cbbe8818a7acb038 # v1 - run: .github/scripts/install_llvm.sh ubuntu - - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + - uses: tempoxyz/gh-actions/vendor/dtolnay/rust-toolchain@d5402286a7abdfb7232173b3228d56473feb8a66 # 2026-09-29T02-04-45Z-d5402286 with: toolchain: stable - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + # rust-cache restores the previous entry on a lockfile change and keeps stale artifacts, + # so the cache only grows. Rotating the key weekly starts from a clean target dir. + - name: Compute cache week + id: cache-week + run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: + key: ${{ steps.cache-week.outputs.week }} cache-on-failure: true - name: Run doctests run: cargo test --doc --workspace --all-features diff --git a/AGENTS.md b/AGENTS.md index 3ed9e42e7fe..c3386b9ef75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,14 @@ cargo docs --document-private-items - Reuse test helpers and isolate test databases and directories. - Run affected packages first; broaden checks for changes across crates. - Use existing benchmarks for performance changes and report measured results. +- E2E tests launch real nodes with the `reth-e2e-test-utils` harness; read its guide, + `crates/e2e-test-utils/README.md`, before writing one. Node tests live in + `crates/ethereum/node/tests/e2e/`. +- Run a single e2e test with + `cargo nextest run -p reth-node-ethereum --test e2e -E 'test(=module::test_name)'`. +- In e2e tests, wait on conditions instead of sleeping, assert exact errors, and add missing + helpers to the harness instead of copying them into tests. +- Before deleting a test as redundant, show that another test fails when the line it guards breaks. ## Commit and PR Style @@ -56,7 +64,15 @@ for performance claims; omit templates and validation boilerplate. ## Code Style -- Follow existing patterns and keep changes focused. +- Before writing, changing, or reviewing Rust, load the `reth-rust-style` skill + (`.agents/skills/reth-rust-style/SKILL.md`) for the review feedback this file doesn't cover. +- Follow existing patterns and keep changes focused: no drive-by fixes, renames, formatting-only + changes, or moved tests. Put unrelated work in its own PR. +- Before adding a helper, type, or constant, search reth and alloy for an existing one. +- Add code only with a caller in the same PR: no unused functions, parameters, variants, fields, + config knobs, CLI flags, RPC methods, or cargo features. When removing code, delete what it leaves + behind. Never commit commented-out code. +- Keep one source of truth: no duplicate fields, flags, or counters for the same state. - Explain non-obvious behavior and safety requirements in comments, not PR history. - Comments end with periods, except URLs. Document safety requirements of unsafe code. - Files use LF and end with a newline. @@ -84,3 +100,11 @@ for performance claims; omit templates and validation boilerplate. - When type hints are needed, prefer turbofish (`let x = Type::::new()`) over annotation (`let x: Type = Type::new()`). - In tests, avoid `.contains` assertions for error/output strings when the project has snapshot testing support such as `snapbox`. Prefer exact snapshot assertions (`stderr_eq`, `stdout_eq`, `assert_data_eq!`, etc.) and use redactions only for genuinely variable parts. - Always leave a blank line in between module doc-comments, items or item categories, unless in rare exceptions: it's a one-shot struct with one single impl block, or it's a list of impls that are all very similar. But in general blank line in between items is the norm but it's just unenforced. Items includes imports (together) too. The previous rules apply first. +- Inline single-use locals and helpers. Don't add generics, `PhantomData`, or `'static` bounds that nothing requires. +- Document new public traits, methods, and fields: when they are called, ordering guarantees, and what `None`, `false`, or an error means. +- Use typed errors, never `Result<_, String>` or an early `.to_string()`. Use the error type the file already uses. +- Don't `unwrap` or `expect` on paths reachable at runtime. Use `assert!` for invariants that must hold in release builds. +- Use named constants (spec constants, `StatusCode`) instead of literals. +- Use a named struct or newtype instead of tuples or `Option` plus casts in public or serialized types. Bump the version constant when a persisted shape changes. +- Use names already imported (`b256!`, `B256`) instead of fully qualified paths. +- Log local faults with `error!` and peer faults with `warn!`. Don't log at `info` inside polling loops. Spawned tasks enter the caller's span. diff --git a/CHANGELOG.md b/CHANGELOG.md index e1b62716ce0..bc6b2d001c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ This changelog covers Firehose-specific changes only. For upstream reth changes, The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## reth-v2.8.0-fh3.1 + +### Changed + +- Update to upstream reth [v2.8.0](https://github.com/paradigmxyz/reth/releases/tag/v2.8.0). + revm and alloy-evm are unchanged, so alloy-evm still comes from the `streamingfast/evm` fork tag + `v0.39.0-sf`. + +- Hoodi activates Amsterdam at timestamp `1793036568` (2026-10-26 17:42:48 UTC). Hoodi nodes must + run this release or later before then. + +- The new `--engine.prewarm-reuse` flag has no effect while Firehose traces. Reusing a prewarm + result commits the transaction without executing it, so Firehose would miss it; the traced path + executes every transaction. + +- The new `--snap.v2` flag syncs a fresh node by downloading state at a recent block instead of + executing the chain. Firehose emits no block below that point, and the first traced block is the + one after it. Don't use it on a node that must produce Firehose blocks from genesis. + ## reth-v2.7.0-fh3.1-2 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 897ac91d851..b37e8445763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,9 +67,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -91,9 +91,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -114,7 +114,7 @@ dependencies = [ "alloy-rlp", "arbitrary", "num_enum", - "phf 0.14.0", + "phf", "proptest", "serde", ] @@ -139,12 +139,12 @@ dependencies = [ "either", "k256", "once_cell", - "rand 0.8.6", + "rand 0.8.8", "secp256k1 0.30.0", "serde", "serde_json", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -181,7 +181,7 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "wasmtimer", @@ -201,7 +201,7 @@ dependencies = [ "itoa", "serde", "serde_json", - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] @@ -214,9 +214,9 @@ dependencies = [ "alloy-rlp", "arbitrary", "crc", - "rand 0.8.6", + "rand 0.8.8", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -229,7 +229,7 @@ dependencies = [ "alloy-rlp", "arbitrary", "borsh", - "rand 0.8.6", + "rand 0.8.8", "serde", ] @@ -244,10 +244,10 @@ dependencies = [ "arbitrary", "borsh", "k256", - "rand 0.8.6", + "rand 0.8.8", "serde", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -262,7 +262,7 @@ dependencies = [ "borsh", "once_cell", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -276,7 +276,7 @@ dependencies = [ "arbitrary", "borsh", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -313,7 +313,7 @@ source = "git+https://github.com/streamingfast/evm.git?tag=v0.39.0-sf#52afebec62 dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-hardforks 0.4.9", + "alloy-hardforks 0.4.10", "alloy-primitives", "alloy-rpc-types-engine", "alloy-rpc-types-eth", @@ -321,7 +321,7 @@ dependencies = [ "auto_impl", "derive_more", "revm", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] @@ -355,9 +355,9 @@ dependencies = [ [[package]] name = "alloy-hardforks" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09d5ff0394d9547798fa4b666c46e356d09a113f33d45fb3d75175681c1e2652" +checksum = "67bc1e34b9e64d1cff9fe7f65627f6e09e3db5893c918cc4b90b8e2a09da1aa5" dependencies = [ "alloy-chains", "alloy-eip2124", @@ -390,7 +390,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] @@ -417,7 +417,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -447,10 +447,10 @@ dependencies = [ "alloy-signer-local", "k256", "libc", - "rand 0.8.6", + "rand 0.8.8", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "url", ] @@ -469,8 +469,8 @@ dependencies = [ "derive_more", "fixed-cache", "foldhash 0.2.0", - "getrandom 0.4.2", - "hashbrown 0.17.0", + "getrandom 0.4.3", + "hashbrown 0.17.1", "indexmap 2.14.2", "itoa", "k256", @@ -478,7 +478,7 @@ dependencies = [ "paste", "proptest", "proptest-derive 0.8.0", - "rand 0.9.4", + "rand 0.9.5", "rapidhash", "ruint", "rustc-hash", @@ -525,7 +525,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -573,7 +573,7 @@ checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -669,7 +669,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tree_hash", "tree_hash_derive", ] @@ -703,7 +703,7 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "jsonwebtoken", - "rand 0.8.6", + "rand 0.8.8", "serde", "strum 0.27.2", ] @@ -728,7 +728,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -757,7 +757,7 @@ dependencies = [ "alloy-serde", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -796,7 +796,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -813,8 +813,8 @@ dependencies = [ "coins-bip32", "coins-bip39", "k256", - "rand 0.8.6", - "thiserror 2.0.20", + "rand 0.8.8", + "thiserror 2.0.21", "zeroize", ] @@ -829,7 +829,7 @@ dependencies = [ "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -846,7 +846,7 @@ dependencies = [ "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] @@ -862,7 +862,7 @@ dependencies = [ "macro-string", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] @@ -873,7 +873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c2a8abc3334044013b23d5e438cc5dc802e5544cc42b713d0840d5185967cc" dependencies = [ "serde", - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] @@ -903,7 +903,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tower", "tracing", @@ -969,9 +969,9 @@ dependencies = [ [[package]] name = "alloy-trie" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7895d0d2c946c0ae9253c227db77b9fc707383831058e6a5232fdfc774b28259" +checksum = "b94e3e0a88f8db47250ac8a95fb825d020719f833eb1dc661bd3609c3bbece7d" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -983,8 +983,9 @@ dependencies = [ "proptest-derive 0.7.0", "serde", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", + "triomphe", ] [[package]] @@ -996,14 +997,14 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -1050,7 +1051,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1061,7 +1062,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1090,7 +1091,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1104,9 +1105,9 @@ dependencies = [ [[package]] name = "archery" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" +checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f" [[package]] name = "ark-bls12-381" @@ -1145,9 +1146,9 @@ dependencies = [ "ark-std 0.6.0", "educe", "fnv", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "itertools 0.14.0", - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", "zeroize", @@ -1164,7 +1165,7 @@ dependencies = [ "ark-serialize 0.3.0", "ark-std 0.3.0", "derivative", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "paste", "rustc_version 0.3.3", @@ -1184,7 +1185,7 @@ dependencies = [ "derivative", "digest 0.10.7", "itertools 0.10.5", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "paste", "rustc_version 0.4.1", @@ -1205,7 +1206,7 @@ dependencies = [ "digest 0.10.7", "educe", "itertools 0.13.0", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "paste", "zeroize", @@ -1223,7 +1224,7 @@ dependencies = [ "ark-std 0.6.0", "digest 0.10.7", "educe", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "zeroize", ] @@ -1255,7 +1256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1265,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1274,7 +1275,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", "quote", "syn 1.0.109", @@ -1286,7 +1287,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", "proc-macro2", "quote", @@ -1299,11 +1300,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1312,11 +1313,11 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1331,7 +1332,7 @@ dependencies = [ "ark-std 0.6.0", "educe", "fnv", - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] [[package]] @@ -1346,7 +1347,7 @@ dependencies = [ "ark-std 0.6.0", "educe", "itertools 0.14.0", - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", "tracing", @@ -1386,7 +1387,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-std 0.4.0", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.8", ] [[package]] @@ -1398,7 +1399,7 @@ dependencies = [ "ark-std 0.5.0", "arrayvec", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.8", ] [[package]] @@ -1410,7 +1411,7 @@ dependencies = [ "ark-serialize-derive", "ark-std 0.6.0", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.8", "serde_with", ] @@ -1422,7 +1423,7 @@ checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1432,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1442,7 +1443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1452,7 +1453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1462,7 +1463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1492,6 +1493,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compression" version = "0.4.48" @@ -1523,7 +1536,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1534,7 +1547,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1565,9 +1578,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "aurora-engine-modexp" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518bc5745a6264b5fd7b09dffb9667e400ee9e2bbe18555fac75e1fe9afa0df9" +checksum = "5188e264926edbd2e90d61bf8b33aa3471db8acdf427fa37946f9c82898fe502" dependencies = [ "hex", "num", @@ -1581,14 +1594,14 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" @@ -1661,6 +1674,12 @@ dependencies = [ "match-lookup", ] +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + [[package]] name = "base64" version = "0.21.7" @@ -1701,6 +1720,14 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bedrock-assertions" +version = "0.1.0" +source = "git+https://github.com/oss-garage/bedrock#1d75d79122a6204efb61cbf1026513e655f38586" +dependencies = [ + "serde", +] + [[package]] name = "bimap" version = "0.6.3" @@ -1731,7 +1758,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1749,20 +1776,40 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + [[package]] name = "bitcoin-io" -version = "0.1.4" +version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.1" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.3", ] [[package]] @@ -1783,9 +1830,9 @@ dependencies = [ [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1804,7 +1851,8 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", + "rayon-core", ] [[package]] @@ -1818,18 +1866,18 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] [[package]] name = "blst" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" dependencies = [ "cc", "glob", @@ -1839,27 +1887,29 @@ dependencies = [ [[package]] name = "boa_ast" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6339a700715bda376f5ea65c76e8fe8fc880930d8b0638cea68e7f3da6538e0a" +checksum = "9221b0a090572b4a118b46c5874c0781fe4bc9546b85f0a615b8bfb609ca4ed7" dependencies = [ "bitflags 2.13.2", "boa_interner", "boa_macros", "boa_string", "indexmap 2.14.2", - "num-bigint", + "num-bigint 0.5.1", "rustc-hash", + "strum 0.28.0", ] [[package]] name = "boa_engine" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1521be326f8a5c8887e95d4ce7f002917a002a23f7b93b9a6a2bf50ed4157824" +checksum = "5d6aaf7fdc4299df68adb67e1a5440a51d34630a1f5271b9698fd74bc97b60d9" dependencies = [ "aligned-vec", "arrayvec", + "async-channel", "bitflags 2.13.2", "boa_ast", "boa_gc", @@ -1874,21 +1924,22 @@ dependencies = [ "dynify", "fast-float2", "float16", - "futures-channel", "futures-concurrency", "futures-lite", - "hashbrown 0.16.1", + "hashbrown 0.17.1", + "icu_calendar", "icu_normalizer", "indexmap 2.14.2", "intrusive-collections", - "itertools 0.14.0", - "num-bigint", + "itertools 0.15.0", + "num-bigint 0.5.1", "num-integer", "num-traits", "num_enum", - "paste", + "oneshot", + "pastey", "portable-atomic", - "rand 0.9.4", + "rand 0.10.3", "regress", "rustc-hash", "ryu-js", @@ -1898,59 +1949,62 @@ dependencies = [ "static_assertions", "tag_ptr", "tap", + "temporal_rs", "thin-vec", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", + "timezone_provider", "xsum", ] [[package]] name = "boa_gc" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17323a98cf2e631afacf1a6d659c1212c48a68bacfa85afab0a66ade80582e51" +checksum = "af82119558ab1002d1978c8459b53a3ebb506177d29a3aca37c07c1777b1d966" dependencies = [ + "arrayvec", "boa_macros", "boa_string", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "thin-vec", ] [[package]] name = "boa_interner" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20510b8b02bcde9b0a01cf34c0c308c56156503d1d91cdab4c8cfbd292b747ea" +checksum = "317c631e791f5cb7d0017e43c0a758b0eaffd40c34c4c360e2a6be7752b78cd1" dependencies = [ "boa_gc", "boa_macros", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap 2.14.2", "once_cell", - "phf 0.13.1", + "phf", "rustc-hash", "static_assertions", ] [[package]] name = "boa_macros" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5822cb4f146d243060e588bc5a5f2e709683fdad3d7111f42c48e6b5c921d23d" +checksum = "b882fd440dc0f4b7441367cf89567bb077920356750c9a76a16c002d8ec2fa5c" dependencies = [ "cfg-if", "cow-utils", "proc-macro2", "quote", - "syn 2.0.118", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "boa_parser" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd957fa9fa93e3a001a8aba5a5cd40c2bbfde486378be4c4b472fd304aaddb" +checksum = "641e81b3c8cbb7a2140547fb3ea534e9edf210b4e8c920b5fa864a919e11fbcf" dependencies = [ "bitflags 2.13.2", "boa_ast", @@ -1958,7 +2012,7 @@ dependencies = [ "boa_macros", "fast-float2", "icu_properties", - "num-bigint", + "num-bigint 0.5.1", "num-traits", "regress", "rustc-hash", @@ -1966,13 +2020,13 @@ dependencies = [ [[package]] name = "boa_string" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2da1d7f4a76fd9040788a122f0d807910800a7b86f5952e9244848c36511de" +checksum = "30bf86c2bcff5bf2547acbfc516c5d3108ca527cb131511d749bf6fbef762c0e" dependencies = [ "fast-float2", "itoa", - "paste", + "pastey", "rustc-hash", "ryu-js", "static_assertions", @@ -1980,34 +2034,32 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.3" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.9.3" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "ident_case", - "prettyplease", + "prettyplease 0.3.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "borsh" -version = "1.6.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -2016,15 +2068,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2049,9 +2101,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -2069,20 +2121,20 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "by_address" @@ -2107,13 +2159,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2157,11 +2209,21 @@ dependencies = [ "serde", ] +[[package]] +name = "calendrical_calculations" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "camino" -version = "1.2.2" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -2186,7 +2248,7 @@ dependencies = [ "semver 1.0.28", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2206,9 +2268,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.7" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -2239,18 +2301,18 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -2307,9 +2369,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -2347,14 +2409,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -2371,14 +2433,14 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "codspeed" -version = "4.5.0" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83592369519f73d5731f9c91aa562e213a33cc14bb0f27ac2f5730fd1ecbcec" +checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3" dependencies = [ "anyhow", "cc", @@ -2394,9 +2456,9 @@ dependencies = [ [[package]] name = "codspeed-criterion-compat" -version = "4.5.0" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab07c1eb2dbfa1dfeca02f86d2325106886f5d41ea294adf3740f56da7bb2c1a" +checksum = "1d31ae2e9ab23c29fa13bdfa06d012524176f5c0f4e25ec262cd829d947ebc5e" dependencies = [ "clap", "codspeed", @@ -2407,9 +2469,9 @@ dependencies = [ [[package]] name = "codspeed-criterion-compat-walltime" -version = "4.5.0" +version = "4.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1601803d919d1bca7338b98948a319f0e40275c304516792aeabdd98db6dcd4f" +checksum = "cc8605e40bab5114dcb0f76268e18880082b5798dec10757b5b58d2c3bbc7a1c" dependencies = [ "anes", "cast", @@ -2459,7 +2521,7 @@ dependencies = [ "hmac", "once_cell", "pbkdf2", - "rand 0.8.6", + "rand 0.8.8", "sha2 0.10.9", "thiserror 1.0.69", ] @@ -2479,7 +2541,7 @@ dependencies = [ "ripemd 0.1.3", "serde", "sha2 0.10.9", - "sha3 0.10.8", + "sha3 0.10.9", "thiserror 1.0.69", ] @@ -2501,9 +2563,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -2522,9 +2584,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -2563,6 +2625,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -2577,9 +2648,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -2588,9 +2659,9 @@ dependencies = [ [[package]] name = "const-hex" -version = "1.18.1" +version = "1.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -2672,6 +2743,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cow-utils" version = "0.1.3" @@ -2689,9 +2769,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -2707,15 +2787,15 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -2747,9 +2827,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2757,9 +2837,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -2837,9 +2917,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -2877,7 +2957,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2893,16 +2973,6 @@ dependencies = [ "serde", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" @@ -2923,20 +2993,6 @@ dependencies = [ "darling_macro 0.24.1", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.118", -] - [[package]] name = "darling_core" version = "0.23.0" @@ -2948,7 +3004,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2961,18 +3017,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.3", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -2983,7 +3028,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2994,7 +3039,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -3021,9 +3066,9 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-encoding-macro" -version = "0.1.19" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -3031,19 +3076,19 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "debug-helper" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f578e8e2c440e7297e008bb5486a3a8a194775224bbc23729b0dbdfaeebf162e" +checksum = "80a4af69c60438a1a82af89d362f4729fd38db7b73f305a237636fad31ceb2bf" [[package]] name = "defmt" @@ -3064,7 +3109,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3073,7 +3118,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3119,13 +3164,13 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3136,7 +3181,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3158,7 +3203,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -3195,8 +3240,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", - "crypto-common 0.2.1", + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -3242,24 +3287,24 @@ dependencies = [ "more-asserts", "multiaddr", "parking_lot", - "rand 0.8.6", + "rand 0.8.8", "smallvec", "socket2", "tokio", "tracing", - "uint 0.10.0", + "uint 0.10.1", "zeroize", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3306,7 +3351,7 @@ checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3358,12 +3403,12 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "ef-test-runner" -version = "2.7.0" +version = "2.8.0" dependencies = [ "clap", "ef-tests", @@ -3371,9 +3416,10 @@ dependencies = [ [[package]] name = "ef-tests" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", + "alloy-eip7928", "alloy-eips", "alloy-genesis", "alloy-primitives", @@ -3391,13 +3437,14 @@ dependencies = [ "reth-primitives-traits", "reth-provider", "reth-revm", + "reth-tasks", "reth-tracing", "reth-trie", "reth-trie-db", "revm", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", ] @@ -3461,31 +3508,31 @@ dependencies = [ "hex", "k256", "log", - "rand 0.8.6", + "rand 0.8.8", "secp256k1 0.30.0", "serde", - "sha3 0.10.8", + "sha3 0.10.9", "zeroize", ] [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -3505,7 +3552,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3521,7 +3568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3537,9 +3584,9 @@ dependencies = [ [[package]] name = "ethereum_serde_utils" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dc1355dbb41fbbd34ec28d4fb2a57d9a70c67ac3c19f6a5ca4d4a176b9e997a" +checksum = "38df44a7a271ab43835678f9215b53cc2523e4714a215da6643d83dc110245da" dependencies = [ "alloy-primitives", "hex", @@ -3572,7 +3619,27 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", ] [[package]] @@ -3601,7 +3668,7 @@ dependencies = [ "reth-ethereum", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3647,7 +3714,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -3705,7 +3772,7 @@ dependencies = [ "reth-payload-builder", "reth-tracing", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] @@ -3825,7 +3892,7 @@ dependencies = [ [[package]] name = "example-full-contract-state" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "eyre", @@ -3943,7 +4010,7 @@ dependencies = [ [[package]] name = "exex-subscription" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "clap", @@ -3970,21 +4037,15 @@ dependencies = [ [[package]] name = "fast-float2" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" - -[[package]] -name = "fast-srgb8" -version = "1.0.0" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fastrlp" @@ -4036,20 +4097,19 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] name = "find-msvc-tools" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "firehose-tracer" @@ -4092,7 +4152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand 0.8.6", + "rand 0.8.8", "rustc-hex", "static_assertions", ] @@ -4115,7 +4175,7 @@ checksum = "6dc7a9cb3326bafb80642c5ce99b39a2c0702d4bfa8ee8a3e773791a6cbe2407" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4137,12 +4197,12 @@ dependencies = [ [[package]] name = "float16" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bffafbd079d520191c7c2779ae9cf757601266cf4167d3f659ff09617ff8483" +checksum = "a9442b5e9a7997eace4990debae01c1b69a1a601b9eeef7fada6c3f3edeff4a1" dependencies = [ "cfg-if", - "rustc_version 0.2.3", + "rustc_version 0.4.1", ] [[package]] @@ -4275,7 +4335,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -4292,12 +4352,12 @@ checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" dependencies = [ "gloo-timers", - "send_wrapper 0.4.0", + "send_wrapper", ] [[package]] @@ -4325,9 +4385,9 @@ checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" [[package]] name = "generator" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" dependencies = [ "cc", "cfg-if", @@ -4369,25 +4429,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -4420,9 +4478,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-net" @@ -4447,9 +4505,9 @@ dependencies = [ [[package]] name = "gloo-timers" -version = "0.2.6" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" dependencies = [ "futures-channel", "futures-core", @@ -4493,9 +4551,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -4560,15 +4618,6 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -4582,9 +4631,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -4595,18 +4644,18 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] [[package]] name = "hdrhistogram" -version = "7.5.4" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" dependencies = [ "byteorder", "num-traits", @@ -4634,9 +4683,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -4646,9 +4695,18 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" -version = "0.2.2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" dependencies = [ "arrayvec", ] @@ -4669,8 +4727,8 @@ dependencies = [ "idna", "ipnet", "jni 0.22.4", - "rand 0.10.1", - "thiserror 2.0.20", + "rand 0.10.3", + "thiserror 2.0.21", "tinyvec", "tokio", "tracing", @@ -4689,10 +4747,10 @@ dependencies = [ "jni 0.22.4", "once_cell", "prefix-trie", - "rand 0.10.1", + "rand 0.10.3", "ring", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "url", @@ -4715,12 +4773,12 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.1", + "rand 0.10.3", "resolv-conf", "serde", "smallvec", "system-configuration", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] @@ -4818,9 +4876,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] @@ -4849,9 +4907,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -4878,16 +4936,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -4923,14 +4982,37 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_calendar_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" + [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -4938,9 +5020,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -4951,12 +5033,31 @@ dependencies = [ ] [[package]] -name = "icu_normalizer" -version = "2.0.1" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b24a59706036ba941c9476a55cd57b82b77f38a3c667d637ee7cabbc85eaedc" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -4969,37 +5070,36 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.0.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a97b8ac6235e69506e8dacfb2adf38461d2ce6d3e9bd9c94c4cbc3cd4400a4" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -5012,12 +5112,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -5095,7 +5189,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5142,7 +5236,7 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "arbitrary", "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -5166,7 +5260,7 @@ dependencies = [ "inkwell_internals", "libc", "llvm-sys", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -5177,14 +5271,14 @@ checksum = "6cfe97ee860815a90ed17e09639513269e39420a7440f3f4c996f238c514cf8d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.2", "inotify-sys", @@ -5193,9 +5287,9 @@ dependencies = [ [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -5215,7 +5309,7 @@ version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ - "console 0.16.3", + "console 0.16.6", "once_cell", "similar", "tempfile", @@ -5223,15 +5317,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -5251,12 +5345,9 @@ dependencies = [ [[package]] name = "intrusive-collections" -version = "0.9.7" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" -dependencies = [ - "memoffset", -] +checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" [[package]] name = "ipconfig" @@ -5288,7 +5379,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5324,12 +5415,27 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "ixdtf" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3" + [[package]] name = "jemalloc_pprof" version = "0.8.2" @@ -5349,9 +5455,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", @@ -5366,23 +5472,24 @@ dependencies = [ [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5428,7 +5535,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -5443,7 +5550,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5471,28 +5578,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -5531,7 +5637,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier 0.5.3", "soketto", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-rustls", "tokio-util", @@ -5555,11 +5661,11 @@ dependencies = [ "jsonrpsee-types", "parking_lot", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "rustc-hash", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tower", @@ -5584,7 +5690,7 @@ dependencies = [ "rustls-platform-verifier 0.5.3", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tower", "url", @@ -5600,7 +5706,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5622,7 +5728,7 @@ dependencies = [ "serde", "serde_json", "soketto", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -5639,7 +5745,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -5670,9 +5776,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "10.3.0" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -5683,6 +5789,7 @@ dependencies = [ "serde_json", "signature", "simple_asn1", + "zeroize", ] [[package]] @@ -5708,7 +5815,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -5722,20 +5829,21 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "keccak-asm" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa468878266ad91431012b3e5ef1bf9b170eab22883503a318d46857afa4579a" +checksum = "f32890f646914a263e39064295005972f0e95b928254061b2aca98445f304ee9" dependencies = [ + "cfg-if", "digest 0.10.7", "sha3-asm", ] @@ -5757,9 +5865,9 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -5767,11 +5875,11 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.2", "libc", ] @@ -5781,17 +5889,11 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "left-right" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" dependencies = [ "crossbeam-utils", "loom", @@ -5806,9 +5908,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.8+1.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", @@ -5844,9 +5946,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libp2p-identity" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" +checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c" dependencies = [ "asn1_der", "bs58", @@ -5854,9 +5956,9 @@ dependencies = [ "hkdf", "k256", "multihash", - "quick-protobuf", + "prost", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "zeroize", ] @@ -5874,14 +5976,11 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags 2.13.2", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -5902,9 +6001,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -5914,9 +6013,9 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ "bitflags 2.13.2", ] @@ -5945,9 +6044,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -5957,9 +6056,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llvm-sys" -version = "221.0.1" +version = "221.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2abcc34a3b190f03c2a61b555f218f529589ff13657bdd2ff8ac3e85f2abe6bb" +checksum = "2e52e36cd9eef0d5c4ba35c751c252f207642293009bb774185f84f678c7683c" dependencies = [ "anyhow", "cc", @@ -5981,9 +6080,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loom" @@ -6000,18 +6099,18 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.2" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lz4" @@ -6034,9 +6133,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" +checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" [[package]] name = "mach2" @@ -6061,7 +6160,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6079,13 +6178,13 @@ dependencies = [ [[package]] name = "match-lookup" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +checksum = "549e39695cc0b640f3cb378053832db3d2133422d49e8dcae5c866a2aaf1f730" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6099,9 +6198,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -6112,15 +6211,6 @@ dependencies = [ "libc", ] -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "metrics" version = "0.24.6" @@ -6139,7 +6229,7 @@ checksum = "161ab904c2c62e7bda0f7562bf22f96440ca35ff79e66c800cbac298f2f4f5ec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6153,8 +6243,8 @@ dependencies = [ "indexmap 2.14.2", "metrics", "metrics-util", - "quanta", - "thiserror 2.0.20", + "quanta 0.12.6", + "thiserror 2.0.21", ] [[package]] @@ -6185,8 +6275,8 @@ dependencies = [ "indexmap 2.14.2", "metrics", "ordered-float", - "quanta", - "rand 0.9.4", + "quanta 0.12.6", + "rand 0.9.5", "rand_xoshiro", "rapidhash", "sketches-ddsketch", @@ -6235,9 +6325,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -6263,7 +6353,7 @@ checksum = "59b43b4fd69e3437618106f7754f34021b831a514f9e1a98ae863cabcd8d8dad" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6310,23 +6400,23 @@ dependencies = [ [[package]] name = "multibase" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" dependencies = [ "base-x", "base256emoji", + "base45", "data-encoding", "data-encoding-macro", ] [[package]] name = "multihash" -version = "0.19.4" +version = "0.19.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" dependencies = [ - "no_std_io2", "unsigned-varint", ] @@ -6338,9 +6428,9 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.2", "cfg-if", @@ -6348,15 +6438,6 @@ dependencies = [ "libc", ] -[[package]] -name = "no_std_io2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" -dependencies = [ - "memchr", -] - [[package]] name = "nom" version = "7.1.3" @@ -6418,7 +6499,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6427,7 +6508,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -6437,9 +6518,19 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", @@ -6463,20 +6554,19 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -6487,7 +6577,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -6531,7 +6621,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6611,6 +6701,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oneshot" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c" + [[package]] name = "oorandom" version = "11.1.5" @@ -6639,7 +6735,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] @@ -6681,7 +6777,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tonic", "tonic-types", @@ -6718,15 +6814,15 @@ dependencies = [ "opentelemetry", "percent-encoding", "portable-atomic", - "rand 0.9.4", - "thiserror 2.0.20", + "rand 0.9.5", + "thiserror 2.0.21", ] [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] @@ -6770,26 +6866,35 @@ dependencies = [ [[package]] name = "palette" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" dependencies = [ "approx", - "fast-srgb8", "libm", "palette_derive", + "palette_math", ] [[package]] name = "palette_derive" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" dependencies = [ "by_address", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -6818,7 +6923,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6845,7 +6950,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -6858,9 +6963,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pbkdf2" @@ -6890,9 +6995,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -6908,37 +7013,17 @@ dependencies = [ "rustc_version 0.4.1", ] -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", -] - [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros 0.14.0", - "phf_shared 0.14.0", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - [[package]] name = "phf_generator" version = "0.14.0" @@ -6946,20 +7031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared 0.14.0", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.118", + "phf_shared", ] [[package]] @@ -6968,20 +7040,11 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator 0.14.0", - "phf_shared 0.14.0", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 2.0.118", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", + "syn 2.0.119", ] [[package]] @@ -7010,7 +7073,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7037,15 +7100,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain_hasher" @@ -7098,9 +7155,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -7126,10 +7183,12 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -7190,7 +7249,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.6", ] [[package]] @@ -7262,7 +7331,7 @@ dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7308,7 +7377,7 @@ dependencies = [ "bit-vec", "bitflags 2.13.2", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -7335,7 +7404,7 @@ checksum = "fb6dc647500e84a25a85b100e76c85b8ace114c209432dc174f20aac11d4ed6c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7346,14 +7415,14 @@ checksum = "c57924a81864dddafba92e1bf92f9bf82f97096c44489548a60e888e1547549b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -7361,22 +7430,22 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -7397,25 +7466,31 @@ dependencies = [ ] [[package]] -name = "quick-error" -version = "1.2.3" +name = "quanta" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +checksum = "73be39b77b45d82a4d1a5793fe9a89a5c143ceaa6ad0d8417982566bb0f34e11" +dependencies = [ + "libc", + "portable-atomic", + "raw-cpuid", + "wasip1", + "wasip2", + "web-sys", + "windows-sys 0.61.2", +] [[package]] -name = "quick-protobuf" -version = "0.8.1" +name = "quick-error" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" -dependencies = [ - "byteorder", -] +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -7425,7 +7500,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -7433,21 +7508,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -7455,16 +7531,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -7496,9 +7572,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7508,9 +7584,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -7519,12 +7595,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -7573,6 +7649,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -7593,11 +7678,11 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ - "rand 0.9.4", + "getrandom 0.3.4", "rustversion", ] @@ -7622,14 +7707,14 @@ checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ "bitflags 2.13.2", "compact_str", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "itertools 0.14.0", "kasuari", "lru", "palette", "serde", "strum 0.28.0", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -7654,7 +7739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags 2.13.2", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indoc", "instability", "itertools 0.14.0", @@ -7711,15 +7796,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags 2.13.2", -] - [[package]] name = "redox_users" version = "0.4.6" @@ -7733,29 +7809,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -7765,9 +7841,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -7782,17 +7858,16 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" -version = "0.10.5" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" dependencies = [ - "hashbrown 0.16.1", "memchr", ] @@ -7820,7 +7895,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", - "rustls-platform-verifier 0.6.2", + "rustls-platform-verifier 0.7.1", "serde", "serde_json", "serde_urlencoded", @@ -7846,7 +7921,7 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "reth" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-evm", "alloy-node-bindings", @@ -7904,7 +7979,7 @@ dependencies = [ [[package]] name = "reth-basic-payload-builder" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7930,12 +8005,12 @@ dependencies = [ [[package]] name = "reth-bb" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", "alloy-evm", - "alloy-hardforks 0.4.9", + "alloy-hardforks 0.4.10", "alloy-primitives", "alloy-rlp", "alloy-rpc-types", @@ -7978,7 +8053,7 @@ dependencies = [ [[package]] name = "reth-chain-state" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -7989,7 +8064,7 @@ dependencies = [ "metrics", "parking_lot", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "reth-chainspec", "reth-ethereum-primitives", "reth-execution-types", @@ -8007,7 +8082,7 @@ dependencies = [ [[package]] name = "reth-chainspec" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-chains", "alloy-consensus", @@ -8027,7 +8102,7 @@ dependencies = [ [[package]] name = "reth-cli" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-genesis", "clap", @@ -8039,7 +8114,7 @@ dependencies = [ [[package]] name = "reth-cli-commands" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-chains", "alloy-consensus", @@ -8130,7 +8205,7 @@ dependencies = [ [[package]] name = "reth-cli-runner" -version = "2.7.0" +version = "2.8.0" dependencies = [ "reth-tasks", "tokio", @@ -8139,21 +8214,20 @@ dependencies = [ [[package]] name = "reth-cli-util" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", - "cfg-if", "eyre", "libc", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "reth-fs-util", "reth-tracing", "secp256k1 0.30.0", "serde", "snmalloc-rs", - "thiserror 2.0.20", + "thiserror 2.0.21", "tikv-jemalloc-sys", "tikv-jemallocator", "tracy-client", @@ -8161,9 +8235,9 @@ dependencies = [ [[package]] name = "reth-codecs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef769807575055ccafe9db3a9d7eca3b97ca8213122942e71c47915776ec52b2" +checksum = "59a24479f12308e4ff8db606bc445fed267899a56661877231667f94eb0fd699" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8182,18 +8256,18 @@ dependencies = [ [[package]] name = "reth-codecs-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0652b5da6dcce54dc8bcfb69c5241b0ef09211963f76929d4d63e23767b43bf6" +checksum = "ecf4de10362fdf18e80e9f3d096f617ce0725c85dc24e9deabb414c52089040c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "reth-config" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "eyre", @@ -8211,7 +8285,7 @@ dependencies = [ [[package]] name = "reth-consensus" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -8219,17 +8293,17 @@ dependencies = [ "auto_impl", "reth-execution-types", "reth-primitives-traits", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-consensus-common" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", "alloy-primitives", - "rand 0.9.4", + "rand 0.9.5", "reth-chainspec", "reth-consensus", "reth-ethereum-primitives", @@ -8238,7 +8312,7 @@ dependencies = [ [[package]] name = "reth-consensus-debug-client" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8262,7 +8336,7 @@ dependencies = [ [[package]] name = "reth-db" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -8291,13 +8365,13 @@ dependencies = [ "strum 0.27.2", "sysinfo", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] [[package]] name = "reth-db-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8310,7 +8384,7 @@ dependencies = [ "modular-bitfield", "proptest", "proptest-arbitrary-interop", - "rand 0.9.4", + "rand 0.9.5", "reth-codecs", "reth-db-models", "reth-ethereum-primitives", @@ -8326,7 +8400,7 @@ dependencies = [ [[package]] name = "reth-db-common" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-genesis", @@ -8351,13 +8425,13 @@ dependencies = [ "reth-trie-db", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] [[package]] name = "reth-db-models" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", @@ -8373,7 +8447,7 @@ dependencies = [ [[package]] name = "reth-discv4" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -8383,7 +8457,7 @@ dependencies = [ "enr", "itertools 0.14.0", "parking_lot", - "rand 0.8.6", + "rand 0.8.8", "reth-ethereum-forks", "reth-net-banlist", "reth-net-nat", @@ -8392,7 +8466,7 @@ dependencies = [ "schnellru", "secp256k1 0.30.0", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -8400,7 +8474,7 @@ dependencies = [ [[package]] name = "reth-discv5" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -8410,22 +8484,22 @@ dependencies = [ "futures", "itertools 0.14.0", "metrics", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "reth-chainspec", "reth-ethereum-forks", "reth-metrics", "reth-network-peers", "reth-tracing", "secp256k1 0.30.0", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "reth-dns-discovery" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-chains", "alloy-primitives", @@ -8435,7 +8509,7 @@ dependencies = [ "enr", "hickory-resolver", "linked_hash_set", - "rand 0.9.4", + "rand 0.9.5", "reth-chainspec", "reth-ethereum-forks", "reth-network-peers", @@ -8445,7 +8519,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -8453,7 +8527,7 @@ dependencies = [ [[package]] name = "reth-downloaders" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8466,7 +8540,7 @@ dependencies = [ "itertools 0.14.0", "metrics", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "rayon", "reth-chainspec", "reth-config", @@ -8484,16 +8558,25 @@ dependencies = [ "reth-tracing", "reth-trie-common", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", "tracing", ] +[[package]] +name = "reth-dst" +version = "2.8.0" +dependencies = [ + "bedrock-assertions", + "serde", + "serde_json", +] + [[package]] name = "reth-e2e-test-utils" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8510,6 +8593,7 @@ dependencies = [ "eyre", "futures-util", "jsonrpsee", + "parking_lot", "reth-chainspec", "reth-cli-commands", "reth-config", @@ -8518,8 +8602,11 @@ dependencies = [ "reth-db-common", "reth-engine-local", "reth-engine-primitives", + "reth-eth-wire-types", "reth-ethereum-primitives", + "reth-fs-util", "reth-network-api", + "reth-network-p2p", "reth-network-peers", "reth-node-api", "reth-node-builder", @@ -8530,6 +8617,7 @@ dependencies = [ "reth-payload-primitives", "reth-primitives-traits", "reth-provider", + "reth-prune-types", "reth-rpc-api", "reth-rpc-builder", "reth-rpc-eth-api", @@ -8538,9 +8626,9 @@ dependencies = [ "reth-tasks", "reth-tokio-util", "reth-tracing", + "reth-transaction-pool", "reth-trie", "reth-trie-db", - "revm", "serde_json", "tempfile", "tokio", @@ -8551,7 +8639,7 @@ dependencies = [ [[package]] name = "reth-ecies" -version = "2.7.0" +version = "2.8.0" dependencies = [ "aes", "alloy-primitives", @@ -8564,11 +8652,11 @@ dependencies = [ "futures", "hmac", "pin-project", - "rand 0.8.6", + "rand 0.8.8", "reth-network-peers", "secp256k1 0.30.0", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -8577,7 +8665,7 @@ dependencies = [ [[package]] name = "reth-engine-local" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -8599,7 +8687,7 @@ dependencies = [ [[package]] name = "reth-engine-primitives" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8620,13 +8708,14 @@ dependencies = [ "reth-storage-api", "reth-trie-common", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", + "tracing", ] [[package]] name = "reth-engine-tree" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -8638,6 +8727,7 @@ dependencies = [ "alloy-rpc-types-eth", "assert_matches", "crossbeam-channel", + "dashmap", "derive_more", "eyre", "firehose-tracer", @@ -8645,11 +8735,10 @@ dependencies = [ "indexmap 2.14.2", "metrics", "metrics-util", - "moka", "parking_lot", "proptest", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "rayon", "reth-chain-state", "reth-chainspec", @@ -8693,14 +8782,15 @@ dependencies = [ "revm", "schnellru", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", + "tracing-subscriber", ] [[package]] name = "reth-engine-util" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -8728,7 +8818,7 @@ dependencies = [ [[package]] name = "reth-era" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8739,7 +8829,7 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "eyre", - "rand 0.9.4", + "rand 0.9.5", "reqwest", "reth-era-downloader", "reth-ethereum-primitives", @@ -8747,13 +8837,13 @@ dependencies = [ "snap", "tempfile", "test-case", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] [[package]] name = "reth-era-downloader" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "bytes", @@ -8771,7 +8861,7 @@ dependencies = [ [[package]] name = "reth-era-utils" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -8802,17 +8892,17 @@ dependencies = [ [[package]] name = "reth-errors" -version = "2.7.0" +version = "2.8.0" dependencies = [ "reth-consensus", "reth-execution-errors", "reth-storage-errors", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-eth-wire" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-chains", "alloy-consensus", @@ -8827,8 +8917,8 @@ dependencies = [ "pin-project", "proptest", "proptest-arbitrary-interop", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "reth-codecs", "reth-ecies", "reth-eth-wire-types", @@ -8841,7 +8931,7 @@ dependencies = [ "serde", "snap", "test-fuzz", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -8850,14 +8940,14 @@ dependencies = [ [[package]] name = "reth-eth-wire-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-chains", "alloy-consensus", "alloy-eip7928", "alloy-eips", "alloy-genesis", - "alloy-hardforks 0.4.9", + "alloy-hardforks 0.4.10", "alloy-primitives", "alloy-rlp", "alloy-trie", @@ -8866,19 +8956,19 @@ dependencies = [ "derive_more", "proptest", "proptest-arbitrary-interop", - "rand 0.9.4", + "rand 0.9.5", "reth-chainspec", "reth-codecs-derive", "reth-ethereum-primitives", "reth-primitives-traits", "serde", "test-case", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-ethereum" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-rpc-types-engine", "alloy-rpc-types-eth", @@ -8918,7 +9008,7 @@ dependencies = [ [[package]] name = "reth-ethereum-cli" -version = "2.7.0" +version = "2.8.0" dependencies = [ "clap", "eyre", @@ -8941,7 +9031,7 @@ dependencies = [ [[package]] name = "reth-ethereum-consensus" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -8957,7 +9047,7 @@ dependencies = [ [[package]] name = "reth-ethereum-engine-primitives" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", @@ -8968,15 +9058,15 @@ dependencies = [ "reth-primitives-traits", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-ethereum-forks" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eip2124", - "alloy-hardforks 0.4.9", + "alloy-hardforks 0.4.10", "alloy-primitives", "arbitrary", "auto_impl", @@ -8986,7 +9076,7 @@ dependencies = [ [[package]] name = "reth-ethereum-payload-builder" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9015,7 +9105,7 @@ dependencies = [ [[package]] name = "reth-ethereum-primitives" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9025,7 +9115,7 @@ dependencies = [ "arbitrary", "proptest", "proptest-arbitrary-interop", - "rand 0.9.4", + "rand 0.9.5", "reth-codecs", "reth-primitives-traits", "reth-zstd-compressors", @@ -9036,7 +9126,7 @@ dependencies = [ [[package]] name = "reth-etl" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "rayon", @@ -9046,7 +9136,7 @@ dependencies = [ [[package]] name = "reth-evm" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -9072,7 +9162,7 @@ dependencies = [ [[package]] name = "reth-evm-ethereum" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9097,13 +9187,16 @@ dependencies = [ [[package]] name = "reth-execution-cache" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "fixed-cache", "metrics", + "metrics-util", + "moka", "parking_lot", "reth-errors", + "reth-evm", "reth-metrics", "reth-primitives-traits", "reth-provider", @@ -9115,19 +9208,19 @@ dependencies = [ [[package]] name = "reth-execution-errors" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-evm", "alloy-primitives", "alloy-rlp", "nybbles", "reth-storage-errors", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-execution-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -9138,7 +9231,7 @@ dependencies = [ "arbitrary", "bincode", "derive_more", - "rand 0.9.4", + "rand 0.9.5", "reth-ethereum-primitives", "reth-primitives-traits", "reth-trie-common", @@ -9149,7 +9242,7 @@ dependencies = [ [[package]] name = "reth-exex" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9160,7 +9253,7 @@ dependencies = [ "itertools 0.14.0", "metrics", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "reth-chain-state", "reth-chainspec", "reth-config", @@ -9186,7 +9279,7 @@ dependencies = [ "rmp-serde", "secp256k1 0.30.0", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -9194,7 +9287,7 @@ dependencies = [ [[package]] name = "reth-exex-test-utils" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "eyre", @@ -9219,19 +9312,19 @@ dependencies = [ "reth-tasks", "reth-transaction-pool", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] [[package]] name = "reth-exex-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", "arbitrary", "bincode", - "rand 0.9.4", + "rand 0.9.5", "reth-chain-state", "reth-ethereum-primitives", "reth-execution-types", @@ -9242,7 +9335,7 @@ dependencies = [ [[package]] name = "reth-firehose" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -9271,7 +9364,7 @@ dependencies = [ [[package]] name = "reth-firehose-tests" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9297,16 +9390,16 @@ dependencies = [ [[package]] name = "reth-fs-util" -version = "2.7.0" +version = "2.8.0" dependencies = [ "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-invalid-block-hooks" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9337,7 +9430,7 @@ dependencies = [ [[package]] name = "reth-ipc" -version = "2.7.0" +version = "2.8.0" dependencies = [ "bytes", "futures", @@ -9345,11 +9438,11 @@ dependencies = [ "interprocess", "jsonrpsee", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "reth-tracing", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -9359,7 +9452,7 @@ dependencies = [ [[package]] name = "reth-libmdbx" -version = "2.7.0" +version = "2.8.0" dependencies = [ "bitflags 2.13.2", "byteorder", @@ -9370,13 +9463,13 @@ dependencies = [ "reth-mdbx-sys", "smallvec", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", ] [[package]] name = "reth-mdbx-sys" -version = "2.7.0" +version = "2.8.0" dependencies = [ "bindgen", "cc", @@ -9384,7 +9477,7 @@ dependencies = [ [[package]] name = "reth-metrics" -version = "2.7.0" +version = "2.8.0" dependencies = [ "futures", "libc", @@ -9397,7 +9490,7 @@ dependencies = [ [[package]] name = "reth-net-banlist" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "ipnet", @@ -9405,21 +9498,21 @@ dependencies = [ [[package]] name = "reth-net-nat" -version = "2.7.0" +version = "2.8.0" dependencies = [ "futures-util", "if-addrs", "reqwest", "reth-tracing", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "reth-network" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -9439,8 +9532,8 @@ dependencies = [ "metrics", "parking_lot", "pin-project", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "rayon", "reth-chainspec", "reth-consensus", @@ -9480,7 +9573,7 @@ dependencies = [ "smallvec", "socket2", "test-case", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -9490,7 +9583,7 @@ dependencies = [ [[package]] name = "reth-network-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -9507,14 +9600,14 @@ dependencies = [ "reth-network-types", "reth-tokio-util", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", ] [[package]] name = "reth-network-p2p" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -9538,24 +9631,24 @@ dependencies = [ [[package]] name = "reth-network-peers" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "alloy-rlp", "enr", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "secp256k1 0.30.0", "serde_json", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "url", ] [[package]] name = "reth-network-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eip2124", "humantime-serde", @@ -9568,26 +9661,26 @@ dependencies = [ [[package]] name = "reth-nippy-jar" -version = "2.7.0" +version = "2.8.0" dependencies = [ "anyhow", "bincode", "derive_more", "lz4_flex", "memmap2", - "rand 0.9.4", + "rand 0.9.5", "reth-fs-util", "serde", "smallvec", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "zstd 0.13.3", ] [[package]] name = "reth-node-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-rpc-types-engine", "eyre", @@ -9610,7 +9703,7 @@ dependencies = [ [[package]] name = "reth-node-builder" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9682,7 +9775,7 @@ dependencies = [ [[package]] name = "reth-node-core" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9696,7 +9789,7 @@ dependencies = [ "humantime", "ipnet", "proptest", - "rand 0.9.4", + "rand 0.9.5", "reth-chainspec", "reth-cli-util", "reth-config", @@ -9727,7 +9820,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "strum 0.27.2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "toml", "tracing", @@ -9738,7 +9831,7 @@ dependencies = [ [[package]] name = "reth-node-ethereum" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-contract", @@ -9762,13 +9855,18 @@ dependencies = [ "http-body-util", "jsonrpsee", "jsonrpsee-core", - "rand 0.9.4", + "rand 0.9.5", "reqwest", "reth-chainspec", + "reth-consensus", "reth-db", + "reth-downloaders", "reth-e2e-test-utils", "reth-engine-local", "reth-engine-primitives", + "reth-engine-tree", + "reth-errors", + "reth-eth-wire-types", "reth-ethereum-consensus", "reth-ethereum-engine-primitives", "reth-ethereum-payload-builder", @@ -9777,12 +9875,15 @@ dependencies = [ "reth-evm-ethereum", "reth-exex", "reth-network", + "reth-network-p2p", + "reth-network-peers", "reth-node-api", "reth-node-builder", "reth-node-core", "reth-payload-primitives", "reth-primitives-traits", "reth-provider", + "reth-prune", "reth-revm", "reth-rpc", "reth-rpc-api", @@ -9792,7 +9893,11 @@ dependencies = [ "reth-rpc-eth-types", "reth-rpc-layer", "reth-rpc-server-types", + "reth-snap-sync", + "reth-stages", + "reth-stages-api", "reth-stages-types", + "reth-static-file", "reth-tasks", "reth-testing-utils", "reth-tracing", @@ -9804,12 +9909,13 @@ dependencies = [ "similar-asserts", "tempfile", "tokio", + "tokio-util", "tower", ] [[package]] name = "reth-node-ethstats" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -9822,7 +9928,7 @@ dependencies = [ "reth-transaction-pool", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-tungstenite", @@ -9832,7 +9938,7 @@ dependencies = [ [[package]] name = "reth-node-events" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9855,10 +9961,11 @@ dependencies = [ [[package]] name = "reth-node-metrics" -version = "2.7.0" +version = "2.8.0" dependencies = [ "bytes", "eyre", + "flate2", "http", "http-body-util", "jemalloc_pprof", @@ -9880,12 +9987,13 @@ dependencies = [ "tikv-jemalloc-ctl", "tokio", "tower", + "tower-http", "tracing", ] [[package]] name = "reth-node-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "reth-chainspec", "reth-db-api", @@ -9896,7 +10004,7 @@ dependencies = [ [[package]] name = "reth-payload-builder" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -9919,7 +10027,7 @@ dependencies = [ [[package]] name = "reth-payload-builder-primitives" -version = "2.7.0" +version = "2.8.0" dependencies = [ "pin-project", "reth-payload-primitives", @@ -9930,7 +10038,7 @@ dependencies = [ [[package]] name = "reth-payload-primitives" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -9948,13 +10056,13 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", ] [[package]] name = "reth-payload-util" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -9963,7 +10071,7 @@ dependencies = [ [[package]] name = "reth-payload-validator" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-rpc-types-engine", @@ -9972,9 +10080,9 @@ dependencies = [ [[package]] name = "reth-primitives-traits" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6835d57ed17047c8ba42992550b3cd1232cb92deb119715d32f63079fb5844d" +checksum = "f6ba9e5768d35c074e997bd8c92938c8a52f3207c5ff97258eb2eca5d7a79dd5" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -9993,7 +10101,7 @@ dependencies = [ "once_cell", "proptest", "proptest-arbitrary-interop", - "quanta", + "quanta 0.13.0", "rayon", "reth-codecs", "revm-bytecode", @@ -10001,12 +10109,12 @@ dependencies = [ "revm-state", "secp256k1 0.30.0", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-provider" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -10021,7 +10129,7 @@ dependencies = [ "metrics", "notify", "parking_lot", - "rand 0.9.4", + "rand 0.9.5", "rayon", "reth-chain-state", "reth-chainspec", @@ -10059,7 +10167,7 @@ dependencies = [ [[package]] name = "reth-prune" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -10083,14 +10191,14 @@ dependencies = [ "reth-tokio-util", "reth-tracing", "rustc-hash", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "reth-prune-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "arbitrary", @@ -10103,14 +10211,14 @@ dependencies = [ "serde", "serde_json", "strum 0.27.2", - "thiserror 2.0.20", + "thiserror 2.0.21", "toml", "tracing", ] [[package]] name = "reth-revm" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -10126,7 +10234,7 @@ dependencies = [ [[package]] name = "reth-rpc" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -10154,12 +10262,13 @@ dependencies = [ "derive_more", "dyn-clone", "futures", + "humantime", "itertools 0.14.0", "jsonrpsee", "jsonrpsee-types", "parking_lot", "pin-project", - "rand 0.9.4", + "rand 0.9.5", "reth-chain-state", "reth-chainspec", "reth-consensus", @@ -10200,7 +10309,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -10209,7 +10318,7 @@ dependencies = [ [[package]] name = "reth-rpc-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-genesis", @@ -10239,7 +10348,7 @@ dependencies = [ [[package]] name = "reth-rpc-builder" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-network", @@ -10287,7 +10396,7 @@ dependencies = [ "reth-transaction-pool", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tower", @@ -10297,7 +10406,7 @@ dependencies = [ [[package]] name = "reth-rpc-convert" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-evm", @@ -10312,15 +10421,14 @@ dependencies = [ "reth-primitives-traits", "reth-rpc-traits", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-rpc-e2e-tests" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-genesis", - "alloy-rpc-types-engine", "eyre", "futures-util", "jsonrpsee", @@ -10337,7 +10445,7 @@ dependencies = [ [[package]] name = "reth-rpc-engine-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", @@ -10366,14 +10474,14 @@ dependencies = [ "reth-testing-utils", "reth-transaction-pool", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "reth-rpc-eth-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -10419,7 +10527,7 @@ dependencies = [ [[package]] name = "reth-rpc-eth-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-chains", "alloy-consensus", @@ -10439,7 +10547,7 @@ dependencies = [ "jsonrpsee-core", "jsonrpsee-types", "metrics", - "rand 0.9.4", + "rand 0.9.5", "reqwest", "reth-chain-state", "reth-chainspec", @@ -10461,7 +10569,7 @@ dependencies = [ "schnellru", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -10470,12 +10578,14 @@ dependencies = [ [[package]] name = "reth-rpc-layer" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-rpc-types-engine", "brotli", + "bytes", "flate2", "http", + "http-body", "http-body-util", "jsonrpsee", "jsonrpsee-http-client", @@ -10490,7 +10600,7 @@ dependencies = [ [[package]] name = "reth-rpc-server-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", @@ -10504,9 +10614,9 @@ dependencies = [ [[package]] name = "reth-rpc-traits" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d6c3c55bf695994b2ce87e05eaf49c1135c84d3d1f5e774df6077a8173ebdb0" +checksum = "8a4658e897dcc67b1710af3ec82e7bb4bcd766c171523b8ecf79baef43851d1a" dependencies = [ "alloy-consensus", "alloy-network", @@ -10514,12 +10624,12 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-signer", "reth-primitives-traits", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-snap-sync" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -10538,6 +10648,7 @@ dependencies = [ "reth-network-peers", "reth-primitives-traits", "reth-provider", + "reth-prune-types", "reth-stages", "reth-stages-api", "reth-stages-types", @@ -10548,7 +10659,7 @@ dependencies = [ "revm", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -10556,7 +10667,7 @@ dependencies = [ [[package]] name = "reth-stages" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -10571,7 +10682,7 @@ dependencies = [ "num-traits", "page_size", "paste", - "rand 0.9.4", + "rand 0.9.5", "rayon", "reqwest", "reth-chainspec", @@ -10612,14 +10723,14 @@ dependencies = [ "reth-trie", "reth-trie-db", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "reth-stages-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", @@ -10645,7 +10756,7 @@ dependencies = [ "reth-tasks", "reth-testing-utils", "reth-tokio-util", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -10653,7 +10764,7 @@ dependencies = [ [[package]] name = "reth-stages-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "arbitrary", @@ -10661,15 +10772,16 @@ dependencies = [ "modular-bitfield", "proptest", "proptest-arbitrary-interop", - "rand 0.9.4", + "rand 0.9.5", "reth-codecs", + "reth-primitives-traits", "reth-trie-common", "serde", ] [[package]] name = "reth-static-file" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "assert_matches", @@ -10692,7 +10804,7 @@ dependencies = [ [[package]] name = "reth-static-file-types" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "clap", @@ -10710,7 +10822,7 @@ dependencies = [ [[package]] name = "reth-storage-api" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eip7928", @@ -10736,7 +10848,7 @@ dependencies = [ [[package]] name = "reth-storage-errors" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-eips", "alloy-primitives", @@ -10747,12 +10859,12 @@ dependencies = [ "reth-prune-types", "reth-static-file-types", "revm", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-storage-overlay" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -10782,7 +10894,7 @@ dependencies = [ [[package]] name = "reth-storage-rpc-provider" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -10791,6 +10903,7 @@ dependencies = [ "alloy-provider", "alloy-rpc-types", "alloy-rpc-types-engine", + "alloy-rpc-types-eth", "dashmap", "reth-chain-state", "reth-chainspec", @@ -10812,7 +10925,7 @@ dependencies = [ [[package]] name = "reth-tasks" -version = "2.7.0" +version = "2.8.0" dependencies = [ "crossbeam-utils", "dashmap", @@ -10823,7 +10936,7 @@ dependencies = [ "pin-project", "rayon", "reth-metrics", - "thiserror 2.0.20", + "thiserror 2.0.21", "thread-priority", "tokio", "tracing", @@ -10832,15 +10945,15 @@ dependencies = [ [[package]] name = "reth-testing-utils" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", "alloy-genesis", "alloy-primitives", "alloy-rlp", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "reth-ethereum-primitives", "reth-primitives-traits", "secp256k1 0.30.0", @@ -10848,7 +10961,7 @@ dependencies = [ [[package]] name = "reth-tokio-util" -version = "2.7.0" +version = "2.8.0" dependencies = [ "tokio", "tokio-stream", @@ -10857,7 +10970,7 @@ dependencies = [ [[package]] name = "reth-tracing" -version = "2.7.0" +version = "2.8.0" dependencies = [ "clap", "eyre", @@ -10876,9 +10989,9 @@ dependencies = [ [[package]] name = "reth-tracing-otlp" -version = "2.7.0" +version = "2.8.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "clap", "eyre", "opentelemetry", @@ -10894,7 +11007,7 @@ dependencies = [ [[package]] name = "reth-transaction-pool" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -10914,7 +11027,7 @@ dependencies = [ "pin-project", "proptest", "proptest-arbitrary-interop", - "rand 0.9.4", + "rand 0.9.5", "reth-chain-state", "reth-chainspec", "reth-eth-wire-types", @@ -10937,7 +11050,7 @@ dependencies = [ "serde_json", "smallvec", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -10945,7 +11058,7 @@ dependencies = [ [[package]] name = "reth-trie" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -10961,7 +11074,7 @@ dependencies = [ "pretty_assertions", "proptest", "proptest-arbitrary-interop", - "rand 0.9.4", + "rand 0.9.5", "reth-execution-errors", "reth-metrics", "reth-primitives-traits", @@ -10977,7 +11090,7 @@ dependencies = [ [[package]] name = "reth-trie-common" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-eips", @@ -11005,12 +11118,12 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "reth-trie-db" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-consensus", "alloy-primitives", @@ -11037,34 +11150,33 @@ dependencies = [ [[package]] name = "reth-trie-parallel" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-evm", "alloy-primitives", - "alloy-rlp", "crossbeam-channel", "crossbeam-utils", "metrics", - "rand 0.9.4", + "rand 0.9.5", "reth-chainspec", + "reth-db-common", "reth-ethereum-primitives", "reth-execution-errors", "reth-metrics", "reth-primitives-traits", "reth-provider", - "reth-storage-errors", "reth-storage-overlay", "reth-tasks", "reth-trie", "revm", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "reth-trie-sparse" -version = "2.7.0" +version = "2.8.0" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -11076,8 +11188,8 @@ dependencies = [ "pretty_assertions", "proptest", "proptest-arbitrary-interop", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "rayon", "reth-execution-errors", "reth-metrics", @@ -11085,7 +11197,6 @@ dependencies = [ "reth-tracing", "reth-trie", "reth-trie-common", - "slotmap", "smallvec", "strum 0.27.2", "tracing", @@ -11093,9 +11204,9 @@ dependencies = [ [[package]] name = "reth-zstd-compressors" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "253f7d5772f3c8d0dcc3fdac398db6bca4f6f6e602d240c3bc47338dde0910dd" +checksum = "65d41037bdcd4d60ca6bbe4e65cc61bbaf29ef7235c2c614e59c25d5a94ac813" dependencies = [ "zstd 0.13.3", ] @@ -11127,7 +11238,7 @@ checksum = "c8d38e16b72ec1c836ec8490562bfb21003165ea8bc496086bf345680a6e6c41" dependencies = [ "bitvec", "paste", - "phf 0.14.0", + "phf", "revm-primitives", "serde", ] @@ -11192,7 +11303,7 @@ dependencies = [ "revm-primitives", "revm-state", "serde", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -11234,9 +11345,9 @@ dependencies = [ [[package]] name = "revm-inspectors" -version = "0.44.0" +version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c1f17ebaa6be220728837731a3c87d1a542bf31a68138e9364a3a33b149415c" +checksum = "d578c9a58c30ce3736b30b5e99368376605c1f7cc34360a91834fb83f673afca" dependencies = [ "alloy-eip2930", "alloy-primitives", @@ -11247,12 +11358,12 @@ dependencies = [ "boa_engine", "boa_gc", "colorchoice", - "num-bigint", + "num-bigint 0.5.1", "revm", "ruint", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -11443,7 +11554,7 @@ dependencies = [ "eyre", "libc", "libloading 0.9.0", - "quanta", + "quanta 0.12.6", "rayon", "revm-bytecode", "revm-context", @@ -11589,9 +11700,9 @@ checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" [[package]] name = "ruint" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +checksum = "2973657b5127d510e230f5c63d2d106af9c8f79393d8b9f4647323e8196bdde5" dependencies = [ "alloy-rlp", "arbitrary", @@ -11602,14 +11713,14 @@ dependencies = [ "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", "parity-scale-codec", "primitive-types", "proptest", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "rlp", "ruint-macro", "serde_core", @@ -11625,9 +11736,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" @@ -11635,7 +11746,7 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" dependencies = [ - "rand 0.9.4", + "rand 0.9.5", ] [[package]] @@ -11644,15 +11755,6 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver 0.9.0", -] - [[package]] name = "rustc_version" version = "0.3.3" @@ -11673,22 +11775,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -11702,9 +11804,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -11714,9 +11816,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -11735,7 +11837,7 @@ dependencies = [ "once_cell", "rustls", "rustls-native-certs", - "rustls-platform-verifier-android", + "rustls-platform-verifier-android 0.1.1", "rustls-webpki", "security-framework", "security-framework-sys", @@ -11745,23 +11847,23 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni 0.21.1", + "jni 0.22.4", "log", "once_cell", "rustls", "rustls-native-certs", - "rustls-platform-verifier-android", + "rustls-platform-verifier-android 0.2.0", "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 1.0.7", - "windows-sys 0.61.2", + "webpki-root-certs 1.0.9", + "windows-sys 0.52.0", ] [[package]] @@ -11770,6 +11872,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -11784,9 +11892,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -11808,9 +11916,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "ryu-js" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" [[package]] name = "safe_arch" @@ -11853,9 +11961,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -11908,7 +12016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.8", "secp256k1-sys 0.10.1", "serde", ] @@ -11920,7 +12028,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes", - "rand 0.9.4", + "rand 0.9.5", "secp256k1-sys 0.11.0", ] @@ -11965,22 +12073,13 @@ dependencies = [ "libc", ] -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser 0.7.0", -] - [[package]] name = "semver" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" dependencies = [ - "semver-parser 0.10.3", + "semver-parser", ] [[package]] @@ -11993,12 +12092,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - [[package]] name = "semver-parser" version = "0.10.3" @@ -12008,12 +12101,6 @@ dependencies = [ "pest", ] -[[package]] -name = "send_wrapper" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" - [[package]] name = "send_wrapper" version = "0.6.0" @@ -12056,7 +12143,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -12096,9 +12183,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ "base64 0.23.1", "bs58", @@ -12108,7 +12195,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -12117,14 +12204,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -12139,9 +12226,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -12166,15 +12253,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest 0.10.7", "keccak 0.1.6", @@ -12187,14 +12274,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", ] [[package]] name = "sha3-asm" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +checksum = "471668161349031e3d415412f996b030c477488eec267cc3cadae3d06c0a367f" dependencies = [ "cc", "cfg-if", @@ -12264,15 +12351,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version 0.4.1", "simdutf8", @@ -12311,17 +12398,17 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", ] [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "sketches-ddsketch" @@ -12335,15 +12422,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - [[package]] name = "small_btree" version = "0.1.0" @@ -12355,9 +12433,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "arbitrary", "serde", @@ -12395,7 +12473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12410,15 +12488,15 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.6", + "rand 0.8.8", "sha1", ] [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -12488,7 +12566,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -12500,7 +12578,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -12528,9 +12606,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -12539,9 +12617,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -12557,7 +12635,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -12577,7 +12655,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -12651,42 +12740,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "temporal_rs" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a978fef907a27736827d336c0bfaab14fb8a7509dd6dc2b43c44e51d59aca5b" +dependencies = [ + "calendrical_calculations", + "core_maths", + "icu_calendar", + "icu_locale_core", + "ixdtf", + "num-traits", + "timezone_provider", + "tinystr", + "writeable", ] [[package]] name = "test-case" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +checksum = "124953e7f67cb0b2fcfb87e899e4ae5a64fb68e2e160767933cc67b646fb0042" dependencies = [ "test-case-macros", ] [[package]] name = "test-case-core" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +checksum = "bd097615b407247e102046ba34426bb594923ab3554cf0f3cdfed050f1a5a3e8" dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "test-case-macros" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +checksum = "f4ce35ece947bccac166e1ded639133827a953f69a48ba134d17f91108333082" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "test-case-core", ] @@ -12723,10 +12829,10 @@ dependencies = [ "darling 0.23.0", "heck", "itertools 0.14.0", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -12744,9 +12850,9 @@ dependencies = [ [[package]] name = "thin-vec" -version = "0.2.16" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259cdf8ed4e4aca6f1e9d011e10bd53f524a2d0637d7b28450f6c64ac298c4c6" +checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" [[package]] name = "thiserror" @@ -12759,11 +12865,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -12774,18 +12880,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -12804,9 +12910,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -12853,9 +12959,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -12875,19 +12981,31 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "timezone_provider" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ec4f4eebb4817fde02a411aedc7ac5f66c89c68c49e5d4b17a8139f077af52" +dependencies = [ + "tinystr", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -12906,18 +13024,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -12938,20 +13047,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -13038,36 +13147,36 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "base64 0.22.1", @@ -13091,9 +13200,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -13102,9 +13211,9 @@ dependencies = [ [[package]] name = "tonic-types" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a875a902255423d34c1f20838ab374126db8eb41625b7947a1d54113b0b7399" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ "prost", "prost-types", @@ -13194,7 +13303,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tracing-subscriber", ] @@ -13207,7 +13316,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -13351,9 +13460,9 @@ dependencies = [ [[package]] name = "tracy-client" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f6fc3baeac5d86ab90c772e9e30620fc653bf1864295029921a15ef478e6a5" +checksum = "6131992ff3e2cb96f407eb4eb005427ba15e2687260d99089b90e60e33169ce0" dependencies = [ "loom", "once_cell", @@ -13363,9 +13472,9 @@ dependencies = [ [[package]] name = "tracy-client-sys" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" +checksum = "ab27f167b093214c68413a2e0bcd318f0591af475597405ff68138ba0a433379" dependencies = [ "cc", "windows-targets 0.52.6", @@ -13393,7 +13502,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -13432,11 +13541,11 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.4", + "rand 0.9.5", "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -13471,9 +13580,9 @@ dependencies = [ [[package]] name = "uint" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +checksum = "6f9227a75a5a540a464c832ad4a4195dbdbecd8787610a56262721fde6f04f90" dependencies = [ "byteorder", "crunchy", @@ -13495,15 +13604,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -13589,11 +13698,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -13663,7 +13772,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -13707,28 +13816,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" +name = "wasip1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] +checksum = "b5e26842486624357dbeb8f0381cf1fb42f022291fd787d4a816768fec8cc760" [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -13739,19 +13845,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -13759,48 +13866,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.2", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -13814,18 +13899,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.2", - "hashbrown 0.15.5", - "indexmap 2.14.2", - "semver 1.0.28", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -13842,9 +13915,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -13866,14 +13939,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "webpki-root-certs 1.0.7", + "webpki-root-certs 1.0.9", ] [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -13884,14 +13957,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -13934,7 +14007,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -13945,27 +14018,27 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "wincode" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37095eb18dd6254c66217edc61a29d83d51f8818de8a2ffe88e4584ad73fb5f9" +checksum = "66d967db7705dc29120bb6e8ce5b5a2e27734ed5976d1c904e95bd238d1c3c5a" dependencies = [ "pastey", "proc-macro2", "quote", - "thiserror 2.0.20", + "thiserror 2.0.21", "wincode-derive", ] [[package]] name = "wincode-derive" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262d55d1261f31e2cfe49cc6385a421d14d99faa0526bbe3cc1bda0d3005c62" +checksum = "15ab90b719560d0fda79c74550ad1c948d17b118765942838055ebaf34d67071" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -14021,7 +14094,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -14032,7 +14105,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -14328,105 +14401,20 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.2", - "prettyplease", - "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ - "anyhow", "bitflags 2.13.2", - "indexmap 2.14.2", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.2", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", ] [[package]] @@ -14437,9 +14425,9 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "ws_stream_wasm" @@ -14453,8 +14441,8 @@ dependencies = [ "log", "pharos", "rustc_version 0.4.1", - "send_wrapper 0.6.0", - "thiserror 2.0.20", + "send_wrapper", + "thiserror 2.0.21", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -14493,9 +14481,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -14510,76 +14498,76 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", - "synstructure", + "syn 2.0.119", + "synstructure 0.13.2", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -14589,9 +14577,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -14601,26 +14589,26 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" @@ -14628,7 +14616,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe 7.2.4", + "zstd-safe 7.3.0", ] [[package]] @@ -14642,9 +14630,9 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 294e1fa1122..bbf191b793a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "2.7.0" +version = "2.8.0" edition = "2024" rust-version = "1.95" license = "MIT OR Apache-2.0" @@ -22,6 +22,7 @@ members = [ "crates/consensus/common/", "crates/consensus/consensus/", "crates/consensus/debug-client/", + "crates/dst/", "crates/e2e-test-utils/", "crates/engine/invalid-block-hooks/", "crates/engine/local", @@ -274,6 +275,9 @@ proptest.opt-level = 3 rand_chacha.opt-level = 3 rand_xorshift.opt-level = 3 unarray.opt-level = 3 +blst.opt-level = 3 +c-kzg.opt-level = 3 +secp256k1-sys.opt-level = 3 # Meant for testing - all optimizations, but with debug assertions and overflow checks. [profile.hivetests] @@ -296,6 +300,11 @@ inherits = "release" debug = "full" strip = "none" +[profile.dst] +inherits = "profiling" +debug-assertions = true +overflow-checks = true + # Include debug info in benchmarks too. [profile.bench] inherits = "profiling" @@ -325,8 +334,8 @@ reth-cli = { path = "crates/cli/cli" } reth-cli-commands = { path = "crates/cli/commands" } reth-cli-runner = { path = "crates/cli/runner" } reth-cli-util = { path = "crates/cli/util" } -reth-codecs = { version = "0.8.1", default-features = false } -reth-codecs-derive = "0.8.1" +reth-codecs = { version = "0.8.2", default-features = false } +reth-codecs-derive = "0.8.2" reth-config = { path = "crates/config", default-features = false } reth-consensus = { path = "crates/consensus/consensus", default-features = false } reth-consensus-common = { path = "crates/consensus/common", default-features = false } @@ -392,7 +401,7 @@ reth-payload-builder = { path = "crates/payload/builder" } reth-payload-builder-primitives = { path = "crates/payload/builder-primitives" } reth-payload-primitives = { path = "crates/payload/primitives" } reth-payload-validator = { path = "crates/payload/validator" } -reth-primitives-traits = { version = "0.8.1", default-features = false } +reth-primitives-traits = { version = "0.8.2", default-features = false } reth-provider = { path = "crates/storage/provider" } reth-prune = { path = "crates/prune/prune" } reth-prune-types = { path = "crates/prune/types", default-features = false } @@ -406,7 +415,8 @@ reth-rpc-eth-types = { path = "crates/rpc/rpc-eth-types", default-features = fal reth-rpc-layer = { path = "crates/rpc/rpc-layer" } reth-rpc-server-types = { path = "crates/rpc/rpc-server-types" } reth-rpc-convert = { path = "crates/rpc/rpc-convert" } -reth-rpc-traits = { version = "0.8.1", default-features = false } +reth-rpc-traits = { version = "0.8.2", default-features = false } +reth-snap-sync = { path = "crates/snap-sync" } reth-stages = { path = "crates/stages/stages" } reth-stages-api = { path = "crates/stages/api" } reth-stages-types = { path = "crates/stages/types", default-features = false } @@ -426,12 +436,12 @@ reth-trie-common = { path = "crates/trie/common", default-features = false } reth-trie-db = { path = "crates/trie/db" } reth-trie-parallel = { path = "crates/trie/parallel" } reth-trie-sparse = { path = "crates/trie/sparse", default-features = false } -reth-zstd-compressors = { version = "0.8.1", default-features = false } +reth-zstd-compressors = { version = "0.8.2", default-features = false } # revm revm = { version = "43.0.3", default-features = false } revmc = { git = "https://github.com/paradigmxyz/revmc", branch = "main", default-features = false, features = ["llvm-prefer-static"] } -revm-inspectors = "0.44.0" +revm-inspectors = "0.44.3" # eth alloy-dyn-abi = "1.6.1" @@ -443,9 +453,9 @@ alloy-eip2124 = { version = "0.2.0", default-features = false } alloy-eip7928 = { version = "0.4.10", default-features = false, features = ["rlp"] } alloy-evm = { version = "0.39.0", default-features = false } alloy-rlp = { version = "0.3.16", default-features = false, features = ["core-net"] } -alloy-trie = { version = "0.9.4", default-features = false } +alloy-trie = { version = "0.9.8", default-features = false } -alloy-hardforks = "0.4.9" +alloy-hardforks = "0.4.10" alloy-consensus = { version = "2.5.0", default-features = false } alloy-contract = { version = "2.5.0", default-features = false } @@ -477,13 +487,13 @@ arrayvec = { version = "0.7.6", default-features = false } aquamarine = "0.6" auto_impl = "1" backon = { version = "1.2", default-features = false, features = ["std-blocking-sleep", "tokio-sleep"] } +bedrock-assertions = { git = "https://github.com/oss-garage/bedrock" } bincode = "1.3" bitflags = "2.4" boyer-moore-magiclen = "0.2.16" bytes = { version = "1.11.1", default-features = false } blake3 = "1.8" brotli = "8" -cfg-if = "1.0" clap = "4" dashmap = "6.2" derive_more = { version = "2", default-features = false, features = ["full"] } @@ -515,7 +525,6 @@ serde = { version = "1.0", default-features = false } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } serde_with = { version = "3", default-features = false, features = ["macros"] } sha2 = { version = "0.10", default-features = false } -slotmap = "1" smallvec = "1" strum = { version = "0.27", default-features = false } thiserror = { version = "2.0.0", default-features = false } diff --git a/Dockerfile.depot b/Dockerfile.depot index 47d293a9404..e36b17d96a7 100644 --- a/Dockerfile.depot +++ b/Dockerfile.depot @@ -77,6 +77,10 @@ ARG BINARY=reth # Runtime defaults ARG RETH_ENGINE_TXPOOL_PREWARMING=false ENV RETH_ENGINE_TXPOOL_PREWARMING=$RETH_ENGINE_TXPOOL_PREWARMING +ARG RETH_ENGINE_PREWARM_REUSE=false +ENV RETH_ENGINE_PREWARM_REUSE=$RETH_ENGINE_PREWARM_REUSE +ARG RETH_DB_SYNC_MODE=durable +ENV RETH_DB_SYNC_MODE=$RETH_DB_SYNC_MODE # Install runtime dependencies RUN apt-get update && \ diff --git a/Makefile b/Makefile index a8ce91dbc06..75e3b828445 100644 --- a/Makefile +++ b/Makefile @@ -235,7 +235,7 @@ maxperf: ## Builds `reth` with the most aggressive optimisations. .PHONY: maxperf-no-asm maxperf-no-asm: ## Builds `reth` with the most aggressive optimisations, minus the "asm-keccak" feature. - RUSTFLAGS="-C target-cpu=native" cargo build --profile maxperf --no-default-features --features jemalloc,min-trace-logs,otlp,otlp-logs,reth-revm/portable,js-tracer,keccak-cache-global,gmp,rocksdb + RUSTFLAGS="-C target-cpu=native" cargo build --profile maxperf --no-default-features --features jemalloc,min-trace-logs,otlp,otlp-logs,reth-revm/portable,js-tracer,keccak-cache-global,gmp fmt: cargo +nightly fmt diff --git a/bin/reth-bb/README.md b/bin/reth-bb/README.md index 2aefdcb78a7..7a43964f4b9 100644 --- a/bin/reth-bb/README.md +++ b/bin/reth-bb/README.md @@ -12,6 +12,8 @@ reth-bb extends the standard Ethereum node with: 2. **Relaxed consensus** — the gas-limit bound-divisor check and blob gas validation are skipped, since merged blocks exceed single-block limits. +BAL benchmarks use the big-block payload format so access lists are carried independently of historical block headers. When `bal` is enabled without big blocks, the workflow selects `big-blocks=1` for both comparison arms, including asymmetric BAL comparisons. This one-gas target uses the existing reth-bb replay path and does not require a single-block adapter in older baseline refs. Reorg and mempool benchmarks are not supported with BAL or big blocks. + ## Quick start The full workflow has five steps: **build** reth-bb, **install** txgen tools, **extract** big-block payloads, **start** reth-bb, and **replay** the payloads. diff --git a/bin/reth/src/main.rs b/bin/reth/src/main.rs index ad875c75dd8..78154486981 100644 --- a/bin/reth/src/main.rs +++ b/bin/reth/src/main.rs @@ -14,10 +14,17 @@ static MALLOC_CONF: &[u8] = b"prof:true,prof_active:true,lg_prof_sample:19\0"; use clap::Parser; use reth::{cli::Cli, FirehoseExecutorBuilder}; use reth_ethereum_cli::chainspec::EthereumChainSpecParser; -use reth_node_ethereum::{node::EthereumAddOns, EthereumNode}; +use reth_node_ethereum::{node::EthereumAddOns, snap::EthereumBackfill, EthereumNode}; use tracing::info; fn main() { + // Ethereum does not use account extensions, so refuse to run a binary that was built with + // them, for example through feature unification. + if let Err(err) = reth_primitives_traits::ensure_no_account_extensions() { + eprintln!("Error: {err}: this binary was built with the `account-ext` feature"); + std::process::exit(1); + } + #[cfg(feature = "jit")] { match reth_node_ethereum::node::maybe_run_jit_helper() { @@ -44,6 +51,7 @@ fn main() { if let Err(err) = Cli::::parse().run(async move |builder, _| { info!(target: "reth::cli", "Launching node"); + let backfill = EthereumBackfill::new(builder.config()); let handle = builder .with_types::() .with_components( @@ -51,7 +59,7 @@ fn main() { ) .with_add_ons(EthereumAddOns::default()) .on_component_initialized(|node| reth_firehose::init_blockchain(&node.provider)) - .launch_with_debug_capabilities() + .launch_with_debug_capabilities_and_backfill(backfill) .await?; handle.wait_for_node_exit().await diff --git a/bin/reth/tests/it/download/mod.rs b/bin/reth/tests/it/download/mod.rs index d76377e4093..65a33233746 100644 --- a/bin/reth/tests/it/download/mod.rs +++ b/bin/reth/tests/it/download/mod.rs @@ -252,13 +252,25 @@ async fn wrong_chain_is_rejected_before_force_removes_data() { let dir = tempfile::tempdir().unwrap(); fs::create_dir(dir.path().join("db")).unwrap(); fs::write(dir.path().join("db/existing"), b"keep").unwrap(); - let output = download(&server, dir.path(), &["--force"]); + let output = download(&server, dir.path(), &["--full", "--force"]); assert!(!output.status.success(), "a Sepolia snapshot must not be installed for mainnet"); assert!(String::from_utf8_lossy(&output.stderr).contains("chain ID")); assert_eq!(fs::read(dir.path().join("db/existing")).unwrap(), b"keep"); assert_eq!(server.requests(), vec![("/snapshot/manifest.json".into(), None)]); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn explicit_archive_source_and_datadir_allow_other_chains() { + let mut snapshot = Snapshot::new(); + snapshot.manifest["chain_id"] = json!(42431); + let server = snapshot.serve(true).await; + let dir = tempfile::tempdir().unwrap(); + success(download(&server, dir.path(), &["--archive"])); + assert_eq!(fs::read(dir.path().join("db/snapshot")).unwrap(), b"state.tar.zst"); + assert_eq!(fs::read(dir.path().join("static_files/txs-0")).unwrap(), b"txs-0.tar.zst"); + assert!(dir.path().join("reth.toml").exists()); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn bad_output_checksum_prevents_finalization() { let mut snapshot = Snapshot::new(); @@ -283,3 +295,34 @@ async fn bad_output_checksum_prevents_finalization() { assert!(!dir.path().join("reth.toml").exists()); assert!(!dir.path().join("db/mdbx.dat").exists()); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn completion_only_suggests_startup_for_matching_chain() { + for (chain_id, expected_suffix) in [(1, " Run `reth node` to start syncing."), (42431, "")] { + let mut snapshot = Snapshot::new(); + snapshot.manifest["chain_id"] = json!(chain_id); + let server = snapshot.serve(true).await; + let dir = tempfile::tempdir().unwrap(); + let output = Command::new(RETH) + .env("RUST_LOG", "reth::cli=info") + .args(["download", "--color", "never", "--archive", "--non-interactive"]) + .args(["--manifest-url", &format!("{}/snapshot/manifest.json", server.url)]) + .arg("--datadir") + .arg(dir.path()) + .output() + .unwrap(); + let logs = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + success(output); + let suffix = logs + .lines() + .find_map(|line| { + line.split_once("Snapshot download complete.").map(|(_, suffix)| suffix) + }) + .unwrap(); + assert_eq!(suffix, expected_suffix); + } +} diff --git a/crates/chain-state/src/in_memory.rs b/crates/chain-state/src/in_memory.rs index 1e6b2af6338..3ccc971dbe7 100644 --- a/crates/chain-state/src/in_memory.rs +++ b/crates/chain-state/src/in_memory.rs @@ -18,9 +18,7 @@ use reth_primitives_traits::{ BlockBody as _, IndexedTx, NodePrimitives, RecoveredBlock, SealedBlock, SealedHeader, SignedTransaction, }; -use reth_trie::{ - updates::TrieUpdatesSorted, ComputedTrieData, HashedPostStateSorted, LazyTrieData, -}; +use reth_trie::{updates::TrieUpdatesSorted, HashedPostStateSorted, LazyTrieData, SortedTrieData}; use std::{collections::BTreeMap, sync::Arc, time::Instant}; use tokio::sync::{broadcast, watch}; @@ -724,6 +722,21 @@ impl BlockState { } } +// The derived drop recurses once per ancestor released here, which can overflow the stack on deep +// in-memory chains, so detach parents in a loop instead. +impl Drop for BlockState { + fn drop(&mut self) { + let mut parent = self.parent.take(); + while let Some(state) = parent { + // A shared parent is left to its last owner, which continues the drain. Unlike + // `try_unwrap`, `into_inner` hands it to exactly one of several concurrent releasers. + let Some(mut state) = Arc::into_inner(state) else { break }; + // Detach the next parent so dropping `state` here doesn't recurse. + parent = state.parent.take(); + } + } +} + /// Represents an executed block stored in-memory. #[derive(Clone, Debug)] pub struct ExecutedBlock { @@ -763,7 +776,7 @@ impl Default for ExecutedBlock { }, state: Default::default(), }), - trie_data: LazyTrieData::ready(ComputedTrieData::default()), + trie_data: LazyTrieData::ready(SortedTrieData::default()), bal: None, } } @@ -786,7 +799,7 @@ impl ExecutedBlock { pub fn new( recovered_block: Arc>, execution_output: Arc>, - trie_data: ComputedTrieData, + trie_data: SortedTrieData, ) -> Self { Self { recovered_block, @@ -856,7 +869,7 @@ impl ExecutedBlock { /// - If not computed: first caller waits for the publishing task, others wait for that result #[inline] #[tracing::instrument(level = "debug", target = "engine::tree", name = "trie_data", skip_all)] - pub fn trie_data(&self) -> ComputedTrieData { + pub fn trie_data(&self) -> SortedTrieData { self.trie_data.get().clone() } @@ -874,7 +887,7 @@ impl ExecutedBlock { /// May wait for trie data if the deferred task hasn't completed. #[inline] pub fn hashed_state(&self) -> Arc { - self.trie_data().sorted.hashed_state + self.trie_data().hashed_state } /// Returns a reference to the hashed state result of the execution outcome. @@ -882,7 +895,7 @@ impl ExecutedBlock { /// May wait for trie data if the deferred task hasn't completed. #[inline] pub fn hashed_state_ref(&self) -> &HashedPostStateSorted { - &self.trie_data.get().sorted.hashed_state + &self.trie_data.get().hashed_state } /// Returns references to the hashed state results of the executed blocks. @@ -897,7 +910,7 @@ impl ExecutedBlock { /// May wait for trie data if the deferred task hasn't completed. #[inline] pub fn trie_updates(&self) -> Arc { - self.trie_data().sorted.trie_updates + self.trie_data().trie_updates } /// Returns a reference to the trie updates resulting from the execution outcome. @@ -905,7 +918,7 @@ impl ExecutedBlock { /// May wait for trie data if the deferred task hasn't completed. #[inline] pub fn trie_updates_ref(&self) -> &TrieUpdatesSorted { - &self.trie_data.get().sorted.trie_updates + &self.trie_data.get().trie_updates } /// Returns references to the trie updates of the executed blocks. @@ -1491,4 +1504,38 @@ mod tests { assert_eq!(Some(bal), block1.bal()); assert!(blocks_and_bals.next().is_none()); } + + fn block_state_chain(depth: usize, mut parent: Option>) -> Arc { + let block = ExecutedBlock::default(); + for _ in 0..depth { + parent = Some(Arc::new(BlockState::with_parent(block.clone(), parent))); + } + parent.expect("nonempty chain") + } + + #[test] + fn deep_chain_drop_preserves_shared_tail() { + const DEPTH: usize = 100_000; + + // A recursive drop of either chain would overflow this stack. + std::thread::Builder::new() + .stack_size(64 * 1024) + .spawn(|| { + let tail = block_state_chain(DEPTH, None); + let tail_payload = Arc::downgrade(&tail.block_ref().execution_output); + let head = block_state_chain(DEPTH, Some(tail.clone())); + let head_payload = Arc::downgrade(&head.block_ref().execution_output); + + drop(head); + assert_eq!(head_payload.strong_count(), 0); + assert_eq!(Arc::strong_count(&tail), 1); + assert_eq!(tail_payload.strong_count(), DEPTH); + + drop(tail); + assert_eq!(tail_payload.strong_count(), 0); + }) + .unwrap() + .join() + .unwrap(); + } } diff --git a/crates/chain-state/src/notifications.rs b/crates/chain-state/src/notifications.rs index 8ff25845d44..b0c97aeb185 100644 --- a/crates/chain-state/src/notifications.rs +++ b/crates/chain-state/src/notifications.rs @@ -266,8 +266,8 @@ mod tests { #[test] fn test_commit_notification() { let block: RecoveredBlock = Default::default(); - let block1_hash = B256::new([0x01; 32]); - let block2_hash = B256::new([0x02; 32]); + let block1_hash = B256::repeat_byte(0x01); + let block2_hash = B256::repeat_byte(0x02); let mut block1 = block.clone(); block1.set_block_number(1); @@ -299,9 +299,9 @@ mod tests { #[test] fn test_reorg_notification() { let block: RecoveredBlock = Default::default(); - let block1_hash = B256::new([0x01; 32]); - let block2_hash = B256::new([0x02; 32]); - let block3_hash = B256::new([0x03; 32]); + let block1_hash = B256::repeat_byte(0x01); + let block2_hash = B256::repeat_byte(0x02); + let block3_hash = B256::repeat_byte(0x03); let mut block1 = block.clone(); block1.set_block_number(1); @@ -346,8 +346,8 @@ mod tests { let mut body = BlockBody::::default(); // Define unique hashes for two blocks to differentiate them in the chain. - let block1_hash = B256::new([0x01; 32]); - let block2_hash = B256::new([0x02; 32]); + let block1_hash = B256::repeat_byte(0x01); + let block2_hash = B256::repeat_byte(0x02); // Create a default transaction to include in block1's transactions. let tx = TxLegacy::default().into_signed(Signature::test_signature()).into(); @@ -431,7 +431,7 @@ mod tests { .try_recover() .unwrap(); old_block1.set_block_number(1); - old_block1.set_hash(B256::new([0x01; 32])); + old_block1.set_hash(B256::repeat_byte(0x01)); // Create a receipt for a transaction in the reverted block. let old_receipt = Receipt { @@ -460,7 +460,7 @@ mod tests { .try_recover() .unwrap(); new_block1.set_block_number(2); - new_block1.set_hash(B256::new([0x02; 32])); + new_block1.set_hash(B256::repeat_byte(0x02)); // Create a receipt for a transaction in the new committed block. let new_receipt = Receipt { diff --git a/crates/chain-state/src/preserved_sparse_trie.rs b/crates/chain-state/src/preserved_sparse_trie.rs index 2d36422283d..0ad64f36ede 100644 --- a/crates/chain-state/src/preserved_sparse_trie.rs +++ b/crates/chain-state/src/preserved_sparse_trie.rs @@ -1,25 +1,19 @@ //! Preserved sparse trie for reuse across payload validations. -use alloy_primitives::B256; +use alloy_primitives::{map::B256Map, B256}; use reth_trie_sparse::SparseStateTrie; -use std::{ - fmt, - sync::mpsc::{self, Receiver, Sender}, -}; +use std::{fmt, sync::mpsc::Receiver}; use tracing::debug; -/// Type alias for the sparse trie type used in preservation. -pub type SparseTrie = SparseStateTrie; - /// A preserved sparse trie that can be reused across payload validations. pub struct PreservedSparseTrie { - /// The preserved sparse state trie, or a handle to wait for it. - trie: PreservedSparseTrieInner, - /// The state root this trie represents. + /// The finalized sparse trie and its revealed storage roots, which may still be pending. + trie: Receiver, + /// Hash of the block whose post-state this trie represents. /// - /// Used to verify continuity: a new payload's `parent_state_root` must match this before the - /// existing sparse trie nodes can be reused. - state_root: B256, + /// Used to verify continuity: a new payload's parent hash must match this before the existing + /// sparse trie nodes can be reused. + block_hash: B256, /// Parent block hash of the earliest overlay state covered by this trie. anchor_hash: B256, } @@ -27,33 +21,25 @@ pub struct PreservedSparseTrie { impl fmt::Debug for PreservedSparseTrie { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("PreservedSparseTrie") - .field("state_root", &self.state_root) + .field("block_hash", &self.block_hash) .field("anchor_hash", &self.anchor_hash) .finish_non_exhaustive() } } impl PreservedSparseTrie { - /// Creates a new anchored preserved trie. - /// - /// The `state_root` is the computed state root from the trie. The `anchor_hash` is the parent - /// block hash of the earliest overlay state covered by the trie. - pub const fn anchored(trie: SparseTrie, state_root: B256, anchor_hash: B256) -> Self { - Self { trie: PreservedSparseTrieInner::Ready(trie), state_root, anchor_hash } - } - - /// Creates a pending preserved trie and a completer that will publish the trie later. - pub fn pending(state_root: B256, anchor_hash: B256) -> (Self, PreservedSparseTrieCompleter) { - let (tx, rx) = mpsc::channel(); - ( - Self { trie: PreservedSparseTrieInner::Pending(rx), state_root, anchor_hash }, - PreservedSparseTrieCompleter { tx }, - ) + /// Associates a trie still being finalized with its completed block and pruning anchor. + pub const fn pending( + trie: Receiver, + block_hash: B256, + anchor_hash: B256, + ) -> Self { + Self { trie, block_hash, anchor_hash } } - /// Returns the state root this trie is anchored to. - pub const fn state_root(&self) -> B256 { - self.state_root + /// Returns the hash of the block whose post-state this trie represents. + pub const fn block_hash(&self) -> B256 { + self.block_hash } /// Returns the parent block hash of the earliest overlay state covered by this trie. @@ -61,29 +47,22 @@ impl PreservedSparseTrie { self.anchor_hash } - /// Consumes self and returns the trie if it can be reused for the parent state root. + /// Consumes self and returns the sparse trie and its revealed storage roots if reusable for + /// the parent block. /// - /// If the parent state root does not match the preserved trie's state root, this drops the trie - /// and returns `None` so the caller can create a fresh sparse trie. + /// If the parent block hash does not match the preserved trie's block hash, this drops the + /// trie and returns `None` so the caller can create a fresh sparse trie. pub fn into_trie_for( self, - parent_state_root: B256, - ) -> Result, PreservedSparseTrieError> { - if self.state_root == parent_state_root { - let trie = match self.trie { - PreservedSparseTrieInner::Ready(trie) => trie, - PreservedSparseTrieInner::Pending(rx) => match rx.recv() { - Ok(trie) => trie, - Err(_) => { - return Err(PreservedSparseTrieError::ProducerDropped { - state_root: self.state_root, - }) - } - }, - }; + parent_hash: B256, + ) -> Result, PreservedSparseTrieError> { + if self.block_hash == parent_hash { + let trie = self.trie.recv().map_err(|_| PreservedSparseTrieError::ProducerDropped { + block_hash: self.block_hash, + })?; debug!( target: "engine::tree::payload_processor", - state_root = %self.state_root, + block_hash = %self.block_hash, anchor_hash = %self.anchor_hash, "Reusing anchored sparse trie for continuation payload" ); @@ -91,10 +70,10 @@ impl PreservedSparseTrie { } else { debug!( target: "engine::tree::payload_processor", - anchor_root = %self.state_root, + block_hash = %self.block_hash, anchor_hash = %self.anchor_hash, - %parent_state_root, - "Dropping anchored sparse trie - parent state root mismatch" + %parent_hash, + "Dropping anchored sparse trie - parent hash mismatch" ); Ok(None) } @@ -106,16 +85,16 @@ impl PreservedSparseTrie { pub enum PreservedSparseTrieError { /// The producer of a pending preserved sparse trie dropped before publishing it. ProducerDropped { - /// The state root the pending trie was expected to represent. - state_root: B256, + /// The block hash the pending trie was expected to represent. + block_hash: B256, }, } impl fmt::Display for PreservedSparseTrieError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::ProducerDropped { state_root } => { - write!(f, "pending preserved sparse trie producer dropped for {state_root}") + Self::ProducerDropped { block_hash } => { + write!(f, "pending preserved sparse trie producer dropped for {block_hash}") } } } @@ -123,49 +102,42 @@ impl fmt::Display for PreservedSparseTrieError { impl std::error::Error for PreservedSparseTrieError {} -#[allow(clippy::large_enum_variant)] -enum PreservedSparseTrieInner { - Ready(SparseTrie), - Pending(Receiver), -} - -/// Completes a pending preserved sparse trie. -#[derive(Debug)] -pub struct PreservedSparseTrieCompleter { - tx: Sender, -} - -impl PreservedSparseTrieCompleter { - /// Publishes the trie for a pending preserved sparse trie. - pub fn complete(self, trie: SparseTrie) -> Result<(), SparseTrie> { - self.tx.send(trie).map_err(|err| err.0) - } +/// A finalized sparse trie and its revealed storage roots for the next block's proof workers. +#[derive(Debug, Default)] +pub struct SparseTrieWithStorageRoots { + /// The sparse trie after finalization and pruning. + pub trie: SparseStateTrie, + /// Roots of all revealed storage tries before pruning, keyed by hashed account address. + pub storage_roots: B256Map, } #[cfg(test)] mod tests { use super::*; + use std::sync::mpsc; #[test] - fn pending_trie_exposes_state_root_before_completion() { - let state_root = B256::with_last_byte(1); + fn pending_trie_exposes_block_hash_before_completion() { + let block_hash = B256::with_last_byte(1); let anchor_hash = B256::with_last_byte(2); - let (preserved, completer) = PreservedSparseTrie::pending(state_root, anchor_hash); + let (tx, rx) = mpsc::channel(); + let preserved = PreservedSparseTrie::pending(rx, block_hash, anchor_hash); - assert_eq!(preserved.state_root(), state_root); + assert_eq!(preserved.block_hash(), block_hash); assert_eq!(preserved.anchor_hash(), anchor_hash); - completer.complete(SparseTrie::default()).unwrap(); - assert!(preserved.into_trie_for(state_root).unwrap().is_some()); + tx.send(SparseTrieWithStorageRoots::default()).unwrap(); + assert!(preserved.into_trie_for(block_hash).unwrap().is_some()); } #[test] - fn pending_trie_with_mismatched_root_does_not_wait() { - let state_root = B256::with_last_byte(1); - let other_state_root = B256::with_last_byte(2); + fn pending_trie_with_mismatched_parent_does_not_wait() { + let block_hash = B256::with_last_byte(1); + let other_block_hash = B256::with_last_byte(2); let anchor_hash = B256::with_last_byte(3); - let (preserved, completer) = PreservedSparseTrie::pending(state_root, anchor_hash); + let (tx, rx) = mpsc::channel(); + let preserved = PreservedSparseTrie::pending(rx, block_hash, anchor_hash); - assert!(preserved.into_trie_for(other_state_root).unwrap().is_none()); - assert!(completer.complete(SparseTrie::default()).is_err()); + assert!(preserved.into_trie_for(other_block_hash).unwrap().is_none()); + assert!(tx.send(SparseTrieWithStorageRoots::default()).is_err()); } } diff --git a/crates/chain-state/src/test_utils.rs b/crates/chain-state/src/test_utils.rs index e2688390019..12a2e98b2e5 100644 --- a/crates/chain-state/src/test_utils.rs +++ b/crates/chain-state/src/test_utils.rs @@ -19,7 +19,7 @@ use reth_primitives_traits::{ Account, NodePrimitives, Recovered, RecoveredBlock, SealedBlock, SealedHeader, SignedTransaction, }; -use reth_trie::{root::state_root_unhashed, ComputedTrieData, SortedTrieData}; +use reth_trie::{root::state_root_unhashed, SortedTrieData}; use revm::{database::BundleState, state::AccountInfo}; use std::{ ops::Range, @@ -28,7 +28,7 @@ use std::{ use tokio::sync::broadcast::{self, Sender}; /// Fixed address used for storage slot writes in test blocks. -const TEST_STORAGE_ADDRESS: Address = Address::new([0xAA; 20]); +const TEST_STORAGE_ADDRESS: Address = Address::repeat_byte(0xAA); /// Fixed storage slot key used in test blocks. const TEST_STORAGE_SLOT: U256 = U256::from_limbs([1, 0, 0, 0]); @@ -240,7 +240,7 @@ impl TestBlockBuilder { }, state: BundleState::default(), }), - ComputedTrieData::default(), + SortedTrieData::default(), ); return executed; } @@ -268,7 +268,7 @@ impl TestBlockBuilder { Some(None) }; - let new_slot_value = U256::from(block_number).wrapping_add(U256::from(1)); + let new_slot_value = U256::from(block_number).wrapping_add(U256::ONE); let bundle = BundleState::builder(block_number..=block_number) .state_present_account_info(self.signer, post_info.clone()) @@ -309,9 +309,8 @@ impl TestBlockBuilder { receipts.into_iter().flatten().collect() }; - let trie_data = ComputedTrieData { - sorted: SortedTrieData { hashed_state: Arc::new(hashed_state), ..Default::default() }, - }; + let trie_data = + SortedTrieData { hashed_state: Arc::new(hashed_state), ..Default::default() }; let block_hash = recovered.hash(); let executed = ExecutedBlock::new( diff --git a/crates/chainspec/src/lib.rs b/crates/chainspec/src/lib.rs index 458f464d87c..bdfc5b0363e 100644 --- a/crates/chainspec/src/lib.rs +++ b/crates/chainspec/src/lib.rs @@ -51,13 +51,13 @@ mod tests { #[test] fn test_named_id() { - let chain = Chain::from_named(NamedChain::Holesky); + let chain = Chain::holesky(); assert_eq!(chain.id(), 17000); } #[test] fn test_display_named_chain() { - let chain = Chain::from_named(NamedChain::Mainnet); + let chain = Chain::mainnet(); assert_eq!(format!("{chain}"), "mainnet"); } @@ -78,7 +78,7 @@ mod tests { #[test] fn test_into_u256() { - let chain = Chain::from_named(NamedChain::Holesky); + let chain = Chain::holesky(); let n: U256 = U256::from(chain.id()); let expected = U256::from(17000); @@ -88,7 +88,7 @@ mod tests { #[test] fn test_from_str_named_chain() { let result = Chain::from_str("mainnet"); - let expected = Chain::from_named(NamedChain::Mainnet); + let expected = Chain::mainnet(); assert!(result.is_ok()); assert_eq!(result.unwrap(), expected); @@ -113,7 +113,7 @@ mod tests { #[test] fn test_default() { let default = Chain::default(); - let expected = Chain::from_named(NamedChain::Mainnet); + let expected = Chain::mainnet(); assert_eq!(default, expected); } @@ -128,14 +128,14 @@ mod tests { #[test] fn test_dns_main_network() { let s = "enrtree://AKA3AM6LPBYEUDMVNU3BSVQJ5AD45Y7YPOHJLEF6W26QOE4VTUDPE@all.mainnet.ethdisco.net"; - let chain: Chain = NamedChain::Mainnet.into(); + let chain = Chain::mainnet(); assert_eq!(s, chain.public_dns_network_protocol().unwrap().as_str()); } #[test] fn test_dns_holesky_network() { let s = "enrtree://AKA3AM6LPBYEUDMVNU3BSVQJ5AD45Y7YPOHJLEF6W26QOE4VTUDPE@all.holesky.ethdisco.net"; - let chain: Chain = NamedChain::Holesky.into(); + let chain = Chain::holesky(); assert_eq!(s, chain.public_dns_network_protocol().unwrap().as_str()); } diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 58c772bf592..da300c235bd 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -189,10 +189,10 @@ pub static HOLESKY: LazyLock> = LazyLock::new(|| { HOLESKY_GENESIS_HASH, ), genesis, - paris_block_and_final_difficulty: Some((0, U256::from(1))), + paris_block_and_final_difficulty: Some((0, U256::ONE)), hardforks, deposit_contract: Some(DepositContract::new( - address!("0x4242424242424242424242424242424242424242"), + Address::repeat_byte(0x42), 0, b256!("0x649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c5"), )), @@ -221,7 +221,7 @@ pub static HOODI: LazyLock> = LazyLock::new(|| { HOODI_GENESIS_HASH, ), genesis, - paris_block_and_final_difficulty: Some((0, U256::from(0))), + paris_block_and_final_difficulty: Some((0, U256::ZERO)), hardforks, deposit_contract: Some(DepositContract::new( address!("0x00000000219ab540356cBB839Cbe05303d7705Fa"), @@ -251,7 +251,7 @@ pub static DEV: LazyLock> = LazyLock::new(|| { chain: Chain::dev(), genesis_header: SealedHeader::seal_slow(make_genesis_header(&genesis, &hardforks)), genesis, - paris_block_and_final_difficulty: Some((0, U256::from(0))), + paris_block_and_final_difficulty: Some((0, U256::ZERO)), hardforks, base_fee_params: BaseFeeParamsKind::Constant(BaseFeeParams::ethereum()), deposit_contract: None, // TODO: do we even have? @@ -272,12 +272,7 @@ pub fn create_chain_config( let block_num = |fork: EthereumHardfork| hardforks.fork(fork).block_number(); // Helper to extract timestamp from a hardfork condition - let timestamp = |fork: EthereumHardfork| -> Option { - match hardforks.fork(fork) { - ForkCondition::Timestamp(t) => Some(t), - _ => None, - } - }; + let timestamp = |fork: EthereumHardfork| hardforks.fork(fork).as_timestamp(); // Extract TTD from Paris fork let (terminal_total_difficulty, terminal_total_difficulty_passed) = @@ -352,9 +347,7 @@ pub fn blob_params_to_schedule( let bpo_forks = EthereumHardfork::bpo_variants(); for (timestamp, blob_params) in ¶ms.scheduled { for bpo_fork in bpo_forks { - if let ForkCondition::Timestamp(fork_ts) = hardforks.fork(bpo_fork) && - fork_ts == *timestamp - { + if hardforks.fork(bpo_fork).as_timestamp() == Some(*timestamp) { schedule.insert(bpo_fork.name().to_lowercase(), *blob_params); break; } @@ -532,7 +525,7 @@ impl ChainSpec { self.genesis.base_fee_per_gas.map(|fee| fee as u64).unwrap_or(INITIAL_BASE_FEE); // If London is activated at genesis, we set the initial base fee as per EIP-1559. - self.hardforks.fork(EthereumHardfork::London).active_at_block(0).then_some(genesis_base_fee) + self.is_london_active_at_block(0).then_some(genesis_base_fee) } /// Get the [`BaseFeeParams`] for the chain at the given timestamp. @@ -582,19 +575,16 @@ impl ChainSpec { // Create an iterator with hardfork, condition, and optional blob metadata let hardforks_with_meta = self.hardforks.forks_iter().map(|(fork, condition)| { // Generate blob metadata for timestamp-based hardforks that have blob params - let metadata = match condition { - ForkCondition::Timestamp(timestamp) => { - // Try to get blob params for this timestamp - // This automatically handles all hardforks with blob support - EthChainSpec::blob_params_at_timestamp(self, timestamp).map(|params| { - format!( - "blob: (target: {}, max: {}, fraction: {})", - params.target_blob_count, params.max_blob_count, params.update_fraction - ) - }) - } - _ => None, - }; + let metadata = condition.as_timestamp().and_then(|timestamp| { + // Try to get blob params for this timestamp + // This automatically handles all hardforks with blob support + EthChainSpec::blob_params_at_timestamp(self, timestamp).map(|params| { + format!( + "blob: (target: {}, max: {}, fraction: {})", + params.target_blob_count, params.max_blob_count, params.update_fraction + ) + }) + }); (fork, condition, metadata) }); @@ -1311,6 +1301,7 @@ pub fn test_fork_ids(spec: &ChainSpec, cases: &[(Head, ForkId)]) { #[cfg(test)] mod tests { + use super::*; use alloy_chains::Chain; use alloy_consensus::constants::ETH_TO_WEI; @@ -1321,7 +1312,7 @@ mod tests { use alloy_trie::{TrieAccount, EMPTY_ROOT_HASH}; use core::ops::Deref; use reth_ethereum_forks::{ForkCondition, ForkHash, ForkId, Head}; - use std::{collections::HashMap, str::FromStr}; + use std::collections::HashMap; fn test_hardfork_fork_ids(spec: &ChainSpec, cases: &[(EthereumHardfork, ForkId)]) { for (hardfork, expected_id) in cases { @@ -1837,6 +1828,60 @@ Post-merge hard forks (timestamp based): next: hoodi::HOODI_BPO1_TIMESTAMP, }, ), + // First BPO1 block. + ( + Head { + number: 0, + timestamp: hoodi::HOODI_BPO1_TIMESTAMP, + ..Default::default() + }, + ForkId { + hash: ForkHash(hex!("0x3893353e")), + next: hoodi::HOODI_BPO2_TIMESTAMP, + }, + ), + // First BPO2 block. + ( + Head { + number: 0, + timestamp: hoodi::HOODI_BPO2_TIMESTAMP, + ..Default::default() + }, + ForkId { + hash: ForkHash(hex!("0x23aa1351")), + next: hoodi::HOODI_AMSTERDAM_TIMESTAMP, + }, + ), + // Last block before Amsterdam. + ( + Head { + number: 0, + timestamp: hoodi::HOODI_AMSTERDAM_TIMESTAMP - 1, + ..Default::default() + }, + ForkId { + hash: ForkHash(hex!("0x23aa1351")), + next: hoodi::HOODI_AMSTERDAM_TIMESTAMP, + }, + ), + // First Amsterdam block. + ( + Head { + number: 0, + timestamp: hoodi::HOODI_AMSTERDAM_TIMESTAMP, + ..Default::default() + }, + ForkId { hash: ForkHash(hex!("0x3d068b59")), next: 0 }, + ), + // After Amsterdam. + ( + Head { + number: 0, + timestamp: hoodi::HOODI_AMSTERDAM_TIMESTAMP + 1, + ..Default::default() + }, + ForkId { hash: ForkHash(hex!("0x3d068b59")), next: 0 }, + ), ], ) } @@ -2421,13 +2466,13 @@ Post-merge hard forks (timestamp based): } let expected_state_root: B256 = - hex!("0x078dc6061b1d8eaa8493384b59c9c65ceb917201221d08b80c4de6770b6ec7e7").into(); + b256!("0x078dc6061b1d8eaa8493384b59c9c65ceb917201221d08b80c4de6770b6ec7e7"); assert_eq!(chainspec.genesis_header().state_root, expected_state_root); assert_eq!(chainspec.genesis_header().withdrawals_root, Some(EMPTY_ROOT_HASH)); let expected_hash: B256 = - hex!("0x1fc027d65f820d3eef441ebeec139ebe09e471cf98516dce7b5643ccb27f418c").into(); + b256!("0x1fc027d65f820d3eef441ebeec139ebe09e471cf98516dce7b5643ccb27f418c"); let hash = chainspec.genesis_hash(); assert_eq!(hash, expected_hash); } @@ -2492,9 +2537,9 @@ Post-merge hard forks (timestamp based): let genesis = serde_json::from_str::(hive_json).unwrap(); let chainspec: ChainSpec = genesis.into(); - assert_eq!(chainspec.chain, Chain::from_named(NamedChain::Optimism)); + assert_eq!(chainspec.chain, Chain::optimism_mainnet()); let expected_state_root: B256 = - hex!("0x9a6049ac535e3dc7436c189eaa81c73f35abd7f282ab67c32944ff0301d63360").into(); + b256!("0x9a6049ac535e3dc7436c189eaa81c73f35abd7f282ab67c32944ff0301d63360"); assert_eq!(chainspec.genesis_header().state_root, expected_state_root); let hard_forks = vec![ EthereumHardfork::Byzantium, @@ -2508,7 +2553,7 @@ Post-merge hard forks (timestamp based): } let expected_hash: B256 = - hex!("0x5ae31c6522bd5856129f66be3d582b842e4e9faaa87f21cce547128339a9db3c").into(); + b256!("0x5ae31c6522bd5856129f66be3d582b842e4e9faaa87f21cce547128339a9db3c"); let hash = chainspec.genesis_header().hash_slow(); assert_eq!(hash, expected_hash); } @@ -2650,11 +2695,9 @@ Post-merge hard forks (timestamp based): fn test_parse_genesis_json() { let s = r#"{"config":{"ethash":{},"chainId":1337,"homesteadBlock":0,"eip150Block":0,"eip155Block":0,"eip158Block":0,"byzantiumBlock":0,"constantinopleBlock":0,"petersburgBlock":0,"istanbulBlock":0,"berlinBlock":0,"londonBlock":0,"terminalTotalDifficulty":0,"terminalTotalDifficultyPassed":true,"shanghaiTime":0},"nonce":"0x0","timestamp":"0x0","extraData":"0x","gasLimit":"0x4c4b40","difficulty":"0x1","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","coinbase":"0x0000000000000000000000000000000000000000","alloc":{"658bdf435d810c91414ec09147daa6db62406379":{"balance":"0x487a9a304539440000"},"aa00000000000000000000000000000000000000":{"code":"0x6042","storage":{"0x0000000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000000","0x0100000000000000000000000000000000000000000000000000000000000000":"0x0100000000000000000000000000000000000000000000000000000000000000","0x0200000000000000000000000000000000000000000000000000000000000000":"0x0200000000000000000000000000000000000000000000000000000000000000","0x0300000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000303"},"balance":"0x1","nonce":"0x1"},"bb00000000000000000000000000000000000000":{"code":"0x600154600354","storage":{"0x0000000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000000","0x0100000000000000000000000000000000000000000000000000000000000000":"0x0100000000000000000000000000000000000000000000000000000000000000","0x0200000000000000000000000000000000000000000000000000000000000000":"0x0200000000000000000000000000000000000000000000000000000000000000","0x0300000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000303"},"balance":"0x2","nonce":"0x1"}},"number":"0x0","gasUsed":"0x0","parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","baseFeePerGas":"0x1337"}"#; let genesis: Genesis = serde_json::from_str(s).unwrap(); - let acc = genesis - .alloc - .get(&"0xaa00000000000000000000000000000000000000".parse::
().unwrap()) - .unwrap(); - assert_eq!(acc.balance, U256::from(1)); + let acc = + genesis.alloc.get(&address!("0xaa00000000000000000000000000000000000000")).unwrap(); + assert_eq!(acc.balance, U256::ONE); assert_eq!(genesis.base_fee_per_gas, Some(0x1337)); } @@ -2662,11 +2705,9 @@ Post-merge hard forks (timestamp based): fn test_parse_cancun_genesis_json() { let s = r#"{"config":{"ethash":{},"chainId":1337,"homesteadBlock":0,"eip150Block":0,"eip155Block":0,"eip158Block":0,"byzantiumBlock":0,"constantinopleBlock":0,"petersburgBlock":0,"istanbulBlock":0,"berlinBlock":0,"londonBlock":0,"terminalTotalDifficulty":0,"terminalTotalDifficultyPassed":true,"shanghaiTime":0,"cancunTime":4661},"nonce":"0x0","timestamp":"0x0","extraData":"0x","gasLimit":"0x4c4b40","difficulty":"0x1","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","coinbase":"0x0000000000000000000000000000000000000000","alloc":{"658bdf435d810c91414ec09147daa6db62406379":{"balance":"0x487a9a304539440000"},"aa00000000000000000000000000000000000000":{"code":"0x6042","storage":{"0x0000000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000000","0x0100000000000000000000000000000000000000000000000000000000000000":"0x0100000000000000000000000000000000000000000000000000000000000000","0x0200000000000000000000000000000000000000000000000000000000000000":"0x0200000000000000000000000000000000000000000000000000000000000000","0x0300000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000303"},"balance":"0x1","nonce":"0x1"},"bb00000000000000000000000000000000000000":{"code":"0x600154600354","storage":{"0x0000000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000000","0x0100000000000000000000000000000000000000000000000000000000000000":"0x0100000000000000000000000000000000000000000000000000000000000000","0x0200000000000000000000000000000000000000000000000000000000000000":"0x0200000000000000000000000000000000000000000000000000000000000000","0x0300000000000000000000000000000000000000000000000000000000000000":"0x0000000000000000000000000000000000000000000000000000000000000303"},"balance":"0x2","nonce":"0x1"}},"number":"0x0","gasUsed":"0x0","parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","baseFeePerGas":"0x3b9aca00"}"#; let genesis: Genesis = serde_json::from_str(s).unwrap(); - let acc = genesis - .alloc - .get(&"0xaa00000000000000000000000000000000000000".parse::
().unwrap()) - .unwrap(); - assert_eq!(acc.balance, U256::from(1)); + let acc = + genesis.alloc.get(&address!("0xaa00000000000000000000000000000000000000")).unwrap(); + assert_eq!(acc.balance, U256::ONE); // assert that the cancun time was picked up assert_eq!(genesis.config.cancun_time, Some(4661)); } @@ -2677,11 +2718,9 @@ Post-merge hard forks (timestamp based): let genesis: Genesis = serde_json::from_str(s).unwrap(); // assert that the alloc was picked up - let acc = genesis - .alloc - .get(&"0xaa00000000000000000000000000000000000000".parse::
().unwrap()) - .unwrap(); - assert_eq!(acc.balance, U256::from(1)); + let acc = + genesis.alloc.get(&address!("0xaa00000000000000000000000000000000000000")).unwrap(); + assert_eq!(acc.balance, U256::ONE); // assert that the cancun time was picked up assert_eq!(genesis.config.cancun_time, Some(4661)); // assert that the prague time was picked up @@ -2694,11 +2733,9 @@ Post-merge hard forks (timestamp based): let genesis: Genesis = serde_json::from_str(s).unwrap(); // assert that the alloc was picked up - let acc = genesis - .alloc - .get(&"0xaa00000000000000000000000000000000000000".parse::
().unwrap()) - .unwrap(); - assert_eq!(acc.balance, U256::from(1)); + let acc = + genesis.alloc.get(&address!("0xaa00000000000000000000000000000000000000")).unwrap(); + assert_eq!(acc.balance, U256::ONE); // assert that the cancun time was picked up assert_eq!(genesis.config.cancun_time, Some(4661)); } @@ -2727,8 +2764,7 @@ Post-merge hard forks (timestamp based): // set the state root to the same as in the hive test the hash was pulled from header.state_root = - B256::from_str("0x62e2595e017f0ca23e08d17221010721a71c3ae932f4ea3cb12117786bb392d4") - .unwrap(); + b256!("0x62e2595e017f0ca23e08d17221010721a71c3ae932f4ea3cb12117786bb392d4"); // shanghai is activated so we should have a withdrawals root assert_eq!(header.withdrawals_root, Some(EMPTY_WITHDRAWALS)); @@ -2812,7 +2848,7 @@ Post-merge hard forks (timestamp based): }; // seed accounts after genesis struct created - let address = hex!("0x6Be02d1d3665660d22FF9624b7BE0551ee1Ac91b").into(); + let address = address!("0x6Be02d1d3665660d22FF9624b7BE0551ee1Ac91b"); let account = GenesisAccount::default().with_balance(U256::from(33)); let genesis = genesis.extend_accounts(HashMap::from([(address, account)])); @@ -2865,8 +2901,8 @@ Post-merge hard forks (timestamp based): #[test] fn latest_hoodi_mainnet_fork_id() { - // BPO2 - assert_eq!(ForkId { hash: ForkHash(hex!("0x23aa1351")), next: 0 }, HOODI.latest_fork_id()) + // Amsterdam. + assert_eq!(ForkId { hash: ForkHash(hex!("0x3d068b59")), next: 0 }, HOODI.latest_fork_id()) } #[test] diff --git a/crates/cli/commands/Cargo.toml b/crates/cli/commands/Cargo.toml index f28d39c3218..838a65b609c 100644 --- a/crates/cli/commands/Cargo.toml +++ b/crates/cli/commands/Cargo.toml @@ -89,7 +89,7 @@ tokio-stream.workspace = true reqwest = { workspace = true, features = ["blocking"] } url.workspace = true metrics.workspace = true -blake3.workspace = true +blake3 = { workspace = true, features = ["rayon"] } rayon.workspace = true # io diff --git a/crates/cli/commands/src/common.rs b/crates/cli/commands/src/common.rs index 77cd552633f..832713000b3 100644 --- a/crates/cli/commands/src/common.rs +++ b/crates/cli/commands/src/common.rs @@ -4,6 +4,7 @@ pub use reth_primitives_traits::header::HeaderMut; use alloy_primitives::B256; use clap::Parser; +use eyre::WrapErr; use reth_chainspec::EthChainSpec; use reth_cli::chainspec::ChainSpecParser; use reth_config::{config::EtlConfig, Config}; @@ -111,11 +112,8 @@ impl EnvironmentArgs { let config_path = self.config.clone().unwrap_or_else(|| data_dir.config()); - let mut config = Config::from_path(config_path) - .inspect_err( - |err| warn!(target: "reth::cli", %err, "Failed to load config file, using default"), - ) - .unwrap_or_default(); + let mut config = Config::from_path(&config_path) + .wrap_err_with(|| format!("Could not load config file {config_path:?}"))?; // Make sure ETL doesn't default to /tmp/, but to whatever datadir is set to if config.stages.etl.dir.is_none() { @@ -371,7 +369,7 @@ where #[cfg(test)] mod tests { - use super::AccessRights; + use super::*; #[test] fn inconsistent_access_rights_skip_consistency_checks() { diff --git a/crates/cli/commands/src/db/account_storage.rs b/crates/cli/commands/src/db/account_storage.rs index d307ac5a0fa..3319f495911 100644 --- a/crates/cli/commands/src/db/account_storage.rs +++ b/crates/cli/commands/src/db/account_storage.rs @@ -119,6 +119,7 @@ impl Command { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::address; #[test] fn parse_address_arg() { @@ -127,9 +128,6 @@ mod tests { "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", ]) .unwrap(); - assert_eq!( - cmd.address, - "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".parse::
().unwrap() - ); + assert_eq!(cmd.address, address!("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")); } } diff --git a/crates/cli/commands/src/db/checksum/rocksdb.rs b/crates/cli/commands/src/db/checksum/rocksdb.rs index d2d06d72511..0821126912e 100644 --- a/crates/cli/commands/src/db/checksum/rocksdb.rs +++ b/crates/cli/commands/src/db/checksum/rocksdb.rs @@ -15,7 +15,7 @@ use tracing::info; /// RocksDB tables that can be checksummed. #[derive(Debug, Clone, Copy, ValueEnum)] -pub enum RocksDbTable { +pub(crate) enum RocksDbTable { /// Transaction hash to transaction number mapping TransactionHashNumbers, /// Account history indices diff --git a/crates/cli/commands/src/db/get.rs b/crates/cli/commands/src/db/get.rs index e81057bbae3..7c5a769c1bb 100644 --- a/crates/cli/commands/src/db/get.rs +++ b/crates/cli/commands/src/db/get.rs @@ -140,8 +140,10 @@ impl Command { } Subcommand::StaticFile { segment, key, subkey, raw } => { if let StaticFileSegment::StorageChangeSets = segment { - let storage_key = - table_subkey::(subkey.as_deref()).ok(); + let storage_key = subkey + .as_deref() + .map(table_subkey::) + .transpose()?; let key = table_key::(&key)?; let provider = tool.provider_factory.static_file_provider(); @@ -196,8 +198,10 @@ impl Command { TransactionSenderMask::MASK, ), StaticFileSegment::AccountChangeSets => { - let subkey = - table_subkey::(subkey.as_deref()).ok(); + let subkey = subkey + .as_deref() + .map(table_subkey::) + .transpose()?; ( table_key::(&key)?, subkey, @@ -339,6 +343,12 @@ fn parse_address(key: &str) -> eyre::Result
{ stripped.parse::
().map_err(|e| eyre::eyre!("failed to parse address: {e}")) } +/// Returns true if the key is shaped like a hex address, so a parse failure is a typo rather than +/// a JSON key. +fn looks_like_address(key: &str) -> bool { + key.trim_matches('"').starts_with("0x") +} + /// Gets account history from RocksDB with ergonomic key parsing. /// /// Accepts a plain address and uses seek to find the relevant shard. @@ -386,6 +396,7 @@ fn get_rocksdb_account_history( println!("{}", serde_json::to_string_pretty(&v)?); } } + Some(Err(e)) => return Err(e.into()), _ => { error!(target: "reth::cli", "No content for the given table key."); } @@ -393,6 +404,7 @@ fn get_rocksdb_account_history( } Ok(()) } + Err(e) if looks_like_address(key) => Err(e), Err(_) => { // Fall back to full JSON key parsing (e.g. // `{"key":"0x...","highest_block_number":...}`) @@ -458,6 +470,7 @@ fn get_rocksdb_storage_history( println!("{}", serde_json::to_string_pretty(&v)?); } } + Some(Err(e)) => return Err(e.into()), _ => { error!(target: "reth::cli", "No content for the given table key."); } @@ -465,6 +478,7 @@ fn get_rocksdb_storage_history( } Ok(()) } + Err(e) if looks_like_address(key) => Err(e), Err(_) => { if all_shards || block.is_some() || storage_key.is_some() { return Err(eyre::eyre!( @@ -507,8 +521,8 @@ pub(crate) fn table_key(key: &str) -> Result { } /// Get an instance of subkey for given dupsort table -fn table_subkey(subkey: Option<&str>) -> Result { - serde_json::from_str(subkey.unwrap_or_default()).map_err(|e| eyre::eyre!(e)) +fn table_subkey(subkey: &str) -> Result { + serde_json::from_str(subkey).map_err(|e| eyre::eyre!(e)) } struct GetValueViewer<'a, N: NodeTypesWithDB> { @@ -597,14 +611,12 @@ impl TableViewer<()> for GetValueViewer<'_, N> { // Check if we're doing a range query if let Some(ref end_key_str) = self.end_key { let end_key = table_key::(end_key_str)?; - let start_subkey = table_subkey::(Some( - self.subkey.as_ref().expect("must have been given if end_key is given").as_str(), - ))?; - let end_subkey_parsed = self - .end_subkey - .as_ref() - .map(|s| table_subkey::(Some(s.as_str()))) - .transpose()?; + let Some(start_subkey) = self.subkey.as_deref() else { + eyre::bail!("a subkey is required when end_key is given"); + }; + let start_subkey = table_subkey::(start_subkey)?; + let end_subkey_parsed = + self.end_subkey.as_deref().map(table_subkey::).transpose()?; self.tool.provider_factory.db_ref().view(|tx| { let mut cursor = tx.cursor_dup_read::()?; @@ -655,7 +667,10 @@ impl TableViewer<()> for GetValueViewer<'_, N> { })??; } else { // Single key/subkey lookup - let subkey = table_subkey::(self.subkey.as_deref())?; + let Some(subkey) = self.subkey.as_deref() else { + eyre::bail!("a subkey is required for dupsort tables"); + }; + let subkey = table_subkey::(subkey)?; let content = if self.raw { self.tool @@ -693,14 +708,14 @@ pub(crate) fn maybe_json_value_parser(value: &str) -> Result(r#"{ "address": "0x01957911244e546ce519fbac6f798958fafadb41", "sharded_key": { "key": "0x0000000000000000000000000000000000000000000000000000000000000003", "highest_block_number": 18446744073709551615 } }"#).unwrap(), StorageShardedKey::new( address!("0x01957911244e546ce519fbac6f798958fafadb41"), - B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000003" - ) - .unwrap(), + B256::with_last_byte(3), 18446744073709551615 ) ); diff --git a/crates/cli/commands/src/db/migrate_v2.rs b/crates/cli/commands/src/db/migrate_v2.rs index 8bb31a89b56..64d77631ece 100644 --- a/crates/cli/commands/src/db/migrate_v2.rs +++ b/crates/cli/commands/src/db/migrate_v2.rs @@ -156,6 +156,10 @@ impl Command { entries.push(entry); } + if matches!(walker.peek(), Some(Err(_))) { + walker.next().expect("peeked")?; + } + count += entries.len() as u64; writer.append_account_changeset(entries, block)?; } @@ -206,6 +210,10 @@ impl Command { }); } + if matches!(walker.peek(), Some(Err(_))) { + walker.next().expect("peeked")?; + } + count += entries.len() as u64; writer.append_storage_changeset(entries, block)?; } @@ -426,10 +434,19 @@ impl Command { info!(target: "reth::cli", ?db_path, ?compact_path, "Swapping compacted database"); - std::fs::rename(db_path, &backup_path)?; + reth_fs_util::rename(db_path, &backup_path)?; - if let Err(e) = std::fs::rename(compact_path, db_path) { - let _ = std::fs::rename(&backup_path, db_path); + if let Err(e) = reth_fs_util::rename(compact_path, db_path) { + if let Err(restore_err) = reth_fs_util::rename(&backup_path, db_path) { + eyre::bail!( + "failed to move compacted database into place ({e}) and failed to restore \ + the original ({restore_err}); the original database is now at {} and the \ + compacted copy is at {}; move the original back to {} manually", + backup_path.display(), + compact_path.display(), + db_path.display(), + ); + } return Err(e.into()); } diff --git a/crates/cli/commands/src/db/state.rs b/crates/cli/commands/src/db/state.rs index abac9fe0b08..edd94dc6a22 100644 --- a/crates/cli/commands/src/db/state.rs +++ b/crates/cli/commands/src/db/state.rs @@ -8,7 +8,6 @@ use reth_db_api::{ transaction::DbTx, }; use reth_db_common::DbTool; -use reth_node_builder::NodeTypesWithDB; use reth_provider::{ providers::{BlockchainProvider, ProviderNodeTypes}, StaticFileProviderFactory, @@ -46,10 +45,7 @@ pub struct Command { impl Command { /// Execute `db state` command - pub fn execute( - self, - tool: &DbTool, - ) -> eyre::Result<()> { + pub fn execute(self, tool: &DbTool) -> eyre::Result<()> { let address = self.address; let limit = self.limit; @@ -60,7 +56,7 @@ impl Command { } } - fn execute_current( + fn execute_current( &self, tool: &DbTool, address: Address, @@ -78,7 +74,7 @@ impl Command { let mut last_log = Instant::now(); for (idx, entry) in walker.enumerate() { let (_, storage_entry) = entry?; - if storage_entry.value != U256::ZERO { + if !storage_entry.value.is_zero() { entries.push((storage_entry.key, storage_entry.value)); } if entries.len() >= limit { @@ -105,7 +101,7 @@ impl Command { let mut last_log = Instant::now(); for (idx, entry) in walker.enumerate() { let (_, storage_entry) = entry?; - if storage_entry.value != U256::ZERO { + if !storage_entry.value.is_zero() { entries.push((storage_entry.key, storage_entry.value)); } if entries.len() >= limit { @@ -134,7 +130,7 @@ impl Command { Ok(()) } - fn execute_historical( + fn execute_historical( &self, tool: &DbTool, address: Address, @@ -176,7 +172,7 @@ impl Command { for (idx, key) in storage_keys.iter().enumerate() { match provider.storage(address, *key) { - Ok(Some(value)) if value != U256::ZERO => { + Ok(Some(value)) if !value.is_zero() => { entries.push((*key, value)); } _ => {} @@ -206,7 +202,7 @@ impl Command { } /// Collects storage keys from static file StorageChangeSets (storage_v2). - fn collect_staticfile_storage_keys( + fn collect_staticfile_storage_keys( &self, tool: &DbTool, address: Address, @@ -263,7 +259,7 @@ impl Command { } /// Collects storage keys from MDBX StorageChangeSets using parallel block range scanning. - fn collect_mdbx_storage_keys_parallel( + fn collect_mdbx_storage_keys_parallel( &self, tool: &DbTool, address: Address, @@ -341,14 +337,12 @@ impl Command { let mut local_keys = BTreeSet::new(); let mut entries_in_chunk = 0usize; - if let Ok(walker) = changeset_cursor.walk_range(start_key..=end_key) - { - for (block_addr, storage_entry) in walker.flatten() { - if block_addr.address() == address { - local_keys.insert(storage_entry.key); - } - entries_in_chunk += 1; + for entry in changeset_cursor.walk_range(start_key..=end_key)? { + let (block_addr, storage_entry) = entry?; + if block_addr.address() == address { + local_keys.insert(storage_entry.key); } + entries_in_chunk += 1; } // Merge into global state @@ -468,6 +462,7 @@ pub enum OutputFormat { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::address; #[test] fn parse_state_args() { @@ -478,10 +473,7 @@ mod tests { "1000000", ]) .unwrap(); - assert_eq!( - cmd.address, - "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".parse::
().unwrap() - ); + assert_eq!(cmd.address, address!("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")); assert_eq!(cmd.block, Some(1000000)); } diff --git a/crates/cli/commands/src/download/archive.rs b/crates/cli/commands/src/download/archive.rs index c4e46e1ddbf..beca5b0688d 100644 --- a/crates/cli/commands/src/download/archive.rs +++ b/crates/cli/commands/src/download/archive.rs @@ -2,7 +2,7 @@ use super::{ extract::{extract_archive_raw, streaming_download_and_extract, CompressionFormat}, fetch::ArchiveFetcher, manifest::SnapshotArchive, - planning::{PlannedArchive, PlannedDownloads}, + planning::{CheckedDownloads, PlannedArchive}, progress::{ spawn_progress_display, ArchiveDownloadProgress, ArchiveExtractionProgress, ArchiveVerificationProgress, DownloadRequestLimiter, SharedProgress, @@ -15,11 +15,7 @@ use eyre::Result; use futures::stream::{self, StreamExt}; use reth_cli_util::cancellation::CancellationToken; use reth_fs_util as fs; -use std::{ - path::Path, - sync::{atomic::Ordering, Arc}, - time::Duration, -}; +use std::{path::Path, sync::Arc, time::Duration}; use tokio::task; use tracing::{debug, info, warn}; @@ -27,7 +23,7 @@ const DOWNLOAD_CACHE_DIR: &str = ".download-cache"; /// Runs all planned modular archive downloads for one command invocation. pub(crate) async fn run_modular_downloads( - planned_downloads: PlannedDownloads, + planned_downloads: CheckedDownloads, target_dir: &Path, static_files_dir: Option<&Path>, download_concurrency: usize, @@ -74,13 +70,17 @@ impl ModularDownloadJob { } /// Runs all planned archives and waits for the shared progress task to finish. - async fn run(self, planned_downloads: PlannedDownloads) -> Result<()> { + async fn run(self, planned_downloads: CheckedDownloads) -> Result<()> { let shared = Arc::clone( self.ctx.session().progress().expect("modular downloads always use shared progress"), ); let progress_handle = spawn_progress_display(Arc::clone(&shared)); + for reused in &planned_downloads.reused { + info!(target: "reth::cli", file = %reused.archive.file_name, component = %reused.component, "Skipping already verified plain files"); + shared.record_reused_archive(reused.archive.size, reused.archive.output_size()); + } let ctx = self.ctx.clone(); - let results: Vec> = stream::iter(planned_downloads.archives) + let results: Vec> = stream::iter(planned_downloads.pending) .map(move |archive| { let ctx = ctx.clone(); async move { Self::process_archive(ctx, archive).await } @@ -89,7 +89,7 @@ impl ModularDownloadJob { .collect() .await; - shared.done.store(true, Ordering::Relaxed); + shared.done.notify_one(); let _ = progress_handle.await; for result in results { @@ -121,7 +121,7 @@ enum ArchiveAttemptState { Fail, } -/// Processes one modular archive from reuse check through extraction and verification. +/// Processes one modular archive through fetch, extraction, and verification. struct ArchiveProcessor { /// The concrete archive and component being processed. archive: PlannedArchive, @@ -136,13 +136,10 @@ impl ArchiveProcessor { } /// Runs the archive retry state machine until outputs are verified or retries are exhausted. + /// + /// Callers only pass archives that failed the startup reuse check. fn run(self) -> Result<()> { let archive = self.archive(); - if self.try_reuse_outputs()? { - info!(target: "reth::cli", file = %archive.file_name, component = %self.archive.component, "Skipping already verified plain files"); - return Ok(()); - } - let mode = ArchiveMode::new(&self.ctx)?; let format = CompressionFormat::from_url(&archive.file_name)?; let mut attempt = 1; @@ -228,33 +225,11 @@ impl ArchiveProcessor { OutputVerifier::new(self.ctx.target_dir(), self.ctx.static_files_dir()) } - /// Returns `true` if this archive can be reused from existing verified outputs. - /// Returns `false` if a fresh archive attempt is still needed. - fn try_reuse_outputs(&self) -> Result { - if self.verify_outputs()? { - self.mark_complete(); - return Ok(true); - } - - Ok(false) - } - /// Removes any partial outputs before a fresh archive attempt. fn cleanup_outputs(&self) { self.output_verifier().cleanup(&self.archive().output_files); } - /// Returns `true` if all declared plain outputs verify. - /// Returns `false` if any output is missing or does not match. - fn verify_outputs(&self) -> Result { - self.output_verifier().verify(&self.archive().output_files) - } - - /// Records archive completion in shared progress once outputs verify. - fn mark_complete(&self) { - self.ctx.session().record_reused_archive(self.archive().size, self.archive().output_size()); - } - /// Executes one archive attempt according to the selected cache-vs-stream mode. fn run_attempt(&self, mode: ArchiveMode, format: CompressionFormat) -> Result<()> { mode.execute(self, format) @@ -336,7 +311,7 @@ impl ArchiveProcessor { ArchiveVerificationProgress::new(self.ctx.session().progress()); let verified = self .output_verifier() - .verify_with_progress(&self.archive().output_files, Some(&mut verification_progress))?; + .verify_with_progress(&self.archive().output_files, Some(&verification_progress))?; if verified { verification_progress.complete(self.archive().output_size()); } diff --git a/crates/cli/commands/src/download/config_gen.rs b/crates/cli/commands/src/download/config_gen.rs index 2eda1df907b..f81922f4f7d 100644 --- a/crates/cli/commands/src/download/config_gen.rs +++ b/crates/cli/commands/src/download/config_gen.rs @@ -43,6 +43,7 @@ pub fn write_config(config: &Config, data_dir: &Path) -> eyre::Result { pub(crate) fn write_prune_checkpoints_tx( tx: &Tx, config: &Config, + selections: &BTreeMap, snapshot_block: u64, ) -> eyre::Result<()> where @@ -72,8 +73,36 @@ where tx.get::(snapshot_block)?.map(|indices| indices.last_tx_num()); for (segment, prune_mode) in &checkpoints { + let (block_number, tx_number) = match segment { + // Record the history actually selected, not the future pruning policy, which may + // retain a minimum distance even when no changesets were downloaded. + PruneSegment::AccountHistory | PruneSegment::StorageHistory => { + let component = if *segment == PruneSegment::AccountHistory { + SnapshotComponentType::AccountChangesets + } else { + SnapshotComponentType::StorageChangesets + }; + let target = match selections.get(&component) { + Some(ComponentSelection::All) => None, + Some(ComponentSelection::Distance(distance)) => { + snapshot_block.checked_sub(*distance) + } + Some(ComponentSelection::Since(block)) => { + block.checked_sub(1).map(|target| target.min(snapshot_block)) + } + Some(ComponentSelection::None) | None => Some(snapshot_block), + }; + let Some(target) = target else { continue }; + // Downloading a wider range cannot restore history already pruned by the source. + let existing = tx + .get::(*segment)? + .and_then(|checkpoint| checkpoint.block_number); + (existing.map_or(target, |existing| existing.max(target)), None) + } + _ => (snapshot_block, tx_number), + }; let checkpoint = PruneCheckpoint { - block_number: Some(snapshot_block), + block_number: Some(block_number), tx_number, prune_mode: *prune_mode, }; @@ -82,7 +111,7 @@ where info!(target: "reth::cli", segment = %segment, - block = snapshot_block, + block = block_number, tx = ?tx_number, mode = ?prune_mode, "Set prune checkpoint" @@ -328,7 +357,7 @@ mod tests { { let tx = db.tx_mut().unwrap(); - write_prune_checkpoints_tx(&tx, &config, snapshot_block).unwrap(); + write_prune_checkpoints_tx(&tx, &config, &selections, snapshot_block).unwrap(); tx.commit().unwrap(); } @@ -352,6 +381,40 @@ mod tests { } } + /// Writes prune checkpoints for state history retained over `distance` blocks. + fn history_checkpoint(snapshot_block: u64, distance: u64) -> Option { + let dir = tempfile::tempdir().unwrap(); + let db = reth_db::init_db(dir.path(), reth_db::mdbx::DatabaseArguments::default()).unwrap(); + let selections = BTreeMap::from([( + SnapshotComponentType::AccountChangesets, + ComponentSelection::Distance(distance), + )]); + let config = config_for_selections( + &selections, + &empty_manifest(), + None, + None::<&reth_chainspec::ChainSpec>, + ); + + let tx = db.tx_mut().unwrap(); + write_prune_checkpoints_tx(&tx, &config, &selections, snapshot_block).unwrap(); + tx.commit().unwrap(); + + db.tx().unwrap().get::(PruneSegment::AccountHistory).unwrap() + } + + #[test] + fn write_prune_checkpoints_sets_history_to_retained_window_start() { + let checkpoint = history_checkpoint(21_000_000, 500_000).unwrap(); + assert_eq!(checkpoint.block_number, Some(20_500_000)); + assert_eq!(checkpoint.prune_mode, PruneMode::Distance(500_000)); + } + + #[test] + fn write_prune_checkpoints_skips_history_shorter_than_distance() { + assert_eq!(history_checkpoint(100_000, 500_000), None); + } + #[test] fn write_prune_checkpoints_archive_no_checkpoints() { let dir = tempfile::tempdir().unwrap(); @@ -371,7 +434,7 @@ mod tests { { let tx = db.tx_mut().unwrap(); - write_prune_checkpoints_tx(&tx, &config, 21_000_000).unwrap(); + write_prune_checkpoints_tx(&tx, &config, &selections, 21_000_000).unwrap(); tx.commit().unwrap(); } @@ -658,4 +721,115 @@ mod tests { .expect("sender prune checkpoint should exist"); assert_eq!(sender_prune_checkpoint.block_number, Some(tip_checkpoint.block_number)); } + + #[test] + fn history_checkpoints_do_not_advertise_undownloaded_changesets() { + let snapshot_block = 21_000_000; + for (selection, expected) in [ + (None, Some(snapshot_block)), + (Some(ComponentSelection::None), Some(snapshot_block)), + (Some(ComponentSelection::Distance(1)), Some(snapshot_block - 1)), + (Some(ComponentSelection::Since(snapshot_block)), Some(snapshot_block - 1)), + (Some(ComponentSelection::Since(0)), None), + (Some(ComponentSelection::All), None), + ] { + let dir = tempfile::tempdir().unwrap(); + let db = + reth_db::init_db(dir.path(), reth_db::mdbx::DatabaseArguments::default()).unwrap(); + let mut selections = BTreeMap::from([ + (SnapshotComponentType::State, ComponentSelection::All), + (SnapshotComponentType::Headers, ComponentSelection::All), + (SnapshotComponentType::RocksdbIndices, ComponentSelection::All), + ]); + if let Some(selection) = selection { + selections.insert(SnapshotComponentType::AccountChangesets, selection); + selections.insert(SnapshotComponentType::StorageChangesets, selection); + } + let config = config_for_selections( + &selections, + &empty_manifest(), + None, + None::<&reth_chainspec::ChainSpec>, + ); + assert!(!super::super::should_reset_index_stage_checkpoints(&selections)); + let tx = db.tx_mut().unwrap(); + write_prune_checkpoints_tx(&tx, &config, &selections, snapshot_block).unwrap(); + let mut checkpoints = Vec::new(); + for segment in [PruneSegment::AccountHistory, PruneSegment::StorageHistory] { + let checkpoint = tx.get::(segment).unwrap(); + assert_eq!(checkpoint.and_then(|checkpoint| checkpoint.block_number), expected); + if let Some(checkpoint) = checkpoint { + checkpoints.push((segment, checkpoint)); + } + } + if let Some(boundary) = expected { + assert!(config + .prune + .segments + .ensure_unwind_target_unpruned(snapshot_block, boundary - 1, &checkpoints,) + .is_err()); + assert!(config + .prune + .segments + .ensure_unwind_target_unpruned(snapshot_block, boundary, &checkpoints,) + .is_ok()); + } + } + } + + #[test] + fn history_checkpoints_preserve_source_pruning_boundary() { + let snapshot_block = 21_000_000; + let selected_boundary = 20_500_000; + for selection in [ + ComponentSelection::Distance(snapshot_block - selected_boundary), + ComponentSelection::Since(selected_boundary + 1), + ] { + for existing_boundary in [None, Some(20_000_000), Some(20_750_000)] { + let dir = tempfile::tempdir().unwrap(); + let db = reth_db::init_db(dir.path(), reth_db::mdbx::DatabaseArguments::default()) + .unwrap(); + let selections = BTreeMap::from([ + (SnapshotComponentType::AccountChangesets, selection), + (SnapshotComponentType::StorageChangesets, selection), + (SnapshotComponentType::RocksdbIndices, ComponentSelection::All), + ]); + let config = config_for_selections( + &selections, + &empty_manifest(), + None, + None::<&reth_chainspec::ChainSpec>, + ); + let tx = db.tx_mut().unwrap(); + for segment in [PruneSegment::AccountHistory, PruneSegment::StorageHistory] { + tx.put::( + segment, + PruneCheckpoint { + block_number: existing_boundary, + tx_number: Some(100), + prune_mode: PruneMode::Full, + }, + ) + .unwrap(); + } + + write_prune_checkpoints_tx(&tx, &config, &selections, snapshot_block).unwrap(); + + let expected = existing_boundary.unwrap_or_default().max(selected_boundary); + for (segment, mode) in [ + (PruneSegment::AccountHistory, config.prune.segments.account_history), + (PruneSegment::StorageHistory, config.prune.segments.storage_history), + ] { + assert_eq!( + tx.get::(segment).unwrap(), + Some(PruneCheckpoint { + block_number: Some(expected), + tx_number: None, + prune_mode: mode.unwrap(), + }), + ); + } + } + } + } } diff --git a/crates/cli/commands/src/download/extract.rs b/crates/cli/commands/src/download/extract.rs index ba405ff6fed..4e500398ba1 100644 --- a/crates/cli/commands/src/download/extract.rs +++ b/crates/cli/commands/src/download/extract.rs @@ -177,11 +177,13 @@ fn unpack_static_file( let mut current = root.clone(); for part in relative_path.components() { current.push(part); - if let Ok(metadata) = std::fs::symlink_metadata(¤t) { - eyre::ensure!( + match std::fs::symlink_metadata(¤t) { + Ok(metadata) => eyre::ensure!( !metadata.file_type().is_symlink(), "Static file archive path contains a symlink" - ); + ), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), } } if relative_path.as_os_str().is_empty() { diff --git a/crates/cli/commands/src/download/fetch.rs b/crates/cli/commands/src/download/fetch.rs index db0300344dd..c278cf85a28 100644 --- a/crates/cli/commands/src/download/fetch.rs +++ b/crates/cli/commands/src/download/fetch.rs @@ -22,7 +22,7 @@ use std::{ }, time::Duration, }; -use tracing::info; +use tracing::{info, warn}; use url::Url; /// Maximum retry attempts for a single download segment. @@ -162,11 +162,20 @@ impl ArchiveFetcher { /// Probes the remote source for file size and HTTP range support. fn probe(&self, client: &BlockingClient) -> Result { - let probe = client - .get(&self.url) - .header(RANGE, "bytes=0-0") - .send() - .and_then(|response| response.error_for_status()); + let mut attempt = 1; + let probe = loop { + let probe = client + .get(&self.url) + .header(RANGE, "bytes=0-0") + .send() + .and_then(|response| response.error_for_status()); + // A transient failure here would permanently downgrade the archive to one stream. + if probe.is_ok() || attempt == SEGMENT_RETRY_ATTEMPTS { + break probe + } + std::thread::sleep(self.session.retry_delay(piece_retry_backoff(attempt, false))); + attempt += 1; + }; let (supports_ranges, total_size) = match probe { Ok(response) if response.status() == StatusCode::PARTIAL_CONTENT => { @@ -669,7 +678,7 @@ impl SegmentedDownload { }; while let Some(piece) = state.next_piece(context.cancel_token) { - if let Err(error) = Self::download_piece_with_retries( + match Self::download_piece_with_retries( client, context.url, &file, @@ -680,9 +689,23 @@ impl SegmentedDownload { context.cancel_token, context.session, ) { - state.note_terminal_failure(); - terminal_failure.record(error); - return; + Ok(()) => {} + Err(PieceAttemptFailure::Retryable { error, .. }) if state.requeue(piece) => { + warn!(target: "reth::cli", + start = piece.start, + end = piece.end, + %error, + "Piece download failed, requeued" + ); + } + Err( + PieceAttemptFailure::Retryable { error, .. } | + PieceAttemptFailure::Terminal(error), + ) => { + state.note_terminal_failure(); + terminal_failure.record(error); + return; + } } } } @@ -702,13 +725,15 @@ impl SegmentedDownload { request_limiter: &DownloadRequestLimiter, cancel_token: &CancellationToken, session: &DownloadSession, - ) -> Result<()> { + ) -> std::result::Result<(), PieceAttemptFailure> { for attempt in 1..=SEGMENT_RETRY_ATTEMPTS { if cancel_token.is_cancelled() { - return Err(eyre::eyre!("Download cancelled")) + return Err(PieceAttemptFailure::Terminal(eyre::eyre!("Download cancelled"))) } - let _request_permit = request_limiter.acquire(shared, cancel_token)?; + let _request_permit = request_limiter + .acquire(shared, cancel_token) + .map_err(PieceAttemptFailure::Terminal)?; match Self::download_piece_once( client, url, @@ -726,12 +751,13 @@ impl SegmentedDownload { session.retry_delay(piece_retry_backoff(attempt, throttled)), ); } - Err(PieceAttemptFailure::Retryable { error, .. }) => return Err(error), - Err(PieceAttemptFailure::Terminal(error)) => return Err(error), + Err(failure) => return Err(failure), } } - Err(eyre::eyre!("Piece download failed after {SEGMENT_RETRY_ATTEMPTS} attempts")) + Err(PieceAttemptFailure::Terminal(eyre::eyre!( + "Piece download failed after {SEGMENT_RETRY_ATTEMPTS} attempts" + ))) } /// Downloads one queued piece once. @@ -838,12 +864,28 @@ struct SegmentedDownloadState { pieces: Mutex>, /// Set once a worker hits a fatal error. failed: AtomicBool, + /// Remaining number of times a piece that exhausted its retries may be requeued. + requeues_left: AtomicU64, } impl SegmentedDownloadState { /// Creates the shared queue state for one segmented archive attempt. fn new(pieces: VecDeque) -> Self { - Self { pieces: Mutex::new(pieces), failed: AtomicBool::new(false) } + let requeues_left = AtomicU64::new(pieces.len() as u64); + Self { pieces: Mutex::new(pieces), failed: AtomicBool::new(false), requeues_left } + } + + /// Puts a piece back on the queue, returning `false` once the requeue budget is spent. + fn requeue(&self, piece: DownloadPiece) -> bool { + if self + .requeues_left + .try_update(Ordering::Relaxed, Ordering::Relaxed, |left| left.checked_sub(1)) + .is_err() + { + return false + } + self.pieces.lock().unwrap().push_back(piece); + true } /// Returns the next piece unless cancellation or a fatal error stopped the attempt. @@ -939,15 +981,8 @@ fn piece_retry_backoff(attempt: u32, throttled: bool) -> Duration { /// Returns whether an HTTP status should retry the current piece. fn is_retryable_piece_status(status: StatusCode) -> bool { - matches!( - status, - StatusCode::REQUEST_TIMEOUT | - StatusCode::TOO_MANY_REQUESTS | - StatusCode::INTERNAL_SERVER_ERROR | - StatusCode::BAD_GATEWAY | - StatusCode::SERVICE_UNAVAILABLE | - StatusCode::GATEWAY_TIMEOUT - ) + matches!(status, StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS) || + status.is_server_error() } /// Returns whether a piece request should retry after the given status. @@ -1030,6 +1065,7 @@ mod tests { fn piece_status_retry_policy_retries_200_ok() { assert!(should_retry_piece_status(StatusCode::OK)); assert!(should_retry_piece_status(StatusCode::TOO_MANY_REQUESTS)); + assert!(should_retry_piece_status(StatusCode::from_u16(520).unwrap())); assert!(!should_retry_piece_status(StatusCode::NOT_FOUND)); } @@ -1078,4 +1114,16 @@ mod tests { assert!(!cache_dir.join("state.tar.zst").exists()); assert!(!cache_dir.join("state.tar.zst.part").exists()); } + + #[test] + fn segmented_state_requeues_failed_pieces_within_budget() { + let state = SegmentedDownloadState::new(build_download_pieces(8, 4)); + let token = CancellationToken::new(); + let first = state.next_piece(&token).unwrap(); + + assert!(state.requeue(first)); + assert!(state.requeue(first)); + assert!(!state.requeue(first)); + assert_eq!(state.pieces.lock().unwrap().back(), Some(&first)); + } } diff --git a/crates/cli/commands/src/download/manifest.rs b/crates/cli/commands/src/download/manifest.rs index 15c5cf1db57..470e62a6ce9 100644 --- a/crates/cli/commands/src/download/manifest.rs +++ b/crates/cli/commands/src/download/manifest.rs @@ -590,6 +590,7 @@ pub fn generate_manifest( chain_id: u64, blocks_per_file: u64, ) -> Result { + eyre::ensure!(blocks_per_file > 0, "blocks_per_file must be greater than zero"); std::fs::create_dir_all(output_dir)?; let mut components = BTreeMap::new(); diff --git a/crates/cli/commands/src/download/manifest_cmd.rs b/crates/cli/commands/src/download/manifest_cmd.rs index 25e2d0398de..1fc22be57fe 100644 --- a/crates/cli/commands/src/download/manifest_cmd.rs +++ b/crates/cli/commands/src/download/manifest_cmd.rs @@ -40,7 +40,7 @@ pub struct SnapshotManifestCommand { /// Blocks per archive file for chunked components. /// /// If omitted, this is inferred from header static file ranges in the source datadir. - #[arg(long)] + #[arg(long, value_parser = clap::value_parser!(u64).range(1..))] blocks_per_file: Option, } diff --git a/crates/cli/commands/src/download/mod.rs b/crates/cli/commands/src/download/mod.rs index 3726743f6bc..5f4c7d89e7a 100644 --- a/crates/cli/commands/src/download/mod.rs +++ b/crates/cli/commands/src/download/mod.rs @@ -18,6 +18,9 @@ //! //! [`DownloadDefaults`] defines the discovery endpoints and default help text used when the command //! needs to discover a manifest instead of consuming an explicit source. +//! The manifest chain ID is checked when the selected chain determines the source, default data +//! directory, or pre-Merge pruning settings. Explicit sources and data directories do not require +//! a matching chain when component selection is independent of its hardforks. //! //! ## Selection and planning //! @@ -39,10 +42,13 @@ //! Archive processing is modeled around `ModularDownloadJob`, which schedules work, and //! `ArchiveProcessor`, which owns the explicit retry state machine for one archive. //! `ArchiveMode` decides whether that archive should be fetched through the cache or streamed -//! directly: +//! directly. //! -//! - reuse verified plain output files when possible, -//! - otherwise fetch and extract the archive, +//! Before any archive is scheduled, `PlannedDownloads::partition_reusable` hashes the existing +//! output files once and skips archives whose outputs already verify. Each remaining archive is +//! processed as follows: +//! +//! - fetch and extract the archive, //! - verify the declared output files, //! - retry the entire archive attempt if extraction succeeded but verification failed. //! @@ -85,6 +91,7 @@ pub mod manifest; pub mod manifest_cmd; mod planning; mod progress; +mod prune; mod session; mod source; mod tui; @@ -99,8 +106,9 @@ use config_gen::{config_for_selections, write_config}; use extract::stream_and_extract; use eyre::Result; use manifest::{ComponentSelection, SnapshotComponentType, SnapshotManifest}; -use planning::{collect_planned_archives, summarize_download_startup, PlannedDownloads}; +use planning::{collect_planned_archives, PlannedDownloads}; use progress::{DownloadProgress, DownloadRequestLimiter}; +use prune::prune_unlisted_outputs; use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks, MAINNET}; use reth_cli::chainspec::ChainSpecParser; use reth_cli_util::cancellation::CancellationToken; @@ -127,7 +135,7 @@ const RETH_SNAPSHOTS_BASE_URL: &str = "https://snapshots-r2.reth.rs"; const RETH_SNAPSHOTS_API_URL: &str = "https://snapshots.reth.rs/api/snapshots"; const RETH_SNAPSHOTS_SOURCE: &str = "https://snapshots.reth.rs (default)"; const SNAPSHOT_API_PATH: &str = "/api/snapshots"; -const FORCE_REMOVED_DATADIR_PATHS: &[&str] = &["db", "rocksdb", "static_files", "reth.toml"]; +const MANAGED_DATADIR_PATHS: &[&str] = &["db", "rocksdb", "static_files", "reth.toml"]; /// Maximum number of simultaneous HTTP downloads across the entire snapshot job. const MAX_CONCURRENT_DOWNLOADS: usize = 8; @@ -174,10 +182,14 @@ pub struct DownloadDefaults { pub snapshot_api_url: Cow<'static, str>, /// Optional custom long help text that overrides the generated help pub long_help: Option, + /// Whether the full preset also downloads the snapshot's RocksDB indices instead of + /// rebuilding them. + pub full_preset_includes_rocksdb_indices: bool, } impl DownloadDefaults { /// Initialize the global download defaults with this configuration + #[expect(clippy::result_large_err, reason = "returns the rejected defaults to the caller")] pub fn try_init(self) -> Result<(), Self> { DOWNLOAD_DEFAULTS.set(self) } @@ -198,6 +210,7 @@ impl DownloadDefaults { default_chain_aware_base_url: None, snapshot_api_url: Cow::Borrowed(RETH_SNAPSHOTS_API_URL), long_help: None, + full_preset_includes_rocksdb_indices: false, } } @@ -307,6 +320,12 @@ impl DownloadDefaults { self } + /// Builder: Make the full preset also download the snapshot's RocksDB indices. + pub const fn with_full_preset_rocksdb_indices(mut self) -> Self { + self.full_preset_includes_rocksdb_indices = true; + self + } + /// Builder: Set custom long help text, overriding the generated help pub fn with_long_help(mut self, help: impl Into) -> Self { self.long_help = Some(help.into()); @@ -414,7 +433,8 @@ pub struct DownloadCommand { /// Skip optional RocksDB indices even when archive components are selected. /// - /// This affects `--archive`/`--all` and TUI archive preset (`a`). + /// This affects `--archive`/`--all`, TUI archive preset (`a`), and the full preset when it + /// includes RocksDB indices. #[arg(long, conflicts_with_all = ["url", "with_rocksdb"])] without_rocksdb: bool, @@ -427,6 +447,22 @@ pub struct DownloadCommand { #[arg(long, conflicts_with = "list")] force: bool, + /// Remove files from db, rocksdb, static_files, and reth.toml that the selected snapshot + /// plan does not list, then reuse the listed files that still verify. + /// + /// Unlike `--force`, this keeps reusable snapshot files, so only missing or changed archives + /// are downloaded. Unlike a plain re-run, the data dir ends up with exactly the snapshot's + /// files, which is required when the node has written data past the snapshot block. + /// + /// Components that are not selected are removed entirely, so `--prune-unlisted --minimal` + /// over an archive node deletes its history. With `--non-interactive`, components must be + /// selected explicitly with a preset or `--with-*` flags. reth.toml is always regenerated, + /// which discards local edits. Symlinks below these paths are removed, not followed. Pruning + /// refuses to run while a node holds the database lock, and happens before downloading, so a + /// failed download leaves the data dir pruned, as with `--force`. + #[arg(long, conflicts_with_all = ["force", "list", "url"])] + prune_unlisted: bool, + /// Enable resumable two-phase downloads (download to disk first, then extract). /// /// Archives are downloaded to a `.part` file with HTTP Range resume support @@ -528,25 +564,33 @@ impl> DownloadCo let _cancel_guard = cancel_token.drop_guard(); if self.force { clear_existing_datadir(target_dir, static_files_dir.as_deref())?; + } else if self.prune_unlisted { + prune_unlisted_outputs(&planned.archives, target_dir, static_files_dir.as_deref())?; } fs::create_dir_all(target_dir)?; - let startup_summary = - summarize_download_startup(&planned.archives, target_dir, static_files_dir.as_deref())?; + let downloads = { + let (target_dir, static_files_dir) = + (target_dir.to_path_buf(), static_files_dir.clone()); + tokio::task::spawn_blocking(move || { + planned.partition_reusable(&target_dir, static_files_dir.as_deref()) + }) + .await?? + }; info!(target: "reth::cli", - reusable = startup_summary.reusable, - needs_download = startup_summary.needs_download, + reusable = downloads.reused.len(), + needs_download = downloads.pending.len(), "Startup integrity summary (plain output files)" ); info!(target: "reth::cli", - archives = planned.total_archives(), - download_total = %DownloadProgress::format_size(planned.total_download_size), - output_total = %DownloadProgress::format_size(planned.total_output_size), + archives = downloads.total_archives(), + download_total = %DownloadProgress::format_size(downloads.total_download_size), + output_total = %DownloadProgress::format_size(downloads.total_output_size), "Downloading all archives" ); run_modular_downloads( - planned, + downloads, target_dir, static_files_dir.as_deref(), self.download_concurrency.max(1), @@ -583,8 +627,17 @@ impl> DownloadCo let manifest = self.load_manifest(chain_id).await?; let ResolvedComponents { mut selections, preset } = self.resolve_components(&manifest)?; - if matches!(preset, Some(SelectionPreset::Archive)) { - inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb); + match preset { + Some(SelectionPreset::Archive) => { + inject_archive_only_components(&mut selections, &manifest, !self.without_rocksdb) + } + Some(SelectionPreset::Full) + if DownloadDefaults::get_global().full_preset_includes_rocksdb_indices && + !self.without_rocksdb => + { + inject_rocksdb_indices(&mut selections, &manifest) + } + _ => {} } let planned = collect_planned_archives(&manifest, &selections)?; @@ -597,11 +650,11 @@ impl> DownloadCo info!(target: "reth::cli", source = %manifest_source, "Fetching snapshot manifest"); let mut manifest = fetch_manifest_from_source(&manifest_source).await?; - eyre::ensure!( - manifest.chain_id == chain_id, - "Snapshot chain ID {} does not match selected chain ID {chain_id}", - manifest.chain_id - ); + if self.env.datadir.datadir.as_ref().is_none() || + (self.manifest_url.is_none() && self.manifest_path.is_none()) + { + self.validate_manifest_chain(&manifest)?; + } manifest.base_url = Some(resolve_manifest_base_url(&manifest, &manifest_source)?); info!(target: "reth::cli", @@ -638,7 +691,7 @@ impl> DownloadCo let tx = db.tx_mut()?; if should_write_prune { - config_gen::write_prune_checkpoints_tx(&tx, &config, manifest.block)?; + config_gen::write_prune_checkpoints_tx(&tx, &config, selections, manifest.block)?; } if should_reset_indices { @@ -648,8 +701,12 @@ impl> DownloadCo tx.commit()?; } - let start_command = startup_node_command::(self.env.chain.as_ref()); - info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command); + if self.env.chain.chain().id() == manifest.chain_id { + let start_command = startup_node_command::(self.env.chain.as_ref()); + info!(target: "reth::cli", "Snapshot download complete. Run `{}` to start syncing.", start_command); + } else { + info!(target: "reth::cli", "Snapshot download complete."); + } Ok(()) } @@ -676,7 +733,7 @@ impl> DownloadCo if self.full { return Ok(ResolvedComponents { - selections: self.full_preset_selections(manifest), + selections: self.full_preset_selections(manifest)?, preset: Some(SelectionPreset::Full), }); } @@ -757,6 +814,13 @@ impl> DownloadCo } if self.non_interactive { + // The implicit minimal default would silently prune history from a full or archive + // node. + eyre::ensure!( + !self.prune_unlisted, + "--prune-unlisted with --non-interactive requires an explicit component selection: \ + --minimal, --full, --archive, or --with-* flags" + ); return Ok(ResolvedComponents { selections: self.minimal_preset_selections(manifest), preset: Some(SelectionPreset::Minimal), @@ -765,7 +829,7 @@ impl> DownloadCo // Interactive TUI let minimal_preset = self.minimal_preset_selections(manifest); - let full_preset = self.full_preset_selections(manifest); + let full_preset = self.full_preset_selections(manifest)?; let SelectorOutput { selections, preset } = run_selector(manifest.clone(), &minimal_preset, &full_preset)?; let selected = @@ -786,8 +850,11 @@ impl> DownloadCo fn full_preset_selections( &self, manifest: &SnapshotManifest, - ) -> BTreeMap { - self.pruning_preset_selections(manifest, SelectionPreset::Full) + ) -> Result> { + if DefaultPruningValues::get_global().full_bodies_history_use_pre_merge { + self.validate_manifest_chain(manifest)?; + } + Ok(self.pruning_preset_selections(manifest, SelectionPreset::Full)) } /// Builds component selections from the configured pruning preset. @@ -887,6 +954,16 @@ impl> DownloadCo } } } + + fn validate_manifest_chain(&self, manifest: &SnapshotManifest) -> Result<()> { + let chain_id = self.env.chain.chain().id(); + eyre::ensure!( + manifest.chain_id == chain_id, + "Snapshot chain ID {} does not match selected chain ID {chain_id}", + manifest.chain_id + ); + Ok(()) + } } /// Resolves explicit `--with-*` / `--with-*-since` / `--with-*-distance` flags @@ -925,12 +1002,8 @@ fn selection_from_prune_mode(mode: Option, snapshot_block: u64) -> Co /// Removes existing snapshot data that is managed by `reth download`. fn clear_existing_datadir(target_dir: &Path, static_files_dir: Option<&Path>) -> Result<()> { info!(target: "reth::cli", dir = ?target_dir, "Clearing existing snapshot data"); - for entry in FORCE_REMOVED_DATADIR_PATHS { - let path = if *entry == "static_files" { - static_files_dir.map_or_else(|| target_dir.join(entry), Path::to_path_buf) - } else { - target_dir.join(entry) - }; + for entry in MANAGED_DATADIR_PATHS { + let path = managed_datadir_path(entry, target_dir, static_files_dir); if !path.try_exists()? { continue; } @@ -946,6 +1019,19 @@ fn clear_existing_datadir(target_dir: &Path, static_files_dir: Option<&Path>) -> Ok(()) } +/// Resolves one of [`MANAGED_DATADIR_PATHS`], honoring a custom static files directory. +fn managed_datadir_path( + entry: &str, + target_dir: &Path, + static_files_dir: Option<&Path>, +) -> PathBuf { + if entry == "static_files" { + static_files_dir.map_or_else(|| target_dir.join(entry), Path::to_path_buf) + } else { + target_dir.join(entry) + } +} + /// If all data components (txs, receipts, changesets) are `All`, automatically /// include hidden archive-only components when available in the manifest. fn inject_archive_only_components( @@ -978,6 +1064,16 @@ fn inject_archive_only_components( } } +/// Selects the manifest's RocksDB indices, if it publishes them. +fn inject_rocksdb_indices( + selections: &mut BTreeMap, + manifest: &SnapshotManifest, +) { + if manifest.component(SnapshotComponentType::RocksdbIndices).is_some() { + selections.insert(SnapshotComponentType::RocksdbIndices, ComponentSelection::All); + } +} + /// Returns `true` when RocksDB-backed index stages should be reset after download. fn should_reset_index_stage_checkpoints( selections: &BTreeMap, @@ -1094,7 +1190,7 @@ mod tests { use super::*; use clap::{Args, Parser}; use extract::CompressionFormat; - use manifest::{ComponentManifest, SingleArchive}; + use manifest::{ComponentManifest, OutputFileChecksum, SingleArchive}; use reth_chainspec::{HOLESKY, MAINNET}; use reth_ethereum_cli::chainspec::EthereumChainSpecParser; @@ -1247,23 +1343,17 @@ mod tests { } #[test] - fn test_download_retry_backoff() { - let parse = |args: Vec<&str>| { + fn test_download_prune_unlisted_conflicts_with_force_list_and_url() { + let parse = |args: &[&str]| { CommandParser::>::try_parse_from(args) }; - assert_eq!(parse(vec!["reth"]).unwrap().args.retry_backoff, None); - for (value, expected) in [ - ("0ms", Duration::ZERO), - ("250ms", Duration::from_millis(250)), - ("2s", Duration::from_secs(2)), - ] { - assert_eq!( - parse(vec!["reth", "--retry-backoff", value]).unwrap().args.retry_backoff, - Some(expected) - ); - } - assert!(parse(vec!["reth", "--retry-backoff=-1s"]).is_err()); - assert!(parse(vec!["reth", "--retry-backoff", "invalid"]).is_err()); + + assert!(parse(&["reth", "--prune-unlisted"]).unwrap().args.prune_unlisted); + assert!(parse(&["reth", "--prune-unlisted", "--force"]).is_err()); + assert!(parse(&["reth", "--prune-unlisted", "--list"]).is_err()); + assert!( + parse(&["reth", "--prune-unlisted", "--url", "https://example.com/a.tar.zst"]).is_err() + ); } #[test] @@ -1480,6 +1570,35 @@ mod tests { assert_eq!(selections.get(&SnapshotComponentType::RocksdbIndices), None); } + #[test] + fn inject_rocksdb_indices_keeps_index_stages() { + let manifest = manifest_with_archive_only_components(); + let mut selections = BTreeMap::new(); + selections + .insert(SnapshotComponentType::AccountChangesets, ComponentSelection::Distance(10)); + + inject_rocksdb_indices(&mut selections, &manifest); + + assert_eq!( + selections.get(&SnapshotComponentType::RocksdbIndices), + Some(&ComponentSelection::All) + ); + assert_eq!(selections.get(&SnapshotComponentType::TransactionSenders), None); + assert!(!should_reset_index_stage_checkpoints(&selections)); + } + + #[test] + fn inject_rocksdb_indices_skips_manifest_without_indices() { + let mut manifest = manifest_with_archive_only_components(); + manifest.components.clear(); + let mut selections = BTreeMap::new(); + + inject_rocksdb_indices(&mut selections, &manifest); + + assert!(selections.is_empty()); + assert!(should_reset_index_stage_checkpoints(&selections)); + } + #[test] fn should_reset_index_stage_checkpoints_without_rocksdb_indices() { let mut selections = BTreeMap::new(); @@ -1518,4 +1637,91 @@ mod tests { fn download_command_uses_binary_name() { assert_eq!(download_command_for_binary("tempo"), "tempo download"); } + + #[tokio::test] + async fn test_download_explicit_source_and_datadir_allow_other_chains() { + let dir = tempfile::tempdir().unwrap(); + let manifest_path = dir.path().join("manifest.json"); + let mut manifest = manifest_with_archive_only_components(); + manifest.chain_id = 42431; + for component in manifest.components.values_mut() { + if let ComponentManifest::Single(archive) = component { + archive.output_files.push(OutputFileChecksum { + path: archive.file.clone(), + size: 1, + blake3: blake3::hash(b"x").to_hex().to_string(), + }); + } + } + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + let manifest_url = format!("file://{}", manifest_path.display()); + let target = dir.path().join("download"); + + for source in ["--manifest-url", "--manifest-path"] { + let location = if source == "--manifest-url" { + manifest_url.as_str() + } else { + manifest_path.to_str().unwrap() + }; + for selection in ["--archive", "--minimal", "--non-interactive", "--with-txs"] { + let args = CommandParser::>::parse_from([ + "reth", + source, + location, + "--datadir", + target.to_str().unwrap(), + selection, + ]) + .args; + let (plan, prepared) = args.plan().await.unwrap(); + assert_eq!(plan.chain_id, 42431); + assert_eq!(prepared.manifest.chain_id, 42431); + assert_eq!(prepared.data_dir, target); + assert!(!target.exists()); + } + } + } + + #[tokio::test] + async fn test_download_chain_derived_datadir_rejects_other_chains() { + let dir = tempfile::tempdir().unwrap(); + let manifest_path = dir.path().join("manifest.json"); + let mut manifest = manifest_with_archive_only_components(); + manifest.chain_id = 42431; + fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); + + for datadir in [vec![], vec!["--datadir", "default"]] { + let mut cli_args = + vec!["reth", "--manifest-path", manifest_path.to_str().unwrap(), "--archive"]; + cli_args.extend(datadir); + let args = + CommandParser::>::parse_from(cli_args) + .args; + assert_eq!( + args.plan().await.unwrap_err().to_string(), + "Snapshot chain ID 42431 does not match selected chain ID 1" + ); + } + } + + #[test] + fn test_download_full_chain_derived_pruning_rejects_other_chains() { + let args = CommandParser::>::parse_from([ + "reth", + "--datadir", + "download", + "--manifest-url", + "https://example.com/manifest.json", + "--full", + ]) + .args; + let mut manifest = manifest_with_archive_only_components(); + manifest.chain_id = 42431; + assert_eq!( + args.full_preset_selections(&manifest).unwrap_err().to_string(), + "Snapshot chain ID 42431 does not match selected chain ID 1" + ); + manifest.chain_id = 1; + assert!(args.full_preset_selections(&manifest).is_ok()); + } } diff --git a/crates/cli/commands/src/download/planning.rs b/crates/cli/commands/src/download/planning.rs index 6a3f8457986..fe2748a9d80 100644 --- a/crates/cli/commands/src/download/planning.rs +++ b/crates/cli/commands/src/download/planning.rs @@ -1,5 +1,6 @@ use super::{manifest::*, verify::OutputVerifier}; use eyre::Result; +use rayon::prelude::*; use serde::Serialize; use std::{collections::BTreeMap, io::Write, path::Path}; use tracing::info; @@ -125,9 +126,54 @@ pub(crate) struct PlannedDownloads { } impl PlannedDownloads { - /// Returns the number of concrete archives queued for this snapshot selection. + /// Verifies every archive's existing outputs in parallel and splits off the archives that + /// already match, so each output file is hashed at most once before work begins. + pub(crate) fn partition_reusable( + self, + target_dir: &Path, + static_files_dir: Option<&Path>, + ) -> Result { + let verifier = OutputVerifier::new(target_dir, static_files_dir); + let checked = self + .archives + .into_par_iter() + .map(|planned| Ok((verifier.verify(&planned.archive.output_files)?, planned))) + .collect::>>()?; + + let mut downloads = CheckedDownloads { + pending: Vec::new(), + reused: Vec::new(), + total_download_size: self.total_download_size, + total_output_size: self.total_output_size, + }; + for (reusable, planned) in checked { + if reusable { + downloads.reused.push(planned); + } else { + downloads.pending.push(planned); + } + } + Ok(downloads) + } +} + +/// Planned archives split by the startup reuse check. +#[derive(Debug)] +pub(crate) struct CheckedDownloads { + /// Archives that still need to be fetched and extracted. + pub(crate) pending: Vec, + /// Archives whose declared outputs already verified on disk. + pub(crate) reused: Vec, + /// Total compressed download size of all planned archives. + pub(crate) total_download_size: u64, + /// Total extracted plain-output size of all planned archives. + pub(crate) total_output_size: u64, +} + +impl CheckedDownloads { + /// Returns the number of concrete archives in this snapshot selection, including reused ones. pub(crate) const fn total_archives(&self) -> usize { - self.archives.len() + self.pending.len() + self.reused.len() } } @@ -140,35 +186,6 @@ pub(crate) const fn archive_priority_rank(ty: SnapshotComponentType) -> u8 { } } -/// Startup summary showing how much of the selected work can be reused. -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct DownloadStartupSummary { - /// Archives whose declared outputs already verify on disk. - pub(crate) reusable: usize, - /// Archives that still need to be downloaded or retried. - pub(crate) needs_download: usize, -} - -/// Checks selected archives against existing output files before work begins. -pub(crate) fn summarize_download_startup( - all_downloads: &[PlannedArchive], - target_dir: &Path, - static_files_dir: Option<&Path>, -) -> Result { - let mut summary = DownloadStartupSummary::default(); - let verifier = OutputVerifier::new(target_dir, static_files_dir); - - for planned in all_downloads { - if verifier.verify(&planned.archive.output_files)? { - summary.reusable += 1; - } else { - summary.needs_download += 1; - } - } - - Ok(summary) -} - /// Converts a selection into the manifest distance form used for archive lookup. fn selection_archive_distance( selection: &ComponentSelection, @@ -253,7 +270,7 @@ mod tests { use tempfile::tempdir; #[test] - fn summarize_download_startup_counts_reusable_and_needs_download() { + fn partition_reusable_moves_only_verified_archives() { let dir = tempdir().unwrap(); let target_dir = dir.path(); let ok_file = target_dir.join("ok.bin"); @@ -304,9 +321,17 @@ mod tests { }, ]; - let summary = summarize_download_startup(&planned, target_dir, None).unwrap(); - assert_eq!(summary.reusable, 1); - assert_eq!(summary.needs_download, 2); + let downloads = + PlannedDownloads { archives: planned, total_download_size: 30, total_output_size: 5 } + .partition_reusable(target_dir, None) + .unwrap(); + assert_eq!(downloads.total_archives(), 3); + + let names = |archives: &[PlannedArchive]| { + archives.iter().map(|planned| planned.archive.file_name.clone()).collect::>() + }; + assert_eq!(names(&downloads.reused), ["ok.tar.zst"]); + assert_eq!(names(&downloads.pending), ["missing.tar.zst", "bad-size.tar.zst"]); } #[test] diff --git a/crates/cli/commands/src/download/progress.rs b/crates/cli/commands/src/download/progress.rs index b0b6b50f696..a0f0cf508e8 100644 --- a/crates/cli/commands/src/download/progress.rs +++ b/crates/cli/commands/src/download/progress.rs @@ -3,11 +3,12 @@ use reth_cli_util::cancellation::CancellationToken; use std::{ io::{self, Read, Write}, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicU64, Ordering}, Arc, Condvar, Mutex, }, time::{Duration, Instant}, }; +use tokio::sync::Notify; use tracing::info; const BYTE_UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; @@ -132,8 +133,8 @@ pub(crate) struct SharedProgress { pub(crate) active_extractions: AtomicU64, /// Number of archives currently verifying extracted outputs. pub(crate) active_verifications: AtomicU64, - /// Signals the background progress task to exit. - pub(crate) done: AtomicBool, + /// Wakes the background progress task to print the final summary and exit. + pub(crate) done: Notify, /// Cancellation token shared by the whole command. cancel_token: CancellationToken, } @@ -164,7 +165,7 @@ impl SharedProgress { active_download_requests: AtomicU64::new(0), active_extractions: AtomicU64::new(0), active_verifications: AtomicU64::new(0), - done: AtomicBool::new(false), + done: Notify::new(), cancel_token, }) } @@ -559,9 +560,11 @@ impl ArchiveExtractionProgressHandle { } /// Tracks one active archive verification attempt. +/// +/// Bytes are recorded through a shared reference because output files are hashed in parallel. pub(crate) struct ArchiveVerificationProgress<'a> { progress: Option<&'a Arc>, - verified: u64, + verified: AtomicU64, completed: bool, } @@ -571,12 +574,12 @@ impl<'a> ArchiveVerificationProgress<'a> { if let Some(progress) = progress { progress.verification_started(); } - Self { progress, verified: 0, completed: false } + Self { progress, verified: AtomicU64::new(0), completed: false } } /// Adds plain-output bytes hashed by this verification attempt. - pub(crate) fn record_verified(&mut self, bytes: u64) { - self.verified += bytes; + pub(crate) fn record_verified(&self, bytes: u64) { + self.verified.fetch_add(bytes, Ordering::Relaxed); if let Some(progress) = self.progress { progress.add_active_verified_output_bytes(bytes); } @@ -587,11 +590,11 @@ impl<'a> ArchiveVerificationProgress<'a> { if self.completed { return; } + let verified = std::mem::take(self.verified.get_mut()); if let Some(progress) = self.progress { - progress.sub_active_verified_output_bytes(self.verified); + progress.sub_active_verified_output_bytes(verified); progress.record_archive_output_complete(total_bytes); } - self.verified = 0; self.completed = true; } } @@ -599,7 +602,7 @@ impl<'a> ArchiveVerificationProgress<'a> { impl Drop for ArchiveVerificationProgress<'_> { fn drop(&mut self) { if let Some(progress) = self.progress { - progress.sub_active_verified_output_bytes(self.verified); + progress.sub_active_verified_output_bytes(*self.verified.get_mut()); progress.verification_finished(); } } @@ -697,10 +700,9 @@ pub(crate) fn spawn_progress_display(progress: Arc) -> tokio::ta let mut interval = tokio::time::interval(Duration::from_secs(3)); interval.tick().await; loop { - interval.tick().await; - - if progress.done.load(Ordering::Relaxed) { - break; + tokio::select! { + _ = interval.tick() => {} + _ = progress.done.notified() => break, } let download_total = progress.total_download_bytes; @@ -841,4 +843,15 @@ mod tests { 40 ); } + + #[tokio::test] + async fn progress_display_exits_when_done() { + let progress = SharedProgress::new(10, 20, 1, CancellationToken::new()); + let handle = spawn_progress_display(Arc::clone(&progress)); + + progress.done.notify_one(); + + // The task must exit right away instead of on its next 3s progress tick. + tokio::time::timeout(Duration::from_secs(1), handle).await.unwrap().unwrap(); + } } diff --git a/crates/cli/commands/src/download/prune.rs b/crates/cli/commands/src/download/prune.rs new file mode 100644 index 00000000000..354f42b55bc --- /dev/null +++ b/crates/cli/commands/src/download/prune.rs @@ -0,0 +1,248 @@ +//! Removes snapshot-managed data that the selected download plan does not list. + +use super::{ + managed_datadir_path, planning::PlannedArchive, verify::OutputVerifier, MANAGED_DATADIR_PATHS, +}; +use eyre::{Result, WrapErr}; +use reth_db::lockfile::StorageLock; +use reth_fs_util as fs; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; +use tracing::info; + +/// Removes every file under the paths that `--force` clears (`db`, `rocksdb`, `static_files`, +/// and `reth.toml`) that no planned archive lists as an output. +/// +/// Listed files are kept so the startup integrity check can reuse the ones that still verify. +/// Once the download completes, those paths hold exactly the snapshot's files, with no leftovers +/// such as blocks or history indices from a chain the node extended past the snapshot block. +/// +/// This relies on each archive's `output_files` listing every file the archive extracts. A file +/// an archive extracts without listing it is removed here and not restored, because the archive +/// is reused once its listed outputs verify. +/// +/// A managed path that is itself a symlink is followed, because operators link whole data +/// directories to other disks. Symlinks below it are removed rather than followed, so pruning +/// never deletes files outside the data dir. Nested directories left empty are removed; the +/// managed directories themselves are kept, since they may be mount points. +/// +/// Fails if a running node holds the database lock, since pruning would otherwise delete its +/// lock and files from under it. +pub(crate) fn prune_unlisted_outputs( + planned: &[PlannedArchive], + target_dir: &Path, + static_files_dir: Option<&Path>, +) -> Result<()> { + let db_dir = target_dir.join("db"); + if db_dir.is_dir() { + drop(StorageLock::try_acquire(&db_dir).wrap_err("refusing to prune a data dir in use")?); + } + + let verifier = OutputVerifier::new(target_dir, static_files_dir); + let listed = planned + .iter() + .flat_map(|planned| &planned.archive.output_files) + .map(|output| verifier.output_path(&output.path)) + .collect::>(); + + let mut removed = 0; + for entry in MANAGED_DATADIR_PATHS { + let root = managed_datadir_path(entry, target_dir, static_files_dir); + if !root.try_exists()? { + continue + } + + if fs::metadata(&root)?.is_dir() { + removed += prune_dir(&root, &listed)?; + } else if !listed.contains(&root) { + fs::remove_file(&root)?; + removed += 1; + } + } + + info!(target: "reth::cli", removed, "Removed existing files not listed in the snapshot plan"); + Ok(()) +} + +/// Removes unlisted entries below `dir` without following symlinks, and returns how many files +/// or links it removed. Removing a symlink leaves its target intact. +fn prune_dir(dir: &Path, listed: &HashSet) -> Result { + let mut removed = 0; + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + // Unlike `fs::metadata`, this does not follow symlinks. + let file_type = entry + .file_type() + .wrap_err_with(|| format!("failed to read file type of {}", path.display()))?; + if file_type.is_dir() { + removed += prune_dir(&path, listed)?; + remove_dir_if_empty(&path)?; + continue + } + if listed.contains(&path) { + continue + } + + // Windows removes a symlink to a directory with `remove_dir`, not `remove_file`. + #[cfg(windows)] + if std::os::windows::fs::FileTypeExt::is_symlink_dir(&file_type) { + std::fs::remove_dir(&path) + .wrap_err_with(|| format!("failed to remove {}", path.display()))?; + removed += 1; + continue + } + fs::remove_file(&path)?; + removed += 1; + } + Ok(removed) +} + +/// Removes `dir` if pruning left it empty. +fn remove_dir_if_empty(dir: &Path) -> Result<()> { + if fs::read_dir(dir)?.next().is_none() { + std::fs::remove_dir(dir) + .wrap_err_with(|| format!("failed to remove empty directory {}", dir.display()))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::download::manifest::{OutputFileChecksum, SnapshotArchive, SnapshotComponentType}; + use tempfile::tempdir; + + fn planned_with_outputs(paths: &[&str]) -> Vec { + vec![PlannedArchive { + ty: SnapshotComponentType::State, + component: "State".to_string(), + archive: SnapshotArchive { + url: "https://example.com/state.tar.zst".to_string(), + file_name: "state.tar.zst".to_string(), + size: 1, + blake3: None, + output_files: paths + .iter() + .map(|path| OutputFileChecksum { + path: (*path).to_string(), + size: 1, + blake3: String::new(), + }) + .collect(), + }, + }] + } + + fn write_file(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"data").unwrap(); + } + + #[test] + fn prune_removes_unlisted_files_and_keeps_listed_ones() { + let dir = tempdir().unwrap(); + let target = dir.path(); + let listed = ["db/mdbx.dat", "static_files/static_file_headers_0_499999"]; + let leftovers = [ + "db/stale.dat", + "rocksdb/000042.sst", + "static_files/static_file_headers_500000_999999", + "reth.toml", + ]; + for path in listed.iter().chain(&leftovers).chain(&["known-peers.json"]) { + write_file(&target.join(path)); + } + + prune_unlisted_outputs(&planned_with_outputs(&listed), target, None).unwrap(); + + assert!(listed.iter().all(|path| target.join(path).exists())); + assert!(leftovers.iter().all(|path| !target.join(path).exists())); + assert!(target.join("known-peers.json").exists()); + } + + #[test] + fn prune_removes_nested_directories_it_empties_and_keeps_managed_ones() { + let dir = tempdir().unwrap(); + write_file(&dir.path().join("rocksdb/nested/000042.sst")); + + prune_unlisted_outputs(&planned_with_outputs(&["db/mdbx.dat"]), dir.path(), None).unwrap(); + + assert!(!dir.path().join("rocksdb/nested").exists()); + assert!(dir.path().join("rocksdb").is_dir()); + } + + #[test] + fn prune_resolves_static_files_in_a_custom_directory() { + let dir = tempdir().unwrap(); + let target = dir.path().join("datadir"); + let static_files = dir.path().join("custom-static-files"); + write_file(&static_files.join("static_file_headers_0_499999")); + write_file(&static_files.join("static_file_headers_500000_999999")); + + prune_unlisted_outputs( + &planned_with_outputs(&["static_files/static_file_headers_0_499999"]), + &target, + Some(&static_files), + ) + .unwrap(); + + assert!(static_files.join("static_file_headers_0_499999").exists()); + assert!(!static_files.join("static_file_headers_500000_999999").exists()); + } + + #[test] + fn prune_skips_missing_managed_paths() { + let dir = tempdir().unwrap(); + + prune_unlisted_outputs(&planned_with_outputs(&["db/mdbx.dat"]), dir.path(), None).unwrap(); + + assert!(!dir.path().join("db").exists()); + } + + #[cfg(unix)] + #[test] + fn prune_removes_nested_symlinks_without_following_them() { + let dir = tempdir().unwrap(); + let target = dir.path().join("datadir"); + let outside = dir.path().join("outside"); + write_file(&outside.join("unrelated.dat")); + write_file(&target.join("static_files/static_file_headers_0_499999")); + std::os::unix::fs::symlink(&outside, target.join("static_files/linked")).unwrap(); + + prune_unlisted_outputs( + &planned_with_outputs(&["static_files/static_file_headers_0_499999"]), + &target, + None, + ) + .unwrap(); + + assert!(std::fs::symlink_metadata(target.join("static_files/linked")).is_err()); + assert!(outside.join("unrelated.dat").exists()); + } + + #[cfg(unix)] + #[test] + fn prune_follows_a_symlinked_managed_directory() { + let dir = tempdir().unwrap(); + let target = dir.path().join("datadir"); + let other_disk = dir.path().join("other-disk"); + write_file(&other_disk.join("static_file_headers_0_499999")); + write_file(&other_disk.join("static_file_headers_500000_999999")); + std::fs::create_dir_all(&target).unwrap(); + std::os::unix::fs::symlink(&other_disk, target.join("static_files")).unwrap(); + + prune_unlisted_outputs( + &planned_with_outputs(&["static_files/static_file_headers_0_499999"]), + &target, + None, + ) + .unwrap(); + + assert!(std::fs::symlink_metadata(target.join("static_files")).unwrap().is_symlink()); + assert!(other_disk.join("static_file_headers_0_499999").exists()); + assert!(!other_disk.join("static_file_headers_500000_999999").exists()); + } +} diff --git a/crates/cli/commands/src/download/session.rs b/crates/cli/commands/src/download/session.rs index 9aa898e66b5..a46bed25115 100644 --- a/crates/cli/commands/src/download/session.rs +++ b/crates/cli/commands/src/download/session.rs @@ -61,13 +61,6 @@ impl DownloadSession { &self.cancel_token } - /// Records one archive whose outputs were already reusable on disk. - pub(crate) fn record_reused_archive(&self, download_bytes: u64, output_bytes: u64) { - if let Some(progress) = self.progress() { - progress.record_reused_archive(download_bytes, output_bytes); - } - } - /// Records one archive whose extracted outputs fully verified. pub(crate) fn record_archive_output_complete(&self, bytes: u64) { if let Some(progress) = self.progress() { @@ -120,22 +113,3 @@ impl ArchiveProcessContext { &self.session } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn retry_delay_preserves_defaults_or_uses_override() { - let session = DownloadSession::new(None, None, CancellationToken::new()); - for default in [Duration::from_secs(2), Duration::from_secs(5), Duration::from_secs(40)] { - assert_eq!(session.retry_delay(default), default); - for delay in [Duration::ZERO, Duration::from_millis(250)] { - assert_eq!( - session.clone().with_retry_backoff(Some(delay)).retry_delay(default), - delay - ); - } - } - } -} diff --git a/crates/cli/commands/src/download/tui.rs b/crates/cli/commands/src/download/tui.rs index 08319e978f9..fd5f4b92f9c 100644 --- a/crates/cli/commands/src/download/tui.rs +++ b/crates/cli/commands/src/download/tui.rs @@ -24,7 +24,7 @@ use std::{ }; /// Result of the interactive component selector. -pub struct SelectorOutput { +pub(crate) struct SelectorOutput { /// User-confirmed selections with per-component ranges. pub selections: BTreeMap, /// Last preset action used in the TUI, if any. @@ -336,7 +336,7 @@ impl SelectorApp { } /// Runs the interactive component selector TUI. -pub fn run_selector( +pub(crate) fn run_selector( manifest: SnapshotManifest, minimal_preset: &BTreeMap, full_preset: &BTreeMap, diff --git a/crates/cli/commands/src/download/verify.rs b/crates/cli/commands/src/download/verify.rs index d87c7bf6fef..897b6c415f0 100644 --- a/crates/cli/commands/src/download/verify.rs +++ b/crates/cli/commands/src/download/verify.rs @@ -1,8 +1,16 @@ use super::{manifest::OutputFileChecksum, progress::ArchiveVerificationProgress}; use blake3::Hasher; use eyre::Result; -use reth_fs_util as fs; -use std::{io::Read, path::Path}; +use rayon::prelude::*; +use reth_fs_util::{self as fs, FsPathError}; +use std::{ + io::{ErrorKind, Read}, + mem, + path::Path, +}; + +/// Read size per hash update. Large enough for `update_rayon` to split across threads. +const HASH_BUFFER_SIZE: usize = 4 * 1024 * 1024; /// Verifies and cleans up extracted output files in one target directory. pub(crate) struct OutputVerifier<'a> { @@ -19,39 +27,49 @@ impl<'a> OutputVerifier<'a> { } /// Returns `true` only when every declared output file exists and matches size and BLAKE3. - /// Returns `false` if any file is missing, mismatched, or no outputs were declared. + /// Returns `false` if any file is missing, mismatched, or no outputs were declared, and an + /// error if a file exists but cannot be inspected or read. pub(crate) fn verify(&self, output_files: &[OutputFileChecksum]) -> Result { self.verify_with_progress(output_files, None) } /// Returns `true` only when every declared output file exists and matches size and BLAKE3, /// updating the optional verification progress as file bytes are hashed. + /// + /// Sizes are checked before any file is hashed, then files are hashed in parallel. pub(crate) fn verify_with_progress( &self, output_files: &[OutputFileChecksum], - mut progress: Option<&mut ArchiveVerificationProgress<'_>>, + progress: Option<&ArchiveVerificationProgress<'_>>, ) -> Result { if output_files.is_empty() { return Ok(false); } + let mut paths = Vec::with_capacity(output_files.len()); for expected in output_files { let output_path = self.output_path(&expected.path); - let meta = match fs::metadata(&output_path) { - Ok(meta) => meta, - Err(_) => return Ok(false), - }; - if meta.len() != expected.size { - return Ok(false); - } - - let actual = Self::file_blake3_hex(&output_path, progress.as_deref_mut())?; - if !actual.eq_ignore_ascii_case(&expected.blake3) { - return Ok(false); + match fs::metadata(&output_path) { + Ok(meta) if meta.len() == expected.size => paths.push(output_path), + Ok(_) => return Ok(false), + Err(FsPathError::Metadata { source, .. }) + if source.kind() == ErrorKind::NotFound => + { + return Ok(false) + } + Err(error) => return Err(error.into()), } } - Ok(true) + output_files + .par_iter() + .zip(paths) + .map(|(expected, path)| { + Self::file_blake3_hex(&path, expected.size, progress) + .map(|actual| actual.eq_ignore_ascii_case(&expected.blake3)) + }) + .find_any(|verified| !matches!(verified, Ok(true))) + .unwrap_or(Ok(true)) } /// Removes any declared output files so a fresh archive attempt can restart cleanly. @@ -62,7 +80,7 @@ impl<'a> OutputVerifier<'a> { } /// Resolves archive paths consistently for verification and retry cleanup. - fn output_path(&self, path: &str) -> std::path::PathBuf { + pub(crate) fn output_path(&self, path: &str) -> std::path::PathBuf { if let Some(static_files_dir) = self.static_files_dir && let Some(relative_path) = super::extract::static_file_relative_path(Path::new(path)) { @@ -72,33 +90,112 @@ impl<'a> OutputVerifier<'a> { } /// Computes the hex-encoded BLAKE3 checksum for one plain output file. + /// + /// The next buffer is read while the current one is hashed, so disk reads overlap hashing + /// instead of alternating with it. fn file_blake3_hex( path: &Path, - mut progress: Option<&mut ArchiveVerificationProgress<'_>>, + size: u64, + progress: Option<&ArchiveVerificationProgress<'_>>, ) -> Result { let mut file = fs::open(path)?; let mut hasher = Hasher::new(); - let mut buf = [0_u8; 64 * 1024]; + let buf_len = size.min(HASH_BUFFER_SIZE as u64) as usize; + let mut current = vec![0_u8; buf_len]; + let mut next = vec![0_u8; buf_len]; - loop { - let n = file.read(&mut buf)?; - if n == 0 { - break; - } - hasher.update(&buf[..n]); - if let Some(progress) = progress.as_deref_mut() { - progress.record_verified(n as u64); + let mut filled = read_full(&mut file, &mut current)?; + while filled > 0 { + let (_, read) = rayon::join( + || { + hasher.update_rayon(¤t[..filled]); + }, + || read_full(&mut file, &mut next), + ); + if let Some(progress) = progress { + progress.record_verified(filled as u64); } + filled = read?; + mem::swap(&mut current, &mut next); } Ok(hasher.finalize().to_hex().to_string()) } } +/// Reads until `buf` is full or the reader is exhausted, returning the number of bytes read. +/// +/// Full buffers keep every `update_rayon` call aligned, so BLAKE3 can hash it as parallel +/// subtrees. +fn read_full(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while filled < buf.len() { + match reader.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(error) if error.kind() == ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + Ok(filled) +} + #[cfg(test)] mod tests { use super::*; + fn checksum(path: &str, data: &[u8]) -> OutputFileChecksum { + OutputFileChecksum { + path: path.into(), + size: data.len() as u64, + blake3: blake3::hash(data).to_hex().to_string(), + } + } + + #[test] + fn verify_detects_each_kind_of_output_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let large: Vec = (0..2 * HASH_BUFFER_SIZE + 123).map(|i| (i % 251) as u8).collect(); + let small = b"small".to_vec(); + fs::write(dir.path().join("large"), &large).unwrap(); + fs::write(dir.path().join("small"), &small).unwrap(); + let outputs = vec![checksum("large", &large), checksum("small", &small)]; + let verifier = OutputVerifier::new(dir.path(), None); + + assert!(verifier.verify(&outputs).unwrap(), "matching multi-buffer outputs verify"); + + let mut uppercase = outputs.clone(); + uppercase[0].blake3 = uppercase[0].blake3.to_ascii_uppercase(); + assert!(verifier.verify(&uppercase).unwrap(), "checksums compare case-insensitively"); + + let mut flipped = large.clone(); + flipped[HASH_BUFFER_SIZE + 7] ^= 1; + fs::write(dir.path().join("large"), &flipped).unwrap(); + assert!(!verifier.verify(&outputs).unwrap(), "one flipped byte fails verification"); + + fs::write(dir.path().join("large"), &large[1..]).unwrap(); + assert!(!verifier.verify(&outputs).unwrap(), "size mismatch fails verification"); + + fs::remove_file(dir.path().join("large")).unwrap(); + assert!(!verifier.verify(&outputs).unwrap(), "missing file fails verification"); + + assert!(!verifier.verify(&[]).unwrap(), "archives without outputs never verify"); + } + + #[cfg(unix)] + #[test] + fn verify_returns_metadata_errors_other_than_not_found() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("file"), b"data").unwrap(); + let verifier = OutputVerifier::new(dir.path(), None); + + assert!(!verifier.verify(&[checksum("missing", b"data")]).unwrap()); + assert!( + verifier.verify(&[checksum("file/child", b"data")]).is_err(), + "a path through a regular file is an error, not a missing output" + ); + } + #[test] fn custom_static_files_verification_and_cleanup() { let datadir = tempfile::tempdir().unwrap(); diff --git a/crates/cli/commands/src/init_state/without_evm.rs b/crates/cli/commands/src/init_state/without_evm.rs index 483ceb5a727..39b9c942d81 100644 --- a/crates/cli/commands/src/init_state/without_evm.rs +++ b/crates/cli/commands/src/init_state/without_evm.rs @@ -1,16 +1,18 @@ use alloy_consensus::BlockHeader; use alloy_primitives::{BlockNumber, B256}; use alloy_rlp::Decodable; +use eyre::WrapErr; use reth_codecs::Compact; +use reth_fs_util::FsPathError; use reth_node_builder::NodePrimitives; use reth_primitives_traits::{SealedBlock, SealedHeader, SealedHeaderFor}; use reth_provider::{ - providers::StaticFileProvider, BlockWriter, ProviderResult, StageCheckpointWriter, - StaticFileProviderFactory, StaticFileWriter, + providers::StaticFileProvider, BlockWriter, ProviderError, ProviderResult, + StageCheckpointWriter, StaticFileProviderFactory, StaticFileWriter, }; use reth_stages::{StageCheckpoint, StageId}; use reth_static_file_types::StaticFileSegment; -use std::path::Path; +use std::{io::ErrorKind, path::Path}; use tracing::info; /// Reads the header RLP from a file and returns the Header. @@ -20,15 +22,18 @@ pub(crate) fn read_header_from_file(path: &Path) -> Result where H: Decodable, { - let buf = if let Ok(content) = reth_fs_util::read_to_string(path) { - alloy_primitives::hex::decode(content.trim())? - } else { - // If UTF-8 decoding fails, read as raw bytes - reth_fs_util::read(path)? + let buf = match reth_fs_util::read_to_string(path) { + Ok(content) => alloy_primitives::hex::decode(content.trim()) + .wrap_err_with(|| format!("failed to hex-decode header file {}", path.display()))?, + // Non-UTF-8 content is raw RLP bytes. + Err(FsPathError::Read { source, .. }) if source.kind() == ErrorKind::InvalidData => { + reth_fs_util::read(path)? + } + Err(err) => return Err(err.into()), }; - let header = H::decode(&mut &buf[..])?; - Ok(header) + H::decode(&mut &buf[..]) + .wrap_err_with(|| format!("failed to RLP-decode header file {}", path.display())) } /// Creates a dummy chain (with no transactions) up to the last EVM block and appends the @@ -116,6 +121,8 @@ where { let (tx, rx) = std::sync::mpsc::channel(); + let mut handles = Vec::new(); + // Spawn jobs for incrementing the block end range of transactions, receipts, and senders. for segment in [ StaticFileSegment::Transactions, @@ -133,7 +140,7 @@ where StaticFileSegment::TransactionSenders => "init-state-senders", _ => "init-state-segment", }; - reth_tasks::spawn_os_thread(thread_name, move || { + let handle = reth_tasks::spawn_os_thread(thread_name, move || { let result = provider.latest_writer(segment).and_then(|mut writer| { for block_num in 1..=target_height { writer.increment_block(block_num)?; @@ -141,13 +148,15 @@ where Ok(()) }); - tx_clone.send(result).unwrap(); + // The receiver is gone if it already returned an earlier error. + let _ = tx_clone.send(result); }); + handles.push((thread_name, handle)); } // Spawn job for appending empty headers let provider = sf_provider.clone(); - reth_tasks::spawn_os_thread("init-state-headers", move || { + let handle = reth_tasks::spawn_os_thread("init-state-headers", move || { let result = provider.latest_writer(StaticFileSegment::Headers).and_then(|mut writer| { for block_num in 1..=target_height { // TODO: should we fill with real parent_hash? @@ -157,8 +166,9 @@ where Ok(()) }); - tx.send(result).unwrap(); + let _ = tx.send(result); }); + handles.push(("init-state-headers", handle)); // Catches any StaticFileWriter error. while let Ok(append_result) = rx.recv() { @@ -168,6 +178,15 @@ where } } + // A panicked worker drops its sender without reporting a result. + for (name, handle) in handles { + if handle.join().is_err() { + let err = std::io::Error::other(format!("init-state worker {name} panicked")); + tracing::error!(target: "reth::cli", "Error appending dummy chain: {err}"); + return Err(ProviderError::other(err)) + } + } + // If, for any reason, rayon crashes this verifies if all segments are at the same // target_height. for segment in [ diff --git a/crates/cli/commands/src/p2p/bootnode.rs b/crates/cli/commands/src/p2p/bootnode.rs index cacc6f041ed..ee5b7ab9c03 100644 --- a/crates/cli/commands/src/p2p/bootnode.rs +++ b/crates/cli/commands/src/p2p/bootnode.rs @@ -5,11 +5,13 @@ use reth_cli_util::{get_secret_key, load_secret_key::rng_secret_key}; use reth_discv4::{DiscoveryUpdate, Discv4, Discv4Config}; use reth_discv5::{ discv5::{self, Event, ListenConfig}, - Config, Discv5, + enr::EnrCombinedKeyWrapper, + Config, Discv5, IpMode, }; use reth_net_nat::NatResolver; -use reth_network_peers::NodeRecord; +use reth_network_peers::{id2pk, pk2id, AnyNode, Enr, NodeRecord}; use secp256k1::SecretKey; +use socket2::{Domain, Protocol, Socket, Type}; use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, path::PathBuf, @@ -17,7 +19,7 @@ use std::{ }; use tokio::{net::UdpSocket, select}; use tokio_stream::StreamExt; -use tracing::info; +use tracing::{info, warn}; /// Start a discovery only bootnode. #[derive(Parser, Debug)] @@ -37,14 +39,23 @@ pub struct Command { /// NAT resolution method (any|none|upnp|publicip|extip:\). /// - /// Can be repeated with one IPv4 and one IPv6 `extip:` to advertise a dual-stack discv5 - /// ENR (discv4 binds a single socket and always advertises only the `--addr` family). + /// Can be repeated with one IPv4 and one IPv6 `extip:` to advertise a dual-stack node. + /// The enode advertises only the `--addr` family; the discv5 and EIP-868 ENRs carry both + /// families, and with `--v5` discv4 also answers the opposite family on the shared port. #[arg(long, default_value = "any")] pub nat: Vec, /// Also run discv5, sharing the discv4 UDP port (`--addr`). + /// + /// The opposite-family sibling socket is only bound when an `extip:` of that family is + /// advertised via `--nat`; the default `--nat any` serves the `--addr` family only. #[arg(long)] pub v5: bool, + + /// Comma-separated nodes to seed discovery: `enode://` URLs with an IP host and/or signed + /// `enr:` records. Each entry seeds discv4 and (with `--v5`) discv5. + #[arg(long, value_delimiter = ',')] + pub bootnodes: Vec, } impl Command { @@ -57,8 +68,9 @@ impl Command { // (`enode://…@:0?discport=`). let local_enr = NodeRecord::from_secret_key(self.addr, &sk).with_tcp_port(0); let nat = self.resolved_nat()?; + let seeds = self.seed_nodes(&nat)?; - let discv4_config = self.discv4_config(&nat); + let discv4_config = self.discv4_config(&nat, &seeds); // In v5 mode discv4 and discv5 share a single UDP socket: discv5 owns the read loop and // discv4 packets surface as `UnrecognizedFrame`s forwarded to its ingress. The discv5 @@ -68,30 +80,31 @@ impl Command { // Actual port, so an ephemeral `--addr` port (`:0`) still yields one shared port. let shared_port = shared_socket.local_addr()?.port(); - let (discv4, discv4_service, mut ingress) = - Discv4::bind_shared(shared_socket.clone(), local_enr, sk, discv4_config)?; - info!("Started discv4 (shared port) at address: {local_enr:?}"); - // Hand discv5 the shared socket and bind the opposite family (if advertised) on the - // same port. - let mut discv5_config = self.discv5_config(&nat); + // same port. That sibling socket doubles as discv4's secondary-family egress, so + // discv4 answers both families on the shared port. + let mut discv5_config = self.discv5_config(&nat, &seeds); let discv5_cfg = discv5_config.discv5_config_mut(); - let (mut ipv4, mut ipv6) = (None, None); - if self.addr.is_ipv4() { - ipv4 = Some(shared_socket); - if let Some(mut addr) = reth_discv5::config::ipv6(&discv5_cfg.listen_config) { - addr.set_port(shared_port); - ipv6 = Some(bind_socket(SocketAddr::V6(addr)).await?); - } + let secondary_addr = reth_discv5::config::sibling_addr( + &discv5_cfg.listen_config, + self.addr, + shared_port, + ); + let secondary_socket = match secondary_addr { + Some(addr) => Some(bind_socket(addr).await?), + None => None, + }; + let (ipv4, ipv6) = if self.addr.is_ipv4() { + (Some(shared_socket.clone()), secondary_socket.clone()) } else { - ipv6 = Some(shared_socket); - if let Some(mut addr) = reth_discv5::config::ipv4(&discv5_cfg.listen_config) { - addr.set_port(shared_port); - ipv4 = Some(bind_socket(SocketAddr::V4(addr)).await?); - } - } + (secondary_socket.clone(), Some(shared_socket.clone())) + }; discv5_cfg.listen_config = ListenConfig::FromSockets { ipv4, ipv6 }; + let (discv4, discv4_service, mut ingress) = + Discv4::bind_shared(shared_socket, secondary_socket, local_enr, sk, discv4_config)?; + info!("Started discv4 (shared port) at address: {local_enr:?}"); + info!("Starting discv5 (shared port)"); let (discv5, mut updates) = Discv5::start(&sk, discv5_config).await?; log_discv5_enr(&discv5); @@ -167,8 +180,8 @@ impl Command { /// /// A single value behaves like the rest of the node: the resolver drives discv4's external IP /// resolution, and any fixed IP is also advertised in the discv5 ENR. Two `extip:` values - /// (one per family) advertise a dual-stack discv5 record; discv4 advertises only the family - /// matching `--addr`, since it binds a single socket. + /// (one per family) advertise a dual-stack record; the enode advertises only the family + /// matching `--addr`, since discv4's own record carries a single address. fn resolved_nat(&self) -> eyre::Result { match self.nat.as_slice() { [] => Ok(BootnodeNat::single(NatResolver::Any, self.addr.port())), @@ -208,11 +221,114 @@ impl Command { } } - fn discv4_config(&self, nat: &BootnodeNat) -> Discv4Config { - Discv4Config::builder().external_ip_resolver(Some(nat.resolver.clone())).build() + fn discv4_config(&self, nat: &BootnodeNat, seeds: &BootnodeSeeds) -> Discv4Config { + let mut builder = Discv4Config::builder(); + builder.external_ip_resolver(Some(nat.resolver.clone())).add_boot_nodes( + seeds.discv4_records().filter(|record| { + let served = self.serves_family(record.address, nat); + if !served { + warn!( + "No socket will be bound for the IP family of --bootnodes entry {record}; \ + it will not be contacted (advertise a matching --nat extip with --v5)" + ); + } + served + }), + ); + // the opposite-family address is only served with the `--v5` sibling socket + if self.v5 && + let Some(ip) = + nat.advertised_ips.iter().find(|ip| ip.is_ipv4() != self.addr.is_ipv4()) + { + builder.secondary_advertised_ip(*ip); + } + builder.build() } - fn discv5_config(&self, nat: &BootnodeNat) -> Config { + /// Parses `--bootnodes`, keeping enode-sourced records separate from signed ENRs so an ENR + /// entry is not seeded into discv5 twice (signed and again as an unsigned enode). + fn seed_nodes(&self, nat: &BootnodeNat) -> eyre::Result { + let mut seeds = BootnodeSeeds::default(); + for node in &self.bootnodes { + let record = match node { + AnyNode::NodeRecord(record) => { + // v4-mapped hosts parse as V6; normalize them so they route as IPv4 + let record = record.into_ipv4_mapped(); + id2pk(record.id).map_err(|err| { + eyre::eyre!("--bootnodes entry has an invalid node id ({err}): {node}") + })?; + seeds.enodes.push(record); + record + } + AnyNode::Enr(enr) => { + let record = self.enr_endpoint(enr, nat)?; + seeds.enr_records.push(record); + seeds.enrs.push(EnrCombinedKeyWrapper::from(enr.clone()).0); + record + } + node => eyre::bail!( + "--bootnodes entries must be enode URLs with an IP host or signed ENRs: {node}" + ), + }; + if record.udp_port == 0 { + eyre::bail!("--bootnodes entry has no discovery port: {node}"); + } + } + Ok(seeds) + } + + /// Derives an ENR seed's discv4 endpoint, preferring a family this bootnode serves over the + /// default order (IPv4 first). + /// + /// Endpoints are derived per IP family so a partial dual-stack ENR (e.g. `ip4` without + /// `udp4`, plus `ip6`/`udp6`) cannot yield a mixed-family socket. + /// + /// Intentionally not `NodeRecord::try_from(&Enr)`: this needs to prefer the family this + /// bootnode serves instead of that conversion's IPv4-first order. + fn enr_endpoint(&self, enr: &Enr, nat: &BootnodeNat) -> eyre::Result { + let id = pk2id(&enr.public_key()); + let v4 = enr.ip4().zip(enr.udp4()).map(|(ip, udp_port)| NodeRecord { + address: ip.into(), + udp_port, + tcp_port: enr.tcp4().unwrap_or(0), + id, + }); + // per EIP-778, generic `udp`/`tcp` keys also apply to IPv6 when the v6-specific + // counterparts are absent + let v6 = enr.ip6().zip(enr.udp6().or_else(|| enr.udp4())).map(|(ip, udp_port)| { + NodeRecord { + address: ip.into(), + udp_port, + tcp_port: enr.tcp6().or_else(|| enr.tcp4()).unwrap_or(0), + id, + } + .into_ipv4_mapped() + }); + + // an unservable endpoint is kept as a last resort (the startup warning covers it), and a + // zero-udp endpoint only after that, so `seed_nodes` reports the missing port + let nonzero = |record: &NodeRecord| record.udp_port != 0; + v4.into_iter() + .chain(v6) + .filter(nonzero) + .find(|record| self.serves_family(record.address, nat)) + .or_else(|| v4.filter(nonzero)) + .or_else(|| v6.filter(nonzero)) + .or(v4) + .or(v6) + .ok_or_else(|| { + eyre::eyre!("unusable --bootnodes ENR (no ip+udp endpoint for one family): {enr}") + }) + } + + /// Whether a socket of `ip`'s family will be bound: the `--addr` family always, the opposite + /// family only when `--v5` binds its sibling socket (an address of that family is advertised). + fn serves_family(&self, ip: IpAddr, nat: &BootnodeNat) -> bool { + ip.is_ipv4() == self.addr.is_ipv4() || + (self.v5 && nat.advertised_ips.iter().any(|a| a.is_ipv4() == ip.is_ipv4())) + } + + fn discv5_config(&self, nat: &BootnodeNat, seeds: &BootnodeSeeds) -> Config { // Discovery-only bootnode: no RLPx, so the ENR carries `tcp=0`; the discovery listen // port is set from `--addr` below. let mut builder = Config::builder(SocketAddr::new(self.addr.ip(), 0)); @@ -222,7 +338,7 @@ impl Command { // Bind every family we advertise (plus the `--addr` family). Without a listen socket of a // given family, `build_local_enr` drops that family's advertised IP even though setting it - // disables `enr_update` — which would leave discv5 with no usable record. + // disables `enr_update`, which would leave discv5 with no usable record. let bind_ipv4 = self.addr.is_ipv4() || nat.advertised_ips.iter().any(IpAddr::is_ipv4); let bind_ipv6 = self.addr.is_ipv6() || nat.advertised_ips.iter().any(IpAddr::is_ipv6); let listen = if bind_ipv4 && bind_ipv6 { @@ -243,6 +359,26 @@ impl Command { builder = builder.advertised_ip(*ip); } + // discv5's bootstrap aborts on an ENR it cannot contact (AddNodeFailed) and wastes a + // request_enr round-trip on an unreachable enode. Seed only what the listen config serves + let enrs = seeds.enrs.iter().filter(|enr| { + let contactable = discv5_contactable(enr, bind_ipv4, bind_ipv6); + if !contactable { + warn!("--bootnodes ENR has no endpoint for a served IP family; discv5 will not dial it: {enr}"); + } + contactable + }); + let enodes = seeds.enodes.iter().filter(|record| { + if record.address.is_ipv4() { + bind_ipv4 + } else { + bind_ipv6 + } + }); + + builder = + builder.add_signed_boot_nodes(enrs.cloned()).add_unsigned_boot_nodes(enodes.copied()); + builder.build() } } @@ -263,6 +399,24 @@ impl BootnodeNat { } } +/// Parsed `--bootnodes`, split by record kind. +#[derive(Debug, Default)] +struct BootnodeSeeds { + /// `enode://` entries: seed discv4 directly and discv5 as unsigned boot nodes. + enodes: Vec, + /// Signed `enr:` entries: seed discv5. + enrs: Vec, + /// Endpoints derived from `enrs`: seed discv4. + enr_records: Vec, +} + +impl BootnodeSeeds { + /// All discv4 bootstrap records (enode entries + ENR-derived endpoints). + fn discv4_records(&self) -> impl Iterator + '_ { + self.enodes.iter().chain(&self.enr_records).copied() + } +} + /// Binds a UDP socket for shared discv4/discv5 use. /// /// IPv6 sockets are bound with `IPV6_V6ONLY=true` so an IPv4 sibling socket on the same port @@ -271,7 +425,6 @@ async fn bind_socket(addr: SocketAddr) -> eyre::Result> { let socket = match addr { SocketAddr::V4(_) => UdpSocket::bind(addr).await?, SocketAddr::V6(_) => { - use socket2::{Domain, Protocol, Socket, Type}; let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?; socket.set_only_v6(true)?; socket.set_nonblocking(true)?; @@ -291,6 +444,19 @@ fn fixed_external_ip(nat: &NatResolver) -> eyre::Result { } } +/// Whether discv5 can dial `enr` with the sockets it binds, delegating to +/// [`discv5::IpMode::get_contactable_addr`]. A zero-port endpoint passes the ip mode check but is +/// not dialable, so it is rejected too. +fn discv5_contactable(enr: &discv5::Enr, bind_ipv4: bool, bind_ipv6: bool) -> bool { + match (bind_ipv4, bind_ipv6) { + (true, true) => IpMode::DualStack, + (false, true) => IpMode::Ip6, + _ => IpMode::Ip4, + } + .get_contactable_addr(enr) + .is_some_and(|addr| addr.port() != 0) +} + fn log_discv5_enr(discv5: &Discv5) { let enr = discv5.local_enr(); info!( @@ -371,7 +537,7 @@ mod tests { "extip:2001:db8::1", ]); let nat = command.resolved_nat().unwrap(); - let config = command.discv5_config(&nat); + let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap()); let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap(); let (enr, _, _, _) = build_local_enr(&sk, &config); @@ -393,7 +559,7 @@ mod tests { "extip:1.2.3.4", ]); let nat = command.resolved_nat().unwrap(); - let config = command.discv5_config(&nat); + let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap()); let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap(); let (enr, _, _, _) = build_local_enr(&sk, &config); @@ -415,7 +581,7 @@ mod tests { "extip:2001:db8::1", ]); let nat = command.resolved_nat().unwrap(); - let config = command.discv5_config(&nat); + let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap()); let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap(); let (enr, _, _, _) = build_local_enr(&sk, &config); @@ -462,10 +628,10 @@ mod tests { } #[test] - fn discv4_config_advertises_only_the_addr_family() { - // discv4 binds a single socket, so it must not advertise the secondary (IPv6) family it - // cannot serve; the IPv4 resolver drives discv4 and only discv5 carries both families. - let command = Command::parse_from([ + fn discv4_config_serves_secondary_family_only_with_v5() { + // The IPv4 resolver drives discv4's own record either way; the v6 address is only + // advertised (EIP-868 ENR) when the `--v5` sibling socket exists to serve it. + let args = [ "reth", "--addr", "0.0.0.0:30303", @@ -473,12 +639,273 @@ mod tests { "extip:1.2.3.4", "--nat", "extip:2001:db8::1", - ]); + ]; + + let command = Command::parse_from(args); let nat = command.resolved_nat().unwrap(); - let config = command.discv4_config(&nat); + let config = command.discv4_config(&nat, &command.seed_nodes(&nat).unwrap()); + assert_eq!(nat.resolver, NatResolver::ExternalIp("1.2.3.4".parse().unwrap())); + assert_eq!(config.secondary_advertised_ip, None); + let command = Command::parse_from(args.iter().chain(&["--v5"])); + let nat = command.resolved_nat().unwrap(); + let config = command.discv4_config(&nat, &command.seed_nodes(&nat).unwrap()); assert_eq!(nat.resolver, NatResolver::ExternalIp("1.2.3.4".parse().unwrap())); - assert!(config.additional_eip868_rlp_pairs.is_empty()); + assert_eq!(config.secondary_advertised_ip, Some("2001:db8::1".parse().unwrap())); + } + + #[test] + fn bootnodes_seed_discv4_and_discv5() { + // The EIP-778 example record: ip 127.0.0.1, udp 30303, no tcp key (discovery-only). + let enr = "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8"; + let enode = "enode://d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666@1.2.3.4:0?discport=30303"; + + let command = Command::parse_from(["reth", "--bootnodes", &format!("{enode},{enr}")]); + let nat = command.resolved_nat().unwrap(); + let seeds = command.seed_nodes(&nat).unwrap(); + + assert_eq!(seeds.enodes.len(), 1); + assert_eq!(seeds.enodes[0].address, "1.2.3.4".parse::().unwrap()); + assert_eq!(seeds.enodes[0].udp_port, 30303); + assert_eq!(seeds.enodes[0].tcp_port, 0); + // the ENR seeds discv4 too, with an absent tcp key meaning no RLPx, but stays out of + // `enodes` so discv5 doesn't bootstrap it twice (signed + unsigned) + assert_eq!(seeds.enr_records.len(), 1); + assert_eq!(seeds.enr_records[0].address, "127.0.0.1".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30303); + assert_eq!(seeds.enr_records[0].tcp_port, 0); + assert_eq!(seeds.enrs.len(), 1); + assert_eq!(seeds.enrs[0].udp4(), Some(30303)); + + let config = command.discv4_config(&command.resolved_nat().unwrap(), &seeds); + assert_eq!(config.bootstrap_nodes.len(), 2); + } + + #[test] + fn discv4_config_filters_unserved_bootnodes() { + let peer_id = "d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666"; + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--bootnodes", + &format!("enode://{peer_id}@[2001:db8::1]:0?discport=30303"), + ]); + let nat = command.resolved_nat().unwrap(); + let seeds = command.seed_nodes(&nat).unwrap(); + + let config = command.discv4_config(&nat, &seeds); + + assert!(config.bootstrap_nodes.is_empty()); + } + + #[test] + fn bootnodes_normalize_and_validate_enodes() { + let peer_id = "d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666"; + + // a v4-mapped host normalizes to IPv4 + let command = Command::parse_from([ + "reth", + "--bootnodes", + &format!("enode://{peer_id}@[::ffff:9.9.9.9]:0?discport=30303"), + ]); + let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap(); + assert_eq!(seeds.enodes[0].address, "9.9.9.9".parse::().unwrap()); + + // a zero discovery port can never be pinged + let command = + Command::parse_from(["reth", "--bootnodes", &format!("enode://{peer_id}@1.2.3.4:0")]); + assert!(command.seed_nodes(&command.resolved_nat().unwrap()).is_err()); + + // an id off the secp256k1 curve would be silently dropped by discv5's bootstrap + let bad_id = "ff".repeat(64); + let command = Command::parse_from([ + "reth", + "--bootnodes", + &format!("enode://{bad_id}@1.2.3.4:30303"), + ]); + assert!(command.seed_nodes(&command.resolved_nat().unwrap()).is_err()); + } + + #[test] + fn enr_seed_prefers_served_family() { + let dual = Enr::builder() + .ip4("9.9.9.9".parse().unwrap()) + .udp4(30303) + .ip6("2001:db8::9".parse().unwrap()) + .udp6(30304) + .build(&rng_secret_key()) + .unwrap() + .to_base64(); + + // v6-primary: the discv4 seed must be the ENR's IPv6 endpoint, not the ip4-first default + let command = Command::parse_from([ + "reth", + "--addr", + "[::]:30303", + "--nat", + "extip:2001:db8::1", + "--bootnodes", + &dual, + ]); + let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap(); + assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30304); + + // v4-primary keeps the IPv4 endpoint + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--nat", + "extip:1.2.3.4", + "--bootnodes", + &dual, + ]); + let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap(); + assert_eq!(seeds.enr_records[0].address, "9.9.9.9".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30303); + } + + #[test] + fn enr_seed_never_mixes_families() { + let sk = rng_secret_key(); + let enr = Enr::builder() + .ip4("9.9.9.9".parse().unwrap()) + .ip6("2001:db8::9".parse().unwrap()) + .udp6(30304) + .build(&sk) + .unwrap(); + + // v4-primary bootnode: the lone ip4 key is not an endpoint; the discv4 seed must be the + // complete v6 endpoint, not a fabricated 9.9.9.9:30304 + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--nat", + "extip:1.2.3.4", + "--bootnodes", + &enr.to_base64(), + ]); + let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap(); + assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30304); + + // no complete endpoint for any family is an error + let enr = Enr::builder().ip4("9.9.9.9".parse().unwrap()).build(&sk).unwrap(); + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--nat", + "extip:1.2.3.4", + "--bootnodes", + &enr.to_base64(), + ]); + let err = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap_err(); + assert_eq!( + err.to_string(), + format!("unusable --bootnodes ENR (no ip+udp endpoint for one family): {enr}") + ); + } + + #[test] + fn enr_seed_uses_generic_ports_for_ip6() { + let sk = rng_secret_key(); + let enr = Enr::builder() + .ip6("2001:db8::9".parse().unwrap()) + .udp4(30305) + .tcp4(30306) + .build(&sk) + .unwrap(); + + let command = + Command::parse_from(["reth", "--addr", "[::]:30303", "--bootnodes", &enr.to_base64()]); + let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap(); + assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30305); + assert_eq!(seeds.enr_records[0].tcp_port, 30306); + } + + #[test] + fn enr_seed_skips_zero_udp_family() { + let sk = rng_secret_key(); + let enr = Enr::builder() + .ip4("9.9.9.9".parse().unwrap()) + .udp4(0) + .ip6("2001:db8::9".parse().unwrap()) + .udp6(30304) + .build(&sk) + .unwrap(); + + // v4-primary: the zero-udp v4 endpoint must not win over the usable v6 endpoint + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--nat", + "extip:1.2.3.4", + "--bootnodes", + &enr.to_base64(), + ]); + let seeds = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap(); + assert_eq!(seeds.enr_records[0].address, "2001:db8::9".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30304); + + // discv5 must not seed the zero-port v4 endpoint either + assert!(!discv5_contactable(&seeds.enrs[0], true, false)); + assert!(discv5_contactable(&seeds.enrs[0], false, true)); + + // zero udp on every family is still an unusable entry + let enr = Enr::builder().ip4("9.9.9.9".parse().unwrap()).udp4(0).build(&sk).unwrap(); + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--bootnodes", + &enr.to_base64(), + ]); + let err = command.seed_nodes(&command.resolved_nat().unwrap()).unwrap_err(); + assert_eq!(err.to_string(), format!("--bootnodes entry has no discovery port: {enr}")); + } + + #[test] + fn enr_seed_normalizes_v4_mapped_ip6() { + let sk = rng_secret_key(); + let enr = + Enr::builder().ip6("::ffff:1.2.3.4".parse().unwrap()).udp6(30303).build(&sk).unwrap(); + + let command = Command::parse_from([ + "reth", + "--addr", + "0.0.0.0:30303", + "--nat", + "extip:9.9.9.9", + "--bootnodes", + &enr.to_base64(), + ]); + let nat = command.resolved_nat().unwrap(); + let seeds = command.seed_nodes(&nat).unwrap(); + assert_eq!(seeds.enr_records[0].address, "1.2.3.4".parse::().unwrap()); + assert_eq!(seeds.enr_records[0].udp_port, 30303); + assert_eq!(command.discv4_config(&nat, &seeds).bootstrap_nodes.len(), 1); + + assert!(!discv5_contactable(&seeds.enrs[0], true, true)); + assert!(!discv5_contactable(&seeds.enrs[0], false, true)); + + let canonical = + Enr::builder().ip6("2001:db8::9".parse().unwrap()).udp6(30303).build(&sk).unwrap(); + let canonical = EnrCombinedKeyWrapper::from(canonical).0; + assert!(discv5_contactable(&canonical, false, true)); + } + + #[test] + fn bootnodes_reject_endpointless_entries() { + let peer_id = "d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666"; + + let command = Command::parse_from(["reth", "--bootnodes", &format!("enode://{peer_id}")]); + assert!(command.seed_nodes(&command.resolved_nat().unwrap()).is_err()); } #[test] @@ -494,7 +921,7 @@ mod tests { "extip:2001:db8::1", ]); let nat = command.resolved_nat().unwrap(); - let config = command.discv5_config(&nat); + let config = command.discv5_config(&nat, &command.seed_nodes(&nat).unwrap()); let sk = SecretKey::from_byte_array(&[1u8; 32]).unwrap(); let (enr, _, _, _) = build_local_enr(&sk, &config); diff --git a/crates/cli/commands/src/p2p/enode.rs b/crates/cli/commands/src/p2p/enode.rs index eb58acb2905..adf1a69f53d 100644 --- a/crates/cli/commands/src/p2p/enode.rs +++ b/crates/cli/commands/src/p2p/enode.rs @@ -1,8 +1,9 @@ //! Enode identifier command use clap::Parser; -use reth_cli_util::get_secret_key; +use eyre::WrapErr; use reth_network_peers::NodeRecord; +use secp256k1::SecretKey; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf, @@ -19,16 +20,38 @@ pub struct Command { /// If not provided, defaults to 0.0.0.0. #[arg(long)] pub ip: Option, + + /// Port to include in the enode URL. + #[arg(long, default_value_t = 30303)] + pub port: u16, } impl Command { /// Execute the enode command. pub fn execute(self) -> eyre::Result<()> { - let sk = get_secret_key(&self.discovery_secret)?; + let contents = reth_fs_util::read_to_string(&self.discovery_secret)?; + // Parse exactly like the node's key loader so only keys it accepts produce an enode. + let sk = contents.parse::().wrap_err_with(|| { + format!("Invalid secret key in {}", self.discovery_secret.display()) + })?; let ip = self.ip.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - let addr = SocketAddr::new(ip, 30303); + let addr = SocketAddr::new(ip, self.port); let enr = NodeRecord::from_secret_key(addr, &sk); println!("{enr}"); Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_secret_key_is_not_generated() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("key"); + let cmd = Command::parse_from(["enode", path.to_str().unwrap()]); + assert!(cmd.execute().is_err()); + assert!(!path.exists()); + } +} diff --git a/crates/cli/commands/src/p2p/mod.rs b/crates/cli/commands/src/p2p/mod.rs index d1f1a83bee8..2bd218bfc34 100644 --- a/crates/cli/commands/src/p2p/mod.rs +++ b/crates/cli/commands/src/p2p/mod.rs @@ -6,6 +6,7 @@ use crate::common::CliNodeTypes; use alloy_eips::BlockHashOrNumber; use backon::{ConstantBuilder, Retryable}; use clap::{Parser, Subcommand}; +use eyre::WrapErr; use reth_chainspec::{EthChainSpec, EthereumHardforks, Hardforks}; use reth_cli::chainspec::ChainSpecParser; use reth_cli_util::hash_or_num_value_parser; @@ -16,6 +17,7 @@ use reth_node_core::{ args::{DatadirArgs, NetworkArgs}, utils::get_single_header, }; +use reth_primitives_traits::BlockBody; use reth_tasks::Runtime; pub mod bootnode; @@ -79,7 +81,7 @@ impl ); } let body = result.into_iter().next().unwrap(); - tracing::info!(target: "reth::cli", ?body, "Successfully downloaded body") + tracing::info!(target: "reth::cli", %hash, txs = body.transaction_count(), "Successfully downloaded body") } Subcommands::Rlpx(command) => { command.execute().await?; @@ -171,14 +173,15 @@ impl DownloadArgs { &self, ) -> eyre::Result> where - C::ChainSpec: EthChainSpec + Hardforks + EthereumHardforks + Send + Sync + 'static, + C::ChainSpec: EthChainSpec + Hardforks + EthereumHardforks + 'static, N: CliNodeTypes, { let data_dir = self.datadir.clone().resolve_datadir(self.chain.chain()); let config_path = self.config.clone().unwrap_or_else(|| data_dir.config()); // Load configuration - let mut config = Config::from_path(&config_path).unwrap_or_default(); + let mut config = Config::from_path(&config_path) + .wrap_err_with(|| format!("Could not load config file {config_path:?}"))?; config.peers.trusted_nodes.extend(self.network.trusted_peers.clone()); diff --git a/crates/cli/commands/src/re_execute.rs b/crates/cli/commands/src/re_execute.rs index 5dee49840db..ee6d83ba764 100644 --- a/crates/cli/commands/src/re_execute.rs +++ b/crates/cli/commands/src/re_execute.rs @@ -49,7 +49,7 @@ pub struct Command { env: EnvironmentArgs, /// The height to start at. - #[arg(long, default_value = "1")] + #[arg(long, default_value = "1", value_parser = clap::value_parser!(u64).range(1..))] from: u64, /// The height to end at. Defaults to the latest block. @@ -57,11 +57,11 @@ pub struct Command { to: Option, /// Number of tasks to run in parallel. Defaults to the number of available CPUs. - #[arg(long)] + #[arg(long, value_parser = clap::value_parser!(u64).range(1..))] num_tasks: Option, /// Number of blocks each worker processes before grabbing the next chunk. - #[arg(long, default_value = "5000")] + #[arg(long, default_value = "5000", value_parser = clap::value_parser!(u64).range(1..))] blocks_per_chunk: u64, /// Continues with execution when an invalid block is encountered and collects these blocks. @@ -157,15 +157,16 @@ impl let db_at = { |block_number: u64| { let provider = provider_factory - .database_provider_ro() - .unwrap() + .database_provider_ro()? .disable_long_read_transaction_safety(); - let hash = provider.block_hash(block_number).unwrap().unwrap(); - StateProviderDatabase( + let hash = provider.block_hash(block_number)?.ok_or_else(|| { + eyre::eyre!("hash for block {block_number} not found in the database") + })?; + eyre::Ok(StateProviderDatabase( state_provider_factory .state_provider_from_database(provider, hash) .into_evm_state_provider(), - ) + )) } }; @@ -182,7 +183,7 @@ impl } let chunk_end = (chunk_start + blocks_per_chunk).min(max_block); - let mut executor = evm_config.batch_executor(db_at(chunk_start - 1)); + let mut executor = evm_config.batch_executor(db_at(chunk_start - 1)?); let mut executor_created = Instant::now(); 'blocks: for block in chunk_start..chunk_end { @@ -192,14 +193,14 @@ impl let block = provider_factory .recovered_block(block.into(), TransactionVariant::NoHash)? - .unwrap(); + .ok_or_else(|| eyre::eyre!("block {block} not found in the database"))?; let result = match executor.execute_one(&block) { Ok(result) => result, Err(err) => { if skip_invalid_blocks { executor = - evm_config.batch_executor(db_at(block.number())); + evm_config.batch_executor(db_at(block.number())?); let _ = info_tx.send((block, eyre::Report::new(err))); continue @@ -224,7 +225,12 @@ impl { let correct_receipts = provider_factory .receipts_by_block(block.number().into())? - .unwrap(); + .ok_or_else(|| { + eyre::eyre!( + "receipts for block {} not found in the database", + block.number() + ) + })?; for (i, (receipt, correct_receipt)) in result.receipts.iter().zip(correct_receipts.iter()).enumerate() @@ -262,7 +268,7 @@ impl error!(number=?block.number(), ?mismatch, "Gas usage mismatch"); if skip_invalid_blocks { executor = evm_config - .batch_executor(db_at(block.number())); + .batch_executor(db_at(block.number())?); let _ = info_tx.send((block, err)); continue 'blocks; } @@ -275,7 +281,7 @@ impl if skip_invalid_blocks { executor = - evm_config.batch_executor(db_at(block.number())); + evm_config.batch_executor(db_at(block.number())?); let _ = info_tx.send((block, err)); continue 'blocks; } @@ -290,7 +296,7 @@ impl let last_block = block.number(); let old_executor = std::mem::replace( &mut executor, - evm_config.batch_executor(db_at(last_block)), + evm_config.batch_executor(db_at(last_block)?), ); let bundle = old_executor.into_state().take_bundle(); verify_bundle_against_changesets( @@ -340,13 +346,13 @@ impl invalid_blocks.push(block.num_hash()); } result = tasks.join_next() => { - if let Some(result) = result { - if matches!(result, Err(_) | Ok(Err(_))) { - error!(?result); - return Err(eyre::eyre!("Re-execution failed: {result:?}")); + let Some(result) = result else { break }; + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err.wrap_err("Re-execution failed")), + Err(err) => { + return Err(eyre::Report::new(err).wrap_err("Re-execution failed")) } - } else { - break; } } _ = interval.tick() => { diff --git a/crates/cli/commands/src/stage/dump/mod.rs b/crates/cli/commands/src/stage/dump/mod.rs index 9cadf8ab592..fed69e15714 100644 --- a/crates/cli/commands/src/stage/dump/mod.rs +++ b/crates/cli/commands/src/stage/dump/mod.rs @@ -74,6 +74,20 @@ pub struct StageCommand { dry_run: bool, } +impl StageCommand { + /// Validates the block range before any database is opened. + fn validate(&self) -> eyre::Result<()> { + eyre::ensure!(self.from > 0, "`--from` must be at least 1"); + eyre::ensure!( + self.from < self.to, + "`--from` ({}) must be lower than `--to` ({})", + self.from, + self.to + ); + Ok(()) + } +} + macro_rules! handle_stage { ($stage_fn:ident, $tool:expr, $command:expr, $runtime:expr) => {{ let StageCommand { output_datadir, from, to, dry_run, .. } = $command; @@ -103,6 +117,13 @@ impl> Command Comp: CliNodeComponents, F: FnOnce(Arc) -> Comp, { + match &self.command { + Stages::Execution(cmd) | + Stages::StorageHashing(cmd) | + Stages::AccountHashing(cmd) | + Stages::Merkle(cmd) => cmd.validate()?, + } + // `unwind_and_copy` opens a RW provider on the source datadir, so open RW here. let Environment { provider_factory, .. } = self.env.init::(AccessRights::RW, runtime.clone())?; @@ -152,8 +173,6 @@ pub(crate) fn setup( output_db: &PathBuf, db_tool: &DbTool, ) -> eyre::Result<(DatabaseEnv, u64)> { - assert!(from < to, "FROM block should be lower than TO block."); - info!(target: "reth::cli", ?output_db, "Creating separate db"); let output_datadir = init_db(output_db, DatabaseArguments::new(ClientVersion::default()))?; @@ -170,7 +189,7 @@ pub(crate) fn setup( .provider_factory .db_ref() .view(|tx| tx.cursor_read::()?.last())?? - .expect("some"); + .ok_or_else(|| eyre::eyre!("`BlockBodyIndices` table of the source database is empty"))?; Ok((output_datadir, tip_block_number)) } diff --git a/crates/cli/commands/src/stage/run.rs b/crates/cli/commands/src/stage/run.rs index c99946219d7..097dd8a93ab 100644 --- a/crates/cli/commands/src/stage/run.rs +++ b/crates/cli/commands/src/stage/run.rs @@ -4,7 +4,7 @@ use crate::common::{AccessRights, CliNodeComponents, CliNodeTypes, Environment, EnvironmentArgs}; use alloy_eips::BlockHashOrNumber; -use alloy_primitives::Sealable; +use backon::{ConstantBuilder, Retryable}; use clap::Parser; use reth_chainspec::{EthChainSpec, EthereumHardforks, Hardforks}; use reth_cli::chainspec::ChainSpecParser; @@ -17,10 +17,10 @@ use reth_downloaders::{ }; use reth_exex::ExExManagerHandle; use reth_network::BlockDownloaderProvider; -use reth_network_p2p::HeadersClient; use reth_node_builder::common::metrics_hooks; use reth_node_core::{ args::{NetworkArgs, StageEnum}, + utils::get_single_header, version::version_metadata, }; use reth_node_metrics::{ @@ -41,10 +41,15 @@ use reth_stages::{ }, ExecInput, ExecOutput, ExecutionStageThresholds, Stage, StageExt, UnwindInput, UnwindOutput, }; -use std::{any::Any, net::SocketAddr, sync::Arc}; +use std::{net::SocketAddr, sync::Arc}; use tokio::sync::watch; use tracing::*; +/// Number of times fetching the tip header is retried before giving up. +const HEADER_FETCH_RETRIES: usize = 5; + +const HASHING_UNSUPPORTED: &str = "The `hashing` stage cannot be run directly, run `account-hashing` and `storage-hashing` instead."; + /// `reth stage` command #[derive(Debug, Parser)] pub struct Command { @@ -70,7 +75,7 @@ pub struct Command { to: u64, /// Batch size for stage execution and unwind - #[arg(long)] + #[arg(long, value_parser = clap::value_parser!(u64).range(1..))] batch_size: Option, /// Normally, running the stage requires unwinding for stages that already @@ -108,6 +113,8 @@ impl Comp: CliNodeComponents, F: FnOnce(Arc) -> Comp, { + self.validate()?; + // Quit early if the stage requires a commit and `--commit` is not provided. if self.requires_commit() && !self.commit { return Err(eyre::eyre!( @@ -147,7 +154,7 @@ impl MetricServer::new(config).serve().await?; } - let batch_size = self.batch_size.unwrap_or(self.to.saturating_sub(self.from) + 1); + let batch_size = self.batch_size.unwrap_or(self.to - self.from + 1); let etl_config = config.stages.etl.clone(); let prune_modes = config.prune.segments.clone(); @@ -181,20 +188,15 @@ impl let fetch_client = Arc::new(network.fetch_client().await?); // Use `to` as the tip for the stage - let tip = loop { - match fetch_client.get_header(BlockHashOrNumber::Number(self.to)).await { - Ok(header) => { - if let Some(header) = header.into_data() { - break header - } - } - Err(error) if error.is_retryable() => { - warn!(target: "reth::cli", "Error requesting header: {error}. Retrying...") - } - Err(error) => return Err(error.into()), - } - }; - let (_, rx) = watch::channel(tip.hash_slow()); + let tip = (|| { + get_single_header(fetch_client.clone(), BlockHashOrNumber::Number(self.to)) + }) + .retry(ConstantBuilder::default().with_max_times(HEADER_FETCH_RETRIES)) + .notify(|err, _| { + warn!(target: "reth::cli", error = %err, "Error requesting header. Retrying...") + }) + .await?; + let (_, rx) = watch::channel(tip.hash()); ( Box::new(HeaderStage::new( provider_factory.clone(), @@ -326,12 +328,8 @@ impl )), None, ), - _ => return Ok(()), + StageEnum::Hashing => eyre::bail!(HASHING_UNSUPPORTED), }; - if let Some(unwind_stage) = &unwind_stage { - assert_eq!((*exec_stage).type_id(), (**unwind_stage).type_id()); - } - let checkpoint = provider_rw.get_stage_checkpoint(exec_stage.id())?.unwrap_or_default(); let unwind_stage = unwind_stage.as_mut().unwrap_or(&mut exec_stage); @@ -395,6 +393,20 @@ impl Command { Some(&self.env.chain) } + /// Validates the arguments that do not require opening the database. + fn validate(&self) -> eyre::Result<()> { + if self.stage == StageEnum::Hashing { + eyre::bail!(HASHING_UNSUPPORTED); + } + eyre::ensure!( + self.from <= self.to, + "`--from` ({}) must not be greater than `--to` ({})", + self.from, + self.to + ); + Ok(()) + } + /// Returns whether or not the configured stage requires committing. /// /// This is the case for stages that mainly modify static files, as there is no way to unwind diff --git a/crates/cli/commands/src/test_vectors/tables.rs b/crates/cli/commands/src/test_vectors/tables.rs index 10b94695399..ef8aa95c484 100644 --- a/crates/cli/commands/src/test_vectors/tables.rs +++ b/crates/cli/commands/src/test_vectors/tables.rs @@ -115,7 +115,7 @@ where /// subkeys are sorted. fn generate_dupsort_vector(runner: &mut TestRunner, per_table: usize) -> Result<()> where - T: Table + DupSort, + T: DupSort, T::Key: for<'a> Arbitrary<'a> + serde::Serialize + Ord + std::hash::Hash + Clone, T::Value: for<'a> Arbitrary<'a> + serde::Serialize + Ord + Clone, { diff --git a/crates/cli/util/Cargo.toml b/crates/cli/util/Cargo.toml index d215716450a..880ddcfabb0 100644 --- a/crates/cli/util/Cargo.toml +++ b/crates/cli/util/Cargo.toml @@ -19,7 +19,6 @@ alloy-primitives.workspace = true alloy-eips.workspace = true # misc -cfg-if.workspace = true eyre.workspace = true secp256k1 = { workspace = true, features = ["rand"] } rand_08.workspace = true diff --git a/crates/cli/util/src/allocator.rs b/crates/cli/util/src/allocator.rs index 7541d64908a..1d5fa7b05e7 100644 --- a/crates/cli/util/src/allocator.rs +++ b/crates/cli/util/src/allocator.rs @@ -5,12 +5,14 @@ // We provide jemalloc allocator support, alongside snmalloc. If both features are enabled, jemalloc // is prioritized. -cfg_if::cfg_if! { - if #[cfg(all(feature = "jemalloc", unix))] { +std::cfg_select! { + all(feature = "jemalloc", unix) => { type AllocatorInner = tikv_jemallocator::Jemalloc; - } else if #[cfg(all(feature = "snmalloc", unix))] { + } + all(feature = "snmalloc", unix) => { type AllocatorInner = snmalloc_rs::SnMalloc; - } else { + } + _ => { type AllocatorInner = std::alloc::System; } } @@ -21,19 +23,21 @@ cfg_if::cfg_if! { pub use tikv_jemalloc_sys; // This is to prevent clippy unused warnings when we do `--all-features` -cfg_if::cfg_if! { - if #[cfg(all(feature = "snmalloc", feature = "jemalloc", unix))] { +std::cfg_select! { + all(feature = "snmalloc", feature = "jemalloc", unix) => { use snmalloc_rs as _; } + _ => {} } -cfg_if::cfg_if! { - if #[cfg(feature = "tracy-allocator")] { +std::cfg_select! { + feature = "tracy-allocator" => { type AllocatorWrapper = tracy_client::ProfiledAllocator; const fn new_allocator_wrapper() -> AllocatorWrapper { AllocatorWrapper::new(AllocatorInner {}, 100) } - } else { + } + _ => { type AllocatorWrapper = AllocatorInner; const fn new_allocator_wrapper() -> AllocatorWrapper { AllocatorInner {} diff --git a/crates/config/src/config.rs b/crates/config/src/config.rs index e0d28692579..d95d5e259ab 100644 --- a/crates/config/src/config.rs +++ b/crates/config/src/config.rs @@ -1232,10 +1232,4 @@ connect_trusted_nodes_only = true let serialized = toml::to_string(&conf).unwrap(); assert_eq!(toml::from_str::(&serialized).unwrap(), conf); } - - #[test] - fn test_bootnodes_default_empty() { - let conf: Config = toml::from_str("").unwrap(); - assert!(conf.bootnodes.is_empty()); - } } diff --git a/crates/consensus/common/src/validation.rs b/crates/consensus/common/src/validation.rs index 9fcac6f6baa..967dfefb91e 100644 --- a/crates/consensus/common/src/validation.rs +++ b/crates/consensus/common/src/validation.rs @@ -187,7 +187,8 @@ where /// /// These fork-specific validations are: /// * EIP-4895 withdrawals validation, if shanghai is active based on the given chainspec. See more -/// information about the specific checks in [`validate_shanghai_withdrawals`]. +/// information about the specific checks in [`validate_shanghai_withdrawals`]. Before shanghai, +/// the block body must not contain withdrawals. /// * EIP-4844 blob gas validation, if cancun is active based on the given chainspec. See more /// information about the specific checks in [`validate_cancun_gas`]. /// * EIP-7934 block size limit validation, if osaka is active based on the given chainspec. @@ -214,6 +215,10 @@ where // EIP-4895: Beacon chain push withdrawals as operations if chain_spec.is_shanghai_active_at_timestamp(block.timestamp()) { validate_shanghai_withdrawals(block)?; + } else if block.body().withdrawals().is_some() { + // Pre-Shanghai headers carry no withdrawals root, so body withdrawals are not committed + // to by the block hash and must be absent. + return Err(ConsensusError::BodyWithdrawalsUnexpected) } if chain_spec.is_cancun_active_at_timestamp(block.timestamp()) { @@ -597,4 +602,26 @@ mod tests { if diff.0.got == wrong_root && diff.0.expected == tx_root )); } + + #[test] + fn pre_shanghai_block_with_withdrawals_fails() { + let chain_spec = ChainSpecBuilder::mainnet().paris_activated().build(); + + let header = Header { + transactions_root: proofs::calculate_transaction_root::(&[]), + ..Default::default() + }; + let body = BlockBody::::default(); + + let block = + SealedBlock::seal_slow(alloy_consensus::Block::new(header.clone(), body.clone())); + assert!(validate_block_pre_execution(&block, &chain_spec).is_ok()); + + let body = BlockBody { withdrawals: Some(Withdrawals::default()), ..body }; + let block = SealedBlock::seal_slow(alloy_consensus::Block::new(header, body)); + assert!(matches!( + validate_block_pre_execution(&block, &chain_spec).unwrap_err(), + ConsensusError::BodyWithdrawalsUnexpected + )); + } } diff --git a/crates/consensus/consensus/src/lib.rs b/crates/consensus/consensus/src/lib.rs index 0223f2d5df7..68c1e44153c 100644 --- a/crates/consensus/consensus/src/lib.rs +++ b/crates/consensus/consensus/src/lib.rs @@ -358,6 +358,10 @@ pub enum ConsensusError { #[error("missing withdrawals")] BodyWithdrawalsMissing, + /// Error when withdrawals are present in a block body before Shanghai. + #[error("unexpected withdrawals")] + BodyWithdrawalsUnexpected, + /// Error when requests are missing. #[error("missing requests")] BodyRequestsMissing, @@ -539,7 +543,7 @@ impl ConsensusError { /// Returns `true` if this type is a [`ConsensusError::Other`] of that error type. pub fn is_other(&self) -> bool { - self.as_other().map(|err| err.is::()).unwrap_or(false) + self.as_other().is_some_and(|err| err.is::()) } } diff --git a/crates/consensus/consensus/src/test_utils.rs b/crates/consensus/consensus/src/test_utils.rs index fd7644c479f..1e633685a7b 100644 --- a/crates/consensus/consensus/src/test_utils.rs +++ b/crates/consensus/consensus/src/test_utils.rs @@ -13,6 +13,9 @@ pub struct TestConsensus { /// for testing networking logic for which the body failing this check is getting completely /// rejected while more high-level failures are handled by the sync logic. fail_body_against_header: AtomicBool, + /// Separate flag for setting whether `validate_header_against_parent` should fail, so a header + /// valid on its own can still be detached from its parent. + fail_header_against_parent: AtomicBool, } impl Default for TestConsensus { @@ -20,6 +23,7 @@ impl Default for TestConsensus { Self { fail_validation: AtomicBool::new(false), fail_body_against_header: AtomicBool::new(false), + fail_header_against_parent: AtomicBool::new(false), } } } @@ -34,6 +38,7 @@ impl TestConsensus { pub fn set_fail_validation(&self, val: bool) { self.fail_validation.store(val, Ordering::SeqCst); self.fail_body_against_header.store(val, Ordering::SeqCst); + self.fail_header_against_parent.store(val, Ordering::SeqCst); } /// Returns the body validation flag. @@ -45,6 +50,16 @@ impl TestConsensus { pub fn set_fail_body_against_header(&self, val: bool) { self.fail_body_against_header.store(val, Ordering::SeqCst); } + + /// Returns the header-against-parent validation flag. + pub fn fail_header_against_parent(&self) -> bool { + self.fail_header_against_parent.load(Ordering::SeqCst) + } + + /// Update the header-against-parent validation flag. + pub fn set_fail_header_against_parent(&self, val: bool) { + self.fail_header_against_parent.store(val, Ordering::SeqCst); + } } impl FullConsensus for TestConsensus { @@ -99,7 +114,7 @@ impl HeaderValidator for TestConsensus { _header: &SealedHeader, _parent: &SealedHeader, ) -> Result<(), ConsensusError> { - if self.fail_validation() { + if self.fail_header_against_parent() { Err(ConsensusError::BaseFeeMissing) } else { Ok(()) diff --git a/crates/consensus/debug-client/src/providers/etherscan.rs b/crates/consensus/debug-client/src/providers/etherscan.rs index 2671624851e..7dfa9703113 100644 --- a/crates/consensus/debug-client/src/providers/etherscan.rs +++ b/crates/consensus/debug-client/src/providers/etherscan.rs @@ -74,7 +74,7 @@ where let resp = req.send().await?.text().await?; - debug!(target: "etherscan", %resp, "fetched block from etherscan"); + debug!(target: "etherscan", %tag, response_bytes = resp.len(), "fetched block from etherscan"); let resp: Response = serde_json::from_str(&resp).inspect_err(|err| { warn!(target: "etherscan", "Failed to parse block response from etherscan: {}", err); diff --git a/crates/dst/Cargo.toml b/crates/dst/Cargo.toml new file mode 100644 index 00000000000..d9f7f3ec38a --- /dev/null +++ b/crates/dst/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "reth-dst" +description = "Bedrock property assertions for reth DST workloads" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +bedrock-assertions.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/dst/README.md b/crates/dst/README.md new file mode 100644 index 00000000000..de6d286d034 --- /dev/null +++ b/crates/dst/README.md @@ -0,0 +1,22 @@ +# reth-dst + +Use `always!` and `sometimes!` to record Bedrock assertions from reth workloads: + +```rust +use reth_dst::{always, sometimes}; + +always!(validator_root == follower_root, "follower agrees with validator"); +sometimes!(recovered, "recovery exercised", serde_json::json!({"block": block})); +``` + +The macros return `()`. False observations are recorded without panicking; +reporting errors panic with the assertion name. Conditions and optional details +evaluate exactly once. Names must be nonempty constant strings. + +Set `BEDROCK_ASSERTIONS_PATH` to a JSONL file path. The first macro call opens +it, and concurrent calls share one writer. The path is read once per process. +Without it, arguments still evaluate, but nothing is serialized or written. +Records use Bedrock's `Always` and `Sometimes` format. `reth-dst` adds the +`timestamp_unix_nano` field used by Bedrock's per-writer assertion collection. +Optional details appear in a `details` field. +Records are limited to 16 KiB. diff --git a/crates/dst/src/lib.rs b/crates/dst/src/lib.rs new file mode 100644 index 00000000000..2405f045043 --- /dev/null +++ b/crates/dst/src/lib.rs @@ -0,0 +1,111 @@ +//! Bedrock assertion macros for reth DST workloads. +//! +//! `always!` records a condition that must always hold; `sometimes!` records +//! one that must hold at least once. False observations are written without +//! panicking. Reporting errors panic with the assertion name. + +use std::{ + fs::{File, OpenOptions}, + io::{self, Write}, + sync::{Mutex, OnceLock}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use bedrock_assertions::Assertion; +use serde::Serialize; + +#[doc(hidden)] +pub use bedrock_assertions as __bedrock; +#[doc(hidden)] +pub use serde_json as __serde_json; + +static OUTPUT: OnceLock>>> = OnceLock::new(); + +/// Appends one assertion to `BEDROCK_ASSERTIONS_PATH` when it is set. +/// The output path is read once per process. Details are evaluated by the +/// caller but serialized only when the output is enabled. +#[doc(hidden)] +pub fn __report(assertion: Assertion, details: impl Serialize) { + let result = (|| { + let output = OUTPUT.get_or_init(|| { + std::env::var_os("BEDROCK_ASSERTIONS_PATH") + .map(|path| OpenOptions::new().create(true).append(true).open(path)) + .transpose() + .map(|file| file.map(Mutex::new)) + }); + let Some(output) = + output.as_ref().map_err(|error| io::Error::new(error.kind(), error.to_string()))? + else { + return Ok(()); + }; + + let mut record = serde_json::to_value(&assertion).map_err(io::Error::other)?; + let details = serde_json::to_value(details).map_err(io::Error::other)?; + let kind = match assertion { + Assertion::Always(_) => "Always", + Assertion::Sometimes(_) => "Sometimes", + }; + let data = record[kind].as_object_mut().expect("Bedrock assertion has an object payload"); + data.insert( + "timestamp_unix_nano".into(), + (SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() as u64) + .into(), + ); + if !details.is_null() { + data.insert("details".into(), details); + } + let mut line = serde_json::to_vec(&record).map_err(io::Error::other)?; + line.push(b'\n'); + if line.len() > 16 * 1024 { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "DST record exceeds 16 KiB")); + } + let mut file = output.lock().map_err(|_| io::Error::other("DST writer poisoned"))?; + // One append syscall keeps records from different processes from interleaving. + if file.write(&line)? != line.len() { + return Err(io::Error::new(io::ErrorKind::WriteZero, "partial DST write")); + } + Ok::<_, io::Error>(()) + })(); + if let Err(error) = result { + panic!("reth-dst could not report assertion {:?}: {error}", assertion.data().message); + } +} + +/// Records a safety assertion. The name must be a nonempty constant string. +#[macro_export] +macro_rules! always { + ($condition:expr, $name:expr $(, $details:expr)? $(,)?) => {{ + $crate::__assert!($condition, $name, always $(, $details)?); + }}; +} + +/// Records an assertion that must be true at least once. +/// The name must be a nonempty constant string. +#[macro_export] +macro_rules! sometimes { + ($condition:expr, $name:expr $(, $details:expr)? $(,)?) => {{ + $crate::__assert!($condition, $name, sometimes $(, $details)?); + }}; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __assert { + ($condition:expr, $name:expr, $kind:ident $(,)?) => {{ + $crate::__assert!($condition, $name, $kind, $crate::__serde_json::Value::Null); + }}; + ($condition:expr, $name:expr, $kind:ident, $details:expr $(,)?) => {{ + const NAME: &str = $name; + const _: () = assert!(!NAME.is_empty(), "assertion name must not be empty"); + let condition: bool = $condition; + let details = $details; + $crate::__report( + $crate::__bedrock::Assertion::$kind( + $crate::__bedrock::Condition::Bool(condition), + NAME, + $crate::__bedrock::Location::new(file!(), line!(), column!()), + ), + details, + ); + }}; +} diff --git a/crates/e2e-test-utils/Cargo.toml b/crates/e2e-test-utils/Cargo.toml index fcf902070fa..456f83e0766 100644 --- a/crates/e2e-test-utils/Cargo.toml +++ b/crates/e2e-test-utils/Cargo.toml @@ -41,8 +41,12 @@ reth-db-common.workspace = true reth-primitives-traits.workspace = true reth-trie.workspace = true reth-trie-db.workspace = true +reth-transaction-pool.workspace = true +reth-prune-types.workspace = true +reth-fs-util.workspace = true +reth-network-p2p.workspace = true +reth-eth-wire-types.workspace = true -revm.workspace = true tempfile.workspace = true # rpc @@ -69,11 +73,22 @@ tokio-stream.workspace = true serde_json.workspace = true tracing.workspace = true derive_more.workspace = true +parking_lot.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } [[test]] name = "e2e_testsuite" path = "tests/e2e-testsuite/main.rs" +required-features = ["chain-import"] [[test]] name = "rocksdb" path = "tests/rocksdb/main.rs" + +[features] +default = ["chain-import"] +# Chain import setup (`setup_import`, `Setup::apply_with_import`). It launches an `EthereumNode` +# from non-generic code, which every dependent compiles even if it never imports a chain. +chain-import = [] diff --git a/crates/e2e-test-utils/README.md b/crates/e2e-test-utils/README.md new file mode 100644 index 00000000000..9accebd8f15 --- /dev/null +++ b/crates/e2e-test-utils/README.md @@ -0,0 +1,552 @@ +# reth-e2e-test-utils + +Harness for end-to-end tests of reth nodes. It launches real nodes in the test process, each with a +temporary database, the RPC and Engine API servers and networking, and hands the test a +`NodeTestContext` per node to produce blocks, reorg, send engine messages, inject transactions and +wait for the node to reach a state. Everything is generic over the node type, so chains built on +reth can use it with their own node, payload and network types. + +## Which style to use + +Write new tests against `NodeTestContext` (`src/node.rs`), launched with `E2ETestSetupBuilder`. +The test calls helpers in order and asserts in between, so it reads top to bottom and a failure +points at the step that failed. + +The `testsuite` module is an action framework: a `TestBuilder` applies a `Setup` and runs a list of +`Action`s against an `Environment` that reaches the nodes over RPC. The engine tree suite +(`crates/engine/tree/tests/e2e-testsuite`) and the execution-apis RPC compatibility suite +(`crates/rpc/rpc-e2e-tests`) are built on it; use it to extend those suites. Its limits: + +- The actions call the versioned Engine API over RPC: `engine_forkchoiceUpdatedV3`, + `engine_getPayloadV3` and `engine_newPayloadV3` (with empty blob versioned hashes), falling back + to or starting with `V2` in some actions. No action uses `V4` or later. +- The actions generate their own payload attributes with empty withdrawals, a zero parent beacon + block root and no slot number, so they need payload attributes convertible from Ethereum's. +- `Setup` takes an Ethereum `ChainSpec`, chain import (`TestBuilder::with_setup_and_import`) + launches `EthereumNode`s only, and `Setup::tree_config` replaces the tree configuration the + harness derives from the node configuration, except for the cross block cache size. + +The engine tree suite runs on Cancun chains, and the RPC compatibility suite imports a prebuilt +chain. See the +[testsuite README](https://github.com/paradigmxyz/reth/blob/main/crates/e2e-test-utils/src/testsuite/README.md) +for the actions. + +## Where tests live and how to run them + +| Location | Test binary | Contents | +|---|---|---| +| `crates/ethereum/node/tests/e2e/` | `reth-node-ethereum`, `e2e` | Node behaviour, one module per area, listed in `main.rs` | +| `crates/ethereum/node/tests/it/` | `reth-node-ethereum`, `it` | Node builder and launch tests | +| `crates/e2e-test-utils/tests/` | `e2e_testsuite`, `rocksdb` | The harness itself, storage v2 | +| `crates/engine/tree/tests/e2e-testsuite/` | `reth-engine-tree`, `e2e_testsuite` | Engine tree, action framework | +| `crates/rpc/rpc-e2e-tests/` | `reth-rpc-e2e-tests`, `e2e_testsuite` | RPC compatibility, action framework | + +CI runs every `e2e_testsuite` binary in the `e2e` workflow and all other test binaries in the +`integration` workflow. `.config/nextest.toml` retries a failing test twice, marks tests slow after +30 seconds and kills them after 60 seconds, except for the `e2e` binary of `reth-node-ethereum` +(killed after 5 minutes) and `e2e_testsuite` binaries (6 minutes). + +```bash +cargo nextest run -p reth-node-ethereum --test e2e +# a single test, by exact name +cargo nextest run -p reth-node-ethereum --test e2e -E 'test(=reorg::can_build_forks_and_reorg)' +# with node logs, for tests that call `reth_tracing::init_test_tracing()` +RUST_LOG=info,engine::tree=debug cargo nextest run -p reth-node-ethereum --test e2e --no-capture \ + -E 'test(=reorg::can_build_forks_and_reorg)' +``` + +## Which helper to use + +| I want to | Use | +|---|---| +| **Setup** | | +| Launch one node with every fork up to X active at genesis | `EthereumNode::test_setup_for(EthereumHardfork::X)`, then `build_single()` | +| Launch several connected nodes | `with_num_nodes(n)` and `build()`; each node peers with the previous one, the last also with the first if there are more than two; `with_connect_nodes(false)` to skip | +| Use my own chain spec, e.g. a fork at a later timestamp | `test_chain_spec_builder()` and `E2ETestSetupExt::test_setup(n, chain_spec)`; `test_genesis()` funds the 20 test accounts | +| Launch a node whose payload attributes do not convert from Ethereum's | `E2ETestSetupBuilder::new_with_attributes_generator`; `with_attributes_generator` replaces the generator of any builder | +| Launch a non-default instance of the node type | `with_node`, called with the node index | +| Install an ExEx, extend the RPC modules, add launch hooks, map the add-ons | `with_node_builder_modifier`, which receives a `TestNodeBuilder` | +| Change node settings that have a CLI flag | `with_node_config_modifier`, `with_rpc_modifier` (default: all modules except `testing` over HTTP), `with_pruning` | +| Change engine tree settings | `with_tree_config_modifier`, applied last, on top of the tree config derived from the node config | +| Choose the storage layout | `with_storage_v2(bool)`, defaults to the node default | +| Sync with another backfill, e.g. snap with `--snap.v2` | `with_backfill(EthereumBackfill::new)`, which picks the backfill from the final node config like the binary, so snap needs `config.network.snap_v2 = true` and `with_storage_v2(true)`; a `--snap.v2` node without `with_backfill` fails to launch; not combinable with `with_dev_mining` | +| Let a local miner build the blocks | `with_dev_mining(block_time)`, `map_dev_payload_attributes`; `with_dev_mode` only sets `--dev` | +| **Accounts and transactions** | | +| Sign transactions from a funded account that tracks its nonce | `wallet.account(i)` (a `TestAccount`), then `account.transfer(to, value).await`, `account.call(to, input)` or `account.deploy(init_code)` | +| Set the nonce, gas or fees of one transaction | the `TestTx` setters, e.g. `.nonce(n)`, `.gas_limit(g)`, `.fees(max, priority)`, `.map_request(f)`; per account `with_gas_limit`, `with_fees` | +| Know the address of the next deployment, resync the nonce | `account.next_contract_address()`, `account.sync_nonce(&provider)` | +| Send through an alloy provider | `node.rpc_provider_with_wallet(wallet.signer(i))` | +| Build a blob or EIP-7702 transaction | `TransactionTestContext::tx_with_blobs_bytes`, `set_code_tx_bytes` (nonce 0, see traps) | +| Put a raw transaction into the pool | `node.rpc.inject_tx(raw)` | +| **Producing blocks** | | +| Mine given transactions and get their receipts | `node.mine(txs)`, which fails if the block misses one of them or includes another pool transaction; `mine_including(txs)` allows other pool transactions | +| Mine signed transactions, e.g. of a custom transaction type, that must all succeed | `node.mine_signed(txs)`, which encodes them, mines them like `mine` and fails if one reverted | +| Mine transactions that are already in the pool, e.g. sent through a provider | `node.mine_pooled(hashes)`, with the checks of `mine` and an error for a hash that is not in the pool | +| Produce blocks from the pool | `advance_block()`, `advance_blocks(n)`; `advance_block_synced()` also waits for the pool to process the block | +| Produce blocks until a transaction is included | `advance_until_receipt(hash)` | +| Produce blocks until the pool has no pending transactions | `advance_until_pool_drained()`, which returns the payloads and leaves queued transactions in the pool | +| Produce blocks while a future runs, e.g. `eth_sendRawTransactionSync` | `advance_while(fut)` | +| Build a payload without submitting it | `new_payload()`; on another parent `build_payload_on(parent)` | +| Control block timestamps, e.g. around a fork | `set_next_payload_timestamp(timestamp)` | +| **Forks and reorgs** | | +| Keep imported blocks reorgable | `set_finality(Finality::Keep)` or `Finality::Lag(n)`; the default `Finality::Head` finalizes every block | +| Build a block or a chain on any known block and make it the head | `advance_block_on(parent)`, `advance_fork(parent, n)` | +| Make a known side chain block the head | `reorg_to(hash)` | +| **Engine** | | +| Insert a payload without making it canonical | `submit_payload(payload)`, an error on `INVALID`; `submit_payload_with_status` returns the status | +| Insert a payload and make it the head | `import_payload(payload)`, which follows the finality policy | +| Send an exact forkchoice state or a sealed block, and get typed errors | `node.engine.forkchoice_updated(state)`, `forkchoice_updated_with_attributes`, `new_payload`, `new_payload_from_block` | +| Test Engine API method versions, fork checks, error codes or encoding | `reth_rpc_api::EngineApiClient` on `node.auth_server_handle().http_client()` | +| **Multiple nodes** | | +| Connect two nodes | `a.connect(&mut b)`, done by `build()` unless disabled | +| Give a node the block of another node | `follower.import_payload(payload)`; the parent must be known | +| Let a node download a chain from its peers | `follower.sync_to(hash)`, which makes the block head, safe and finalized; `sync_to_forkchoice(state)` to keep the finalized block below the head; after a forkchoice update of the test's own, `wait_for_head(hash)` | +| **Stopping and restarting** | | +| Make nodes restartable | `with_restartable_nodes()`; opt-in because each node then runs on a runtime of its own, which costs a few threads per node; not combinable with `with_runtime` | +| Stop a node and keep its datadir | `node.stop()`, which returns a `StoppedNode`; `stopped.data_dir()` while it is stopped; dropping it removes the datadir | +| Launch a stopped node again | `stopped.start()`, which returns a new `NodeTestContext` | +| Stop and start in one step | `node.restart()` | +| Notice that the engine of a node exited | `node.take_exit_future()`, which resolves with `Ok` once the node is stopped and with an error if the engine exits on a fatal error | +| **Waits** | | +| Wait for any condition | `wait::poll_until(what, poll)`; `poll_until_with(PollOpts { .. }, ..)` for another timeout or interval | +| Assert that something does not happen | `wait::assert_holds_for(duration, what, check)` | +| Wait for a block, or a pool state | `wait_block(n, hash, wait_finish_checkpoint)`, `wait_for_pool(condition)`, `wait_for_pool_head(hash)` | +| Wait until a block is the head | `wait_for_head(hash)`, which only observes the node; `wait_block` is also satisfied by a canonical block below the head | +| Wait for transactions to enter or leave the pool | `wait_for_pooled(hashes)`, `wait_for_pool_removal(hashes)`, which returns right away for transactions that never entered | +| Wait for persistence or pruning | `wait_for_persisted_block(n)`, `wait_for_prune_checkpoint(segment, n)` | +| **Assertions and inspection** | | +| Inspect a mined block | `MinedBlock`: `block()`, `receipts`, `chain` (the committed `Chain` with its execution outcome), `ensure_success()` | +| Wait for the receipt of a transaction sent through a provider | `PendingTransactionExt::successful_receipt`, `receipt::await_successful_receipts` | +| Query the node over JSON-RPC | `rpc_provider()`, `rpc_provider_with_wallet(w)`, `rpc_provider_for::()` for other networks | +| Read node state directly | `node.inner.provider`, `node.inner.pool`, `node.current_forkchoice_state()`, `node.block_hash(n)` (panics if unknown) | +| Check that the persisted state and trie are consistent | `trie::assert_trie_consistency(&node.inner.provider)`, after `wait_for_persisted_block` | + +## Recipes + +Each recipe is a test without its `#[tokio::test]` attribute, which doctests would compile away. +In a test file, add the attribute and call `reth_tracing::init_test_tracing()` first, so `RUST_LOG` +shows the node logs. + +Mine transactions, then read their receipts and the execution outcome of the block: + +```rust,no_run +use alloy_primitives::{Address, U256}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::E2ETestSetupExt; +use reth_node_ethereum::EthereumNode; + +async fn mines_transfers() -> eyre::Result<()> { + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let mut account = wallet.account(0); + let recipient = Address::with_last_byte(0x77); + + let first = account.transfer(recipient, U256::from(1)).await; + let second = account.transfer(recipient, U256::from(2)).await; + let mined = node.mine([first, second]).await?.ensure_success()?; + + assert_eq!(mined.block().number, 1); + assert_eq!(mined.receipts[1].gas_used, 21_000); + let recipient_account = mined.chain.execution_outcome().account(&recipient).flatten(); + assert_eq!(recipient_account.map(|account| account.balance), Some(U256::from(3))); + Ok(()) +} +``` + +Two nodes, where one imports a block of the other and then syncs to its head: + +```rust,no_run +use alloy_primitives::{Address, U256}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::E2ETestSetupExt; +use reth_node_ethereum::EthereumNode; + +async fn follower_imports_and_syncs() -> eyre::Result<()> { + let (mut nodes, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).with_num_nodes(2).build().await?; + let follower = nodes.pop().unwrap(); + let mut producer = nodes.pop().unwrap(); + + // Hand the block to the follower like a consensus client would. + let tx = wallet.account(0).transfer(Address::with_last_byte(1), U256::from(1)).await; + let mined = producer.mine([tx]).await?; + follower.import_payload(mined.payload.clone()).await?; + assert_eq!(follower.block_hash(1), mined.block().hash()); + + // A head the follower does not know: it downloads the blocks from its peer. + let head = producer.advance_blocks(3).await?.pop().unwrap().block().hash(); + follower.sync_to(head).await?; + assert_eq!(follower.current_forkchoice_state()?.head_block_hash, head); + Ok(()) +} +``` + +A reorg that returns a mined transaction to the pool, and a reorg back: + +```rust,no_run +use alloy_primitives::{Address, U256}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{node::Finality, E2ETestSetupExt}; +use reth_node_ethereum::EthereumNode; + +async fn reorg_returns_transaction_to_pool() -> eyre::Result<()> { + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + // Keep genesis finalized, so the blocks above it can be reorged. + node.set_finality(Finality::Keep); + let genesis = node.block_hash(0); + + let tx = wallet.account(0).transfer(Address::with_last_byte(1), U256::from(1)).await; + let mined = node.mine([tx]).await?; + let (a1, tx_hash) = (mined.block().hash(), mined.receipts[0].transaction_hash); + // Otherwise the pool may still hold the transaction when the fork is built. + node.wait_for_pool_head(a1).await?; + + let b1 = node.advance_block_on(genesis).await?.block().hash(); + assert_eq!(node.current_forkchoice_state()?.head_block_hash, b1); + node.wait_for_pooled([tx_hash]).await?; + + node.reorg_to(a1).await?; + assert_eq!(node.current_forkchoice_state()?.head_block_hash, a1); + Ok(()) +} +``` + +A hardfork that activates mid-test: + +```rust,no_run +use reth_e2e_test_utils::{test_chain_spec_builder, E2ETestSetupExt}; +use reth_node_ethereum::EthereumNode; +use std::sync::Arc; + +async fn activates_prague() -> eyre::Result<()> { + let prague_time = 1_000; + let chain_spec = + Arc::new(test_chain_spec_builder().cancun_activated().with_prague_at(prague_time).build()); + let (mut node, _) = EthereumNode::test_setup(1, chain_spec).build_single().await?; + + // Payload timestamps start far after the fork, so move them right before it. + node.set_next_payload_timestamp(prague_time - 1)?; + let cancun = node.advance_block().await?; + let prague = node.advance_block().await?; + assert_eq!(prague.block().timestamp, prague_time); + assert!(cancun.block().requests_hash.is_none()); + assert!(prague.block().requests_hash.is_some()); + Ok(()) +} +``` + +An invalid payload. `submit_payload` would return this as an error naming the block, the latest +valid hash and the validation error: + +```rust,no_run +use alloy_primitives::B256; +use alloy_rpc_types_engine::PayloadStatusEnum; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::E2ETestSetupExt; +use reth_node_ethereum::EthereumNode; +use reth_primitives_traits::SealedBlock; + +async fn rejects_wrong_state_root() -> eyre::Result<()> { + let (mut node, _) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let payload = node.new_payload().await?; + + // A wrong state root is only detected after executing the block. + let mut block = payload.block().clone_block(); + block.header.state_root = B256::ZERO; + let status = node.engine.new_payload_from_block(SealedBlock::seal_slow(block), None).await?; + + assert_eq!(status.latest_valid_hash, Some(payload.block().parent_hash)); + let validation_error = format!( + "mismatched block state root: got {}, expected {}", + payload.block().state_root, + B256::ZERO + ); + assert_eq!(status.status, PayloadStatusEnum::Invalid { validation_error }); + Ok(()) +} +``` + +A forkchoice update the engine rejects, with the typed error. Over the Engine API it would be error +code -38002: + +```rust,no_run +use alloy_rpc_types_engine::{ForkchoiceState, ForkchoiceUpdateError}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{node::Finality, E2ETestSetupExt}; +use reth_node_api::BeaconForkChoiceUpdateError; +use reth_node_ethereum::EthereumNode; + +async fn rejects_safe_block_above_head() -> eyre::Result<()> { + let (mut node, _) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + node.set_finality(Finality::Keep); + let genesis = node.block_hash(0); + let blocks = node.advance_blocks(2).await?; + let (b1, b2) = (blocks[0].block().hash(), blocks[1].block().hash()); + + let state = + ForkchoiceState { head_block_hash: b1, safe_block_hash: b2, finalized_block_hash: genesis }; + let err = node.engine.forkchoice_updated(state).await.unwrap_err(); + assert!( + matches!( + err, + BeaconForkChoiceUpdateError::ForkchoiceUpdateError(ForkchoiceUpdateError::InvalidState) + ), + "{err}" + ); + assert_eq!(node.current_forkchoice_state()?.head_block_hash, b2); + Ok(()) +} +``` + +A negative pool assertion, without a fixed sleep: + +```rust,no_run +use alloy_primitives::{Address, U256}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{wait::assert_holds_for, E2ETestSetupExt}; +use reth_node_ethereum::EthereumNode; +use reth_transaction_pool::TransactionPool; +use std::time::Duration; + +async fn gapped_transaction_stays_queued() -> eyre::Result<()> { + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + + // Nonce 1 while the account is at nonce 0. + let tx = wallet.account(0).transfer(Address::with_last_byte(1), U256::from(1)).nonce(1).await; + let hash = node.rpc.inject_tx(tx).await?; + // Fails if the block includes any pool transaction. + node.mine([]).await?; + + let pool = &node.inner.pool; + assert_holds_for(Duration::from_secs(1), "gapped transaction to stay queued", move || { + async move { Ok(pool.contains(&hash) && pool.pending_transactions().is_empty()) } + }) + .await +} +``` + +Persistence and pruning: + +```rust,no_run +use alloy_primitives::{Address, U256}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{trie::assert_trie_consistency, E2ETestSetupExt}; +use reth_node_core::args::PruningArgs; +use reth_node_ethereum::EthereumNode; +use reth_prune_types::PruneSegment; + +async fn persists_and_prunes() -> eyre::Result<()> { + let (mut node, wallet) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) + // Persist every canonical block instead of keeping the recent ones in memory. + .with_tree_config_modifier(|config| { + config.with_persistence_threshold(0).with_memory_block_buffer_target(0) + }) + .with_pruning(PruningArgs { + account_history_distance: Some(5), + minimum_distance: Some(5), + block_interval: Some(1), + ..Default::default() + }) + .build_single() + .await?; + + // The account history checkpoint only reaches blocks that changed an account. + let mut account = wallet.account(0); + for _ in 0..20 { + node.mine([account.transfer(Address::with_last_byte(1), U256::from(1)).await]).await?; + } + node.wait_for_persisted_block(20).await?; + node.wait_for_prune_checkpoint(PruneSegment::AccountHistory, 15).await?; + assert_trie_consistency(&node.inner.provider)?; + Ok(()) +} +``` + +A restart, after which the node still has its chain and builds on its head: + +```rust,no_run +use alloy_primitives::{Address, U256}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::E2ETestSetupExt; +use reth_node_ethereum::EthereumNode; + +async fn restart_keeps_chain() -> eyre::Result<()> { + let (mut node, wallet) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) + .with_restartable_nodes() + .build_single() + .await?; + let mut account = wallet.account(0); + let recipient = Address::with_last_byte(1); + let head = node.mine([account.transfer(recipient, U256::from(1)).await]).await?.block().hash(); + + // Persists the canonical chain, closes the database and launches the node on its datadir. + let mut node = node.restart().await?; + assert_eq!(node.block_hash(1), head); + let next = node.mine([account.transfer(recipient, U256::from(2)).await]).await?; + assert_eq!(next.block().parent_hash, head); + Ok(()) +} +``` + +An ExEx installed through the node builder: + +```rust,no_run +use futures_util::TryStreamExt; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{wait::WAIT_TIMEOUT, E2ETestSetupExt}; +use reth_node_ethereum::EthereumNode; +use tokio::sync::mpsc; + +async fn exex_receives_committed_chain() -> eyre::Result<()> { + let (committed_tx, mut committed_rx) = mpsc::unbounded_channel(); + let (mut node, _) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) + .with_node_builder_modifier(move |builder| { + let committed_tx = committed_tx.clone(); + builder.install_exex("committed-chains", |mut ctx| async move { + Ok(async move { + while let Some(notification) = ctx.notifications.try_next().await? { + if let Some(chain) = notification.committed_chain() { + ctx.send_finished_height(chain.tip().num_hash())?; + let _ = committed_tx.send(chain); + } + } + Ok(()) + }) + }) + }) + .build_single() + .await?; + + let payload = node.advance_block().await?; + let chain = tokio::time::timeout(WAIT_TIMEOUT, committed_rx.recv()) + .await? + .ok_or_else(|| eyre::eyre!("ExEx stopped before receiving a committed chain"))?; + assert_eq!(chain.tip().hash(), payload.block().hash()); + Ok(()) +} +``` + +A node with its own payload attributes generator, here to set the fee recipient: + +```rust,no_run +use alloy_primitives::Address; +use alloy_rpc_types_engine::PayloadAttributes; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{eth_payload_attributes, test_chain_spec, E2ETestSetupBuilder}; +use reth_node_ethereum::EthereumNode; + +async fn builds_with_fee_recipient() -> eyre::Result<()> { + let chain_spec = test_chain_spec(EthereumHardfork::Cancun); + let attributes_chain_spec = chain_spec.clone(); + let fee_recipient = Address::with_last_byte(0x42); + let (mut node, _) = E2ETestSetupBuilder::::new_with_attributes_generator( + 1, + chain_spec, + move |timestamp| PayloadAttributes { + suggested_fee_recipient: fee_recipient, + ..eth_payload_attributes(&attributes_chain_spec, timestamp) + }, + ) + .build_single() + .await?; + + let payload = node.advance_block().await?; + assert_eq!(payload.block().header().beneficiary, fee_recipient); + Ok(()) +} +``` + +## Traps + +- **`update_forkchoice(current_head, new_head)`** makes its first argument the safe and finalized + block, not the previous head, and returns the engine's response without checking it, so a + `SYNCING` or `INVALID` update does not fail the test. Use `import_payload`, `reorg_to`, or + `node.engine.forkchoice_updated` with an explicit `ForkchoiceState` and assert on the result. If + the update makes the node sync, wait for the block with `wait_for_head`: `wait_block` can return + before the engine made a backfilled block its head. +- **`new_payload` only builds.** It starts a payload job and resolves it; the engine has not seen + the block. `build_and_submit_payload` inserts it without making it canonical. +- **Every imported block is finalized by default.** Under `Finality::Head`, `import_payload` and + every helper built on it make the new block safe and finalized, and the engine refuses to reorg + below the finalized block, so `reorg_to`, `advance_block_on` and `build_payload_on` fail on older + blocks with an error naming the finalized block. Call `set_finality(Finality::Keep)` before + producing the blocks to reorg. `sync_to` and `update_forkchoice` ignore the policy. +- **The pool lags behind the chain.** `advance_block`, `mine` and `import_payload` return once the + block is canonical, but the pool processes it in the background, so right afterwards it can still + hold the mined transactions and old nonces, and a block built next, e.g. on a fork, can include + them. Use `advance_block_synced` or `wait_for_pool_head(hash)`. These only cover the pool's own + update; a node that runs additional pool maintenance tasks on new blocks, e.g. for a separate + sub-pool or its own eviction rules, can report the new head before they ran, so wait for the + expected contents with `wait_for_pool_removal` or `wait_for_pooled` instead. After a reorg, the + transactions of the reorged blocks return to the pool later still; wait for them with + `wait_for_pooled`. +- **`canonical_stream` is a backlog.** It holds every canonical notification since the context was + created, up to 256 unread ones (older ones are dropped without an error). `mine`, + `mine_including`, `inject_and_advance` and `advance` skip ahead to the notification of their + block; nothing else reads it. After a few `advance_block` calls, `canonical_stream.next()` + returns the oldest unread notification, not the latest. Prefer `MinedBlock::chain`. +- **The harness bypasses the Engine API RPC layer.** `node.engine` and all block producing and + importing helpers send messages to the engine in-process: no check that the method version + matches the fork, no check of fork-specific fields, no JSON or SSZ decoding, no error codes. Test + those with `EngineApiClient` on the auth server. +- **`TransactionTestContext` signs with nonce 0** (except `transfer_tx_bytes_with_nonce`) and fixed + fees, so a second transaction from the same signer conflicts with the first. Use `TestAccount`, + which tracks nonces. `wallet.inner` is the signer of account 0. +- **Dev mining nodes drive themselves.** With `with_dev_mining`, the local miner sends the + forkchoice updates and builds the blocks, so the block producing and forkchoice helpers of + `NodeTestContext` must not be used, and only a single node can be launched. Wait for receipts + with `successful_receipt`. +- **Payload timestamps do not start at genesis.** The first payload gets timestamp 1710338136, + or the timestamp of the latest block plus one if that is later, so a fork scheduled at a smaller + timestamp is active from the first block. Use `set_next_payload_timestamp`. +- **Persistence is lazy, and the waits are narrow.** The engine only persists once more than + `persistence_threshold` (default 50) canonical blocks are in memory, and keeps + `memory_block_buffer_target` (default 5) of them; lower both with `with_tree_config_modifier` in + tests that read the database. `wait_for_persisted_block(n)` returns once the database tip reaches + `n`: it does not check the block hash, the state of the most recently persisted blocks can still + lag unless `num_state_masking_blocks` or the persistence threshold is 0, and pruning happens in a + later commit, see `wait_for_prune_checkpoint`. The account history checkpoint is the last pruned + block that changed an account, so it can stay below the pruning target if the last blocks in + the range changed none, e.g. empty blocks. +- **`stop` needs the database to itself.** `stop` and `restart` consume the context and wait until + the harness holds the last handle of the node's database. A clone of `inner.provider`, a + `NodeClient` from `to_node_client`, or a handle of `inner.provider.rocksdb_provider()` makes them + fail after `node::DATABASE_RELEASE_TIMEOUT` (10 seconds, or the timeout passed to + `stop_with_release_timeout`), an open `database_provider_ro` transaction right away. Drop those + first. Payloads, `MinedBlock` with its `chain`, clones of + `node.engine` and alloy providers do not hold the database. +- **A restart keeps the chain, not the context.** Stopping persists every canonical block and saves + the local transactions of the pool, which the node reinserts when it starts; blocks that are not + canonical, e.g. only submitted or reorged out, are lost. Unless it mines in dev mode, the + restarted node gets a forkchoice update that restates the head, safe and finalized blocks it + persisted. It also gets a new `NodeTestContext`: its finality policy is `Finality::Head` again, + and its `canonical_stream` only sees notifications from the restart on. +- **An unfinished snap sync needs a target after restart.** The node may have saved headers before + downloading and verifying their state. The harness therefore skips the automatic forkchoice + update, which would require the saved head to be ready for execution. The test must reconnect + the node to its peers and explicitly send a forkchoice update with the target to sync to. +- **A restarted node does not reconnect.** It keeps its peer id but listens on new ports, and test + nodes do not persist their peers. Connect it with `restarted.connect(&mut peer)` once the peer + noticed the disconnect, e.g. once `peer.inner.network.num_connected_peers()` dropped. + `peer.connect(&mut restarted)` panics, because the network events of the peer still hold the + disconnect. + +## Rules for new tests + +- No fixed sleeps. Wait for the condition with `poll_until` or a `wait_for_*` helper, and assert + that something does not happen with `assert_holds_for`. +- Set nodes up with `E2ETestSetupBuilder`. Use `NodeBuilder` directly only to test the builder + itself, as `crates/ethereum/node/tests/it` does. If the setup builder can't express a setup, add + the option to it. +- Assert exact errors: compare the whole error message or match the typed error, not `is_err()` or + a substring. +- Test one behaviour per test, and name the test after it. +- If a helper is missing, add it to the harness instead of a copy in the test: node steps to + `NodeTestContext` (`src/node.rs`), waits to `src/wait.rs`, transaction building to `TestAccount` + and `TestTx` (`src/wallet.rs`), setup options to `E2ETestSetupBuilder` (`src/setup_builder.rs`), + engine messages to `EngineTestContext` (`src/engine.rs`). Document what the helper guarantees + and add its future to `test_helper_futures_are_send`. diff --git a/crates/e2e-test-utils/src/engine.rs b/crates/e2e-test-utils/src/engine.rs new file mode 100644 index 00000000000..b8aaa3f2bd7 --- /dev/null +++ b/crates/e2e-test-utils/src/engine.rs @@ -0,0 +1,103 @@ +//! Version-agnostic access to the consensus engine of a test node. + +use alloy_primitives::Bytes; +use alloy_rpc_types_engine::{ForkchoiceState, ForkchoiceUpdated, PayloadStatus}; +use reth_engine_primitives::{ + BeaconForkChoiceUpdateError, BeaconOnNewPayloadError, ConsensusEngineHandle, +}; +use reth_payload_primitives::{BuiltPayload, PayloadTypes}; +use reth_primitives_traits::{NodePrimitives, SealedBlock}; + +/// Sends `newPayload` and `forkchoiceUpdated` messages to the consensus engine of a test node like +/// a consensus client, without picking an Engine API version for the active fork. +/// +/// The messages go to the engine in-process and skip the Engine API RPC layer. That layer decodes +/// the JSON or SSZ request, rejects a method version that does not match the fork of the payload +/// or payload attributes timestamp, checks that the fields of that fork are present, and maps the +/// errors of the engine to RPC error codes. None of this happens here, so tests of that behaviour +/// must call the versioned RPC methods, e.g. with [`EngineApiClient`] on the client of +/// [`NodeTestContext::auth_server_handle`]. +/// +/// [`EngineApiClient`]: reth_rpc_api::EngineApiClient +/// [`NodeTestContext::auth_server_handle`]: crate::node::NodeTestContext::auth_server_handle +#[derive(Debug, Clone)] +pub struct EngineTestContext { + handle: ConsensusEngineHandle, +} + +impl EngineTestContext { + /// Creates a new engine context that sends its messages to the given engine handle. + pub const fn new(handle: ConsensusEngineHandle) -> Self { + Self { handle } + } + + /// Sends a payload to the engine like `engine_newPayload` and returns the payload status. + /// + /// The engine inserts the block without making it canonical, see [`Self::forkchoice_updated`]. + /// An invalid block is reported as an `INVALID` status, not as an error. A built payload of the + /// node converts into the payload type of the engine. + /// + /// Returns an error if the engine can not process the payload at all, e.g. because its + /// parameters are malformed, which the Engine API returns as an invalid params error. + pub async fn new_payload( + &self, + payload: impl Into, + ) -> Result { + self.handle.new_payload(payload.into()).await + } + + /// Sends the payload of a sealed block to the engine, see [`Self::new_payload`]. + /// + /// `block_access_list` is the encoded block access list of the block, which payloads carry + /// from Amsterdam on. + pub async fn new_payload_from_block( + &self, + block: SealedBlock< + <::Primitives as NodePrimitives>::Block, + >, + block_access_list: Option, + ) -> Result { + self.new_payload(T::block_to_payload(block, block_access_list)).await + } + + /// Sends a forkchoice update without payload attributes to the engine like + /// `engine_forkchoiceUpdated` and returns the response. + /// + /// The payload status of the response is the outcome for the head block: `VALID` once it is + /// canonical, `SYNCING` if the engine does not know it or is backfilling, and `INVALID` if it + /// is or descends from an invalid block. + /// + /// Returns an error if the engine rejects the forkchoice state, e.g. + /// [`ForkchoiceUpdateError::InvalidState`] for a zero head hash, or a safe or finalized block + /// that is unknown or not on the chain of the head. The Engine API returns that error as code + /// -38002. + /// + /// [`ForkchoiceUpdateError::InvalidState`]: alloy_rpc_types_engine::ForkchoiceUpdateError::InvalidState + pub async fn forkchoice_updated( + &self, + state: ForkchoiceState, + ) -> Result { + self.handle.fork_choice_updated(state, None).await + } + + /// Sends a forkchoice update with payload attributes to the engine like + /// `engine_forkchoiceUpdated`, which starts a payload job on top of the head, and returns the + /// response with the id of the job. + /// + /// The forkchoice state is handled like in [`Self::forkchoice_updated`]. The response has no + /// payload id if the engine starts no payload job, e.g. because it is syncing. + /// + /// Returns [`ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes`] if the attributes are + /// invalid for the head, e.g. because their timestamp is not greater than the timestamp of the + /// head. The engine still applies the forkchoice state in that case. The Engine API returns + /// that error as code -38003. + /// + /// [`ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes`]: alloy_rpc_types_engine::ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes + pub async fn forkchoice_updated_with_attributes( + &self, + state: ForkchoiceState, + attributes: impl Into, + ) -> Result { + self.handle.fork_choice_updated(state, Some(attributes.into())).await + } +} diff --git a/crates/e2e-test-utils/src/lib.rs b/crates/e2e-test-utils/src/lib.rs index d1631f71891..d871f1e0f37 100644 --- a/crates/e2e-test-utils/src/lib.rs +++ b/crates/e2e-test-utils/src/lib.rs @@ -1,13 +1,12 @@ -//! Utilities for end-to-end tests. +#![doc = include_str!("../README.md")] -use alloy_rpc_types_engine::PayloadAttributes; use node::NodeTestContext; use reth_db::{test_utils::TempDatabase, DatabaseEnv}; use reth_network_api::test_utils::PeersHandleProvider; use reth_node_builder::{ components::NodeComponentsBuilder, rpc::{EngineValidatorAddOn, RethRpcAddOns}, - FullNodeTypesAdapter, Node, NodeAdapter, NodeComponents, NodeTypesWithDBAdapter, PayloadTypes, + FullNodeTypesAdapter, Node, NodeAdapter, NodeComponents, NodeTypesWithDBAdapter, }; use reth_provider::providers::{BlockchainProvider, NodeTypesForProvider}; use std::sync::Arc; @@ -26,6 +25,7 @@ pub mod wallet; mod payload; /// Helper for setting up nodes with pre-imported chain data +#[cfg(feature = "chain-import")] pub mod setup_import; /// Helper for network operations @@ -44,6 +44,10 @@ pub mod receipt; pub mod wait; +pub mod engine; + +pub mod snap; + mod chain_spec; pub use chain_spec::{ eth_payload_attributes, test_chain_spec, test_chain_spec_builder, test_genesis, @@ -51,7 +55,7 @@ pub use chain_spec::{ /// Builder for configuring test node setups mod setup_builder; -pub use setup_builder::{E2ETestSetupBuilder, E2ETestSetupExt}; +pub use setup_builder::{E2ETestSetupBuilder, E2ETestSetupExt, TestNodeBuilder}; // Type aliases @@ -76,7 +80,7 @@ pub type NodeHelperType>> + + NodeTypesForProvider + Node< TmpNodeAdapter, ComponentsBuilder: NodeComponentsBuilder< @@ -90,7 +94,7 @@ where impl NodeBuilderHelper for T where Self: Default - + NodeTypesForProvider>> + + NodeTypesForProvider + Node< TmpNodeAdapter, ComponentsBuilder: NodeComponentsBuilder< diff --git a/crates/e2e-test-utils/src/node.rs b/crates/e2e-test-utils/src/node.rs index 7eb43c73b7d..ec81c0265e3 100644 --- a/crates/e2e-test-utils/src/node.rs +++ b/crates/e2e-test-utils/src/node.rs @@ -1,36 +1,64 @@ use crate::{ + engine::EngineTestContext, network::NetworkTestContext, payload::PayloadTestContext, + receipt::ensure_successful, rpc::RpcTestContext, - wait::{poll_until, WAIT_TIMEOUT}, + wait::{poll_until, POLL_INTERVAL, WAIT_TIMEOUT}, + TmpDB, }; -use alloy_consensus::{transaction::TxHashRef, BlockHeader}; -use alloy_eips::BlockId; -use alloy_network::{Ethereum, IntoWallet}; +use alloy_consensus::BlockHeader; +use alloy_eips::{eip2718::Encodable2718, BlockId}; +use alloy_network::{Ethereum, IntoWallet, ReceiptResponse}; use alloy_primitives::{BlockHash, BlockNumber, Bytes, Sealable, B256}; use alloy_provider::{ fillers::{FillProvider, RecommendedFillers, TxFiller}, Provider, ProviderBuilder, RootProvider, }; -use alloy_rpc_types_engine::{ExecutionPayloadEnvelopeV5, ForkchoiceState, ForkchoiceUpdated}; +use alloy_rpc_types_engine::{ + ForkchoiceState, ForkchoiceUpdateError, ForkchoiceUpdated, PayloadStatus, PayloadStatusEnum, +}; use alloy_rpc_types_eth::BlockNumberOrTag; -use eyre::{ensure, eyre, Ok}; -use futures_util::Future; +use eyre::{bail, ensure, eyre, Ok, OptionExt, WrapErr}; +use futures_util::{ + future::{select, BoxFuture, Either}, + Future, +}; use jsonrpsee::{core::client::ClientT, http_client::HttpClient}; use reth_chainspec::EthereumHardforks; +use reth_db::{mdbx::DatabaseArguments, open_db_read_only}; +use reth_engine_primitives::BeaconForkChoiceUpdateError; use reth_network_api::test_utils::PeersHandleProvider; -use reth_node_api::{Block, BlockBody, BlockTy, FullNodeComponents, PayloadTypes, PrimitivesTy}; +use reth_node_api::{ + Block, BlockBody, BlockTy, FullNodeComponents, NodePrimitives, PayloadTypes, PrimitivesTy, +}; use reth_node_builder::{rpc::RethRpcAddOns, FullNode, NodeTypes}; +use reth_node_core::{ + dirs::{ChainPath, DataDirPath}, + exit::NodeExitFuture, +}; use reth_payload_primitives::BuiltPayload; +use reth_primitives_traits::SealedBlock; use reth_provider::{ - BlockNumReader, BlockReader, BlockReaderIdExt, CanonStateNotificationStream, - CanonStateSubscriptions, DatabaseProviderFactory, HeaderProvider, StageCheckpointReader, + providers::RocksDBProvider, BlockHashReader, BlockIdReader, BlockNumReader, BlockReader, + BlockReaderIdExt, CanonStateNotificationStream, CanonStateSubscriptions, Chain, + DatabaseProviderFactory, HeaderProvider, MetadataProvider, PruneCheckpointReader, SnapAttempt, + StageCheckpointReader, }; -use reth_rpc_api::TestingBuildBlockRequestV1; +use reth_prune_types::PruneSegment; +use reth_rpc_api::{TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; use reth_rpc_builder::auth::AuthServerHandle; -use reth_rpc_eth_api::helpers::{EthApiSpec, EthTransactions, TraceExt}; +use reth_rpc_eth_api::{ + helpers::{EthApiSpec, EthTransactions, LoadReceipt, TraceExt}, + EthApiTypes, RpcReceipt, +}; use reth_stages_types::StageId; -use std::{pin::Pin, sync::Arc, time::Duration}; +use reth_transaction_pool::TransactionPool; +use std::{ + pin::{pin, Pin}, + sync::{Arc, Mutex, PoisonError}, + time::{Duration, Instant}, +}; use tokio_stream::StreamExt; use url::Url; @@ -41,6 +69,13 @@ use url::Url; /// with short dev block times. pub const RPC_PROVIDER_POLL_INTERVAL: Duration = Duration::from_millis(10); +/// Maximum time [`NodeTestContext::stop`] waits for the database of a node to be released after +/// the node shut down. +/// +/// The node itself releases it within milliseconds, so running into this timeout means that +/// something else still holds a handle of the database. +pub const DATABASE_RELEASE_TIMEOUT: Duration = Duration::from_secs(10); + /// A helper struct to handle node actions #[expect(missing_debug_implementations)] pub struct NodeTestContext @@ -56,8 +91,21 @@ where pub network: NetworkTestContext, /// Context for testing RPC features. pub rpc: RpcTestContext, + /// Context for sending payloads and forkchoice updates to the engine independent of the + /// Engine API version. + pub engine: EngineTestContext<::Payload>, /// Canonical state events. pub canonical_stream: CanonStateNotificationStream>, + /// Which blocks [`Self::import_payload`] marks as safe and finalized. + finality: Finality, + /// What [`Self::stop`] needs to stop the node and launch it again, only set for restartable + /// nodes of [`E2ETestSetupBuilder`](crate::E2ETestSetupBuilder). + pub(crate) restart: Option>, + /// Resolves when the consensus engine of the node exits, see [`Self::take_exit_future`]. + /// + /// The future is not `Sync`, but the context must be, so that helpers borrowing it return + /// `Send` futures. + pub(crate) exit_future: Mutex>, } impl NodeTestContext @@ -90,7 +138,11 @@ where payload, network: NetworkTestContext::new(node.network.clone()), rpc: RpcTestContext { inner: node.add_ons_handle.rpc_registry }, + engine: EngineTestContext::new(node.add_ons_handle.beacon_engine_handle), canonical_stream: node.provider.canonical_state_stream(), + finality: Finality::default(), + restart: None, + exit_future: Mutex::new(None), }) } @@ -127,25 +179,31 @@ where /// /// Returns an error if the transaction is not included in the block, see /// [`Self::assert_new_block`]. - pub async fn inject_and_advance( + pub fn inject_and_advance( &mut self, raw_tx: Bytes, - ) -> eyre::Result<(B256, Payload::BuiltPayload)> + ) -> BoxFuture<'_, eyre::Result<(B256, Payload::BuiltPayload)>> where AddOns::EthApi: EthApiSpec>> + EthTransactions + TraceExt, { - let tx_hash = self.rpc.inject_tx(raw_tx).await?; - let payload = self.advance_block().await?; - self.assert_new_block(tx_hash, payload.block().hash(), payload.block().number()).await?; - Ok((tx_hash, payload)) + Box::pin(async move { + let tx_hash = self.rpc.inject_tx(raw_tx).await?; + let payload = self.advance_block().await?; + self.assert_new_block(tx_hash, payload.block().hash(), payload.block().number()) + .await?; + Ok((tx_hash, payload)) + }) } /// Returns the current forkchoice state of the node. pub fn current_forkchoice_state(&self) -> eyre::Result { - let latest_header = - self.inner.provider.sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)?.unwrap(); + let latest_header = self + .inner + .provider + .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)? + .ok_or_else(|| eyre!("latest block not found"))?; if latest_header.number() == 0 { return Ok(ForkchoiceState::same_hash(latest_header.hash())); @@ -157,40 +215,83 @@ where .inner .provider .sealed_header_by_number_or_tag(BlockNumberOrTag::Safe)? - .unwrap() + .ok_or_else(|| eyre!("safe block not found"))? .hash(), finalized_block_hash: self .inner .provider .sealed_header_by_number_or_tag(BlockNumberOrTag::Finalized)? - .unwrap() + .ok_or_else(|| eyre!("finalized block not found"))? .hash(), }) } - /// Creates a new payload from given attributes generator - /// expects a payload attribute event and waits until the payload is built. + /// Sets the timestamp of the next payload built by [`Self::new_payload`] and the methods + /// built on it, such as [`Self::advance_block`]. /// - /// It triggers the resolve payload via engine api and expects the built payload event. - pub async fn new_payload(&mut self) -> eyre::Result { - let eth_attr = self.payload.next_attributes(); - let payload_id = self + /// Later payloads continue from this timestamp, one second apart. The attributes generator is + /// called with the new timestamp, so fork-dependent attributes follow it. + /// + /// Returns an error if the timestamp is not greater than the timestamp of the current latest + /// block, which is the parent of the next payload. + pub fn set_next_payload_timestamp(&mut self, timestamp: u64) -> eyre::Result<()> { + let latest = self .inner - .add_ons_handle - .beacon_engine_handle - .fork_choice_updated(self.current_forkchoice_state()?, Some(eth_attr.clone())) - .await? - .payload_id - .unwrap(); - // first event is the payload attributes - self.payload.expect_attr_event(eth_attr).await?; - // wait for the payload builder to have finished building - self.payload.wait_for_built_payload(payload_id).await; - // ensure we're also receiving the built payload as event - Ok(self.payload.expect_built_payload().await?) + .provider + .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)? + .ok_or_else(|| eyre!("latest block not found"))?; + ensure!( + timestamp > latest.timestamp(), + "next payload timestamp {timestamp} must be greater than the latest block timestamp {}", + latest.timestamp() + ); + // The payload context increments its timestamp before generating the next attributes. + self.payload.timestamp = timestamp - 1; + Ok(()) + } + + /// Builds a payload on top of the latest block and returns it without submitting it to the + /// engine. + /// + /// Sends a forkchoice update with the next payload attributes, see + /// [`Self::set_next_payload_timestamp`], and resolves the payload job it starts with + /// [`PayloadKind::WaitForPending`], which waits for the build in progress instead of returning + /// an empty payload. The payload can still contain no transactions, e.g. if the pool had none + /// when the build started. Payload jobs started by others, e.g. by forkchoice updates with + /// payload attributes sent by the test, do not affect this. + /// + /// Returns an error if the forkchoice update fails or starts no payload job, e.g. because the + /// engine is syncing, or if the payload job fails to build a payload. + /// + /// [`PayloadKind::WaitForPending`]: reth_payload_primitives::PayloadKind::WaitForPending + pub async fn new_payload(&mut self) -> eyre::Result { + self.build_payload(self.current_forkchoice_state()?).await + } + + /// Builds a payload on the head of `state` with the next payload attributes and returns it + /// without submitting it. + /// + /// Returns an error if the engine does not report the forkchoice update valid, see + /// [`Self::build_payload_on`]. + async fn build_payload( + &mut self, + state: ForkchoiceState, + ) -> eyre::Result { + let attributes = self.payload.next_attributes(); + let updated = self.send_forkchoice_updated(state, Some(attributes)).await?; + let payload_id = updated.payload_id.ok_or_else(|| { + eyre!( + "forkchoice update to block {} with payload attributes started no payload job: {}", + state.head_block_hash, + updated.payload_status.status + ) + })?; + self.payload.resolve_payload(payload_id).await } /// Triggers payload building job and submits it to the engine. + /// + /// Returns an error if the engine reports the payload invalid, see [`Self::submit_payload`]. pub async fn build_and_submit_payload(&mut self) -> eyre::Result { let payload = self.new_payload().await?; @@ -199,16 +300,447 @@ where Ok(payload) } - /// Advances the node forward one block + /// Advances the node forward one block by building a payload and importing it with + /// [`Self::import_payload`]. + /// + /// The block becomes the head, the safe and finalized blocks follow the [`Finality`] policy of + /// the context. pub async fn advance_block(&mut self) -> eyre::Result { - let payload = self.build_and_submit_payload().await?; + let payload = self.new_payload().await?; + + self.import_payload(payload.clone()).await?; - // trigger forkchoice update via engine api to commit the block to the blockchain - self.update_forkchoice(payload.block().hash(), payload.block().hash()).await?; + Ok(payload) + } + /// Advances the node forward one block like [`Self::advance_block`] and waits until the + /// transaction pool processed the new block, see [`Self::wait_for_pool_head`]. + /// + /// This only waits for the canonical state update of the pool itself, which removes the + /// transactions mined in the block and updates sender nonces and pending fees. Nodes that run + /// additional pool maintenance tasks on new blocks have to wait for the expected contents of + /// the pool instead, e.g. with [`Self::wait_for_pool_removal`] for the mined transactions. + pub async fn advance_block_synced(&mut self) -> eyre::Result { + let payload = self.advance_block().await?; + self.wait_for_pool_head(payload.block().hash()).await?; Ok(payload) } + /// Advances the chain `length` blocks, see [`Self::advance_block`]. + /// + /// Unlike [`Self::advance`], this does not inject transactions, so the blocks include the + /// pending transactions of the pool, if any. + /// + /// Returns the built payloads. + pub async fn advance_blocks( + &mut self, + length: u64, + ) -> eyre::Result> { + let mut chain = Vec::with_capacity(length as usize); + for _ in 0..length { + chain.push(self.advance_block().await?); + } + Ok(chain) + } + + /// Builds a payload on the block with the given hash with the next payload attributes and + /// returns it without submitting it, like [`Self::new_payload`] does for the head. + /// + /// The payload job is started with a forkchoice update to `parent` that keeps the safe and + /// finalized blocks of the node. Its effect depends on `parent`: + /// - The head, or a canonical ancestor of it that is not below the finalized block: the head + /// stays, the engine builds on `parent` without changing the canonical chain. The exception + /// is a tree config with both `always_process_payload_attributes_on_canonical_head` and + /// `unwind_canonical_header`, under which the engine moves the head back to an ancestor. + /// - A block on a side chain: the engine first makes the side chain canonical, so `parent` + /// becomes the head, reorging the chain. + /// - A canonical block below the finalized block, or a side chain block that does not descend + /// from the finalized block: the engine rejects the update and this returns an error, the + /// head stays. Blocks imported under [`Finality::Head`] are finalized, import them under + /// [`Finality::Keep`] or [`Finality::Lag`] to build on them later, see + /// [`Self::set_finality`]. + /// - A block the node does not know: the engine starts no payload job and requests the block + /// from its peers, and this returns an error. + /// + /// The payload includes transactions of the pool, which tracks the head: transactions mined in + /// the blocks above `parent` are not in the pool anymore, so the payload does not include them, + /// and pool transactions that are invalid on `parent` are skipped. + pub async fn build_payload_on(&mut self, parent: B256) -> eyre::Result { + self.build_payload(ForkchoiceState { + head_block_hash: parent, + ..self.current_forkchoice_state()? + }) + .await + } + + /// Builds a block on the block with the given hash and imports it with + /// [`Self::import_payload`], so it becomes the head. + /// + /// If `parent` is not the head, this reorgs the chain, so `parent` must be the finalized block + /// or descend from it, see [`Self::build_payload_on`]. The safe and finalized blocks follow + /// the [`Finality`] policy of the context. The transaction pool processes the reorg in the + /// background, see [`Self::reorg_to`]. + pub async fn advance_block_on(&mut self, parent: B256) -> eyre::Result { + let payload = self.build_payload_on(parent).await?; + + self.import_payload(payload.clone()).await?; + + Ok(payload) + } + + /// Builds a chain of `length` blocks on the block with the given hash and makes its tip the + /// head, see [`Self::advance_block_on`]. + /// + /// The first block is built on `parent` and each further block on the previous one, so this + /// reorgs the chain if `parent` is not the head. Returns the built payloads, does nothing for + /// a length of 0. + pub async fn advance_fork( + &mut self, + parent: B256, + length: u64, + ) -> eyre::Result> { + let mut chain = Vec::with_capacity(length as usize); + let mut parent = parent; + for _ in 0..length { + let payload = self.advance_block_on(parent).await?; + parent = payload.block().hash(); + chain.push(payload); + } + Ok(chain) + } + + /// Advances the chain one block at a time until the transaction with the given hash is + /// included in a canonical block, returning its receipt. + /// + /// Returns the receipt without advancing if the transaction is already included. Returns an + /// error if the transaction is not included within [`WAIT_TIMEOUT`]. + pub fn advance_until_receipt( + &mut self, + hash: B256, + ) -> BoxFuture<'_, eyre::Result::NetworkTypes>>> + where + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + Box::pin(async move { + let wait = async { + loop { + if let Some(receipt) = self.rpc.transaction_receipt(hash).await? { + return Ok(receipt) + } + self.advance_block().await?; + } + }; + tokio::time::timeout(WAIT_TIMEOUT, wait) + .await + .map_err(|_| eyre!("timed out waiting for the receipt of transaction {hash}"))? + }) + } + + /// Advances the chain one block at a time until the transaction pool has no pending + /// transactions left, returning the built payloads. + /// + /// Pending transactions are the ones the pool considers ready for the next block, which the + /// payload builder picks from. This does not wait for the other transactions of the pool, + /// which stay in it: queued transactions, e.g. behind a nonce gap, and transactions whose fee + /// cap is below the base fee or blob fee of the next block. Transactions that become pending + /// once a built block is processed, e.g. because it lowered the base fee, are included in the + /// following blocks. The pool decides when to stop, not the content of the blocks, since + /// payload builders can add transactions of their own that are not pool transactions. + /// + /// The pool processes new blocks in the background, so this waits until it processed the + /// current head before it looks at the pool, see [`Self::wait_for_pool_head`], and advances + /// with [`Self::advance_block_synced`]. No block is built once the pool has no pending + /// transactions: the last payload is the block after which none were left, not an extra empty + /// block, and no payload is returned if the pool has none to begin with. + /// + /// Returns an error if the pool does not process the current head within [`WAIT_TIMEOUT`], + /// e.g. because it was synced by backfill, or, listing them, if pending transactions are left + /// after [`WAIT_TIMEOUT`], e.g. because the payload builder skips them. + pub async fn advance_until_pool_drained(&mut self) -> eyre::Result> { + let head = self + .inner + .provider + .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)? + .ok_or_else(|| eyre!("latest block not found"))?; + self.wait_for_pool_head(head.hash()).await?; + + let wait = async { + let mut chain = Vec::new(); + while self.inner.pool.pool_size().pending > 0 { + chain.push(self.advance_block_synced().await?); + } + Ok(chain) + }; + tokio::time::timeout(WAIT_TIMEOUT, wait).await.map_err(|_| { + eyre!( + "timed out advancing the chain until the transaction pool has no pending \ + transactions, {}", + describe_pending_transactions(&self.inner.pool) + ) + })? + } + + /// Drives `fut` to completion while advancing the chain one block every [`POLL_INTERVAL`], + /// returning its output. + /// + /// This is useful for futures that only complete once their transaction is mined, e.g. an + /// `eth_sendRawTransactionSync` request. `fut` is also polled while a block is built, but the + /// block is always built to completion, so the chain may advance one more block after `fut` + /// completed. + /// + /// Returns an error if `fut` does not complete within [`WAIT_TIMEOUT`]. + pub async fn advance_while(&mut self, fut: F) -> eyre::Result { + let wait = async { + let mut fut = pin!(fut); + loop { + if let Result::Ok(output) = tokio::time::timeout(POLL_INTERVAL, fut.as_mut()).await + { + return Ok(output) + } + // The engine can still import a cancelled block and make it canonical after this + // returns, so finish it even if `fut` completes first. + let mut advance = pin!(self.advance_block()); + match select(advance.as_mut(), fut.as_mut()).await { + Either::Left((payload, _)) => { + payload?; + } + Either::Right((output, _)) => { + advance.await?; + return Ok(output) + } + } + } + }; + tokio::time::timeout(WAIT_TIMEOUT, wait) + .await + .map_err(|_| eyre!("timed out advancing the chain until the future completed"))? + } + + /// Injects the raw transactions into the pool in the given order, advances the node one block + /// and returns the mined block with the receipts of the transactions. + /// + /// The block must include all given transactions, in any order, and no other transaction of the + /// pool, so a transaction that is dropped or that rides along fails instead of going unnoticed. + /// An empty `txs` mines a block without pool transactions. Transactions that the payload + /// builder adds by itself, e.g. from the payload attributes, are not pool transactions and may + /// be included. Use [`Self::mine_including`] if other pool transactions may be included too. + /// + /// The receipts are in the order of `txs` and can report reverted transactions, see + /// [`MinedBlock::ensure_success`]. The committed chain segment is taken from + /// [`Self::canonical_stream`], whose notifications up to the one of the mined block are + /// consumed, so the stream does not need to be aligned with the mined blocks before. + /// + /// The block is imported with [`Self::import_payload`], so the transaction pool processes it in + /// the background, see [`Self::advance_block_synced`]. + /// + /// Returns an error if the pool rejects a transaction, or, without importing the block, if the + /// block does not include the expected transactions, listing the missing and the unexpected + /// ones. + /// + /// Use [`Self::mine_pooled`] for transactions that are already in the pool, e.g. sent through + /// [`Self::rpc_provider_with_wallet`] or added with the pool API, and [`Self::mine_signed`] + /// for signed transactions that should all succeed. + /// + /// The helpers that drive the eth API return boxed futures: with the new trait solver, + /// awaiting the unboxed future makes every caller borrow-check its whole state machine, which + /// dominates compile time for nodes with large eth API impls. + pub fn mine( + &mut self, + txs: impl IntoIterator, + ) -> MinedBlockFuture< + '_, + Payload::BuiltPayload, + RpcReceipt<::NetworkTypes>, + > + where + // Implied by `NodeTypes`, but not derived by the compiler through the `Payload` parameter. + Payload::BuiltPayload: BuiltPayload>, + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + let txs: Vec = txs.into_iter().collect(); + Box::pin(async move { self.mine_txs(txs, true).await }) + } + + /// Mines the raw transactions like [`Self::mine`], but the block may also include other + /// transactions of the pool, e.g. transactions that peers gossiped to the node. + /// + /// Only the receipts of the given transactions are returned. + pub fn mine_including( + &mut self, + txs: impl IntoIterator, + ) -> MinedBlockFuture< + '_, + Payload::BuiltPayload, + RpcReceipt<::NetworkTypes>, + > + where + Payload::BuiltPayload: BuiltPayload>, + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + let txs: Vec = txs.into_iter().collect(); + Box::pin(async move { self.mine_txs(txs, false).await }) + } + + /// Mines the signed transactions like [`Self::mine`] and returns an error if one of them + /// reverted, see [`MinedBlock::ensure_success`]. + /// + /// The transactions are EIP-2718 encoded before they are sent to the pool, so signed + /// transactions or envelopes, e.g. of a custom transaction type, can be passed without + /// encoding them first. + pub fn mine_signed( + &mut self, + txs: impl IntoIterator, + ) -> MinedBlockFuture< + '_, + Payload::BuiltPayload, + RpcReceipt<::NetworkTypes>, + > + where + Payload::BuiltPayload: BuiltPayload>, + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + // Encoded up front, so the returned future does not hold `T`. + let txs: Vec = txs.into_iter().map(|tx| tx.encoded_2718().into()).collect(); + Box::pin(async move { self.mine(txs).await?.ensure_success() }) + } + + /// Mines the transactions with the given hashes like [`Self::mine`], for transactions that are + /// already in the pool, e.g. sent through [`Self::rpc_provider_with_wallet`] or added with the + /// pool API. + /// + /// The receipts are in the order of `hashes`. + /// + /// Before building a block, returns an error naming the first hash that is not in the pool, + /// e.g. of a transaction that was already mined or replaced. Transactions in any sub-pool pass + /// this check, not only pending ones, since the pool sorts transactions for its own view of the + /// next block, which can lag behind the block that is built. A transaction the block can not + /// include, e.g. one queued behind a nonce gap, is reported as missing like with + /// [`Self::mine`]. + pub fn mine_pooled( + &mut self, + hashes: impl IntoIterator, + ) -> MinedBlockFuture< + '_, + Payload::BuiltPayload, + RpcReceipt<::NetworkTypes>, + > + where + Payload::BuiltPayload: BuiltPayload>, + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + let hashes: Vec = hashes.into_iter().collect(); + Box::pin(async move { self.mine_pooled_txs(hashes, true).await }) + } + + /// Mines the raw transactions, see [`Self::mine`]. Unless `exclusive` is set, the block may + /// include other pool transactions. + async fn mine_txs( + &mut self, + txs: impl IntoIterator, + exclusive: bool, + ) -> eyre::Result< + MinedBlock< + Payload::BuiltPayload, + RpcReceipt<::NetworkTypes>, + >, + > + where + Payload::BuiltPayload: BuiltPayload>, + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + let mut hashes = Vec::new(); + for (index, raw_tx) in txs.into_iter().enumerate() { + let hash = self + .rpc + .inject_tx(raw_tx) + .await + .wrap_err_with(|| format!("failed to inject transaction {index}"))?; + hashes.push(hash); + } + + self.mine_pooled_txs(hashes, exclusive).await + } + + /// Mines the pool transactions with the given hashes, see [`Self::mine_pooled`]. Unless + /// `exclusive` is set, the block may include other pool transactions. + async fn mine_pooled_txs( + &mut self, + hashes: Vec, + exclusive: bool, + ) -> eyre::Result< + MinedBlock< + Payload::BuiltPayload, + RpcReceipt<::NetworkTypes>, + >, + > + where + Payload::BuiltPayload: BuiltPayload>, + AddOns::EthApi: EthApiSpec>> + + EthTransactions + + TraceExt + + LoadReceipt + + 'static, + { + for hash in &hashes { + ensure!(self.inner.pool.contains(hash), "transaction {hash} is not in the pool"); + } + + let payload = self.new_payload().await?; + let (number, block_hash) = (payload.block().number(), payload.block().hash()); + let body = payload.block().body(); + let missing: Vec<_> = + hashes.iter().filter(|hash| !body.contains_transaction(hash)).collect(); + // The pool only removes the transactions of a block once it is canonical, so all pool + // transactions of the block are still in the pool before the import. + let unexpected: Vec<_> = body + .transaction_hashes_iter() + .filter(|hash| exclusive && !hashes.contains(hash) && self.inner.pool.contains(hash)) + .collect(); + ensure!( + missing.is_empty() && unexpected.is_empty(), + "block {number} does not include the expected transactions: missing {missing:?}, \ + unexpected pool transactions {unexpected:?}" + ); + + self.import_payload(payload.clone()).await?; + let chain = + wait_for_committed_chain(&mut self.canonical_stream, block_hash, number).await?; + + let mut receipts = Vec::with_capacity(hashes.len()); + for hash in hashes { + let receipt = self.rpc.transaction_receipt(hash).await?.ok_or_else(|| { + eyre!("receipt of transaction {hash} in block {number} not found") + })?; + receipts.push(receipt); + } + + Ok(MinedBlock { payload, receipts, chain }) + } + /// Waits for block to be available on node. /// /// Returns an error if the block is not available within [`WAIT_TIMEOUT`]. @@ -271,6 +803,156 @@ where .await } + /// Waits until the transaction pool of the node processed the canonical state update that made + /// the block with the given hash its head. + /// + /// The pool maintenance task processes new heads in the background, so right after e.g. a + /// forkchoice update the pool can still contain the transactions mined in the new block or use + /// outdated sender nonces and pending fees. The pool updates its last seen block together with + /// these, so they are up to date once this returns. Exceptions are reorgs, after which the + /// maintenance task re-injects the transactions of the old chain only afterwards, so wait for + /// them with [`Self::wait_for_pooled`], and commits deeper than the maximum update depth of the + /// maintenance task, e.g. after a long sync, which only update the last seen block. + /// + /// This only covers the canonical state update of the pool itself. Nodes can run additional + /// pool maintenance tasks on new blocks, e.g. to remove mined transactions from a separate + /// sub-pool or to evict transactions by their own rules, and the pool can report the new head + /// before these processed the block. Tests of such nodes have to wait for the expected + /// contents of the pool instead, with [`Self::wait_for_pool_removal`], + /// [`Self::wait_for_pooled`] or [`Self::wait_for_pool`]. + /// + /// Returns an error if the pool does not process the block within [`WAIT_TIMEOUT`], e.g. + /// because the block is not the canonical head, the pool already processed a newer head, or + /// the block was synced by backfill, which does not notify the pool. + pub async fn wait_for_pool_head(&self, hash: B256) -> eyre::Result<()> { + let pool = &self.inner.pool; + poll_until(format!("transaction pool to process block {hash}"), move || { + let ready = pool.block_info().last_seen_block_hash == hash; + async move { Ok(ready.then_some(())) } + }) + .await + } + + /// Waits until all transactions with the given hashes are in the transaction pool of the + /// node. + /// + /// The transactions must be in the pool at the same time, so this keeps waiting if one of them + /// leaves the pool again, e.g. because it is mined or replaced, before the others entered. + /// + /// Returns an error listing the transactions that are still missing if they are not all in + /// the pool within [`WAIT_TIMEOUT`]. + pub async fn wait_for_pooled( + &self, + hashes: impl IntoIterator, + ) -> eyre::Result<()> { + wait_for_pool_transactions(&self.inner.pool, hashes, true).await + } + + /// Waits until none of the transactions with the given hashes is in the transaction pool of + /// the node, regardless of why they left, e.g. because they were mined, replaced or evicted. + /// + /// Unlike [`Self::wait_for_pool_head`], this does not depend on which task removes the + /// transactions, so it also covers nodes with additional pool maintenance tasks. It returns + /// right away for transactions that never entered the pool, so wait for transactions that + /// arrive in the background, e.g. from peers, with [`Self::wait_for_pooled`] first. + /// + /// Returns an error listing the transactions that are still pooled if they do not all leave + /// the pool within [`WAIT_TIMEOUT`]. + pub async fn wait_for_pool_removal( + &self, + hashes: impl IntoIterator, + ) -> eyre::Result<()> { + wait_for_pool_transactions(&self.inner.pool, hashes, false).await + } + + /// Waits until the node has persisted at least the block with the given number to disk. + /// + /// The engine keeps the most recent blocks in memory, so tests that inspect the database + /// directly, e.g. via [`assert_trie_consistency`], must first advance the chain far enough + /// and wait for the persistence service to catch up. This checks the `Finish` stage checkpoint + /// of the database, which is committed together with the saved blocks, so all blocks up to + /// `number` are readable from disk once this returns. Their state and trie are committed in + /// the same transaction, unless the engine keeps the state of the most recently persisted + /// blocks masked by its in-memory suffix (`TreeConfig::num_state_masking_blocks`, disabled by a + /// persistence threshold of 0), in which case the persisted state can lag behind `number`. The + /// pruner runs after the save in a separate commit, so pruning of the saved blocks can still be + /// pending, wait for it with [`Self::wait_for_prune_checkpoint`]. + /// + /// Unlike [`Self::wait_block`], this does not check the block hash, so it also returns if the + /// persisted block at `number` is not canonical anymore. + /// + /// Returns an error if the block is not persisted within [`WAIT_TIMEOUT`]. + /// + /// [`assert_trie_consistency`]: crate::trie::assert_trie_consistency + pub async fn wait_for_persisted_block(&self, number: BlockNumber) -> eyre::Result<()> { + let provider = &self.inner.provider; + poll_until(format!("block {number} to be persisted"), move || async move { + let persisted = provider.database_provider_ro()?.best_block_number()?; + Ok((persisted >= number).then_some(())) + }) + .await + } + + /// Waits until the pruner has pruned `segment` of the node up to at least the block with the + /// given number. + /// + /// The persistence service acknowledges a save before it runs the pruner for the new database + /// tip in a separate commit, so a block being persisted, e.g. awaited with + /// [`Self::wait_for_persisted_block`], does not mean the pruner has run for it yet. The + /// pruner saves the checkpoint of a segment in the same provider commit as the pruned data, + /// which commits static files and `RocksDB` before the database transaction, so the data of + /// `segment` up to `block` is pruned once this returns. + /// + /// Returns an error if the segment is not pruned within [`WAIT_TIMEOUT`], e.g. because the + /// node is not configured to prune it or `block` is within its retention window. + pub async fn wait_for_prune_checkpoint( + &self, + segment: PruneSegment, + block: BlockNumber, + ) -> eyre::Result<()> { + let provider = &self.inner.provider; + poll_until(format!("{segment} to be pruned up to block {block}"), move || async move { + let checkpoint = provider.get_prune_checkpoint(segment)?; + Ok(checkpoint + .and_then(|checkpoint| checkpoint.block_number) + .is_some_and(|pruned| pruned >= block) + .then_some(())) + }) + .await + } + + /// Waits until the block with the given hash is the latest block of the node, i.e. its + /// canonical head. + /// + /// This only observes the node and sends nothing to the engine, so the block has to become the + /// head otherwise, e.g. through a forkchoice update the test sent itself, which may start a + /// backfill sync to the block. [`Self::sync_to`] instead keeps sending forkchoice updates that + /// make the block the head, safe and finalized block until it is the head, and then waits + /// briefly for the transaction pool, which this does not, see [`Self::wait_for_pool_head`]. + /// [`Self::wait_block`] waits for the header at a block number, which a canonical block that is + /// not the head also satisfies, and after a backfill sync it can return before the engine made + /// the synced block its head, even when it waits for the `Finish` checkpoint of the pipeline. + /// + /// Returns an error naming the head of the node if the block is not the head within + /// [`WAIT_TIMEOUT`], e.g. because the node made another block its head. + pub async fn wait_for_head(&self, hash: BlockHash) -> eyre::Result<()> { + let provider = &self.inner.provider; + let latest = || provider.sealed_header_by_number_or_tag(BlockNumberOrTag::Latest); + let wait = async { + while latest()?.is_none_or(|head| head.hash() != hash) { + tokio::time::sleep(POLL_INTERVAL).await; + } + Ok(()) + }; + tokio::time::timeout(WAIT_TIMEOUT, wait).await.map_err(|_| { + let head = latest().ok().flatten().map_or_else( + || "unknown".to_string(), + |head| format!("block {} ({})", head.number(), head.hash()), + ); + eyre!("timed out waiting for block {hash} to become the head, the head is {head}") + })? + } + /// Asserts that a new block has been added to the blockchain and the tx has been included in /// the block, at any position. /// @@ -282,16 +964,14 @@ where block_hash: B256, block_number: BlockNumber, ) -> eyre::Result<()> { - // Get the head block from the notification stream and verify the tx is included in the - // canonical block. - let head = tokio::time::timeout(WAIT_TIMEOUT, self.canonical_stream.next()) - .await - .map_err(|_| eyre!("timed out waiting for block {block_number}"))? - .ok_or_else(|| eyre!("canonical state stream closed"))?; + let chain = + wait_for_committed_chain(&mut self.canonical_stream, block_hash, block_number).await?; ensure!( - head.tip().body().transactions().iter().any(|tx| *tx.tx_hash() == tip_tx_hash), - "transaction {tip_tx_hash} is not included in block {}", - head.tip().number() + chain + .blocks() + .get(&block_number) + .is_some_and(|block| block.body().contains_transaction(&tip_tx_hash)), + "transaction {tip_tx_hash} is not included in block {block_number}" ); // Wait for the block to commit and make sure the block hash we submitted via FCU engine @@ -324,19 +1004,44 @@ where .hash() } + /// Returns the snap attempt the node persisted. + pub fn snap_attempt(&self) -> eyre::Result + where + ::Provider: MetadataProvider, + { + self.inner.provider.database_provider_ro()?.snap_attempt()?.ok_or_eyre("no snap attempt") + } + /// Sends FCU and waits for the node to sync to the given block. /// - /// Returns an error if the node does not sync within [`WAIT_TIMEOUT`]. + /// The forkchoice update makes the block the head, safe and finalized block, regardless of the + /// [`Finality`] policy of the context. Returns an error if the node does not sync within + /// [`WAIT_TIMEOUT`]. pub async fn sync_to(&self, block: BlockHash) -> eyre::Result<()> { + self.sync_to_forkchoice(ForkchoiceState::same_hash(block)).await + } + + /// Sends the forkchoice update `state` and waits for the node to sync to its head, like + /// [`Self::sync_to`], but with the safe and finalized blocks of `state`. + /// + /// A finalized block below the head, e.g. one the node does not know yet, lets a backfill + /// anchor to it, as a snap sync anchors its pivot to the finalized block. Returns an error if + /// the node does not sync within [`WAIT_TIMEOUT`]. + pub async fn sync_to_forkchoice(&self, state: ForkchoiceState) -> eyre::Result<()> { + let block = state.head_block_hash; let sync = async { while self .inner .provider - .sealed_header_by_id(BlockId::Number(BlockNumberOrTag::Latest))? + .sealed_header_by_id(BlockId::latest())? .is_none_or(|h| h.hash() != block) { tokio::time::sleep(Duration::from_millis(100)).await; - self.update_forkchoice(block, block).await?; + self.inner + .add_ons_handle + .beacon_engine_handle + .fork_choice_updated(state, None) + .await?; } Ok(()) }; @@ -344,15 +1049,18 @@ where .await .map_err(|_| eyre!("timed out syncing to block {block}"))??; - // Hack to make sure that all components have time to process canonical state update. - // Otherwise, this might result in e.g "nonce too low" errors when advancing chain further, - // making tests flaky. - tokio::time::sleep(Duration::from_millis(1000)).await; + // The transaction pool processes the canonical state update in the background, and + // advancing the chain before it did can fail with e.g. "nonce too low" errors. Blocks + // synced by backfill don't notify the pool, so wait for at most a second. + let _ = tokio::time::timeout(Duration::from_secs(1), self.wait_for_pool_head(block)).await; Ok(()) } /// Sends a forkchoice update message to the engine and returns its response. + /// + /// The update makes `new_head` the head and `current_head` the safe and finalized block, + /// regardless of the [`Finality`] policy of the context. pub async fn update_forkchoice( &self, current_head: B256, @@ -382,14 +1090,213 @@ where self.update_forkchoice(B256::ZERO, hash).await } - /// Submits a payload to the engine. + /// Submits a payload to the engine with `newPayload` and returns its block hash. + /// + /// This only inserts the block, it does not make it canonical, see [`Self::import_payload`]. + /// Returns the block hash if the engine reports the payload: + /// - `VALID`: the engine executed the block on top of its parent, or knew it as valid already. + /// - `SYNCING` or `ACCEPTED`: the engine did not execute the block, e.g. because its parent is + /// unknown, so the block is not known to be valid yet, see + /// [`Self::submit_payload_with_status`]. + /// + /// Returns an error with the validation error and the latest valid hash if the engine reports + /// the payload `INVALID`. Use [`Self::submit_payload_with_status`] to inspect the status + /// instead, e.g. to test that a payload is rejected. pub async fn submit_payload(&self, payload: Payload::BuiltPayload) -> eyre::Result { let block_hash = payload.block().hash(); - self.inner.add_ons_handle.beacon_engine_handle.new_payload(payload.into()).await?; + let status = self.submit_payload_with_status(payload).await?; + if let PayloadStatusEnum::Invalid { validation_error } = status.status { + let latest_valid_hash = status + .latest_valid_hash + .map_or_else(|| "none".to_string(), |hash| hash.to_string()); + bail!( + "payload {block_hash} is invalid (latest valid hash: {latest_valid_hash}): \ + {validation_error}" + ) + } Ok(block_hash) } + /// Submits a payload to the engine with `newPayload` and returns the payload status the engine + /// reports. + /// + /// Unlike [`Self::submit_payload`], this returns `INVALID` statuses instead of an error. This + /// only inserts the block, it does not make it canonical. The engine reports: + /// - `VALID` if it executed the block on top of its parent, or knew it as valid already. The + /// latest valid hash is the block hash. + /// - `INVALID` if the block failed validation, or descends from a block that did. The latest + /// valid hash is the hash of the most recent valid ancestor, or `None` if the engine can't + /// determine it, e.g. because the block hash does not match the payload. + /// - `SYNCING` if it did not execute the block because the parent of the block is unknown, in + /// which case it buffers the block and executes it once the parent is inserted, or because + /// the node is syncing. + /// - `ACCEPTED` if it accepted the block without executing it. The reth engine tree does not + /// report this status. + /// + /// Returns an error if the engine fails to process the payload, e.g. because it shut down. + pub async fn submit_payload_with_status( + &self, + payload: Payload::BuiltPayload, + ) -> eyre::Result { + Ok(self.inner.add_ons_handle.beacon_engine_handle.new_payload(payload.into()).await?) + } + + /// Sets which blocks [`Self::import_payload`] and the block producing helpers built on it, such + /// as [`Self::advance_block`], mark as safe and finalized, see [`Finality`]. + /// + /// The policy applies from the next imported block on, the current safe and finalized blocks + /// are not changed. Defaults to [`Finality::Head`]. + pub const fn set_finality(&mut self, finality: Finality) { + self.finality = finality; + } + + /// Submits a payload to the engine and makes its block the canonical head, returning the block + /// hash. + /// + /// The forkchoice update marks the safe and finalized blocks according to the [`Finality`] + /// policy of the context, see [`Self::set_finality`]. Under the default [`Finality::Head`], + /// the block also becomes the safe and finalized block, so only a forkchoice update that moves + /// the finalized block to another chain can reorg it. The engine only reports the forkchoice + /// update valid after making the head canonical, so the block is the latest block of the node + /// once this returns. The transaction pool processes the new block in the background, see + /// [`Self::wait_for_pool`]. + /// + /// The parent of the payload must be known to the node, e.g. to import a payload built by + /// another node into its peers. Returns an error if the engine reports the payload invalid, see + /// [`Self::submit_payload`], or does not report the forkchoice update valid, e.g. because the + /// parent is unknown, or because the block does not descend from the safe and finalized blocks + /// that [`Finality::Keep`] and [`Finality::Lag`] keep. + pub async fn import_payload(&self, payload: Payload::BuiltPayload) -> eyre::Result { + let block_number = payload.block().number(); + let block_hash = self.submit_payload(payload).await?; + let state = match self.finality { + Finality::Head => ForkchoiceState::same_hash(block_hash), + Finality::Keep | Finality::Lag(_) => { + ForkchoiceState { head_block_hash: block_hash, ..self.current_forkchoice_state()? } + } + }; + self.send_forkchoice_updated(state, None).await?; + + // Look up the block at the lagging height only now that the block is canonical, so it is an + // ancestor of the block even if the block reorged the chain. + if let Finality::Lag(lag) = self.finality && + let Some(number) = block_number.checked_sub(lag) && + self.inner + .provider + .finalized_block_number()? + .is_none_or(|finalized| number > finalized) && + let Some(finalized) = self.inner.provider.block_hash(number)? + { + self.send_forkchoice_updated( + ForkchoiceState { + head_block_hash: block_hash, + safe_block_hash: finalized, + finalized_block_hash: finalized, + }, + None, + ) + .await?; + } + + Ok(block_hash) + } + + /// Makes the known block with the given hash the head of the node, keeping its safe and + /// finalized blocks, and returns once the block is the latest block. + /// + /// The engine makes a block on a side chain canonical, which reorgs the chain, e.g. back to a + /// chain the node left with [`Self::advance_fork`]. This requires the block to descend from + /// the finalized block, so blocks imported under [`Finality::Head`] can't be reorged away + /// from, import them under [`Finality::Keep`] or [`Finality::Lag`], see + /// [`Self::set_finality`]. The engine does not move the head back to a canonical ancestor, so + /// this returns an error for those, build a block on the ancestor with + /// [`Self::advance_block_on`] instead. + /// + /// The transaction pool processes the reorg in the background: once + /// [`Self::wait_for_pool_head`] returns for the block, the pool has removed the transactions + /// mined in the new chain and updated sender nonces and fees to it, while the maintenance task + /// re-injects the transactions of the reorged blocks only afterwards, wait for them with + /// [`Self::wait_for_pool`]. + /// + /// Returns an error if the node does not know the block or one of its ancestors, if the block + /// is invalid, or if it does not descend from the safe and finalized blocks. + pub async fn reorg_to(&self, hash: B256) -> eyre::Result<()> { + let state = ForkchoiceState { head_block_hash: hash, ..self.current_forkchoice_state()? }; + self.send_forkchoice_updated(state, None).await?; + + let latest = self + .inner + .provider + .sealed_header_by_number_or_tag(BlockNumberOrTag::Latest)? + .ok_or_else(|| eyre!("latest block not found"))?; + ensure!( + latest.hash() == hash, + "block {hash} is a canonical ancestor of the head {}, which the engine does not move \ + the head back to, build a block on it with `advance_block_on` instead", + latest.hash() + ); + + Ok(()) + } + + /// Sends the forkchoice state to the engine and returns its response, or an error that + /// explains why the engine did not report it valid. + async fn send_forkchoice_updated( + &self, + state: ForkchoiceState, + attributes: Option, + ) -> eyre::Result { + let head = state.head_block_hash; + let updated = self + .inner + .add_ons_handle + .beacon_engine_handle + .fork_choice_updated(state, attributes) + .await + .map_err(|err| self.explain_forkchoice_error(err, head))?; + ensure!( + !updated.is_syncing(), + "the node does not know block {head} or one of its ancestors, or is syncing, submit \ + the blocks first, e.g. with `submit_payload`" + ); + ensure!( + updated.is_valid(), + "forkchoice update to head {head}, safe {}, finalized {} is not valid: {}", + state.safe_block_hash, + state.finalized_block_hash, + updated.payload_status.status + ); + Ok(updated) + } + + /// Converts an error of a forkchoice update to `head` into a report, explaining how to avoid it + /// if the engine rejected the update because `head` does not descend from the finalized block. + fn explain_forkchoice_error( + &self, + err: BeaconForkChoiceUpdateError, + head: B256, + ) -> eyre::Report { + if !matches!( + err, + BeaconForkChoiceUpdateError::ForkchoiceUpdateError( + ForkchoiceUpdateError::TooDeepReorg | ForkchoiceUpdateError::InvalidState + ) + ) { + return err.into() + } + let finalized = match self.inner.provider.finalized_block_num_hash() { + Result::Ok(Some(finalized)) => format!("{} ({})", finalized.number, finalized.hash), + _ => "unknown".to_string(), + }; + eyre::Report::new(err).wrap_err(format!( + "the node can't reorg to block {head}: it does not descend from the safe and finalized \ + blocks of the node (finalized: {finalized}). Only blocks above the finalized block \ + can be reorged, import them under `Finality::Keep` or `Finality::Lag`, see \ + `NodeTestContext::set_finality`" + )) + } + /// Returns the RPC URL. pub fn rpc_url(&self) -> Url { let addr = self.inner.rpc_server_handle().http_local_addr().unwrap(); @@ -508,34 +1415,487 @@ where pub async fn testing_build_block_v1( &self, request: TestingBuildBlockRequestV1, - ) -> eyre::Result { + ) -> eyre::Result { let client = self.rpc_client().ok_or_else(|| eyre::eyre!("HTTP RPC client not available"))?; - let res: ExecutionPayloadEnvelopeV5 = + let res: TestingBuildBlockResponseV1 = client.request("testing_buildBlockV1", request.into_params()).await?; eyre::Ok(res) } + + /// Stops the node and closes its database, keeping its datadir to launch the node again with + /// [`StoppedNode::start`]. + /// + /// The node shuts down like a node that receives `SIGTERM`: the engine persists every block of + /// its canonical chain together with its state, and the tasks of the node exit. Blocks that + /// are not canonical, e.g. payloads that were only submitted with [`Self::submit_payload`], + /// are lost. Local transactions of the pool are saved to the datadir and reinserted when the + /// node starts again. + /// + /// Returns once the node released its database, so the database, static files and `RocksDB` + /// are closed and nothing writes to the datadir anymore, including the pruner that runs after + /// the last save. Peers of the node notice the disconnect in the background. + /// + /// Only restartable nodes, see + /// [`E2ETestSetupBuilder::with_restartable_nodes`](crate::E2ETestSetupBuilder::with_restartable_nodes), + /// can be stopped. Returns an error for other nodes, if the node does not shut down within + /// [`WAIT_TIMEOUT`], or if the test keeps the database open: + /// - a handle of the node that holds its database, e.g. a clone of `inner.provider` or a + /// [`NodeClient`](crate::testsuite::NodeClient), makes this fail once the database is still + /// in use [`DATABASE_RELEASE_TIMEOUT`] after the node shut down, + /// - an open database transaction, e.g. a provider of `database_provider_ro`, makes this fail + /// right away, + /// - a handle of `RocksDB`, e.g. of `inner.provider.rocksdb_provider()`, makes this fail once + /// `RocksDB` is still open [`DATABASE_RELEASE_TIMEOUT`] after the node shut down. + /// + /// Drop such handles before stopping the node. + pub async fn stop(self) -> eyre::Result> { + self.stop_with_release_timeout(DATABASE_RELEASE_TIMEOUT).await + } + + /// Stops the node like [`Self::stop`], but waits at most `release_timeout` instead of + /// [`DATABASE_RELEASE_TIMEOUT`] for the database and `RocksDB` to be released. + pub async fn stop_with_release_timeout( + mut self, + release_timeout: Duration, + ) -> eyre::Result> { + let Some(NodeRestart { database, relaunch }) = self.restart.take() else { + bail!( + "the node is not restartable, launch it with \ + `E2ETestSetupBuilder::with_restartable_nodes` to stop it" + ) + }; + let data_dir = self.inner.data_dir.clone(); + let runtime = self.inner.task_executor.clone(); + // Stop the RPC servers even if the test kept handles to them, which would otherwise keep + // the database open. + let _ = self.inner.rpc_server_handle().clone().stop(); + let _ = self.inner.auth_server_handle().clone().stop(); + drop(self); + + // The consensus engine is a graceful task that persists the canonical chain before it + // exits. Waiting for graceful tasks blocks the thread. + let shut_down = tokio::task::spawn_blocking(move || { + runtime.graceful_shutdown_with_timeout(WAIT_TIMEOUT) + }) + .await?; + ensure!(shut_down, "the node did not shut down within {WAIT_TIMEOUT:?}"); + + close_database(database, &data_dir, release_timeout).await?; + Ok(StoppedNode { data_dir, relaunch, remove_data_dir: true }) + } + + /// Stops the node with [`Self::stop`] and starts it again with [`StoppedNode::start`], + /// returning its new test context. + pub async fn restart(self) -> eyre::Result { + self.stop().await?.start().await + } + + /// Takes the future that resolves when the consensus engine of the node exits. + /// + /// It resolves with an error if the engine exits because of a fatal error, and with `Ok` once + /// the node is stopped, so a test can use it to notice a node that died. Returns `None` if the + /// future was already taken or the node was not launched by + /// [`E2ETestSetupBuilder`](crate::E2ETestSetupBuilder). + pub fn take_exit_future(&mut self) -> Option { + self.exit_future.get_mut().unwrap_or_else(PoisonError::into_inner).take() + } +} + +/// Skips canonical state notifications until the one that commits the block with the given hash +/// and number, returning the chain segment it commits. +/// +/// The stream buffers every canonical notification since it was created, e.g. of blocks mined with +/// [`NodeTestContext::advance_block`], so it does not need to be aligned with the block. +/// +/// Returns an error if the block is not committed within [`WAIT_TIMEOUT`]. +async fn wait_for_committed_chain( + stream: &mut CanonStateNotificationStream, + block_hash: B256, + block_number: BlockNumber, +) -> eyre::Result>> { + let wait = async { + loop { + let notification = + stream.next().await.ok_or_else(|| eyre!("canonical state stream closed"))?; + let committed = notification.committed(); + if committed.blocks().get(&block_number).is_some_and(|block| block.hash() == block_hash) + { + return Ok(committed) + } + } + }; + tokio::time::timeout(WAIT_TIMEOUT, wait) + .await + .map_err(|_| eyre!("timed out waiting for block {block_number}"))? } +/// Waits until `database` is the last handle of the database of a stopped node, closes the +/// database without removing its datadir, and waits until `RocksDB` is closed. +/// +/// Returns an error if the database or `RocksDB` is still open afterwards. +async fn close_database( + database: TmpDB, + data_dir: &ChainPath, + release_timeout: Duration, +) -> eyre::Result<()> { + let released = tokio::time::timeout(release_timeout, async { + while Arc::strong_count(&database) > 1 { + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await; + if released.is_err() { + bail!( + "the database of the stopped node is still in use {release_timeout:?} after the node \ + shut down: drop all handles that hold a provider of the node, e.g. a clone of \ + `inner.provider`, before stopping it" + ) + } + let database = Arc::into_inner(database) + .ok_or_else(|| eyre!("the database of the stopped node is still in use"))?; + // Unlike dropping the temporary database, this keeps the datadir. + drop(database.into_inner_db()); + + // A database transaction keeps the database open without a handle of it. MDBX does not open + // a database twice in a process, so opening it fails until the transaction is dropped. + open_db_read_only(data_dir.db(), DatabaseArguments::test()).wrap_err( + "the database of the stopped node is still open: drop all database transactions of the \ + node, e.g. providers of `database_provider_ro`, before stopping it", + )?; + + // Providers drop their database handle before their `RocksDB` handle, whose drop flushes and + // closes `RocksDB`, so the last provider can still be closing it. `RocksDB` does not open a + // database twice in a process either, so wait until opening it succeeds. + let deadline = Instant::now() + release_timeout; + loop { + let path = data_dir.rocksdb(); + let opened = tokio::task::spawn_blocking(move || { + RocksDBProvider::builder(path).with_default_tables().build().map(drop) + }) + .await?; + match opened { + Result::Ok(()) => return Ok(()), + Err(err) if Instant::now() >= deadline => { + return Err(err).wrap_err(format!( + "the RocksDB of the stopped node is still open {release_timeout:?} after \ + the node shut down: drop all handles of it, e.g. of \ + `inner.provider.rocksdb_provider()`, before stopping the node" + )) + } + Err(_) => tokio::time::sleep(POLL_INTERVAL).await, + } + } +} + +/// Waits until all `hashes` are in `pool` if `pooled` is true, or none of them otherwise. +/// +/// On timeout, the error lists the transactions that are still missing or still pooled. +async fn wait_for_pool_transactions( + pool: &P, + hashes: impl IntoIterator, + pooled: bool, +) -> eyre::Result<()> { + let hashes = hashes.into_iter().collect::>(); + let (what, pending_kind) = if pooled { + ("transactions to enter the pool", "still missing") + } else { + ("transactions to leave the pool", "still pooled") + }; + let mut pending = Vec::new(); + poll_until(what, || { + pending = hashes.iter().copied().filter(|hash| pool.contains(hash) != pooled).collect(); + let done = pending.is_empty(); + async move { Ok(done.then_some(())) } + }) + .await + .map_err(|err| eyre!("{err}, {pending_kind}: {pending:?}")) +} + +/// Describes the pending transactions of `pool` for an error message, listing at most ten. +fn describe_pending_transactions(pool: &P) -> String { + const MAX_LISTED: usize = 10; + + let pending = pool.pending_transactions(); + let mut listed = pending + .iter() + .take(MAX_LISTED) + .map(|tx| format!("{} (sender {}, nonce {})", tx.hash(), tx.sender(), tx.nonce())) + .collect::>(); + if pending.len() > MAX_LISTED { + listed.push(format!("and {} more", pending.len() - MAX_LISTED)); + } + format!( + "the pool still has {} at block {}: {}", + pending.len(), + pool.block_info().last_seen_block_number, + listed.join(", ") + ) +} + +/// The future returned by [`NodeTestContext::mine`] and the related mining helpers. +pub type MinedBlockFuture<'a, Payload, Receipt> = + BoxFuture<'a, eyre::Result>>; + +/// A block mined by [`NodeTestContext::mine`], [`NodeTestContext::mine_including`], +/// [`NodeTestContext::mine_signed`] or [`NodeTestContext::mine_pooled`]. +#[derive(Debug)] +pub struct MinedBlock { + /// The built payload of the block. + pub payload: Payload, + /// The RPC receipts of the mined transactions, in the order they were passed. + pub receipts: Vec, + /// The chain segment committed by the canonical state notification of the block. + /// + /// The block is built on the canonical head, so the segment consists of this block only and + /// its execution outcome, e.g. the bundle state, is the outcome of the block. + pub chain: Arc>, +} + +impl MinedBlock { + /// Returns the mined block. + pub fn block(&self) -> &SealedBlock<::Block> { + self.payload.block() + } + + /// Returns the mined block if none of the mined transactions reverted. + /// + /// Returns an error naming the first reverted transaction otherwise. + pub fn ensure_success(self) -> eyre::Result { + for receipt in &self.receipts { + ensure_successful(receipt)?; + } + Ok(self) + } +} + +/// Which blocks [`NodeTestContext::import_payload`] marks as safe and finalized when it makes a +/// block the head. +/// +/// The policy applies to [`NodeTestContext::import_payload`] and the block producing helpers built +/// on it, e.g. [`NodeTestContext::advance_block`], [`NodeTestContext::advance`] and +/// [`NodeTestContext::advance_while`]. [`NodeTestContext::update_forkchoice`] and +/// [`NodeTestContext::sync_to`] send the forkchoice state given by their arguments, and +/// [`NodeTestContext::new_payload`] keeps the safe and finalized blocks the node reports. +/// +/// The engine accepts a forkchoice update only if its safe and finalized blocks are ancestors of +/// its head, and rejects moving the head back to a canonical block below the finalized block. So +/// while the finalized block stays, e.g. under [`Finality::Keep`], only blocks above it can be +/// reorged. +/// +/// Finality does not hold back persistence: the engine persists canonical blocks and evicts them +/// from memory once more of them than the persistence threshold are in memory, and the pruner runs +/// on the persisted tip, so [`NodeTestContext::wait_for_persisted_block`] and +/// [`NodeTestContext::wait_for_prune_checkpoint`] behave the same under every policy. The engine +/// only evicts a side chain from memory once the finalized block passes its fork point, and the +/// transaction pool keeps the blob sidecars of mined transactions until their block is finalized, +/// so under [`Finality::Keep`] both remain available for reorgs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Finality { + /// The imported block becomes the safe and finalized block. + #[default] + Head, + /// The safe and finalized blocks stay what the node reports, e.g. genesis after the setup, so + /// the imported blocks can be reorged. + Keep, + /// The safe and finalized blocks trail the imported block by the given number of blocks. + /// + /// After importing the block at height `h`, its ancestor at height `h - n` becomes the safe + /// and finalized block if it is above the current finalized block. Otherwise they stay, so + /// they never move backwards, e.g. after switching from [`Finality::Head`], and stay at + /// genesis until the chain is `n` blocks past it. The latest `n` blocks can be reorged. + Lag(u64), +} + +/// A test node that was stopped with [`NodeTestContext::stop`]. +/// +/// The datadir of the node is kept until the node is launched again with [`Self::start`], and +/// removed when this is dropped. +pub struct StoppedNode +where + Node: FullNodeComponents, + AddOns: RethRpcAddOns, +{ + data_dir: ChainPath, + relaunch: Relaunch>, + /// Whether the datadir is removed on drop, which is unset once a launched node owns it. + remove_data_dir: bool, +} + +impl StoppedNode +where + Node: FullNodeComponents, + AddOns: RethRpcAddOns, +{ + /// Returns the datadir of the node. + pub const fn data_dir(&self) -> &ChainPath { + &self.data_dir + } + + /// Launches the node again on its datadir and returns its new test context. + /// + /// The node is launched like by + /// [`E2ETestSetupBuilder::build`](crate::E2ETestSetupBuilder::build), with the same node + /// factory, node builder modifiers, node and tree configuration and attributes generator, + /// on a new runtime. It opens the database, static files and `RocksDB` in its datadir + /// again, so it recovers from disk like a node restarted from the command line. + /// + /// Its head is the canonical head it had when it was stopped, and its safe and finalized blocks + /// are the ones it persisted, which are the head after [`NodeTestContext::advance_block`]. + /// Unless the node mines in dev mode, it receives a forkchoice update that restates these + /// blocks instead of the forkchoice update to genesis of a new node, and + /// [`NodeTestContext::advance_block`] builds on its head right away. + /// + /// The node keeps its peer identity, which is stored in its datadir, but listens on new ports + /// and does not reconnect to its peers. Connect it with `node.connect(&mut peer)` once `peer` + /// noticed that the stopped node disconnected, e.g. once its number of connected peers + /// dropped. `peer.connect(&mut node)` panics instead, because the network events of `peer` + /// still contain the disconnect of the stopped node. + /// + /// Returns an error if the node can not be launched, or if the forkchoice update is not valid. + pub async fn start(mut self) -> eyre::Result> { + // The database of the launched node removes the datadir once it is dropped. + self.remove_data_dir = false; + (self.relaunch)().await + } +} + +impl std::fmt::Debug for StoppedNode +where + Node: FullNodeComponents, + AddOns: RethRpcAddOns, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoppedNode").field("data_dir", &self.data_dir).finish_non_exhaustive() + } +} + +impl Drop for StoppedNode +where + Node: FullNodeComponents, + AddOns: RethRpcAddOns, +{ + fn drop(&mut self) { + if self.remove_data_dir { + let _ = reth_fs_util::remove_dir_all(self.data_dir.data_dir()); + } + } +} + +/// What a restartable node of [`E2ETestSetupBuilder`](crate::E2ETestSetupBuilder) needs to be +/// stopped and launched again. +pub(crate) struct NodeRestart { + /// A handle of the database of the node, to wait until the stopped node released it. + pub(crate) database: TmpDB, + /// Launches the node again on its datadir. + pub(crate) relaunch: Relaunch, +} + +/// Closure that launches a stopped node again, returning its new test context. +pub(crate) type Relaunch = + Arc BoxFuture<'static, eyre::Result> + Send + Sync>; + #[cfg(test)] mod tests { use super::*; - use crate::NodeHelperType; - use reth_node_ethereum::EthereumNode; + use crate::{ + snap::SnapControl, + wait::{assert_holds_for, poll_until_with, PollOpts}, + NodeHelperType, + }; + use alloy_consensus::TxEnvelope; + use reth_node_ethereum::{EthEngineTypes, EthereumNode}; + use reth_transaction_pool::test_utils::{testing_pool, MockTransaction}; fn assert_send(_: T) {} /// Tests of downstream nodes await these helpers in spawned tasks, so their futures must be /// `Send`. #[expect(dead_code)] - fn test_helper_futures_are_send(node: &mut NodeHelperType) { + fn test_helper_futures_are_send( + node: &mut NodeHelperType, + payload: ::BuiltPayload, + ) { + assert_send(node.new_payload()); assert_send(node.advance_block()); + assert_send(node.advance_block_synced()); assert_send(node.inject_and_advance(Bytes::new())); + assert_send(node.advance_blocks(0)); + assert_send(node.build_payload_on(B256::ZERO)); + assert_send(node.advance_block_on(B256::ZERO)); + assert_send(node.advance_fork(B256::ZERO, 0)); + assert_send(node.reorg_to(B256::ZERO)); + assert_send(node.advance_until_receipt(B256::ZERO)); + assert_send(node.advance_until_pool_drained()); + assert_send(node.advance_while(async {})); + assert_send(node.engine.new_payload(payload.clone())); + assert_send(node.engine.new_payload_from_block(payload.block().clone(), None)); + assert_send(node.engine.forkchoice_updated(ForkchoiceState::default())); + assert_send(node.engine.forkchoice_updated_with_attributes( + ForkchoiceState::default(), + ::PayloadAttributes::default(), + )); + assert_send(node.mine([Bytes::new()])); + assert_send(node.mine_including(Vec::new())); + assert_send(node.mine_signed(Vec::::new())); + assert_send(node.mine_pooled([B256::ZERO])); assert_send(node.wait_block(0, B256::ZERO, false)); assert_send(node.wait_unwind(0)); assert_send(node.wait_for_pool(|_| true)); + assert_send(node.wait_for_pool_head(B256::ZERO)); + assert_send(node.wait_for_pooled([B256::ZERO])); + assert_send(node.wait_for_pool_removal([B256::ZERO])); + assert_send(node.wait_for_persisted_block(0)); + assert_send(node.wait_for_prune_checkpoint(PruneSegment::SenderRecovery, 0)); + assert_send(node.wait_for_head(B256::ZERO)); assert_send(node.assert_new_block(B256::ZERO, B256::ZERO, 0)); assert_send(node.sync_to(B256::ZERO)); + assert_send(node.sync_to_forkchoice(ForkchoiceState::default())); + assert_send(node.submit_payload(payload.clone())); + assert_send(node.submit_payload_with_status(payload.clone())); + assert_send(node.import_payload(payload)); + assert_send(poll_until_with(PollOpts::default(), "", || async { Ok(Some(())) })); + assert_send(assert_holds_for(Duration::ZERO, "", || async { Ok(true) })); + let gate = SnapControl::default().pause_on(|_| true); + assert_send(gate.reached()); + } + + /// Like [`test_helper_futures_are_send`], for the helpers that stop and restart a node. + #[expect(dead_code)] + fn test_restart_futures_are_send( + node: NodeHelperType, + other: NodeHelperType, + ) { + assert_send(node.stop()); + assert_send(other.restart()); + } + + // The paused clock lets the waits run into their timeout without waiting for it. + #[tokio::test(start_paused = true)] + async fn pool_transaction_waits_list_pending_transactions_on_timeout() { + let pool = testing_pool(); + let tx = MockTransaction::eip1559(); + let pooled = *tx.get_hash(); + pool.add_external_transaction(tx).await.unwrap(); + let absent = B256::repeat_byte(1); + + let err = wait_for_pool_transactions(&pool, [pooled, absent], true).await.unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "timed out after 60s waiting for transactions to enter the pool, still missing: \ + [{absent}]" + ) + ); + + let err = wait_for_pool_transactions(&pool, [pooled, absent], false).await.unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "timed out after 60s waiting for transactions to leave the pool, still pooled: \ + [{pooled}]" + ) + ); } } diff --git a/crates/e2e-test-utils/src/payload.rs b/crates/e2e-test-utils/src/payload.rs index fe0d4452c9f..c928f8cbc26 100644 --- a/crates/e2e-test-utils/src/payload.rs +++ b/crates/e2e-test-utils/src/payload.rs @@ -1,3 +1,4 @@ +use eyre::eyre; use futures_util::StreamExt; use reth_node_api::{PayloadAttributes, PayloadKind}; use reth_payload_builder::{PayloadBuilderHandle, PayloadId}; @@ -8,8 +9,22 @@ use tokio_stream::wrappers::BroadcastStream; /// Helper for payload operations #[derive(derive_more::Debug)] pub struct PayloadTestContext { + /// Events of all payload jobs of the node since the context was created. + /// + /// The harness does not read the stream, so it also yields the events of the payloads built by + /// [`NodeTestContext::new_payload`]. The payload builder only buffers the most recent events, + /// so a reader that falls behind receives a lagged error first. + /// + /// [`NodeTestContext::new_payload`]: crate::node::NodeTestContext::new_payload pub payload_event_stream: BroadcastStream>, payload_builder: PayloadBuilderHandle, + /// Timestamp of the last generated payload attributes, or the starting point if none were + /// generated yet. + /// + /// [`Self::next_attributes`] increments it before generating the next attributes. Prefer + /// [`NodeTestContext::set_next_payload_timestamp`] over setting it directly. + /// + /// [`NodeTestContext::set_next_payload_timestamp`]: crate::node::NodeTestContext::set_next_payload_timestamp pub timestamp: u64, #[debug(skip)] attributes_generator: Box T::PayloadAttributes + Send + Sync>, @@ -49,15 +64,16 @@ impl PayloadTestContext { Ok(()) } - /// Wait until the best built payload is ready. + /// Resolves the payload job with the given id like [`Self::resolve_payload`] and discards the + /// payload. /// - /// Panics if the payload builder does not produce a non-empty payload within 30 seconds. + /// # Panics + /// + /// If the payload job can not be resolved. pub async fn wait_for_built_payload(&self, payload_id: PayloadId) { - self.payload_builder - .resolve_kind(payload_id, PayloadKind::WaitForPending) - .await - .unwrap() - .unwrap(); + if let Err(err) = self.resolve_payload(payload_id).await { + panic!("{err}") + } } /// Expects the next event to be a built payload event or panics @@ -69,4 +85,21 @@ impl PayloadTestContext { panic!("Expect a built payload event."); } } + + /// Resolves the payload job with the given id and returns its payload. + /// + /// The job is resolved with [`PayloadKind::WaitForPending`], so if it has not built a payload + /// yet, this waits for its build in progress instead of racing an empty payload against it like + /// `engine_getPayload` does. The payload can still contain no transactions, e.g. if the pool + /// had none when the build started. This has no timeout of its own. + /// + /// Returns an error if the payload builder has no job with the given id, e.g. because the job + /// reached its deadline, or if the job fails to build a payload. + pub async fn resolve_payload(&self, payload_id: PayloadId) -> eyre::Result { + self.payload_builder + .resolve_kind(payload_id, PayloadKind::WaitForPending) + .await + .ok_or_else(|| eyre!("payload builder has no payload job {payload_id}"))? + .map_err(|err| eyre!("failed to resolve payload job {payload_id}: {err}")) + } } diff --git a/crates/e2e-test-utils/src/receipt.rs b/crates/e2e-test-utils/src/receipt.rs index 7462104c8e8..a41e6bddf19 100644 --- a/crates/e2e-test-utils/src/receipt.rs +++ b/crates/e2e-test-utils/src/receipt.rs @@ -20,16 +20,22 @@ pub trait PendingTransactionExt { impl PendingTransactionExt for PendingTransactionBuilder { async fn successful_receipt(self) -> eyre::Result { let receipt = self.get_receipt().await?; - ensure!( - receipt.status(), - "transaction {} reverted after using {} gas", - receipt.transaction_hash(), - receipt.gas_used() - ); + ensure_successful(&receipt)?; Ok(receipt) } } +/// Returns an error naming the transaction and its gas usage if the receipt reports a revert. +pub(crate) fn ensure_successful(receipt: &impl ReceiptResponse) -> eyre::Result<()> { + ensure!( + receipt.status(), + "transaction {} reverted after using {} gas", + receipt.transaction_hash(), + receipt.gas_used() + ); + Ok(()) +} + /// Waits for the receipts of all transactions in order, failing if any of them reverted. /// /// See [`PendingTransactionExt::successful_receipt`]. diff --git a/crates/e2e-test-utils/src/rpc.rs b/crates/e2e-test-utils/src/rpc.rs index bc40b9235a9..7f56b9db148 100644 --- a/crates/e2e-test-utils/src/rpc.rs +++ b/crates/e2e-test-utils/src/rpc.rs @@ -1,7 +1,7 @@ -use alloy_consensus::{EthereumTxEnvelope, TxEip4844Variant}; -use alloy_eips::eip7594::BlobTransactionSidecarVariant; +use alloy_consensus::TxEnvelope; use alloy_network::eip2718::Decodable2718; use alloy_primitives::{Bytes, B256}; +use futures_util::future::BoxFuture; use reth_chainspec::EthereumHardforks; use reth_node_api::{BlockTy, FullNodeComponents}; use reth_node_builder::{rpc::RpcRegistry, NodeTypes}; @@ -25,17 +25,16 @@ where + TraceExt, { /// Injects a raw transaction into the node tx pool via RPC server - pub async fn inject_tx(&self, raw_tx: Bytes) -> Result { - let eth_api = self.inner.eth_api(); - eth_api.send_raw_transaction(raw_tx).await + /// + /// Boxed like [`NodeTestContext::mine`](crate::node::NodeTestContext::mine), to keep callers + /// from borrow-checking the eth API future. + pub fn inject_tx(&self, raw_tx: Bytes) -> BoxFuture<'_, Result> { + Box::pin(async move { self.inner.eth_api().send_raw_transaction(raw_tx).await }) } /// Retrieves an Ethereum transaction envelope by its hash, including its blob sidecar if the /// transaction is still pooled. - pub async fn envelope_by_hash( - &self, - hash: B256, - ) -> eyre::Result>> { + pub async fn envelope_by_hash(&self, hash: B256) -> eyre::Result { self.decoded_transaction_by_hash(hash).await } diff --git a/crates/e2e-test-utils/src/setup_builder.rs b/crates/e2e-test-utils/src/setup_builder.rs index d891e4eb183..1ef7442401b 100644 --- a/crates/e2e-test-utils/src/setup_builder.rs +++ b/crates/e2e-test-utils/src/setup_builder.rs @@ -4,42 +4,65 @@ //! configurations through closures that modify `NodeConfig` and `TreeConfig`. use crate::{ - eth_payload_attributes, node::NodeTestContext, test_chain_spec, wallet::Wallet, Adapter, - NodeBuilderHelper, NodeHelperType, TmpNodeAdapter, + eth_payload_attributes, + node::{NodeRestart, NodeTestContext, Relaunch}, + test_chain_spec, + wallet::Wallet, + Adapter, NodeBuilderHelper, NodeHelperType, TmpDB, TmpNodeAdapter, }; -use eyre::ensure; +use alloy_primitives::B256; +use alloy_rpc_types_engine::{ForkchoiceState, PayloadAttributes}; +use alloy_rpc_types_eth::BlockNumberOrTag; +use eyre::{ensure, eyre, WrapErr}; use futures_util::future::{BoxFuture, TryJoinAll}; use reth_chainspec::{ChainSpec, EthChainSpec, EthereumHardfork}; +use reth_db::{init_db, mdbx::DatabaseArguments, test_utils::TempDatabase}; use reth_node_api::{PayloadAttrTy, TreeConfig}; use reth_node_builder::{ - DebugNode, DebugNodeLauncher, EngineNodeLauncher, Node, NodeBuilder, NodeBuilderWithComponents, - NodeConfig, NodeHandle, + sync::{BackfillClientFor, BackfillSyncBuilder, PipelineBackfill}, + ComponentsFor, DebugNode, DebugNodeLauncher, EngineNodeLauncher, Node, NodeBuilder, + NodeBuilderWithComponents, NodeConfig, NodeHandle, NodeTypesWithDBAdapter, }; use reth_node_core::{ args::{DatadirArgs, DiscoveryArgs, NetworkArgs, PruningArgs, RpcServerArgs, StorageArgs}, dirs::{ChainPath, DataDirPath, MaybePlatformPath}, }; use reth_primitives_traits::AlloyBlockHeader; -use reth_provider::providers::BlockchainProvider; +use reth_provider::{ + providers::BlockchainProvider, BlockReaderIdExt, DatabaseProviderFactory, MetadataProvider, +}; use reth_rpc_server_types::RpcModuleSelection; use reth_tasks::Runtime; -use std::{path::PathBuf, sync::Arc, time::Duration}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; use tracing::{span, Instrument, Level}; /// Builder for configuring and launching test node setups. /// /// By default, the nodes: -/// - run on a shared [`Runtime::test`] runtime, +/// - are [`Default`] instances of `N`, see [`Self::with_node`], +/// - run on a shared [`Runtime::test`] runtime, unless they are +/// [restartable](Self::with_restartable_nodes), /// - build payloads with [`eth_payload_attributes`] for the hardforks active in the chain spec, +/// unless created with [`Self::new_with_attributes_generator`], /// - have discovery disabled, use unused ports and serve all RPC modules except `testing` over /// HTTP, /// - report an idle sync state from startup, so they gossip transactions before their first block, +/// - do not persist their peers, so a restarted node only connects to the peers a test connects it +/// to, /// - are connected to each other. /// /// Once launched, each node receives a forkchoice update that makes genesis the head, safe and /// finalized block, unless [dev mining](Self::with_dev_mining) is enabled. /// -/// Configuration and tree configuration modifiers are applied in the order they are added. +/// Configuration and tree configuration modifiers are applied in the order they are added. Like +/// for a node launched from the command line, the tree configuration of a node is derived from its +/// final node configuration, see [`NodeConfig::tree_config`], so the engine arguments and +/// `--debug.skip-state-root` set by node configuration modifiers take effect. Tree configuration +/// modifiers are applied last and take precedence over them. /// /// Use [`E2ETestSetupExt::test_setup_for`] to set up nodes on the [`test_chain_spec`] at a /// hardfork, or [`E2ETestSetupExt::test_setup`] for any other chain spec: @@ -54,29 +77,64 @@ pub struct E2ETestSetupBuilder { num_nodes: usize, chain_spec: Arc, runtime: Option, - attributes_generator: Option>, + attributes_generator: AttributesGenerator, connect_nodes: bool, tree_config_modifiers: Vec, node_config_modifiers: Vec>, storage_v2: bool, dev_launcher: Option>, + backfill_launcher: Option>, dev_payload_attributes: Option>, + node_factory: NodeFactory, + node_builder_modifiers: Vec>, + restartable: bool, } impl E2ETestSetupBuilder { /// Creates a new builder for `num_nodes` nodes of the given chain. - pub fn new(num_nodes: usize, chain_spec: Arc) -> Self { + /// + /// The nodes build payloads with [`eth_payload_attributes`] for the chain spec, which requires + /// the payload attributes of the node to be convertible from Ethereum's. Use + /// [`Self::new_with_attributes_generator`] for other nodes. + pub fn new(num_nodes: usize, chain_spec: Arc) -> Self + where + PayloadAttrTy: From, + { + let attributes_chain_spec = chain_spec.clone(); + Self::new_with_attributes_generator(num_nodes, chain_spec, move |timestamp| { + eth_payload_attributes(&attributes_chain_spec, timestamp).into() + }) + } + + /// Creates a new builder for `num_nodes` nodes of the given chain, whose payloads are built + /// with the attributes returned by `attributes_generator`, see + /// [`Self::with_attributes_generator`]. + /// + /// Unlike [`Self::new`], this does not require the payload attributes of the node to be + /// convertible from Ethereum's. + pub fn new_with_attributes_generator( + num_nodes: usize, + chain_spec: Arc, + attributes_generator: G, + ) -> Self + where + G: Fn(u64) -> PayloadAttrTy + Send + Sync + 'static, + { Self { num_nodes, chain_spec, runtime: None, - attributes_generator: None, + attributes_generator: Arc::new(attributes_generator), connect_nodes: true, tree_config_modifiers: Vec::new(), node_config_modifiers: Vec::new(), storage_v2: StorageArgs::default().v2, dev_launcher: None, + backfill_launcher: None, dev_payload_attributes: None, + node_factory: Arc::new(|_| N::default()), + node_builder_modifiers: Vec::new(), + restartable: false, } } @@ -90,7 +148,8 @@ impl E2ETestSetupBuilder { /// /// This lets multiple setups, or other components of a test, share the same tokio handle and /// rayon pools. Note that the tasks of the nodes are only shut down once all handles to the - /// runtime are dropped, not when the nodes are dropped. + /// runtime are dropped, not when the nodes are dropped. Restartable nodes, see + /// [`Self::with_restartable_nodes`], can not share a runtime. pub fn with_runtime(mut self, runtime: Runtime) -> Self { self.runtime = Some(runtime); self @@ -98,13 +157,14 @@ impl E2ETestSetupBuilder { /// Sets the generator for the payload attributes of the payloads built by the test nodes. /// - /// The generator is called with the timestamp of the next payload. Defaults to - /// [`eth_payload_attributes`] for the chain spec of the setup. + /// The generator is called with the timestamp of the next payload. It replaces the generator + /// of the constructor, which is [`eth_payload_attributes`] for the chain spec of the setup for + /// [`Self::new`]. pub fn with_attributes_generator(mut self, generator: G) -> Self where G: Fn(u64) -> PayloadAttrTy + Send + Sync + 'static, { - self.attributes_generator = Some(Arc::new(generator)); + self.attributes_generator = Arc::new(generator); self } @@ -117,7 +177,9 @@ impl E2ETestSetupBuilder { /// Adds a modifier for the tree configuration. /// /// The closure receives the current tree config and returns a modified version. The base - /// config is the default config with a small cross block cache. + /// config is derived from the node configuration after all node configuration modifiers ran, + /// see [`NodeConfig::tree_config`]. Unless a node configuration modifier changes + /// `engine.cross_block_cache_size`, it uses a small cross block cache of 1 MiB. pub fn with_tree_config_modifier(mut self, modifier: G) -> Self where G: Fn(TreeConfig) -> TreeConfig + Send + Sync + 'static, @@ -128,7 +190,9 @@ impl E2ETestSetupBuilder { /// Adds a modifier for the node configuration. /// - /// The closure receives the current node config and returns a modified version. + /// The closure receives the current node config and returns a modified version. Its engine + /// arguments and `debug.skip_state_root` configure the engine tree, unless a + /// [tree configuration modifier](Self::with_tree_config_modifier) overrides them. pub fn with_node_config_modifier(mut self, modifier: G) -> Self where G: Fn(NodeConfig) -> NodeConfig + Send + Sync + 'static, @@ -187,7 +251,8 @@ impl E2ETestSetupBuilder { where N: DebugNode>, { - self.dev_launcher = Some(|args| Box::pin(launch_dev_node::(args))); + self.dev_launcher = + Some(Arc::new(|args, database| Box::pin(launch_dev_node::(args, database)))); self.with_node_config_modifier(move |mut config| { config.dev.dev = true; config.dev.block_time = block_time; @@ -211,6 +276,73 @@ impl E2ETestSetupBuilder { self } + /// Launches each node with the engine's backfill built by the [`BackfillSyncBuilder`] that + /// `backfill` returns for the node's final configuration, instead of the staged pipeline. + /// + /// Like a node launched from the command line, the backfill follows the node's flags, e.g. + /// `with_backfill(EthereumBackfill::new)` snap syncs once a node configuration modifier sets + /// `--snap.v2`. A restarted node builds its backfill again from its configuration. + /// + /// Cannot be combined with [dev mining](Self::with_dev_mining): the harness launches dev mining + /// nodes with the staged pipeline. + pub fn with_backfill(mut self, backfill: F) -> Self + where + F: Fn(&NodeConfig) -> B + Send + Sync + 'static, + B: BackfillSyncBuilder, BackfillClient> + 'static, + { + self.backfill_launcher = Some(Arc::new(move |args, database| { + let backfill = backfill(&args.node_config); + Box::pin(launch_test_node(args, database, backfill)) + })); + self + } + + /// Sets the factory of the node instances, which is called with the index of each node when it + /// is launched. + /// + /// Defaults to [`Default::default`]. Use this to configure the node type, or to keep a handle + /// of the node instance, e.g. one that reads state of the node once it is launched. + pub fn with_node(mut self, node: F) -> Self + where + F: Fn(usize) -> N + Send + Sync + 'static, + { + self.node_factory = Arc::new(node); + self + } + + /// Adds a modifier for the node builder of each test node. + /// + /// The closure receives the [`TestNodeBuilder`] after the node types, components and add-ons + /// are configured, and returns it to be launched. It can e.g. install an `ExEx` with + /// [`install_exex`](NodeBuilderWithComponents::install_exex), extend the RPC modules with + /// [`extend_rpc_modules`](NodeBuilderWithComponents::extend_rpc_modules), set the + /// `on_component_initialized`, `on_node_started` and `on_rpc_started` hooks, or modify the + /// add-ons with [`map_add_ons`](NodeBuilderWithComponents::map_add_ons). + /// + /// Modifiers are applied to every node in the order they are added. The node builder keeps a + /// single hook of each kind, so a modifier that sets e.g. the `extend_rpc_modules` hook + /// replaces the one set by an earlier modifier. The datadir and tree configuration of the node + /// are derived before the modifiers run, so change the node configuration with + /// [`Self::with_node_config_modifier`] instead of `builder.config`. + pub fn with_node_builder_modifier(mut self, modifier: G) -> Self + where + G: Fn(TestNodeBuilder) -> TestNodeBuilder + Send + Sync + 'static, + { + self.node_builder_modifiers.push(Arc::new(modifier)); + self + } + + /// Makes the nodes restartable, so they can be stopped and launched again on the same datadir + /// with [`NodeTestContext::stop`] and [`NodeTestContext::restart`]. + /// + /// Each restartable node runs on a [`Runtime::test`] runtime of its own instead of the shared + /// one, so stopping it shuts down only its tasks. This costs a few threads per node, which is + /// why nodes are not restartable by default. Conflicts with [`Self::with_runtime`]. + pub const fn with_restartable_nodes(mut self) -> Self { + self.restartable = true; + self + } + /// Builds and launches the test nodes. pub async fn build(self) -> eyre::Result<(Vec>, Wallet)> { ensure!( @@ -218,38 +350,51 @@ impl E2ETestSetupBuilder { "dev mining requires a single node setup, got {} nodes", self.num_nodes ); + ensure!( + !self.restartable || self.runtime.is_none(), + "restartable nodes run on a runtime of their own and can not use the runtime set with \ + `with_runtime`" + ); + ensure!( + self.dev_launcher.is_none() || self.backfill_launcher.is_none(), + "dev mining nodes backfill with the staged pipeline and can not use the backfill set \ + with `with_backfill`" + ); + // The pipeline would silently replace the snap sync the flag opts into. + ensure!( + self.backfill_launcher.is_some() || !self.node_and_tree_config().0.network.snap_v2, + "nodes with `--snap.v2` need a backfill set with `with_backfill`, e.g. \ + `EthereumBackfill::new`" + ); let dev_mining = self.dev_launcher.is_some(); - let launch = self.dev_launcher.unwrap_or(|args| Box::pin(launch_test_node::(args))); - let runtime = self.runtime.unwrap_or_else(Runtime::test); - let attributes_generator = self.attributes_generator.unwrap_or_else(|| { - let chain_spec = self.chain_spec.clone(); - Arc::new(move |timestamp| eth_payload_attributes(&chain_spec, timestamp).into()) + let launch = self.dev_launcher.as_ref().or(self.backfill_launcher.as_ref()).cloned(); + let launch = launch.unwrap_or_else(|| { + Arc::new(|args, database| Box::pin(launch_test_node(args, database, PipelineBackfill))) }); - let tree_config = self - .tree_config_modifiers - .iter() - .fold(test_tree_config(), |config, modifier| modifier(config)); + // Restartable nodes create their own runtime when they are launched. + let runtime = + (!self.restartable).then(|| self.runtime.clone().unwrap_or_else(Runtime::test)); let mut nodes = (0..self.num_nodes) .map(async |idx| { - let node_config = self.node_config_modifiers.iter().fold( - test_node_config(self.chain_spec.clone()) - .with_storage(StorageArgs { v2: self.storage_v2 }), - |config, modifier| modifier(config), - ); + let (node_config, tree_config) = self.node_and_tree_config(); // The local miner of dev nodes drives forkchoice, unless a modifier disabled dev // mode. let mines = dev_mining && node_config.dev.dev; - let node = launch(LaunchArgs { + let args = LaunchArgs { + idx, + node_factory: self.node_factory.clone(), + node_builder_modifiers: self.node_builder_modifiers.clone(), node_config, runtime: runtime.clone(), - tree_config: tree_config.clone(), + tree_config, datadir: reth_db::test_utils::tempdir_path(), - attributes_generator: attributes_generator.clone(), + attributes_generator: self.attributes_generator.clone(), dev_payload_attributes: self.dev_payload_attributes.clone(), - }) - .instrument(span!(Level::INFO, "node", idx)) - .await?; + }; + let node = launch_node(launch.clone(), args, self.restartable, mines) + .instrument(span!(Level::INFO, "node", idx)) + .await?; if !mines { let genesis = node.block_hash(self.chain_spec.genesis_header().number()); @@ -285,6 +430,23 @@ impl E2ETestSetupBuilder { let (mut nodes, wallet) = self.build().await?; Ok((nodes.pop().expect("one node was launched"), wallet)) } + + /// Returns the configuration of a test node and the tree configuration derived from it. + /// + /// The node configuration modifiers run first, the tree configuration modifiers are applied to + /// the tree configuration of the resulting node configuration. + fn node_and_tree_config(&self) -> (NodeConfig, TreeConfig) { + let node_config = self.node_config_modifiers.iter().fold( + test_node_config(self.chain_spec.clone()) + .with_storage(StorageArgs { v2: self.storage_v2 }), + |config, modifier| modifier(config), + ); + let tree_config = self + .tree_config_modifiers + .iter() + .fold(node_config.tree_config(), |config, modifier| modifier(config)); + (node_config, tree_config) + } } impl std::fmt::Debug for E2ETestSetupBuilder { @@ -297,24 +459,33 @@ impl std::fmt::Debug for E2ETestSetupBuilder { .field("node_config_modifiers", &self.node_config_modifiers.len()) .field("storage_v2", &self.storage_v2) .field("dev_mining", &self.dev_launcher.is_some()) + .field("custom_backfill", &self.backfill_launcher.is_some()) + .field("node_builder_modifiers", &self.node_builder_modifiers.len()) + .field("restartable", &self.restartable) .finish_non_exhaustive() } } /// Extension trait to create an [`E2ETestSetupBuilder`] from a node type. pub trait E2ETestSetupExt: NodeBuilderHelper { - /// Returns an [`E2ETestSetupBuilder`] for `num_nodes` nodes of this type. - fn test_setup(num_nodes: usize, chain_spec: Arc) -> E2ETestSetupBuilder { + /// Returns an [`E2ETestSetupBuilder`] for `num_nodes` nodes of this type, see + /// [`E2ETestSetupBuilder::new`]. + fn test_setup(num_nodes: usize, chain_spec: Arc) -> E2ETestSetupBuilder + where + PayloadAttrTy: From, + { E2ETestSetupBuilder::new(num_nodes, chain_spec) } /// Returns an [`E2ETestSetupBuilder`] for a single node of this type on the - /// [`test_chain_spec`] with every hardfork up to and including `fork` active at genesis. + /// [`test_chain_spec`] with every hardfork up to and including `fork` active at genesis, see + /// [`E2ETestSetupBuilder::new`]. /// /// Use [`E2ETestSetupBuilder::with_num_nodes`] to launch more nodes. fn test_setup_for(fork: EthereumHardfork) -> E2ETestSetupBuilder where Self::ChainSpec: From, + PayloadAttrTy: From, { let chain_spec = Arc::unwrap_or_clone(test_chain_spec(fork)); E2ETestSetupBuilder::new(1, Arc::new(chain_spec.into())) @@ -335,22 +506,45 @@ pub(crate) type AttributesGenerator = Arc PayloadAttrTy + S /// Closure that maps payload attributes. type PayloadAttributesMapper = Arc) -> PayloadAttrTy + Send + Sync>; -/// Builder of a test node that is ready to be launched. -type TestNodeBuilder = NodeBuilderWithComponents< +/// Builder of a test node with the node types, components and add-ons of `N`, which is ready to +/// be launched. +/// +/// This is what [`E2ETestSetupBuilder::with_node_builder_modifier`] receives. +pub type TestNodeBuilder = NodeBuilderWithComponents< TmpNodeAdapter, >>::ComponentsBuilder, >>::AddOns, >; -/// Function that launches a single test node. -type NodeLauncher = fn(LaunchArgs) -> BoxFuture<'static, eyre::Result>>; +/// Closure that returns the node instance of the test node with the given index. +type NodeFactory = Arc N + Send + Sync>; + +/// Closure that modifies the node builder of each test node. +type NodeBuilderModifier = Arc) -> TestNodeBuilder + Send + Sync>; + +/// Closure that launches a single test node on the given database in its datadir. +type NodeLauncher = Arc< + dyn Fn(LaunchArgs, TmpDB) -> BoxFuture<'static, eyre::Result>> + + Send + + Sync, +>; + +/// Client the network of a test node hands its backfill. +type BackfillClient = BackfillClientFor, ComponentsFor>>; /// Arguments for launching a single test node. pub(crate) struct LaunchArgs { + /// The index of the node in the setup. + pub(crate) idx: usize, + /// Factory of the node instance, called with the index of the node. + pub(crate) node_factory: NodeFactory, + /// Modifiers for the node builder, applied in order. + pub(crate) node_builder_modifiers: Vec>, /// The node configuration. pub(crate) node_config: NodeConfig, - /// The runtime to launch the node on. - pub(crate) runtime: Runtime, + /// The runtime to launch the node on, or `None` to launch it on a new [`Runtime::test`] + /// runtime of its own. + pub(crate) runtime: Option, /// The engine tree configuration. pub(crate) tree_config: TreeConfig, /// The datadir of the node. @@ -361,15 +555,28 @@ pub(crate) struct LaunchArgs { pub(crate) dev_payload_attributes: Option>, } -/// Returns the base tree configuration of test nodes. -pub(crate) fn test_tree_config() -> TreeConfig { - TreeConfig::default().with_cross_block_cache_size(1024 * 1024) +// Derived `Clone` would require the chain spec to be `Clone`. +impl Clone for LaunchArgs { + fn clone(&self) -> Self { + Self { + idx: self.idx, + node_factory: self.node_factory.clone(), + node_builder_modifiers: self.node_builder_modifiers.clone(), + node_config: self.node_config.clone(), + runtime: self.runtime.clone(), + tree_config: self.tree_config.clone(), + datadir: self.datadir.clone(), + attributes_generator: self.attributes_generator.clone(), + dev_payload_attributes: self.dev_payload_attributes.clone(), + } + } } /// Returns the base configuration of a test node. /// /// Discovery is disabled, all ports are unused, all RPC modules except `testing` are served over -/// HTTP and the node reports an idle sync state from startup. +/// HTTP, the node reports an idle sync state from startup, does not persist its peers and the +/// engine uses a cross block cache of 1 MiB. pub(crate) fn test_node_config(chain_spec: Arc) -> NodeConfig { let mut config = NodeConfig::new(chain_spec) .with_network(NetworkArgs { @@ -386,27 +593,65 @@ pub(crate) fn test_node_config(chain_spec: Arc) -> NodeConfig { // Nodes otherwise report that they are syncing until their first canonical block, which // e.g. stops transaction gossip. config.debug.startup_sync_state_idle = true; + // A stopped node would otherwise save its peers and dial them on its own once it is started + // again, racing the test that connects it. + config.network.no_persist_peers = true; + // The cross block cache is allocated up front, and the default of 4 GiB is far more than tests + // need. The size is in MiB. + config.engine.cross_block_cache_size = 1; + // Most tests never validate a blob, so only load the KZG trusted setup when needed. + config.debug.lazy_kzg_settings = true; config } -/// Launches a test node with the engine launcher. -pub(crate) async fn launch_test_node( +/// Launches a test node with the engine launcher and the engine's backfill built by `backfill`. +pub(crate) async fn launch_test_node( args: LaunchArgs, -) -> eyre::Result> { - let LaunchArgs { node_config, runtime, tree_config, datadir, attributes_generator, .. } = args; - let (builder, datadir) = test_node_builder::(node_config, datadir); - let NodeHandle { node, node_exit_future: _ } = - builder.launch_with(EngineNodeLauncher::new(runtime, datadir, tree_config)).await?; - - NodeTestContext::new(node, move |timestamp| attributes_generator(timestamp)).await + database: TmpDB, + backfill: B, +) -> eyre::Result> +where + N: NodeBuilderHelper, + B: BackfillSyncBuilder, BackfillClient> + 'static, +{ + let LaunchArgs { + idx, + node_factory, + node_builder_modifiers, + node_config, + runtime, + tree_config, + datadir, + attributes_generator, + .. + } = args; + let (builder, datadir) = test_node_builder( + node_factory(idx), + node_config, + datadir, + database, + &node_builder_modifiers, + ); + let runtime = runtime.unwrap_or_else(Runtime::test); + let NodeHandle { node, node_exit_future } = builder + .launch_with(EngineNodeLauncher::new(runtime, datadir, tree_config).with_backfill(backfill)) + .await?; + + let mut node = + NodeTestContext::new(node, move |timestamp| attributes_generator(timestamp)).await?; + node.exit_future = Mutex::new(Some(node_exit_future)); + Ok(node) } /// Launches a test node with the debug launcher, which runs a local miner in dev mode. -async fn launch_dev_node(args: LaunchArgs) -> eyre::Result> +async fn launch_dev_node(args: LaunchArgs, database: TmpDB) -> eyre::Result> where N: NodeBuilderHelper + DebugNode>, { let LaunchArgs { + idx, + node_factory, + node_builder_modifiers, node_config, runtime, tree_config, @@ -414,7 +659,14 @@ where attributes_generator, dev_payload_attributes, } = args; - let (builder, datadir) = test_node_builder::(node_config, datadir); + let (builder, datadir) = test_node_builder( + node_factory(idx), + node_config, + datadir, + database, + &node_builder_modifiers, + ); + let runtime = runtime.unwrap_or_else(Runtime::test); let launch = builder.launch_with(DebugNodeLauncher::new(EngineNodeLauncher::new( runtime, datadir, @@ -424,29 +676,196 @@ where Some(map) => launch.map_debug_payload_attributes(move |attributes| map(attributes)), None => launch, }; - let NodeHandle { node, node_exit_future: _ } = launch.await?; + let NodeHandle { node, node_exit_future } = launch.await?; - NodeTestContext::new(node, move |timestamp| attributes_generator(timestamp)).await + let mut node = + NodeTestContext::new(node, move |timestamp| attributes_generator(timestamp)).await?; + node.exit_future = Mutex::new(Some(node_exit_future)); + Ok(node) } -/// Returns the builder of a test node with a temporary database in `datadir`, and the resolved -/// datadir of the node. -/// -/// The datadir is removed when the node is dropped. +/// Returns the builder of the test node `node` with the database in `datadir` and the node +/// builder modifiers applied, and the resolved datadir of the node. fn test_node_builder( + node: N, node_config: NodeConfig, datadir: PathBuf, + database: TmpDB, + node_builder_modifiers: &[NodeBuilderModifier], ) -> (TestNodeBuilder, ChainPath) { let datadir_args = DatadirArgs { datadir: MaybePlatformPath::from(datadir), ..node_config.datadir.clone() }; let node_config = node_config.with_datadir_args(datadir_args); let datadir = node_config.datadir(); - let database = reth_db::test_utils::create_test_rw_db_with_datadir(datadir.data_dir()); - let node = N::default(); let builder = NodeBuilder::new(node_config) .with_database(database) .with_types_and_provider::>() .with_components(node.components_builder()) .with_add_ons(node.add_ons()); + let builder = + node_builder_modifiers.iter().fold(builder, |builder, modifier| modifier(builder)); (builder, datadir) } + +/// Launches a test node with `launch` on the database in the datadir of `args`, which is created +/// if it does not exist yet. +/// +/// A `restartable` node keeps what [`NodeTestContext::stop`] needs to stop it and launch it again +/// with the same arguments. Unless the node `mines` in dev mode, the relaunched node receives a +/// forkchoice update that restates the forkchoice state it loaded from disk. +fn launch_node( + launch: NodeLauncher, + args: LaunchArgs, + restartable: bool, + mines: bool, +) -> BoxFuture<'static, eyre::Result>> { + Box::pin(async move { + let database = open_test_database(&args.datadir)?; + let relaunch_args = restartable.then(|| args.clone()); + let mut node = launch(args, database.clone()).await?; + if let Some(args) = relaunch_args { + let relaunch: Relaunch<_> = + Arc::new(move || relaunch_node(launch.clone(), args.clone(), mines)); + node.restart = Some(NodeRestart { database, relaunch }); + } + Ok(node) + }) +} + +/// Launches a stopped restartable node again, see [`launch_node`]. +fn relaunch_node( + launch: NodeLauncher, + args: LaunchArgs, + mines: bool, +) -> BoxFuture<'static, eyre::Result>> { + let span = span!(Level::INFO, "node", idx = args.idx); + Box::pin( + async move { + let node = launch_node(launch, args, true, mines).await?; + if !mines { + restate_forkchoice(&node).await?; + } + Ok(node) + } + .instrument(span), + ) +} + +/// Sends a forkchoice update to a restarted node that restates the head, safe and finalized block +/// it loaded from disk. +/// +/// Unfinished snap attempts wait for the test to reconnect peers and supply a target. +/// Returns an error if the engine does not report the update valid. +async fn restate_forkchoice(node: &NodeHelperType) -> eyre::Result<()> { + let provider = &node.inner.provider; + // During snap sync the latest stored header has no executable state yet. Let the test send + // the consensus target after reconnecting peers instead of requiring a VALID response here. + if provider + .database_provider_ro()? + .snap_attempt()? + .is_some_and(|attempt| !attempt.is_verified()) + { + return Ok(()) + } + let hash = |tag| -> eyre::Result> { + Ok(provider.sealed_header_by_number_or_tag(tag)?.map(|header| header.hash())) + }; + let head = hash(BlockNumberOrTag::Latest)? + .ok_or_else(|| eyre!("the restarted node has no latest block"))?; + // A zero hash leaves the safe or finalized block unset if the node did not persist one. + let state = ForkchoiceState { + head_block_hash: head, + safe_block_hash: hash(BlockNumberOrTag::Safe)?.unwrap_or_default(), + finalized_block_hash: hash(BlockNumberOrTag::Finalized)?.unwrap_or_default(), + }; + let updated = + node.inner.add_ons_handle.beacon_engine_handle.fork_choice_updated(state, None).await?; + ensure!( + updated.is_valid(), + "forkchoice update to the head {head} the restarted node loaded from disk is not valid: {}", + updated.payload_status.status + ); + Ok(()) +} + +/// Opens the database in `datadir` like [`create_test_rw_db_with_datadir`], creating it if it does +/// not exist yet. +/// +/// The datadir is removed when the returned database is dropped. +/// +/// [`create_test_rw_db_with_datadir`]: reth_db::test_utils::create_test_rw_db_with_datadir +pub(crate) fn open_test_database(datadir: &Path) -> eyre::Result { + let path = datadir.join("db"); + let database = init_db(&path, DatabaseArguments::test()) + .wrap_err_with(|| format!("failed to open the database at {}", path.display()))?; + Ok(Arc::new(TempDatabase::new(database, datadir.to_path_buf()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use reth_node_ethereum::EthereumNode; + + #[test] + fn tree_config_uses_small_cross_block_cache() { + let (_, tree_config) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).node_and_tree_config(); + assert_eq!(tree_config.cross_block_cache_size(), 1024 * 1024); + } + + #[test] + fn tree_config_follows_node_config() { + let (_, tree_config) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) + .with_node_config_modifier(|mut config| { + config.engine.cross_block_cache_size = 2; + config.engine.persistence_threshold = 0; + config.debug.skip_state_root = true; + config + }) + .node_and_tree_config(); + assert_eq!(tree_config.cross_block_cache_size(), 2 * 1024 * 1024); + assert_eq!(tree_config.persistence_threshold(), 0); + assert!(tree_config.skip_state_root()); + } + + #[test] + fn tree_config_modifiers_override_node_config() { + // Tree config modifiers apply last, even if they are added before node config modifiers. + let (_, tree_config) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) + .with_tree_config_modifier(|config| config.with_state_root_fallback(false)) + .with_node_config_modifier(|mut config| { + config.engine.state_root_fallback = true; + config + }) + .node_and_tree_config(); + assert!(!tree_config.state_root_fallback()); + } + + /// Nodes whose payload attributes are not convertible from Ethereum's can be set up with an + /// explicit attributes generator. + #[expect(dead_code)] + async fn build_with_attributes_generator( + chain_spec: Arc, + attributes_generator: fn(u64) -> PayloadAttrTy, + ) -> eyre::Result<(Vec>, Wallet)> { + E2ETestSetupBuilder::new_with_attributes_generator(1, chain_spec, attributes_generator) + .build() + .await + } + + /// The node builder modifier can use the hooks of the node builder for any node. + #[expect(dead_code)] + fn node_builder_modifier_can_use_builder_hooks( + setup: E2ETestSetupBuilder, + ) -> E2ETestSetupBuilder { + setup.with_node_builder_modifier(|builder| { + builder + .on_component_initialized(|_| Ok(())) + .on_node_started(|_| Ok(())) + .on_rpc_started(|_, _| Ok(())) + .extend_rpc_modules(|_| Ok(())) + .map_add_ons(|add_ons| add_ons) + .install_exex("exex", |_| async { Ok(async { Ok(()) }) }) + }) + } +} diff --git a/crates/e2e-test-utils/src/setup_import.rs b/crates/e2e-test-utils/src/setup_import.rs index b1ddda23f09..2efec72df93 100644 --- a/crates/e2e-test-utils/src/setup_import.rs +++ b/crates/e2e-test-utils/src/setup_import.rs @@ -2,7 +2,7 @@ use crate::{ eth_payload_attributes, - setup_builder::{launch_test_node, test_node_config, LaunchArgs}, + setup_builder::{launch_test_node, open_test_database, test_node_config, LaunchArgs}, wallet::Wallet, NodeHelperType, }; @@ -12,6 +12,7 @@ use reth_cli_commands::import_core::{import_blocks_from_file, ImportConfig, Impo use reth_config::Config; use reth_db::DatabaseEnv; use reth_node_api::{NodeTypesWithDBAdapter, TreeConfig}; +use reth_node_builder::sync::PipelineBackfill; use reth_node_core::args::StorageArgs; use reth_node_ethereum::EthereumNode; use reth_provider::{ @@ -107,14 +108,22 @@ pub async fn setup_engine_with_chain_import( // Now launch the node with the pre-populated datadir. debug!(target: "e2e::import", "Launching node with datadir: {:?}", datadir); - let node = launch_test_node::(LaunchArgs { - node_config, - runtime: runtime.clone(), - tree_config: tree_config.clone(), - datadir, - attributes_generator: attributes_generator.clone(), - dev_payload_attributes: None, - }) + let database = open_test_database(&datadir)?; + let node = launch_test_node( + LaunchArgs { + idx, + node_factory: Arc::new(|_| EthereumNode::default()), + node_builder_modifiers: Vec::new(), + node_config, + runtime: Some(runtime.clone()), + tree_config: tree_config.clone(), + datadir, + attributes_generator: attributes_generator.clone(), + dev_payload_attributes: None, + }, + database, + PipelineBackfill, + ) .instrument(span) .await?; diff --git a/crates/e2e-test-utils/src/snap.rs b/crates/e2e-test-utils/src/snap.rs new file mode 100644 index 00000000000..47daecaa8af --- /dev/null +++ b/crates/e2e-test-utils/src/snap.rs @@ -0,0 +1,282 @@ +//! Observes and pauses the snap requests of a node's production backfill in e2e tests. +//! +//! [`SnapControl`] records every request and can pause one at a [`SnapGate`], while responses +//! still come from the real serving peer and go through production validation. + +use crate::{wait::poll_until, E2ETestSetupBuilder}; +use parking_lot::Mutex; +use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage, + GetStorageRangesMessage, SnapProtocolMessage, +}; +use reth_network_p2p::{ + download::DownloadClient, + error::PeerRequestResult, + headers::client::{HeadersClient, HeadersRequest}, + priority::Priority, + snap::client::{SnapClient, SnapResponse}, +}; +use reth_network_peers::PeerId; +use reth_node_builder::sync::{BackfillContext, BackfillSyncBuilder}; +use reth_node_ethereum::{ + snap::{EthereumBackfill, EthereumBackfillSync}, + EthereumNode, +}; +use reth_provider::{providers::ProviderNodeTypes, ProviderFactory}; +use std::{future::Future, mem, pin::Pin, sync::Arc}; +use tokio::sync::watch; + +/// Controls the requests of a node's production snap backfill, including across restarts. +#[derive(Clone, Debug, Default)] +pub struct SnapControl { + // Byte limit set on account and storage range requests, if any. + response_bytes: Option, + // Recorded requests and registered gates, shared by every clone. + state: Arc>, +} + +impl SnapControl { + /// Creates a control that limits account and storage responses to force real peer pagination. + pub fn with_response_bytes(bytes: u64) -> Self { + Self { response_bytes: Some(bytes), state: Default::default() } + } + + /// Pauses the next matching request before it reaches the network. + /// + /// Only requests sent after this call can match. The gate fires once, so a retry of the paused + /// request is not paused again. Dropping the gate releases the request. + pub fn pause_on( + &self, + matches: impl Fn(&SnapProtocolMessage) -> bool + Send + Sync + 'static, + ) -> SnapGate { + let (state, _) = watch::channel(GateState::Waiting); + self.state + .lock() + .gates + .push(RequestGate { matches: Box::new(matches), state: state.clone() }); + SnapGate { state } + } + + /// Returns the requests seen since the last call and clears them, including a request + /// currently paused at a gate. + pub fn take_requests(&self) -> Vec { + mem::take(&mut self.state.lock().requests) + } +} + +impl E2ETestSetupBuilder { + /// Wraps the backfill chosen by the node configuration with `control`. + pub fn with_snap_control(self, control: &SnapControl) -> Self { + let control = control.clone(); + self.with_backfill(move |config| ControlledBackfill { + inner: EthereumBackfill::new(config), + control: control.clone(), + }) + } +} + +/// Builds the production backfill with an observed network client. +#[derive(Debug)] +pub struct ControlledBackfill { + // Backfill chosen by the node configuration. + inner: EthereumBackfill, + // Control handed to the network client the backfill is built with. + control: SnapControl, +} + +impl BackfillSyncBuilder for ControlledBackfill +where + N: ProviderNodeTypes, + C: SnapClient + HeadersClient + Clone + Unpin + 'static, +{ + type Backfill = EthereumBackfillSync>; + + fn build(self, ctx: BackfillContext) -> eyre::Result { + let client = ControlledSnapClient { inner: ctx.client().clone(), control: self.control }; + let factory = ctx.provider_factory().clone(); + let runtime = ctx.runtime().clone(); + self.inner.build(BackfillContext::new(ctx.into_pipeline(), client, factory, runtime)) + } + + fn recover(&mut self, factory: &ProviderFactory) -> eyre::Result<()> { + >::recover(&mut self.inner, factory) + } +} + +/// A real network client with request observation and deterministic gates. +#[derive(Clone, Debug)] +pub struct ControlledSnapClient { + // Network client that sends the requests to peers. + inner: C, + // Control that records and gates the requests. + control: SnapControl, +} + +impl DownloadClient for ControlledSnapClient { + fn report_bad_message(&self, peer_id: PeerId) { + self.inner.report_bad_message(peer_id); + } + + fn num_connected_peers(&self) -> usize { + self.inner.num_connected_peers() + } +} + +impl HeadersClient for ControlledSnapClient { + type Header = C::Header; + type Output = C::Output; + + fn get_headers_with_priority( + &self, + request: HeadersRequest, + priority: Priority, + ) -> Self::Output { + self.inner.get_headers_with_priority(request, priority) + } +} + +impl SnapClient for ControlledSnapClient { + type Output = Pin> + Send + Sync>>; + + fn get_account_range_with_priority( + &self, + mut request: GetAccountRangeMessage, + priority: Priority, + ) -> Self::Output { + if let Some(bytes) = self.control.response_bytes { + request.response_bytes = bytes; + } + self.gated(SnapProtocolMessage::GetAccountRange(request.clone()), move |client| { + client.get_account_range_with_priority(request, priority) + }) + } + + fn get_storage_ranges(&self, request: GetStorageRangesMessage) -> Self::Output { + self.get_storage_ranges_with_priority(request, Priority::Normal) + } + + fn get_storage_ranges_with_priority( + &self, + mut request: GetStorageRangesMessage, + priority: Priority, + ) -> Self::Output { + if let Some(bytes) = self.control.response_bytes { + request.response_bytes = bytes; + } + self.gated(SnapProtocolMessage::GetStorageRanges(request.clone()), move |client| { + client.get_storage_ranges_with_priority(request, priority) + }) + } + + fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output { + self.get_byte_codes_with_priority(request, Priority::Normal) + } + + fn get_byte_codes_with_priority( + &self, + request: GetByteCodesMessage, + priority: Priority, + ) -> Self::Output { + self.gated(SnapProtocolMessage::GetByteCodes(request.clone()), move |client| { + client.get_byte_codes_with_priority(request, priority) + }) + } + + fn get_block_access_lists_with_priority( + &self, + request: GetBlockAccessListsMessage, + priority: Priority, + ) -> Self::Output { + self.gated(SnapProtocolMessage::GetBlockAccessLists(request.clone()), move |client| { + client.get_block_access_lists_with_priority(request, priority) + }) + } +} + +impl ControlledSnapClient { + // Record and gate before dispatching; all responses still come from the real network. + fn gated( + &self, + request: SnapProtocolMessage, + send: impl FnOnce(&C) -> C::Output + Send + Sync + 'static, + ) -> ::Output { + let released = self.control.state.lock().observe(request); + let client = self.inner.clone(); + Box::pin(async move { + if let Some(mut released) = released { + // An error means the gate was dropped, which releases the request too. + let _ = released.wait_for(|state| matches!(state, GateState::Released)).await; + } + send(&client).await + }) + } +} + +/// A single request boundary a test can await and release. +#[derive(Debug)] +#[must_use = "dropping the gate releases the paused request"] +pub struct SnapGate { + // Progress of the gate, shared with its `RequestGate`. + state: watch::Sender, +} + +impl SnapGate { + /// Waits until the downloader reaches this request, with the harness timeout, and returns it. + pub async fn reached(&self) -> eyre::Result { + poll_until("a paused snap request", || async { + Ok(match &*self.state.borrow() { + GateState::Reached(request) => Some(request.clone()), + GateState::Waiting | GateState::Released => None, + }) + }) + .await + } + + /// Lets the paused request proceed to the real peer. + pub fn release(&self) { + self.state.send_replace(GateState::Released); + } +} + +// State shared by the clones of a `SnapControl`. +#[derive(Debug, Default)] +struct ControlState { + // Requests seen since the last `take_requests`. + requests: Vec, + // Gates still waiting for a matching request, in registration order. + gates: Vec, +} + +impl ControlState { + // Signals a matching gate right away: the request future may be dropped before it is polled. + fn observe(&mut self, request: SnapProtocolMessage) -> Option> { + let released = self.gates.iter().position(|gate| (gate.matches)(&request)).map(|index| { + let gate = self.gates.remove(index); + gate.state.send_replace(GateState::Reached(request.clone())); + gate.state.subscribe() + }); + self.requests.push(request); + released + } +} + +// A registered gate waiting for its first matching request. +#[derive(derive_more::Debug)] +struct RequestGate { + // Selects the request to pause. + #[debug(skip)] + matches: Box bool + Send + Sync>, + // Progress shared with the test's `SnapGate`. + state: watch::Sender, +} + +// Progress of a gate from registration to release. +#[derive(Debug)] +enum GateState { + // No request matched yet. + Waiting, + // The matched request is paused before it reaches the network. + Reached(SnapProtocolMessage), + // The test released the request. + Released, +} diff --git a/crates/e2e-test-utils/src/test_rlp_utils.rs b/crates/e2e-test-utils/src/test_rlp_utils.rs index a05f62b92ee..3b1be40ebad 100644 --- a/crates/e2e-test-utils/src/test_rlp_utils.rs +++ b/crates/e2e-test-utils/src/test_rlp_utils.rs @@ -37,7 +37,7 @@ pub fn generate_test_blocks(chain_spec: &ChainSpec, count: u64) -> Vec], path: &Path) -> std::i pub fn create_fcu_json(tip: &SealedBlock) -> serde_json::Value { serde_json::json!({ "params": [{ - "headBlockHash": format!("0x{:x}", tip.hash()), - "safeBlockHash": format!("0x{:x}", tip.hash()), - "finalizedBlockHash": format!("0x{:x}", tip.hash()), + "headBlockHash": format!("{:#x}", tip.hash()), + "safeBlockHash": format!("{:#x}", tip.hash()), + "finalizedBlockHash": format!("{:#x}", tip.hash()), }] }) } diff --git a/crates/e2e-test-utils/src/testsuite/README.md b/crates/e2e-test-utils/src/testsuite/README.md index 51ac9e9712c..eb2fdd10362 100644 --- a/crates/e2e-test-utils/src/testsuite/README.md +++ b/crates/e2e-test-utils/src/testsuite/README.md @@ -1,5 +1,8 @@ # E2E Test Suite Framework +New tests should use the `NodeTestContext` API described in the [crate guide](../../README.md), +which also explains when this framework fits. + This directory contains the framework for writing end-to-end (e2e) tests in Reth. The framework provides utilities for setting up test environments, performing actions, and verifying blockchain behavior. ## Test Organization diff --git a/crates/e2e-test-utils/src/testsuite/actions/custom_fcu.rs b/crates/e2e-test-utils/src/testsuite/actions/custom_fcu.rs index 397947caef1..9bd94b62198 100644 --- a/crates/e2e-test-utils/src/testsuite/actions/custom_fcu.rs +++ b/crates/e2e-test-utils/src/testsuite/actions/custom_fcu.rs @@ -147,7 +147,7 @@ where } } else { // Just validate it's not an error - if matches!(fcu_response.payload_status.status, PayloadStatusEnum::Invalid { .. }) { + if fcu_response.is_invalid() { return Err(eyre::eyre!( "Node {node_idx}: FCU returned unexpected INVALID status: {:?}", fcu_response.payload_status.status diff --git a/crates/e2e-test-utils/src/testsuite/actions/engine_api.rs b/crates/e2e-test-utils/src/testsuite/actions/engine_api.rs index f70bbf73375..f536c1704d0 100644 --- a/crates/e2e-test-utils/src/testsuite/actions/engine_api.rs +++ b/crates/e2e-test-utils/src/testsuite/actions/engine_api.rs @@ -7,7 +7,7 @@ use alloy_rpc_types_eth::{Block, Header, Receipt, Transaction, TransactionReques use eyre::Result; use futures_util::future::BoxFuture; use reth_ethereum_primitives::TransactionSigned; -use reth_node_api::{EngineTypes, PayloadTypes}; +use reth_node_api::EngineTypes; use reth_rpc_api::clients::{EngineApiClient, EthApiClient}; use std::marker::PhantomData; use tracing::debug; @@ -63,7 +63,7 @@ where impl Action for SendNewPayload where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, { fn execute<'a>(&'a mut self, env: &'a mut Environment) -> BoxFuture<'a, Result<()>> { Box::pin(async move { @@ -276,7 +276,7 @@ where impl Action for SendNewPayloads where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, { fn execute<'a>(&'a mut self, env: &'a mut Environment) -> BoxFuture<'a, Result<()>> { Box::pin(async move { diff --git a/crates/e2e-test-utils/src/testsuite/actions/fork.rs b/crates/e2e-test-utils/src/testsuite/actions/fork.rs index 154b695adde..447c9b98a65 100644 --- a/crates/e2e-test-utils/src/testsuite/actions/fork.rs +++ b/crates/e2e-test-utils/src/testsuite/actions/fork.rs @@ -9,7 +9,7 @@ use alloy_rpc_types_eth::{Block, Header, Receipt, Transaction, TransactionReques use eyre::Result; use futures_util::future::BoxFuture; use reth_ethereum_primitives::TransactionSigned; -use reth_node_api::{EngineTypes, PayloadTypes}; +use reth_node_api::EngineTypes; use reth_rpc_api::clients::EthApiClient; use std::marker::PhantomData; use tracing::debug; @@ -52,7 +52,7 @@ impl CreateFork { impl Action for CreateFork where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, @@ -157,11 +157,8 @@ where active_node_state.latest_header_time = fork_base_block.header.timestamp; // update fork choice state to the fork base - active_node_state.latest_fork_choice_state = ForkchoiceState { - head_block_hash: fork_base_block.header.hash, - safe_block_hash: fork_base_block.header.hash, - finalized_block_hash: fork_base_block.header.hash, - }; + active_node_state.latest_fork_choice_state = + ForkchoiceState::same_hash(fork_base_block.header.hash); debug!( "Set fork base to block {} (hash: {})", @@ -192,11 +189,8 @@ where active_node_state.latest_header_time = block_info.timestamp; // update fork choice state to the fork base - active_node_state.latest_fork_choice_state = ForkchoiceState { - head_block_hash: block_info.hash, - safe_block_hash: block_info.hash, - finalized_block_hash: block_info.hash, - }; + active_node_state.latest_fork_choice_state = + ForkchoiceState::same_hash(block_info.hash); debug!("Set fork base to block {} (hash: {})", block_info.number, block_info.hash); diff --git a/crates/e2e-test-utils/src/testsuite/actions/mod.rs b/crates/e2e-test-utils/src/testsuite/actions/mod.rs index 28edd360091..b8221737749 100644 --- a/crates/e2e-test-utils/src/testsuite/actions/mod.rs +++ b/crates/e2e-test-utils/src/testsuite/actions/mod.rs @@ -51,7 +51,7 @@ pub struct ActionBox(Box>); impl ActionBox where - I: EngineTypes + 'static, + I: EngineTypes, { /// Constructor for [`ActionBox`]. pub fn new>(action: A) -> Self { @@ -95,7 +95,7 @@ impl Sequence { impl Action for Sequence where - I: EngineTypes + Sync + Send + 'static, + I: EngineTypes, { fn execute<'a>(&'a mut self, env: &'a mut Environment) -> BoxFuture<'a, Result<()>> { Box::pin(async move { @@ -130,7 +130,7 @@ impl MakeCanonical { impl Action for MakeCanonical where - Engine: EngineTypes + reth_node_api::PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, diff --git a/crates/e2e-test-utils/src/testsuite/actions/produce_blocks.rs b/crates/e2e-test-utils/src/testsuite/actions/produce_blocks.rs index 6475566afd1..6ac75e56c7f 100644 --- a/crates/e2e-test-utils/src/testsuite/actions/produce_blocks.rs +++ b/crates/e2e-test-utils/src/testsuite/actions/produce_blocks.rs @@ -92,11 +92,7 @@ where debug!("Latest block hash: {parent_hash}"); // create a simple forkchoice state with the latest block as head - let fork_choice_state = ForkchoiceState { - head_block_hash: parent_hash, - safe_block_hash: parent_hash, - finalized_block_hash: parent_hash, - }; + let fork_choice_state = ForkchoiceState::same_hash(parent_hash); // Try v2 first for backwards compatibility, fall back to v3 on error. match EngineApiClient::::fork_choice_updated_v2( @@ -210,7 +206,7 @@ pub struct GeneratePayloadAttributes {} impl Action for GeneratePayloadAttributes where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From, { fn execute<'a>(&'a mut self, env: &'a mut Environment) -> BoxFuture<'a, Result<()>> { @@ -246,7 +242,7 @@ pub struct GenerateNextPayload {} impl Action for GenerateNextPayload where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, { fn execute<'a>(&'a mut self, env: &'a mut Environment) -> BoxFuture<'a, Result<()>> { @@ -258,11 +254,7 @@ where let parent_hash = latest_block.hash; debug!("Latest block hash: {parent_hash}"); - let fork_choice_state = ForkchoiceState { - head_block_hash: parent_hash, - safe_block_hash: parent_hash, - finalized_block_hash: parent_hash, - }; + let fork_choice_state = ForkchoiceState::same_hash(parent_hash); let payload_attributes = env .active_node_state()? @@ -368,7 +360,7 @@ pub struct BroadcastLatestForkchoice {} impl Action for BroadcastLatestForkchoice where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, { @@ -475,7 +467,7 @@ pub struct UpdateBlockInfoToLatestPayload {} impl Action for UpdateBlockInfoToLatestPayload where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::ExecutionPayloadEnvelopeV3: Into, { fn execute<'a>(&'a mut self, env: &'a mut Environment) -> BoxFuture<'a, Result<()>> { @@ -583,7 +575,7 @@ where continue; } - if rpc_latest_header.inner.difficulty != alloy_primitives::U256::ZERO { + if !rpc_latest_header.inner.difficulty.is_zero() { debug!( "Client {}: difficulty != 0: {:?}", idx, rpc_latest_header.inner.difficulty @@ -649,7 +641,7 @@ impl BroadcastNextNewPayload { impl Action for BroadcastNextNewPayload where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, { @@ -725,7 +717,7 @@ where debug!("Node {}: new_payload broadcast status: {:?}", idx, result.status); // Check if this node accepted the payload - if result.status == PayloadStatusEnum::Valid && !first_valid_seen { + if result.is_valid() && !first_valid_seen { first_valid_seen = true; } else if let PayloadStatusEnum::Invalid { validation_error } = result.status { debug!( @@ -741,8 +733,7 @@ where } // Check if at least one node accepted the payload - let any_valid = - broadcast_results.iter().any(|(_, status)| *status == PayloadStatusEnum::Valid); + let any_valid = broadcast_results.iter().any(|(_, status)| status.is_valid()); if !any_valid { return Err(eyre::eyre!( "Failed to successfully broadcast payload to any client" @@ -781,7 +772,7 @@ impl Default for ProduceBlocks { impl Action for ProduceBlocks where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, { @@ -835,11 +826,7 @@ where .ok_or_else(|| eyre::eyre!("Block tag '{}' not found in registry", self.tag))?; let engine_client = env.node_clients[0].engine.http_client(); - let fcu_state = ForkchoiceState { - head_block_hash: target_block.hash, - safe_block_hash: target_block.hash, - finalized_block_hash: target_block.hash, - }; + let fcu_state = ForkchoiceState::same_hash(target_block.hash); let fcu_response = EngineApiClient::::fork_choice_updated_v2(&engine_client, fcu_state, None) @@ -976,7 +963,7 @@ impl Default for ProduceBlocksLocally { impl Action for ProduceBlocksLocally where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, { @@ -1033,7 +1020,7 @@ impl ProduceInvalidBlocks { impl Action for ProduceInvalidBlocks where - Engine: EngineTypes + PayloadTypes, + Engine: EngineTypes, Engine::PayloadAttributes: From + Clone, Engine::ExecutionPayloadEnvelopeV3: Into, { diff --git a/crates/e2e-test-utils/src/testsuite/actions/reorg.rs b/crates/e2e-test-utils/src/testsuite/actions/reorg.rs index 2b2adec8660..74ad90dac70 100644 --- a/crates/e2e-test-utils/src/testsuite/actions/reorg.rs +++ b/crates/e2e-test-utils/src/testsuite/actions/reorg.rs @@ -111,11 +111,8 @@ where active_node_state.latest_header_time = block_info.timestamp; // update fork choice state to make the target block canonical - active_node_state.latest_fork_choice_state = ForkchoiceState { - head_block_hash: block_info.hash, - safe_block_hash: block_info.hash, - finalized_block_hash: block_info.hash, - }; + active_node_state.latest_fork_choice_state = + ForkchoiceState::same_hash(block_info.hash); debug!("Set reorg target to block {}", block_info.hash); Ok(()) diff --git a/crates/e2e-test-utils/src/testsuite/mod.rs b/crates/e2e-test-utils/src/testsuite/mod.rs index b7776ae425e..0e340a57364 100644 --- a/crates/e2e-test-utils/src/testsuite/mod.rs +++ b/crates/e2e-test-utils/src/testsuite/mod.rs @@ -7,7 +7,7 @@ use crate::{ use alloy_primitives::{Bytes, B256}; use eyre::Result; use jsonrpsee::http_client::HttpClient; -use reth_node_api::{EngineTypes, PayloadTypes}; +use reth_node_api::{EngineTypes, PayloadAttrTy, PayloadTypes}; use reth_payload_builder::{PayloadBuilderHandle, PayloadId}; use std::{collections::HashMap, marker::PhantomData}; pub mod actions; @@ -346,7 +346,7 @@ where impl TestBuilder where - I: EngineTypes + 'static, + I: EngineTypes, { /// Create a new test builder pub fn new() -> Self { @@ -359,7 +359,7 @@ where self } - /// Set the test setup with chain import from RLP file + /// Set the test setup with chain import from RLP file, see [`Setup::apply_with_import`]. pub fn with_setup_and_import( mut self, mut setup: Setup, @@ -393,6 +393,7 @@ where pub async fn run(mut self) -> Result<()> where N: NodeBuilderHelper>, + PayloadAttrTy: From, { let mut setup = self.setup.take(); diff --git a/crates/e2e-test-utils/src/testsuite/setup.rs b/crates/e2e-test-utils/src/testsuite/setup.rs index 3e10ea822e0..b87ff2f8b26 100644 --- a/crates/e2e-test-utils/src/testsuite/setup.rs +++ b/crates/e2e-test-utils/src/testsuite/setup.rs @@ -1,19 +1,21 @@ //! Test setup utilities for configuring the initial state. -use crate::{testsuite::Environment, E2ETestSetupExt, NodeBuilderHelper}; +use crate::{ + testsuite::{actions::expect_fcu_valid, Environment}, + wait::{poll_until, poll_until_with, PollOpts}, + E2ETestSetupExt, NodeBuilderHelper, +}; use alloy_eips::BlockNumberOrTag; -use alloy_rpc_types_engine::ForkchoiceState; +use alloy_rpc_types_engine::{ForkchoiceState, PayloadAttributes}; use eyre::{eyre, Result}; use reth_chainspec::ChainSpec; -use reth_ethereum_primitives::Block; -use reth_node_api::{EngineTypes, PayloadTypes, TreeConfig}; -use reth_node_core::{args::StorageArgs, primitives::RecoveredBlock}; -use revm::state::EvmState; -use std::{marker::PhantomData, path::Path, sync::Arc}; -use tokio::{ - sync::mpsc, - time::{sleep, Duration}, -}; +use reth_node_api::{EngineTypes, PayloadAttrTy, PayloadTypes, TreeConfig}; +use reth_node_core::args::StorageArgs; +use reth_rpc_api::clients::EngineApiClient; +#[cfg(feature = "chain-import")] +use std::path::Path; +use std::{marker::PhantomData, sync::Arc, time::Duration}; +use tokio::sync::mpsc; use tracing::debug; /// Configuration for setting up test environment @@ -21,12 +23,6 @@ use tracing::debug; pub struct Setup { /// Chain specification to use pub chain_spec: Option>, - /// Genesis block to use - pub genesis: Option, - /// Blocks to replay during setup - pub blocks: Vec>, - /// Initial state to load - pub state: Option, /// Network configuration pub network: NetworkSetup, /// Engine tree configuration @@ -43,6 +39,7 @@ pub struct Setup { _phantom: PhantomData, /// Holds the import result to keep nodes alive when using imported chain /// This is stored as an option to avoid lifetime issues with `tokio::spawn` + #[cfg(feature = "chain-import")] import_result_holder: Option, /// Path to RLP file to import during setup pub import_rlp_path: Option, @@ -52,15 +49,13 @@ impl Default for Setup { fn default() -> Self { Self { chain_spec: None, - genesis: None, - blocks: Vec::new(), - state: None, network: NetworkSetup::default(), tree_config: TreeConfig::default(), shutdown_tx: None, is_dev: true, storage_v2: StorageArgs::default().v2, _phantom: Default::default(), + #[cfg(feature = "chain-import")] import_result_holder: None, import_rlp_path: None, } @@ -86,30 +81,6 @@ where self } - /// Set the genesis block - pub const fn with_genesis(mut self, genesis: Genesis) -> Self { - self.genesis = Some(genesis); - self - } - - /// Add a block to replay during setup - pub fn with_block(mut self, block: RecoveredBlock) -> Self { - self.blocks.push(block); - self - } - - /// Add multiple blocks to replay during setup - pub fn with_blocks(mut self, blocks: Vec>) -> Self { - self.blocks.extend(blocks); - self - } - - /// Set the initial state - pub fn with_state(mut self, state: EvmState) -> Self { - self.state = Some(state); - self - } - /// Set the network configuration pub const fn with_network(mut self, network: NetworkSetup) -> Self { self.network = network; @@ -135,6 +106,13 @@ where } /// Apply setup using pre-imported chain data from RLP file + /// + /// Returns once the engine of every node accepted the latest imported block as head and safe + /// block, with genesis as finalized block, which is the forkchoice state recorded in `env`. + /// Returns an error if a node does not accept it within [`WAIT_TIMEOUT`]. + /// + /// [`WAIT_TIMEOUT`]: crate::wait::WAIT_TIMEOUT + #[cfg(feature = "chain-import")] pub async fn apply_with_import( &mut self, env: &mut Environment, @@ -148,6 +126,7 @@ where } /// Apply setup using pre-imported chain data from RLP file + #[cfg(feature = "chain-import")] async fn apply_with_import_( &mut self, env: &mut Environment, @@ -181,6 +160,7 @@ where pub async fn apply(&mut self, env: &mut Environment) -> Result<()> where N: NodeBuilderHelper>, + PayloadAttrTy: From, { // Note: this future is quite large so we box it Box::pin(self.apply_::(env)).await @@ -190,10 +170,17 @@ where async fn apply_(&mut self, env: &mut Environment) -> Result<()> where N: NodeBuilderHelper>, + PayloadAttrTy: From, { // If import_rlp_path is set, use apply_with_import instead if let Some(rlp_path) = self.import_rlp_path.take() { + #[cfg(feature = "chain-import")] return self.apply_with_import::(env, &rlp_path).await; + #[cfg(not(feature = "chain-import"))] + return Err(eyre!( + "importing {} requires the `chain-import` feature", + rlp_path.display() + )); } let chain_spec = self.chain_spec.clone().ok_or_else(|| eyre!("Chain specification is required"))?; @@ -247,6 +234,7 @@ where /// Note: Currently this only supports `EthereumNode` due to the import process /// being Ethereum-specific. The generic parameter N is kept for consistency /// with other methods but is not used. + #[cfg(feature = "chain-import")] async fn create_nodes_with_import( &self, rlp_path: &Path, @@ -298,14 +286,15 @@ where }; // Initialize all node states + let fork_choice_state = ForkchoiceState { + head_block_hash: initial_block_info.hash, + safe_block_hash: initial_block_info.hash, + finalized_block_hash: genesis_block_info.hash, + }; for (node_idx, node_state) in env.node_states.iter_mut().enumerate() { node_state.current_block_info = Some(initial_block_info); node_state.latest_header_time = initial_block_info.timestamp; - node_state.latest_fork_choice_state = ForkchoiceState { - head_block_hash: initial_block_info.hash, - safe_block_hash: initial_block_info.hash, - finalized_block_hash: genesis_block_info.hash, - }; + node_state.latest_fork_choice_state = fork_choice_state; debug!( "Node {} initialized with block {} (hash: {})", @@ -313,6 +302,12 @@ where ); } + // Fresh nodes are launched with genesis as their forkchoice state, nodes on an imported + // chain are not, so make the imported head canonical before actions build on it. + if use_latest_block { + self.wait_for_forkchoice_valid(&env.node_clients, fork_choice_state).await?; + } + debug!( "Environment initialized with {} nodes, starting from block {} (hash: {})", self.network.node_count, initial_block_info.number, initial_block_info.hash @@ -330,25 +325,44 @@ where P: PayloadTypes, { for (idx, client) in node_clients.iter().enumerate() { - let mut retry_count = 0; - const MAX_RETRIES: usize = 10; - - while retry_count < MAX_RETRIES { - if client.is_ready().await { - debug!("Node {idx} RPC endpoint is ready"); - break; - } - - retry_count += 1; - debug!("Node {idx} RPC endpoint not ready, retry {retry_count}/{MAX_RETRIES}"); - sleep(Duration::from_millis(500)).await; - } + poll_until(format!("node {idx} RPC endpoint to accept requests"), || async { + Ok(client.is_ready().await.then_some(())) + }) + .await?; + debug!("Node {idx} RPC endpoint is ready"); + } + Ok(()) + } - if retry_count == MAX_RETRIES { - return Err(eyre!( - "Failed to connect to node {idx} RPC endpoint after {MAX_RETRIES} retries" - )); - } + /// Waits until the engine of every node accepts `state` as its forkchoice state. + /// + /// The chain import leaves some stage checkpoints, e.g. of the prune stages, behind the + /// imported head, so a node launched on an imported chain starts with a backfill run to the + /// head and answers forkchoice updates with SYNCING until it finished. This resends the update + /// until the node answers with another status, and returns an error unless that status is + /// VALID. Every attempt is a forkchoice update, so attempts are spaced further apart than the + /// default poll interval. + async fn wait_for_forkchoice_valid( + &self, + node_clients: &[crate::testsuite::NodeClient], + state: ForkchoiceState, + ) -> Result<()> { + for (idx, client) in node_clients.iter().enumerate() { + let engine = client.engine.http_client(); + let response = poll_until_with( + PollOpts { interval: Duration::from_millis(100), ..Default::default() }, + format!("node {idx} to stop syncing to block {}", state.head_block_hash), + || async { + let response = + EngineApiClient::::fork_choice_updated_v3(&engine, state, None).await?; + Ok((!response.is_syncing()).then_some(response)) + }, + ) + .await?; + expect_fcu_valid( + &response, + &format!("Node {idx} forkchoice update to block {}", state.head_block_hash), + )?; } Ok(()) } @@ -375,10 +389,6 @@ where } } -/// Genesis block configuration -#[derive(Debug)] -pub struct Genesis {} - /// Network configuration for setup #[derive(Debug, Default)] pub struct NetworkSetup { diff --git a/crates/e2e-test-utils/src/transaction.rs b/crates/e2e-test-utils/src/transaction.rs index 0125203374b..e44aa35a2c1 100644 --- a/crates/e2e-test-utils/src/transaction.rs +++ b/crates/e2e-test-utils/src/transaction.rs @@ -1,12 +1,12 @@ use alloy_consensus::{ EnvKzgSettings, EthereumTxEnvelope, SidecarBuilder, SimpleCoder, TxEip4844Variant, TxEnvelope, }; -use alloy_eips::{eip7594::BlobTransactionSidecarVariant, eip7702::SignedAuthorization}; +use alloy_eips::eip7702::SignedAuthorization; use alloy_network::{ eip2718::Encodable2718, Ethereum, EthereumWallet, NetworkTransactionBuilder, TransactionBuilder4844, }; -use alloy_primitives::{hex, Address, Bytes, TxKind, B256, U256}; +use alloy_primitives::{bytes, Address, Bytes, TxKind, B256, U256}; use alloy_rpc_types_eth::{Authorization, TransactionInput, TransactionRequest}; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; @@ -156,9 +156,7 @@ impl TransactionTestContext { wallet: PrivateKeySigner, nonce: u64, ) -> Bytes { - let l1_block_info = Bytes::from_static(&hex!( - "7ef9015aa044bae9d41b8380d781187b426c6fe43df5fb2fb57bd4466ef6a701e1f01e015694deaddeaddeaddeaddeaddeaddeaddeaddead000194420000000000000000000000000000000000001580808408f0d18001b90104015d8eb900000000000000000000000000000000000000000000000000000000008057650000000000000000000000000000000000000000000000000000000063d96d10000000000000000000000000000000000000000000000000000000000009f35273d89754a1e0387b89520d989d3be9c37c1f32495a88faf1ea05c61121ab0d1900000000000000000000000000000000000000000000000000000000000000010000000000000000000000002d679b567db6187c0c8323fa982cfb88b74dbcc7000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240" - )); + let l1_block_info = bytes!("7ef9015aa044bae9d41b8380d781187b426c6fe43df5fb2fb57bd4466ef6a701e1f01e015694deaddeaddeaddeaddeaddeaddeaddeaddead000194420000000000000000000000000000000000001580808408f0d18001b90104015d8eb900000000000000000000000000000000000000000000000000000000008057650000000000000000000000000000000000000000000000000000000063d96d10000000000000000000000000000000000000000000000000000000000009f35273d89754a1e0387b89520d989d3be9c37c1f32495a88faf1ea05c61121ab0d1900000000000000000000000000000000000000000000000000000000000000010000000000000000000000002d679b567db6187c0c8323fa982cfb88b74dbcc7000000000000000000000000000000000000000000000000000000000000083400000000000000000000000000000000000000000000000000000000000f4240"); let tx = tx(chain_id, 210000, Some(l1_block_info), None, nonce, Some(20e9 as u128)); let signer = EthereumWallet::from(wallet); >::build(tx, &signer) @@ -170,9 +168,7 @@ impl TransactionTestContext { /// Validates the sidecar of a given tx envelope and returns the versioned hashes #[track_caller] - pub fn validate_sidecar( - tx: EthereumTxEnvelope>, - ) -> Vec { + pub fn validate_sidecar(tx: TxEnvelope) -> Vec { let proof_setting = EnvKzgSettings::Default; match tx { diff --git a/crates/e2e-test-utils/src/trie.rs b/crates/e2e-test-utils/src/trie.rs index 5e091cf76e0..8a407fba6d8 100644 --- a/crates/e2e-test-utils/src/trie.rs +++ b/crates/e2e-test-utils/src/trie.rs @@ -11,28 +11,6 @@ use reth_trie::{ StateRoot, }; use reth_trie_db::{DatabaseHashedCursorFactory, DatabaseTrieCursorFactory}; -use std::time::{Duration, Instant}; - -/// Waits until the node has persisted at least the given block number to disk. -/// -/// The engine keeps the most recent blocks in memory, so tests that want to inspect the -/// database, e.g. via [`assert_trie_consistency`], must first advance the chain far enough and -/// wait for the persistence task to catch up. Unlike `NodeTestContext::wait_block` this only -/// needs the block number and fails after `timeout` instead of polling indefinitely. -pub async fn wait_for_persisted_block(factory: &F, number: u64, timeout: Duration) -> Result<()> -where - F: DatabaseProviderFactory, -{ - let start = Instant::now(); - loop { - // the finish checkpoint is committed in the same transaction as the block's state - if factory.database_provider_ro()?.best_block_number()? >= number { - return Ok(()) - } - ensure!(start.elapsed() < timeout, "timed out waiting for block {number} to be persisted"); - tokio::time::sleep(Duration::from_millis(50)).await; - } -} /// Asserts that the persisted state and trie representation is internally consistent and matches /// the state root of the latest persisted block. @@ -44,7 +22,9 @@ where /// of a `reth db repair-trie --dry-run`). /// /// Only on-disk data is read, so callers must ensure the blocks of interest have been persisted, -/// e.g. via [`wait_for_persisted_block`]. +/// e.g. via [`NodeTestContext::wait_for_persisted_block`]. +/// +/// [`NodeTestContext::wait_for_persisted_block`]: crate::node::NodeTestContext::wait_for_persisted_block pub fn assert_trie_consistency(factory: &F) -> Result<()> where F: DatabaseProviderFactory< diff --git a/crates/e2e-test-utils/src/wait.rs b/crates/e2e-test-utils/src/wait.rs index aa669ebe8e8..e4925b46c5e 100644 --- a/crates/e2e-test-utils/src/wait.rs +++ b/crates/e2e-test-utils/src/wait.rs @@ -1,36 +1,195 @@ //! Helpers for waiting on conditions of test nodes. -use eyre::eyre; +use eyre::{ensure, eyre}; use std::{fmt::Display, future::Future, time::Duration}; +use tokio::time::Instant; /// Maximum time the wait helpers wait for a condition, e.g. for a node to sync to or commit a /// block. pub const WAIT_TIMEOUT: Duration = Duration::from_secs(60); -/// Interval at which [`poll_until`] polls. +/// Interval at which [`poll_until`] polls, [`assert_holds_for`] checks and +/// [`NodeTestContext::advance_while`] advances the chain. +/// +/// [`NodeTestContext::advance_while`]: crate::node::NodeTestContext::advance_while pub const POLL_INTERVAL: Duration = Duration::from_millis(20); /// Calls `poll` every [`POLL_INTERVAL`] until it returns a value. /// /// Returns an error describing `what` was awaited if the condition is not met within -/// [`WAIT_TIMEOUT`], or the first error returned by `poll`. +/// [`WAIT_TIMEOUT`], or the first error returned by `poll`. This is [`poll_until_with`] with the +/// default [`PollOpts`], see there for when `poll` is called. /// /// `what` is held across awaits, so it must be `Send` for the returned future to be `Send`. This /// rules out `format_args!`, use `format!` instead. -pub async fn poll_until(what: impl Display + Send, mut poll: F) -> eyre::Result +pub async fn poll_until(what: impl Display + Send, poll: F) -> eyre::Result where F: FnMut() -> Fut, Fut: Future>>, { + poll_until_with(PollOpts::default(), what, poll).await +} + +/// Calls `poll` until it returns a value, with the timeout and interval of `opts`. +/// +/// The first poll starts immediately, and every further poll `opts.interval` after the previous +/// one returned `None`. The wait ends `opts.timeout` after the call: a poll that is still running +/// is dropped, and there is no final poll at the deadline, so a condition that only becomes true +/// during the last interval is reported as timed out, and an interval at least as long as the +/// timeout polls once. +/// +/// Returns the value of the first poll that returns `Some`, the first error returned by `poll`, or +/// an error naming `what` and the timeout, e.g. `timed out after 30s waiting for block 1`. +/// +/// `what` is held across awaits, so it must be `Send` for the returned future to be `Send`. This +/// rules out `format_args!`, use `format!` instead. +/// +/// ```ignore +/// let receipt = poll_until_with( +/// PollOpts { interval: Duration::from_secs(1), ..Default::default() }, +/// format!("receipt of transaction {tx_hash}"), +/// || async { Ok(provider.get_transaction_receipt(tx_hash).await?) }, +/// ) +/// .await?; +/// ``` +pub async fn poll_until_with( + opts: PollOpts, + what: impl Display + Send, + mut poll: F, +) -> eyre::Result +where + F: FnMut() -> Fut, + Fut: Future>>, +{ + let PollOpts { timeout, interval } = opts; let wait = async { loop { if let Some(value) = poll().await? { return Ok(value) } - tokio::time::sleep(POLL_INTERVAL).await; + tokio::time::sleep(interval).await; } }; - tokio::time::timeout(WAIT_TIMEOUT, wait) + tokio::time::timeout(timeout, wait) + .await + .map_err(|_| eyre!("timed out after {timeout:?} waiting for {what}"))? +} + +/// Asserts that the condition checked by `check` holds for `duration`. +/// +/// Use this instead of a fixed sleep followed by an assertion to verify that something does not +/// happen, e.g. that a transaction stays in the pool. `check` returns whether the condition still +/// holds, as a future like the poll of [`poll_until`], so it can query the node over RPC and +/// return errors with `?`. +/// +/// The first check starts immediately, every further check [`POLL_INTERVAL`] after the previous +/// one returned, and the last check starts at `duration` after the call, so the condition is +/// verified at both ends of `duration`. A condition that breaks and recovers between two checks +/// goes unnoticed. Checks are not cancelled, so a successful call takes `duration` plus the time of +/// the last check. +/// +/// Returns the first error returned by `check`, or an error as soon as a check returns `false`, +/// naming `what` and the time from the call to the start of that check, e.g. `expected +/// transaction to stay in the pool for 2s, but it did not hold after 1.24s`. +/// +/// `what` is held across awaits, so it must be `Send` for the returned future to be `Send`. This +/// rules out `format_args!`, use `format!` instead. +pub async fn assert_holds_for( + duration: Duration, + what: impl Display + Send, + mut check: F, +) -> eyre::Result<()> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let start = Instant::now(); + let deadline = start + duration; + loop { + let checked_at = Instant::now(); + ensure!( + check().await?, + "expected {what} for {duration:?}, but it did not hold after {:?}", + checked_at - start + ); + if checked_at >= deadline { + return Ok(()) + } + tokio::time::sleep_until(deadline.min(Instant::now() + POLL_INTERVAL)).await; + } +} + +/// Timeout and interval of a single [`poll_until_with`] call. +/// +/// The default is [`WAIT_TIMEOUT`] and [`POLL_INTERVAL`], which [`poll_until`] uses, so set only +/// what differs, e.g. `PollOpts { interval: Duration::from_secs(1), ..Default::default() }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PollOpts { + /// Maximum time to wait for the condition, from the start of the wait. + pub timeout: Duration, + /// Time between a poll returning `None` and the start of the next poll. + pub interval: Duration, +} + +impl Default for PollOpts { + fn default() -> Self { + Self { timeout: WAIT_TIMEOUT, interval: POLL_INTERVAL } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn poll_until_with_times_out_without_final_poll() { + let mut polls = 0; + let opts = PollOpts { timeout: Duration::from_millis(50), interval: WAIT_TIMEOUT }; + let err = poll_until_with(opts, "condition", || { + polls += 1; + async { Ok(None::<()>) } + }) .await - .map_err(|_| eyre!("timed out waiting for {what}"))? + .unwrap_err(); + assert_eq!(err.to_string(), "timed out after 50ms waiting for condition"); + assert_eq!(polls, 1); + } + + #[tokio::test] + async fn assert_holds_for_returns_after_duration() { + let duration = Duration::from_millis(50); + let start = Instant::now(); + assert_holds_for(duration, "condition", || async { Ok(true) }).await.unwrap(); + assert!(start.elapsed() >= duration); + } + + #[tokio::test] + async fn assert_holds_for_fails_when_condition_breaks() { + let mut checks = 0; + let err = assert_holds_for(WAIT_TIMEOUT, "condition", || { + checks += 1; + let holds = checks < 2; + async move { Ok(holds) } + }) + .await + .unwrap_err() + .to_string(); + assert!( + err.starts_with("expected condition for 60s, but it did not hold after "), + "unexpected error: {err}" + ); + assert_eq!(checks, 2); + } + + #[tokio::test] + async fn assert_holds_for_checks_at_deadline() { + let duration = Duration::from_millis(50); + let deadline = Instant::now() + duration; + let result = assert_holds_for(duration, "condition", || { + let holds = Instant::now() < deadline; + async move { Ok(holds) } + }) + .await; + assert!(result.is_err(), "condition broken at the deadline was not checked"); + } } diff --git a/crates/e2e-test-utils/src/wallet.rs b/crates/e2e-test-utils/src/wallet.rs index ed93166b758..77e7b0f41bd 100644 --- a/crates/e2e-test-utils/src/wallet.rs +++ b/crates/e2e-test-utils/src/wallet.rs @@ -1,11 +1,14 @@ -use crate::transaction::TransactionTestContext; -use alloy_eips::BlockId; -use alloy_network::Network; -use alloy_primitives::{Address, Bytes}; +use alloy_eips::{eip2718::Encodable2718, BlockId}; +use alloy_network::{ + Ethereum, EthereumWallet, Network, NetworkTransactionBuilder, NetworkWallet, TransactionBuilder, +}; +use alloy_primitives::{Address, Bytes, U256}; use alloy_provider::Provider; use alloy_rpc_types_eth::TransactionRequest; use alloy_signer::Signer; use alloy_signer_local::{coins_bip39::English, MnemonicBuilder, PrivateKeySigner}; +use futures_util::future::BoxFuture; +use std::future::IntoFuture; /// Mnemonic of the test accounts funded by the [`test_genesis`](crate::test_genesis). pub const TEST_MNEMONIC: &str = "test test test test test test test test test test test junk"; @@ -71,30 +74,70 @@ pub fn test_signer(index: u32) -> PrivateKeySigner { /// [`test_genesis`](crate::test_genesis). /// /// [`Self::sign_tx_bytes`] fills the nonce, chain id, gas limit and fees of transaction requests -/// that do not set them, so tests only need to set the fields they care about. +/// that do not set them, so tests only need to set the fields they care about. The gas limit and +/// fees default to [`Self::DEFAULT_GAS_LIMIT`] and the `DEFAULT_*_FEE_PER_GAS` constants and can be +/// changed once per account with [`Self::with_gas_limit`] and [`Self::with_fees`]. +/// +/// [`Self::tx`] and its shortcuts [`Self::call`], [`Self::transfer`] and [`Self::deploy`] build and +/// sign a transaction in a single expression: +/// +/// ```ignore +/// let mut account = wallet.account(0).with_fees(max_fee_per_gas, max_priority_fee_per_gas); +/// let contract = account.next_contract_address(); +/// let deploy = account.deploy(init_code).await; +/// let call = account.call(contract, calldata).gas_limit(100_000).await; +/// ``` #[derive(Debug, Clone)] pub struct TestAccount { signer: PrivateKeySigner, chain_id: u64, nonce: u64, + gas_limit: u64, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, } impl TestAccount { - /// Gas limit of transactions signed by [`Self::sign_tx_bytes`] that do not set one. + /// Initial gas limit of transactions signed by [`Self::sign_tx_bytes`] that do not set one. pub const DEFAULT_GAS_LIMIT: u64 = 1_000_000; - /// Max fee per gas of transactions signed by [`Self::sign_tx_bytes`] that set no fees. + /// Initial max fee per gas of transactions signed by [`Self::sign_tx_bytes`] that set no fees. /// /// High enough that transactions are accepted regardless of the base fee of test chains. pub const DEFAULT_MAX_FEE_PER_GAS: u128 = 1_000_000_000_000; - /// Max priority fee per gas of transactions signed by [`Self::sign_tx_bytes`] that set no - /// fees. + /// Initial max priority fee per gas of transactions signed by [`Self::sign_tx_bytes`] that set + /// no fees. pub const DEFAULT_MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000; /// Creates a new account for the given signer and chain id, starting at nonce 0. pub const fn new(signer: PrivateKeySigner, chain_id: u64) -> Self { - Self { signer, chain_id, nonce: 0 } + Self { + signer, + chain_id, + nonce: 0, + gas_limit: Self::DEFAULT_GAS_LIMIT, + max_fee_per_gas: Self::DEFAULT_MAX_FEE_PER_GAS, + max_priority_fee_per_gas: Self::DEFAULT_MAX_PRIORITY_FEE_PER_GAS, + } + } + + /// Sets the gas limit of signed transactions that do not set one. + pub const fn with_gas_limit(mut self, gas_limit: u64) -> Self { + self.gas_limit = gas_limit; + self + } + + /// Sets the EIP-1559 fees of signed transactions that set neither these fees nor a legacy gas + /// price. + pub const fn with_fees( + mut self, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + ) -> Self { + self.max_fee_per_gas = max_fee_per_gas; + self.max_priority_fee_per_gas = max_priority_fee_per_gas; + self } /// Returns the signer of the account. @@ -137,26 +180,285 @@ impl TestAccount { /// Signs the transaction request, returning the EIP-2718 encoded bytes. /// - /// Fills unset fields: the nonce with [`Self::next_nonce`], the chain id of the account, the - /// gas limit with [`Self::DEFAULT_GAS_LIMIT`] and, unless the request sets a legacy gas price, - /// the EIP-1559 fees with [`Self::DEFAULT_MAX_FEE_PER_GAS`] and - /// [`Self::DEFAULT_MAX_PRIORITY_FEE_PER_GAS`]. An explicit nonce does not advance the tracked - /// nonce. Contract creations must set `to`, e.g. with - /// [`TransactionBuilder::into_create`](alloy_network::TransactionBuilder::into_create). + /// Fills unset fields: the nonce with [`Self::next_nonce`], the chain id, gas limit and, unless + /// the request sets a legacy gas price, the EIP-1559 fees of the account. An explicit nonce + /// does not advance the tracked nonce. Contract creations must set `to`, e.g. with + /// [`TransactionRequest::create`] or [`Self::deploy`]. + /// + /// See [`Self::sign_request`] for other networks. + /// + /// # Panics + /// + /// If the request can not be built into a signed transaction. + pub async fn sign_tx_bytes(&mut self, tx: TransactionRequest) -> Bytes { + self.sign_request::(tx).await + } + + /// Returns the address of the contract created by the next transaction of the account, if it + /// is a contract creation that does not set an explicit nonce. + pub fn next_contract_address(&self) -> Address { + self.address().create(self.nonce) + } + + /// Starts building a transaction of the account that sets no fields yet. + /// + /// See [`Self::tx_for`] for other networks. + pub fn tx(&mut self) -> TestTx<'_> { + self.tx_for() + } + + /// Starts building a call of `to` with the given input. + pub fn call(&mut self, to: Address, input: impl Into) -> TestTx<'_> { + self.tx().to(to).input(input) + } + + /// Starts building a transfer of `value` to `to`. + pub fn transfer(&mut self, to: Address, value: U256) -> TestTx<'_> { + self.tx().to(to).value(value) + } + + /// Starts building a contract creation with the given init code. + /// + /// See [`Self::next_contract_address`] for the address of the created contract. + pub fn deploy(&mut self, init_code: impl Into) -> TestTx<'_> { + self.tx().create().input(init_code) + } + + /// Signs the transaction request of the network `N` like [`Self::sign_tx_bytes`], returning + /// the EIP-2718 encoded bytes. + /// + /// Only the secp256k1 key of the account signs, so transactions of networks that are signed + /// with other keys, e.g. P256 or passkeys, need their own wallets. Transactions using a + /// network-specific nonce scheme, such as 2D nonces, should set an explicit nonce so that the + /// tracked protocol nonce is not advanced. /// /// # Panics /// /// If the request can not be built into a signed transaction. - pub async fn sign_tx_bytes(&mut self, mut tx: TransactionRequest) -> Bytes { - if tx.nonce.is_none() { - tx.nonce = Some(self.next_nonce()); + pub async fn sign_request(&mut self, mut tx: N::TransactionRequest) -> Bytes + where + EthereumWallet: NetworkWallet, + { + if tx.nonce().is_none() { + tx.set_nonce(self.next_nonce()); } - tx.chain_id.get_or_insert(self.chain_id); - tx.gas.get_or_insert(Self::DEFAULT_GAS_LIMIT); - if tx.gas_price.is_none() { - tx.max_fee_per_gas.get_or_insert(Self::DEFAULT_MAX_FEE_PER_GAS); - tx.max_priority_fee_per_gas.get_or_insert(Self::DEFAULT_MAX_PRIORITY_FEE_PER_GAS); + if tx.chain_id().is_none() { + tx.set_chain_id(self.chain_id); } - TransactionTestContext::sign_tx_bytes(self.signer.clone(), tx).await + if tx.gas_limit().is_none() { + tx.set_gas_limit(self.gas_limit); + } + if tx.gas_price().is_none() { + if tx.max_fee_per_gas().is_none() { + tx.set_max_fee_per_gas(self.max_fee_per_gas); + } + if tx.max_priority_fee_per_gas().is_none() { + tx.set_max_priority_fee_per_gas(self.max_priority_fee_per_gas); + } + } + let wallet = EthereumWallet::from(self.signer.clone()); + NetworkTransactionBuilder::::build(tx, &wallet) + .await + .unwrap_or_else(|err| panic!("failed to sign the test transaction: {err}")) + .encoded_2718() + .into() + } + + /// Starts building a transaction of the network `N` that sets no fields yet. + /// + /// The shortcuts [`Self::call`], [`Self::transfer`] and [`Self::deploy`] build Ethereum + /// transactions; for other networks, use the setters of [`TestTx`], e.g. + /// `account.tx_for::().to(to).input(input)`. See [`Self::sign_request`] for the limits. + pub fn tx_for(&mut self) -> TestTx<'_, N> { + TestTx { account: self, request: Default::default() } + } +} + +/// A transaction of a [`TestAccount`] being built for the network `N`, see [`TestAccount::tx`] +/// and [`TestAccount::tx_for`]. +/// +/// Awaiting it signs the transaction with [`TestAccount::sign_request`], like [`Self::sign`], +/// which fills the fields that are not set from the account. Network-specific fields can be set +/// with [`Self::map_request`]. +#[derive(Debug)] +#[must_use = "the transaction is only signed when awaited"] +pub struct TestTx<'a, N: Network = Ethereum> { + account: &'a mut TestAccount, + request: N::TransactionRequest, +} + +impl TestTx<'_, N> { + /// Sets the recipient. + pub fn to(mut self, to: Address) -> Self { + self.request.set_to(to); + self + } + + /// Makes the transaction a contract creation, with the input as init code. + pub fn create(mut self) -> Self { + self.request.set_create(); + self + } + + /// Sets the value transferred with the transaction. + pub fn value(mut self, value: U256) -> Self { + self.request.set_value(value); + self + } + + /// Sets the input, i.e. the calldata of a call or the init code of a contract creation. + pub fn input(mut self, input: impl Into) -> Self { + self.request.set_input(input); + self + } + + /// Sets the gas limit instead of the gas limit of the account. + pub fn gas_limit(mut self, gas_limit: u64) -> Self { + self.request.set_gas_limit(gas_limit); + self + } + + /// Sets the EIP-1559 fees instead of the fees of the account. + pub fn fees(mut self, max_fee_per_gas: u128, max_priority_fee_per_gas: u128) -> Self { + self.request.set_max_fee_per_gas(max_fee_per_gas); + self.request.set_max_priority_fee_per_gas(max_priority_fee_per_gas); + self + } + + /// Sets a legacy gas price, which makes this a legacy transaction without the EIP-1559 fees of + /// the account. + pub fn gas_price(mut self, gas_price: u128) -> Self { + self.request.set_gas_price(gas_price); + self + } + + /// Sets an explicit nonce, which does not advance the tracked nonce of the account. + pub fn nonce(mut self, nonce: u64) -> Self { + self.request.set_nonce(nonce); + self + } + + /// Modifies the underlying transaction request, e.g. to set network-specific fields or fields + /// without a dedicated setter. + pub fn map_request( + mut self, + f: impl FnOnce(N::TransactionRequest) -> N::TransactionRequest, + ) -> Self { + self.request = f(self.request); + self + } + + /// Signs the transaction, returning the EIP-2718 encoded bytes. + /// + /// See [`TestAccount::sign_request`]. + pub async fn sign(self) -> Bytes + where + EthereumWallet: NetworkWallet, + { + self.account.sign_request::(self.request).await + } +} + +impl<'a, N: Network> IntoFuture for TestTx<'a, N> +where + EthereumWallet: NetworkWallet, +{ + type Output = Bytes; + type IntoFuture = BoxFuture<'a, Bytes>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.sign()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{transaction::SignerRecoverable, Transaction, TxEnvelope}; + use alloy_eips::eip2718::Decodable2718; + use alloy_network::AnyNetwork; + use alloy_primitives::TxKind; + + fn decode(raw: Bytes) -> TxEnvelope { + let tx = TxEnvelope::decode_2718(&mut raw.as_ref()).unwrap(); + assert_eq!(tx.recover_signer().unwrap(), Wallet::default().account(0).address()); + tx + } + + fn assert_send(_: T) {} + + /// Tests of downstream nodes sign transactions in spawned tasks, so the futures must be + /// `Send`. + #[expect(dead_code)] + fn test_sign_futures_are_send(account: &mut TestAccount) { + assert_send(account.sign_tx_bytes(TransactionRequest::default())); + assert_send(account.tx().sign()); + assert_send(account.tx().into_future()); + assert_send(account.sign_request::(Default::default())); + assert_send(account.tx_for::().into_future()); + } + + #[tokio::test] + async fn tx_builder_fills_account_defaults() { + let mut account = Wallet::default() + .with_chain_id(1337) + .account(0) + .with_gas_limit(300_000) + .with_fees(7, 3); + let to = Address::repeat_byte(0x11); + + let call = decode(account.call(to, [0xab, 0xcd]).await); + assert!(call.is_eip1559()); + assert_eq!(call.chain_id(), Some(1337)); + assert_eq!(call.nonce(), 0); + assert_eq!(call.kind(), TxKind::Call(to)); + assert_eq!(call.input().as_ref(), [0xab, 0xcd]); + assert_eq!(call.value(), U256::ZERO); + assert_eq!(call.gas_limit(), 300_000); + assert_eq!(call.max_fee_per_gas(), 7); + assert_eq!(call.max_priority_fee_per_gas(), Some(3)); + + let transfer = + decode(account.transfer(to, U256::from(5)).gas_limit(21_000).fees(9, 1).sign().await); + assert_eq!(transfer.nonce(), 1); + assert_eq!(transfer.value(), U256::from(5)); + assert_eq!(transfer.gas_limit(), 21_000); + assert_eq!(transfer.max_fee_per_gas(), 9); + assert_eq!(transfer.max_priority_fee_per_gas(), Some(1)); + + assert_eq!(account.next_contract_address(), account.address().create(2)); + let deploy = decode(account.deploy([0x00]).await); + assert_eq!(deploy.nonce(), 2); + assert_eq!(deploy.kind(), TxKind::Create); + assert_eq!(deploy.input().as_ref(), [0x00]); + + // An explicit nonce does not advance the tracked nonce. + let legacy = decode(account.transfer(to, U256::ZERO).gas_price(11).nonce(9).await); + assert!(legacy.is_legacy()); + assert_eq!(legacy.nonce(), 9); + assert_eq!(legacy.gas_price(), Some(11)); + assert_eq!(account.nonce(), 3); + + let mapped = decode(account.tx().map_request(|tx| tx.to(to).value(U256::ONE)).await); + assert_eq!(mapped.nonce(), 3); + assert_eq!(mapped.value(), U256::ONE); + } + + /// The builder works for any network whose transactions the account's wallet can sign. + #[tokio::test] + async fn tx_builder_is_network_generic() { + let mut account = Wallet::default().account(0); + let to = Address::repeat_byte(0x11); + + let ethereum = account.transfer(to, U256::ONE).input([0xab]).nonce(0).await; + let any = account.tx_for::().to(to).value(U256::ONE).input([0xab]).await; + assert_eq!(any, ethereum); + assert_eq!(account.nonce(), 1); + + let deploy = account.tx_for::().create().input([0x00]).fees(9, 1).sign().await; + let deploy = decode(deploy); + assert_eq!(deploy.kind(), TxKind::Create); + assert_eq!(deploy.nonce(), 1); + assert_eq!(deploy.max_fee_per_gas(), 9); } } diff --git a/crates/e2e-test-utils/tests/e2e-testsuite/main.rs b/crates/e2e-test-utils/tests/e2e-testsuite/main.rs index 2d1c625900a..aff88cda15b 100644 --- a/crates/e2e-test-utils/tests/e2e-testsuite/main.rs +++ b/crates/e2e-test-utils/tests/e2e-testsuite/main.rs @@ -1,22 +1,22 @@ //! Example tests using the test suite framework. use alloy_primitives::{Address, B256}; -use alloy_rpc_types_engine::{ForkchoiceState, PayloadAttributes, PayloadStatusEnum}; +use alloy_rpc_types_engine::PayloadAttributes; use eyre::Result; use reth_chainspec::EthereumHardfork; use reth_e2e_test_utils::{ - test_chain_spec, + eth_payload_attributes, test_chain_spec, test_rlp_utils::{generate_test_blocks, write_blocks_to_rlp}, testsuite::{ actions::{ - expect_fcu_valid, Action, AssertChainTip, AssertMineBlock, BlockReference, - CaptureBlock, CaptureBlockOnNode, CompareNodeChainTips, CreateFork, FinalizeBlock, - MakeCanonical, ProduceBlocks, ReorgTo, SelectActiveNode, UpdateBlockInfo, + Action, AssertChainTip, AssertMineBlock, BlockReference, CaptureBlock, + CaptureBlockOnNode, CompareNodeChainTips, CreateFork, FinalizeBlock, MakeCanonical, + ProduceBlocks, ReorgTo, SelectActiveNode, UpdateBlockInfo, }, setup::{NetworkSetup, Setup}, Environment, TestBuilder, }, - E2ETestSetupExt, + E2ETestSetupBuilder, E2ETestSetupExt, }; use reth_node_api::TreeConfig; use reth_node_ethereum::{EthEngineTypes, EthereumNode}; @@ -59,32 +59,6 @@ async fn test_apply_with_import() -> Result<()> { let mut make_canonical = MakeCanonical::new(); make_canonical.execute(&mut env).await?; - // Imported blocks are already readable from the database while the engine may still be - // syncing. Wait for the engine to accept the imported head before building on top of it. - let head = test_blocks.last().expect("imported blocks").hash(); - let engine = env.node_clients[0].engine.http_client(); - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - let response = - reth_rpc_api::clients::EngineApiClient::::fork_choice_updated_v3( - &engine, - ForkchoiceState { - head_block_hash: head, - safe_block_hash: head, - finalized_block_hash: B256::ZERO, - }, - None, - ) - .await?; - if response.payload_status.status != PayloadStatusEnum::Syncing { - return expect_fcu_valid(&response, "imported head"); - } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - }) - .await - .map_err(|_| eyre::eyre!("Timeout waiting for engine to accept imported head"))??; - // Update block info again after making canonical let mut update_block_info_2 = UpdateBlockInfo::default(); update_block_info_2.execute(&mut env).await?; @@ -318,3 +292,27 @@ async fn test_setup_builder_with_storage_v2() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_setup_builder_with_attributes_generator() -> Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = test_chain_spec(EthereumHardfork::Cancun); + let fee_recipient = Address::with_last_byte(0x42); + let attributes_chain_spec = chain_spec.clone(); + let (mut node, _) = E2ETestSetupBuilder::::new_with_attributes_generator( + 1, + chain_spec, + move |timestamp| PayloadAttributes { + suggested_fee_recipient: fee_recipient, + ..eth_payload_attributes(&attributes_chain_spec, timestamp) + }, + ) + .build_single() + .await?; + + let payload = node.advance_block().await?; + assert_eq!(payload.block().header().beneficiary, fee_recipient); + + Ok(()) +} diff --git a/crates/e2e-test-utils/tests/rocksdb/main.rs b/crates/e2e-test-utils/tests/rocksdb/main.rs index 15d5d00b131..4871f8074bb 100644 --- a/crates/e2e-test-utils/tests/rocksdb/main.rs +++ b/crates/e2e-test-utils/tests/rocksdb/main.rs @@ -1,35 +1,18 @@ //! E2E tests for `RocksDB` provider functionality. use alloy_consensus::BlockHeader; -use alloy_primitives::{Address, Bytes, TxKind, B256, U256}; -use alloy_rpc_types_eth::{Transaction, TransactionInput, TransactionReceipt, TransactionRequest}; -use eyre::{ensure, Result}; +use alloy_primitives::{Address, Bytes, B256, U256}; +use alloy_rpc_types_eth::{Transaction, TransactionReceipt}; +use eyre::Result; use jsonrpsee::core::client::ClientT; use reth_chainspec::EthereumHardfork; use reth_db::tables; -use reth_e2e_test_utils::{transaction::TransactionTestContext, wait::poll_until, E2ETestSetupExt}; +use reth_e2e_test_utils::{node::Finality, wait::poll_until, E2ETestSetupExt}; use reth_node_ethereum::EthereumNode; use reth_provider::RocksDBProviderFactory; +use reth_prune_types::PruneSegment; use std::time::Duration; -/// Polls RPC until the given `tx_hash` is visible as pending (not yet mined). -/// Prevents race conditions where `advance_block` is called before txs are in the pool. -/// Returns the pending transaction. -async fn wait_for_pending_tx(client: &C, tx_hash: B256) -> Result { - poll_until(format!("transaction {tx_hash} in the pending pool"), || async move { - let tx: Option = client.request("eth_getTransactionByHash", [tx_hash]).await?; - if let Some(tx) = &tx { - ensure!( - tx.block_number.is_none(), - "expected pending transaction {tx_hash}, but it is mined in block {:?}", - tx.block_number - ); - } - Ok(tx) - }) - .await -} - /// Polls `RocksDB` until the given `tx_hash` appears in `TransactionHashNumbers` and returns its /// `tx_number`. async fn poll_tx_in_rocksdb(provider: &P, tx_hash: B256) -> Result { @@ -52,7 +35,7 @@ async fn test_rocksdb_node_startup() -> Result<()> { // Verify RocksDB provider is functional (can query without error) let rocksdb = node.inner.provider.rocksdb_provider(); - let missing_hash = B256::from([0xab; 32]); + let missing_hash = B256::repeat_byte(0xab); let result: Option = rocksdb.get::(missing_hash)?; assert!(result.is_none(), "Missing hash should return None"); @@ -71,24 +54,18 @@ async fn test_rocksdb_block_mining() -> Result<()> { .with_storage_v2(true) .build_single() .await?; - let chain_id = wallet.chain_id; let genesis_hash = node.block_hash(0); assert_ne!(genesis_hash, B256::ZERO); // Mine 3 blocks with transactions - let signer = wallet.signer(0); + let mut account = wallet.account(0); let client = node.rpc_client().expect("RPC client should be available"); for i in 1..=3u64 { - let raw_tx = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), i - 1) - .await; + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; let tx_hash = node.rpc.inject_tx(raw_tx).await?; - // Wait for tx to enter pending pool before mining - wait_for_pending_tx(&client, tx_hash).await?; - let payload = node.advance_block().await?; let block = payload.block(); assert_eq!(block.number(), i); @@ -122,18 +99,13 @@ async fn test_rocksdb_transaction_queries() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; // Inject and mine a transaction - let signer = wallet.signer(0); let client = node.rpc_client().expect("RPC client should be available"); - let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, signer).await; + let raw_tx = wallet.account(0).transfer(Address::random(), U256::from(100)).await; let tx_hash = node.rpc.inject_tx(raw_tx).await?; - // Wait for tx to enter pending pool before mining - wait_for_pending_tx(&client, tx_hash).await?; - let payload = node.advance_block().await?; assert_eq!(payload.block().number(), 1); @@ -153,7 +125,7 @@ async fn test_rocksdb_transaction_queries() -> Result<()> { assert_eq!(tx_number, 0, "First tx should have TxNumber 0"); // Verify missing hash returns None - let missing_hash = B256::from([0xde; 32]); + let missing_hash = B256::repeat_byte(0xde); let rocksdb = node.inner.provider.rocksdb_provider(); let missing_tx_number: Option = rocksdb.get::(missing_hash)?; @@ -182,26 +154,18 @@ async fn test_rocksdb_multi_tx_same_block() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; // Create 3 txs from the same wallet with sequential nonces - let signer = wallet.signer(0); + let mut account = wallet.account(0); let client = node.rpc_client().expect("RPC client"); let mut tx_hashes = Vec::new(); - for nonce in 0..3 { - let raw_tx = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), nonce) - .await; + for _ in 0..3 { + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; let tx_hash = node.rpc.inject_tx(raw_tx).await?; tx_hashes.push(tx_hash); } - // Wait for all txs to appear in pending pool before mining - for tx_hash in &tx_hashes { - wait_for_pending_tx(&client, *tx_hash).await?; - } - // Mine one block containing all 3 txs let payload = node.advance_block().await?; assert_eq!(payload.block().number(), 1); @@ -245,41 +209,22 @@ async fn test_rocksdb_txs_across_blocks() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; - let signer = wallet.signer(0); + let mut account = wallet.account(0); let client = node.rpc_client().expect("RPC client"); // Block 1: 2 transactions - let tx_hash_0 = node - .rpc - .inject_tx( - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 0).await, - ) - .await?; - let tx_hash_1 = node - .rpc - .inject_tx( - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 1).await, - ) - .await?; - - // Wait for both txs to appear in pending pool - wait_for_pending_tx(&client, tx_hash_0).await?; - wait_for_pending_tx(&client, tx_hash_1).await?; + let tx_hash_0 = + node.rpc.inject_tx(account.transfer(Address::random(), U256::from(100)).await).await?; + let tx_hash_1 = + node.rpc.inject_tx(account.transfer(Address::random(), U256::from(100)).await).await?; let payload1 = node.advance_block().await?; assert_eq!(payload1.block().number(), 1); // Block 2: 1 transaction - let tx_hash_2 = node - .rpc - .inject_tx( - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 2).await, - ) - .await?; - - wait_for_pending_tx(&client, tx_hash_2).await?; + let tx_hash_2 = + node.rpc.inject_tx(account.transfer(Address::random(), U256::from(100)).await).await?; let payload2 = node.advance_block().await?; assert_eq!(payload2.block().number(), 2); @@ -325,18 +270,13 @@ async fn test_rocksdb_pending_tx_not_in_storage() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; - - let signer = wallet.signer(0); // Inject tx but do NOT mine - let raw_tx = TransactionTestContext::transfer_tx_bytes(chain_id, signer).await; + let raw_tx = wallet.account(0).transfer(Address::random(), U256::from(100)).await; let tx_hash = node.rpc.inject_tx(raw_tx).await?; - // Verify tx is in pending pool via RPC + // `inject_tx` returns after the pool accepted the tx, so it is immediately visible via RPC. let client = node.rpc_client().expect("RPC client"); - wait_for_pending_tx(&client, tx_hash).await?; - let pending_tx: Option = client.request("eth_getTransactionByHash", [tx_hash]).await?; assert!(pending_tx.is_some(), "Pending tx should be visible via RPC"); @@ -372,7 +312,8 @@ async fn test_rocksdb_pending_tx_not_in_storage() -> Result<()> { /// /// This test exercises `unwind_trie_state_from` which previously failed with /// `UnsortedInput` errors because it read changesets directly from MDBX tables -/// instead of using storage-aware methods that check `is_v2()`. +/// instead of using storage-aware methods that check `is_v2()`. It reorgs out the persisted +/// blocks 2 and 3, and checks that the unwind removed their transactions from `RocksDB`. #[tokio::test] async fn test_rocksdb_reorg_unwind() -> Result<()> { reth_tracing::init_test_tracing(); @@ -384,17 +325,16 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; + // Keep genesis finalized, so the mined blocks can be reorged. + node.set_finality(Finality::Keep); // Use two separate accounts to avoid nonce conflicts during reorg - let (signer1, signer2) = (wallet.signer(0), wallet.signer(1)); + let (mut account1, mut account2) = (wallet.account(0), wallet.account(1)); let client = node.rpc_client().expect("RPC client"); - // Mine block 1 with a transaction from signer1 - let raw_tx1 = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer1.clone(), 0).await; + // Mine block 1 with a transaction from account1 + let raw_tx1 = account1.transfer(Address::random(), U256::from(100)).await; let tx_hash1 = node.rpc.inject_tx(raw_tx1).await?; - wait_for_pending_tx(&client, tx_hash1).await?; let payload1 = node.advance_block().await?; let block1_hash = payload1.block().hash(); @@ -404,11 +344,9 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let tx_number1 = poll_tx_in_rocksdb(&node.inner.provider, tx_hash1).await?; assert_eq!(tx_number1, 0, "First tx should have tx_number 0"); - // Mine block 2 with transaction from signer1 (nonce 1) - let raw_tx2 = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer1.clone(), 1).await; + // Mine block 2 with transaction from account1 (nonce 1) + let raw_tx2 = account1.transfer(Address::random(), U256::from(100)).await; let tx_hash2 = node.rpc.inject_tx(raw_tx2).await?; - wait_for_pending_tx(&client, tx_hash2).await?; let payload2 = node.advance_block().await?; assert_eq!(payload2.block().number(), 2); @@ -417,11 +355,9 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let tx_number2 = poll_tx_in_rocksdb(&node.inner.provider, tx_hash2).await?; assert_eq!(tx_number2, 1, "Second tx should have tx_number 1"); - // Mine block 3 with transaction from signer1 (nonce 2) - let raw_tx3 = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer1.clone(), 2).await; + // Mine block 3 with transaction from account1 (nonce 2) + let raw_tx3 = account1.transfer(Address::random(), U256::from(100)).await; let tx_hash3 = node.rpc.inject_tx(raw_tx3).await?; - wait_for_pending_tx(&client, tx_hash3).await?; let payload3 = node.advance_block().await?; assert_eq!(payload3.block().number(), 3); @@ -430,52 +366,51 @@ async fn test_rocksdb_reorg_unwind() -> Result<()> { let tx_number3 = poll_tx_in_rocksdb(&node.inner.provider, tx_hash3).await?; assert_eq!(tx_number3, 2, "Third tx should have tx_number 2"); - // Now create an alternate block 2 using signer2 (different wallet, avoids nonce conflict) - // Inject a tx from signer2 (nonce 0) before building the alternate block - let raw_alt_tx = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer2.clone(), 0).await; - let alt_tx_hash = node.rpc.inject_tx(raw_alt_tx).await?; - wait_for_pending_tx(&client, alt_tx_hash).await?; + // The reorg must unwind blocks 2 and 3 from disk. Also wait for the pool to drop their + // transactions, so the alternate block can't include tx2. + node.wait_for_persisted_block(3).await?; + node.wait_for_pool_head(payload3.block().hash()).await?; - // Build an alternate payload (this builds on top of the current head, i.e., block 3) - // But we want to reorg back to block 1, so we'll use the payload and then FCU to it - let alt_payload = node.new_payload().await?; - let alt_block_hash = node.submit_payload(alt_payload.clone()).await?; - - // Trigger reorg: make the alternate chain canonical by sending FCU pointing to block 1's hash - // as finalized, which should trigger an unwind of blocks 2 and 3 - // The alt block becomes the new head - node.update_forkchoice(block1_hash, alt_block_hash).await?; - - // Give time for the reorg to complete - tokio::time::sleep(Duration::from_millis(500)).await; + // Reorg blocks 2 and 3 out with an alternate block 2 on block 1, which only includes a tx + // from account2 (nonce 0). + let raw_alt_tx = account2.transfer(Address::random(), U256::from(100)).await; + let alt_tx_hash = node.rpc.inject_tx(raw_alt_tx).await?; + let alt_payload = node.advance_block_on(block1_hash).await?; + let alt_block_hash = alt_payload.block().hash(); + assert_eq!(alt_payload.block().number(), 2); + assert_eq!( + alt_payload.block().body().transactions().map(|tx| *tx.hash()).collect::>(), + [alt_tx_hash] + ); + assert_eq!(node.block_hash(2), alt_block_hash); - // Verify we can still query transactions and the chain is consistent - // If unwind_trie_state_from failed, this would have errored during reorg - let latest: Option = - client.request("eth_getBlockByNumber", ("latest", false)).await?; - let latest = latest.expect("Latest block should exist"); - // The alt block is at height 4 (on top of block 3) - assert!(latest.header.number >= 3, "Should be at height >= 3 after operation"); + // The engine unwinds blocks 2 and 3 before it persists the alternate block 2, whose tx reuses + // tx number 1. + let alt_tx_number = poll_tx_in_rocksdb(&node.inner.provider, alt_tx_hash).await?; + assert_eq!(alt_tx_number, 1, "Alternate tx should reuse tx_number 1"); + let rocksdb = node.inner.provider.rocksdb_provider(); + assert_eq!(rocksdb.get::(tx_hash1)?, Some(0)); + for tx_hash in [tx_hash2, tx_hash3] { + assert_eq!(rocksdb.get::(tx_hash)?, None); + } // tx1 from block 1 should still be there let tx1: Option = client.request("eth_getTransactionByHash", [tx_hash1]).await?; assert!(tx1.is_some(), "tx1 from block 1 should still be queryable"); assert_eq!(tx1.unwrap().block_number, Some(1)); - // Mine another block to verify the chain can continue - let raw_tx_final = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer2.clone(), 1).await; + // Mine another block to verify the chain continues on the alternate block 2 + let raw_tx_final = account2.transfer(Address::random(), U256::from(100)).await; let tx_hash_final = node.rpc.inject_tx(raw_tx_final).await?; - wait_for_pending_tx(&client, tx_hash_final).await?; let final_payload = node.advance_block().await?; - assert!(final_payload.block().number() > 3, "Should be able to mine block after reorg"); + assert_eq!(final_payload.block().number(), 3); + assert_eq!(final_payload.block().parent_hash(), alt_block_hash); // Verify tx_final is included - let tx_final: Option = - client.request("eth_getTransactionByHash", [tx_hash_final]).await?; - assert!(tx_final.is_some(), "final tx should be in latest block"); + let receipt: Option = + client.request("eth_getTransactionReceipt", [tx_hash_final]).await?; + assert_eq!(receipt.expect("final tx should be mined").block_number, Some(3)); Ok(()) } @@ -499,10 +434,9 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; - let signer = wallet.signer(0); - let sender: Address = signer.address(); + let mut account = wallet.account(0); + let sender = account.address(); let client = node.rpc_client().expect("RPC client"); // Query the sender's balance and nonce at genesis (block 0) @@ -512,10 +446,8 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { assert_eq!(genesis_nonce, U256::ZERO, "Sender nonce should be 0 at genesis"); // Mine block 1 with a transfer (nonce 0) - let raw_tx1 = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 0).await; + let raw_tx1 = account.transfer(Address::random(), U256::from(100)).await; let tx_hash1 = node.rpc.inject_tx(raw_tx1).await?; - wait_for_pending_tx(&client, tx_hash1).await?; let payload1 = node.advance_block().await?; assert_eq!(payload1.block().number(), 1); @@ -525,13 +457,11 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { let balance_at_1: U256 = client.request("eth_getBalance", (sender, "0x1")).await?; let nonce_at_1: U256 = client.request("eth_getTransactionCount", (sender, "0x1")).await?; assert!(balance_at_1 < genesis_balance, "Balance should decrease after transfer + gas"); - assert_eq!(nonce_at_1, U256::from(1), "Nonce should be 1 after first tx"); + assert_eq!(nonce_at_1, U256::ONE, "Nonce should be 1 after first tx"); // Mine block 2 with another transfer (nonce 1) - let raw_tx2 = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 1).await; + let raw_tx2 = account.transfer(Address::random(), U256::from(100)).await; let tx_hash2 = node.rpc.inject_tx(raw_tx2).await?; - wait_for_pending_tx(&client, tx_hash2).await?; let payload2 = node.advance_block().await?; assert_eq!(payload2.block().number(), 2); @@ -543,10 +473,8 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { assert_eq!(nonce_at_2, U256::from(2), "Nonce should be 2 after second tx"); // Mine block 3 with a third transfer (nonce 2) - let raw_tx3 = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), 2).await; + let raw_tx3 = account.transfer(Address::random(), U256::from(100)).await; let tx_hash3 = node.rpc.inject_tx(raw_tx3).await?; - wait_for_pending_tx(&client, tx_hash3).await?; let payload3 = node.advance_block().await?; assert_eq!(payload3.block().number(), 3); @@ -563,12 +491,9 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { // more blocks ensures the engine loop has completed at least one full // persist-then-evict cycle covering blocks 1-3. // Each block needs a transaction because the payload builder requires non-empty payloads. - for nonce in 3..8u64 { - let raw_tx = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), nonce) - .await; - let tx_hash = node.rpc.inject_tx(raw_tx).await?; - wait_for_pending_tx(&client, tx_hash).await?; + for _ in 3..8 { + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; + node.rpc.inject_tx(raw_tx).await?; node.advance_block().await?; } // Allow the engine loop to process the persistence completions @@ -586,7 +511,7 @@ async fn test_rocksdb_historical_account_queries() -> Result<()> { let hist_balance_1: U256 = client.request("eth_getBalance", (sender, "0x1")).await?; let hist_nonce_1: U256 = client.request("eth_getTransactionCount", (sender, "0x1")).await?; assert_eq!(hist_balance_1, balance_at_1, "Historical balance at block 1 should match"); - assert_eq!(hist_nonce_1, U256::from(1), "Historical nonce at block 1 should be 1"); + assert_eq!(hist_nonce_1, U256::ONE, "Historical nonce at block 1 should be 1"); let hist_balance_2: U256 = client.request("eth_getBalance", (sender, "0x2")).await?; let hist_nonce_2: U256 = client.request("eth_getTransactionCount", (sender, "0x2")).await?; @@ -645,32 +570,32 @@ async fn test_rocksdb_account_history_pruning() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; - let signer = wallet.signer(0); - let sender: Address = signer.address(); - let client = node.rpc_client().expect("RPC client"); + let mut account = wallet.account(0); + let sender = account.address(); // Mine blocks one at a time with a delay so each save_blocks + pruner cycle // completes independently. The race fires every cycle (the pruner reads stale // committed state that doesn't include save_blocks' pending batch), but processing // one block at a time makes the outcome deterministic. - for nonce in 0..TOTAL_BLOCKS { - let raw_tx = - TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer.clone(), nonce) - .await; + for block_number in 1..=TOTAL_BLOCKS { + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; let tx_hash = node.rpc.inject_tx(raw_tx).await?; - wait_for_pending_tx(&client, tx_hash).await?; let payload = node.advance_block().await?; - assert_eq!(payload.block().number(), nonce + 1); + assert_eq!(payload.block().number(), block_number); - // Let the persistence cycle (save_blocks → pruner → commit) complete before - // producing the next block, so each cycle processes exactly one block. The - // transaction becomes visible in RocksDB with the commit. + // Wait for the block's save to commit before producing the next block, so each save + // processes exactly one block. The transaction becomes visible in RocksDB with the + // commit, and the persistence service runs the pruner before it handles the next save. poll_tx_in_rocksdb(&node.inner.provider, tx_hash).await?; } + // The pruner runs after the save of the last block is acknowledged, so wait for it to prune + // the blocks outside the retention window. + node.wait_for_prune_checkpoint(PruneSegment::AccountHistory, TOTAL_BLOCKS - PRUNE_DISTANCE) + .await?; + // Read the AccountsHistory shard for `sender` directly from RocksDB. // This is the data structure corrupted by the race. let rocksdb = node.inner.provider.rocksdb_provider(); @@ -731,9 +656,8 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { }) .build_single() .await?; - let chain_id = wallet.chain_id; - let signer = wallet.signer(0); + let mut account = wallet.account(0); let client = node.rpc_client().expect("RPC client"); // Deploy a minimal contract that stores CALLDATA[0..32] into slot 0: @@ -774,34 +698,15 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { ]); // Deploy in block 1 (nonce 0) - let deploy_tx = TransactionRequest { - nonce: Some(0), - value: Some(U256::ZERO), - to: Some(TxKind::Create), - gas: Some(100_000), - max_fee_per_gas: Some(1000e9 as u128), - max_priority_fee_per_gas: Some(20e9 as u128), - chain_id: Some(chain_id), - input: TransactionInput { input: None, data: Some(init_code) }, - ..Default::default() - }; - let deploy_bytes = TransactionTestContext::sign_tx_bytes(signer.clone(), deploy_tx).await; - let deploy_hash = node.rpc.inject_tx(deploy_bytes).await?; - wait_for_pending_tx(&client, deploy_hash).await?; + let contract_address = account.next_contract_address(); + let deploy_hash = + node.rpc.inject_tx(account.deploy(init_code).gas_limit(100_000).await).await?; let payload1 = node.advance_block().await?; assert_eq!(payload1.block().number(), 1); // Let the persistence cycle complete before the next block (same cadence as the loop below) poll_tx_in_rocksdb(&node.inner.provider, deploy_hash).await?; - // Get the deployed contract address from the receipt - let receipt: Option = - client.request("eth_getTransactionReceipt", [deploy_hash]).await?; - let contract_address = receipt - .expect("deploy receipt should exist") - .contract_address - .expect("deploy should create a contract"); - // Sanity check: verify the runtime bytecode is what we expect let code: Bytes = client.request("eth_getCode", (contract_address, "latest")).await?; assert_eq!( @@ -816,25 +721,12 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { // Mine TOTAL_BLOCKS - 1 more blocks (block 2..=TOTAL_BLOCKS), each calling the // contract to write a new value to slot 0. This creates a storage changeset entry // per block for (contract_address, slot 0). - for nonce in 1..TOTAL_BLOCKS { + for block_num in 2..=TOTAL_BLOCKS { // calldata = abi encode the block number so each write is unique - let block_num = nonce + 1; let calldata = B256::from(U256::from(block_num)); - let call_tx = TransactionRequest { - nonce: Some(nonce), - value: Some(U256::ZERO), - to: Some(TxKind::Call(contract_address)), - gas: Some(50_000), - max_fee_per_gas: Some(1000e9 as u128), - max_priority_fee_per_gas: Some(20e9 as u128), - chain_id: Some(chain_id), - input: TransactionInput { input: None, data: Some(Bytes::from(calldata.0)) }, - ..Default::default() - }; - let call_bytes = TransactionTestContext::sign_tx_bytes(signer.clone(), call_tx).await; + let call_bytes = account.call(contract_address, calldata).gas_limit(50_000).await; let tx_hash = node.rpc.inject_tx(call_bytes).await?; - wait_for_pending_tx(&client, tx_hash).await?; let payload = node.advance_block().await?; assert_eq!(payload.block().number(), block_num); @@ -843,6 +735,11 @@ async fn test_rocksdb_storage_history_pruning() -> Result<()> { poll_tx_in_rocksdb(&node.inner.provider, tx_hash).await?; } + // The pruner runs after the save of the last block is acknowledged, so wait for it to prune + // the blocks outside the retention window. + node.wait_for_prune_checkpoint(PruneSegment::StorageHistory, TOTAL_BLOCKS - PRUNE_DISTANCE) + .await?; + // Read StoragesHistory shard for (contract_address, slot 0) directly from RocksDB let rocksdb = node.inner.provider.rocksdb_provider(); let shards = rocksdb.storage_history_shards(contract_address, storage_slot).unwrap(); diff --git a/crates/engine/execution-cache/Cargo.toml b/crates/engine/execution-cache/Cargo.toml index f6e482d8a64..228ad2483e3 100644 --- a/crates/engine/execution-cache/Cargo.toml +++ b/crates/engine/execution-cache/Cargo.toml @@ -15,16 +15,19 @@ workspace = true alloy-primitives.workspace = true fixed-cache = { workspace = true, features = ["stats"] } metrics.workspace = true +moka = { workspace = true, features = ["sync"] } parking_lot.workspace = true reth-errors.workspace = true +reth-evm.workspace = true reth-metrics = { workspace = true, features = ["common"] } -reth-primitives-traits = { workspace = true, features = ["std"] } +reth-primitives-traits = { workspace = true, features = ["std", "dashmap"] } reth-revm.workspace = true reth-storage-api.workspace = true tracing.workspace = true [dev-dependencies] alloy-primitives = { workspace = true, features = ["rand"] } +metrics-util = { workspace = true, features = ["debugging"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-revm = { workspace = true, features = ["test-utils"] } revm.workspace = true @@ -34,4 +37,5 @@ test-utils = [ "reth-primitives-traits/test-utils", "reth-revm/test-utils", "reth-provider/test-utils", + "reth-evm/test-utils", ] diff --git a/crates/engine/execution-cache/src/cached_state.rs b/crates/engine/execution-cache/src/cached_state.rs index 1783bd07f15..c082ca32583 100644 --- a/crates/engine/execution-cache/src/cached_state.rs +++ b/crates/engine/execution-cache/src/cached_state.rs @@ -1340,7 +1340,7 @@ mod tests { fn test_uncached_storage_cached_state_provider() { let address = Address::random(); let storage_key = StorageKey::random(); - let storage_value = U256::from(1); + let storage_value = U256::ONE; let account = ExtendedAccount::new(0, U256::ZERO).extend_storage(vec![(storage_key, storage_value)]); @@ -1363,7 +1363,7 @@ mod tests { fn test_get_storage_populated() { let address = Address::random(); let storage_key = StorageKey::random(); - let storage_value = U256::from(1); + let storage_value = U256::ONE; let caches = ExecutionCache::new(1000); caches.insert_storage(address, storage_key, Some(storage_value)); @@ -1401,7 +1401,7 @@ mod tests { #[test] fn test_saved_cache_multiple_references() { let execution_cache = ExecutionCache::new(1000); - let cache = SavedCache::new(B256::from([2u8; 32]), execution_cache); + let cache = SavedCache::new(B256::repeat_byte(2u8), execution_cache); let cache1 = cache.clone_guard_for_test(); let cache2 = cache.clone_guard_for_test(); diff --git a/crates/engine/execution-cache/src/lib.rs b/crates/engine/execution-cache/src/lib.rs index a419b9e3167..79d0b3b9549 100644 --- a/crates/engine/execution-cache/src/lib.rs +++ b/crates/engine/execution-cache/src/lib.rs @@ -5,6 +5,7 @@ //! - [`SavedCache`]: An execution cache snapshot associated with a specific block hash //! - [`PayloadExecutionCache`]: Thread-safe wrapper for sharing cached state across payload //! processing tasks +//! - [`precompile_cache`]: Cross-block cache of precompile results #![doc( html_logo_url = "https://raw.githubusercontent.com/paradigmxyz/reth/main/assets/reth-docs.png", @@ -20,6 +21,8 @@ pub use cached_state::*; mod txpool; pub use txpool::*; +pub mod precompile_cache; + use alloy_primitives::B256; use metrics::{Counter, Histogram}; use parking_lot::Mutex; @@ -171,7 +174,7 @@ mod tests { #[test] fn single_checkout_blocks_second() { let cache = PayloadExecutionCache::default(); - let hash = B256::from([1u8; 32]); + let hash = B256::repeat_byte(1u8); cache.update_with_guard(|slot| { *slot = Some(SavedCache::new(hash, ExecutionCache::new(1_000))) @@ -187,7 +190,7 @@ mod tests { #[test] fn checkout_available_after_drop() { let cache = PayloadExecutionCache::default(); - let hash = B256::from([2u8; 32]); + let hash = B256::repeat_byte(2u8); cache.update_with_guard(|slot| { *slot = Some(SavedCache::new(hash, ExecutionCache::new(1_000))) @@ -204,7 +207,7 @@ mod tests { #[test] fn raw_cache_handle_blocks_checkout_until_drop() { let cache = PayloadExecutionCache::default(); - let hash = B256::from([3u8; 32]); + let hash = B256::repeat_byte(3u8); cache.update_with_guard(|slot| { *slot = Some(SavedCache::new(hash, ExecutionCache::new(1_000))) @@ -226,8 +229,8 @@ mod tests { #[test] fn hash_mismatch_clears_and_retags() { let cache = PayloadExecutionCache::default(); - let hash_a = B256::from([0xAA; 32]); - let hash_b = B256::from([0xBB; 32]); + let hash_a = B256::repeat_byte(0xAA); + let hash_b = B256::repeat_byte(0xBB); cache.update_with_guard(|slot| { *slot = Some(SavedCache::new(hash_a, ExecutionCache::new(1_000))) diff --git a/crates/engine/tree/src/tree/precompile_cache.rs b/crates/engine/execution-cache/src/precompile_cache.rs similarity index 97% rename from crates/engine/tree/src/tree/precompile_cache.rs rename to crates/engine/execution-cache/src/precompile_cache.rs index e4b570043ab..57f6c5b5a81 100644 --- a/crates/engine/tree/src/tree/precompile_cache.rs +++ b/crates/engine/execution-cache/src/precompile_cache.rs @@ -7,7 +7,7 @@ use alloy_primitives::{ use moka::policy::EvictionPolicy; use reth_evm::precompiles::{DynPrecompile, Precompile, PrecompileInput}; use reth_primitives_traits::dashmap::DashMap; -use revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult}; +use reth_revm::precompile::{PrecompileId, PrecompileOutput, PrecompileResult}; use std::{hash::Hash, sync::Arc}; use tracing::error; @@ -291,7 +291,7 @@ mod tests { state_gas_spilled: 0, reservoir: 0, gas_refunded: 0, - bytes: alloy_primitives::Bytes::copy_from_slice(b"cached_result"), + bytes: Bytes::from_static(b"cached_result"), }; let input = b"test_input"; @@ -326,7 +326,7 @@ mod tests { state_gas_spilled: 0, reservoir: 0, gas_refunded: 0, - bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_1"), + bytes: Bytes::from_static(b"output_from_precompile_1"), }) } }) @@ -344,7 +344,7 @@ mod tests { state_gas_spilled: 0, reservoir: 0, gas_refunded: 0, - bytes: alloy_primitives::Bytes::copy_from_slice(b"output_from_precompile_2"), + bytes: Bytes::from_static(b"output_from_precompile_2"), }) } }) diff --git a/crates/engine/execution-cache/src/txpool.rs b/crates/engine/execution-cache/src/txpool.rs index 66eed02eeb2..0faab603b84 100644 --- a/crates/engine/execution-cache/src/txpool.rs +++ b/crates/engine/execution-cache/src/txpool.rs @@ -73,7 +73,7 @@ mod tests { let mut reads = CachedReads::default(); let mut storage = U256Map::default(); - storage.insert(U256::from(1), U256::from(7)); + storage.insert(U256::ONE, U256::from(7)); storage.insert(U256::from(2), U256::ZERO); reads.insert_account(owner, AccountInfo { nonce: 3, ..Default::default() }, storage); reads.accounts.insert(missing, CachedAccount { info: None, storage: Default::default() }); diff --git a/crates/engine/invalid-block-hooks/src/witness.rs b/crates/engine/invalid-block-hooks/src/witness.rs index 811d8543cb0..472acb34182 100644 --- a/crates/engine/invalid-block-hooks/src/witness.rs +++ b/crates/engine/invalid-block-hooks/src/witness.rs @@ -128,8 +128,7 @@ fn collect_execution_data( // Collect codes db.cache.contracts.values().chain(bundle_state.contracts.values()).for_each(|code| { - let code_bytes = code.original_bytes(); - codes.insert(keccak256(&code_bytes), code_bytes); + codes.insert(code.hash_slow(), code.original_bytes()); }); // Collect preimages @@ -206,7 +205,7 @@ impl InvalidBlockWitnessHook { impl InvalidBlockWitnessHook where - P: StateProviderFactory + Send + Sync + 'static, + P: StateProviderFactory + 'static, E: ConfigureEvm + 'static, N: NodePrimitives, { @@ -328,11 +327,11 @@ where if &trie_output != original_updates { let original_path = self.save_file( format!("{}.trie_updates.original.json", block_prefix), - &original_updates.into_sorted_ref(), + &original_updates.to_sorted_ref(), )?; let re_executed_path = self.save_file( format!("{}.trie_updates.re_executed.json", block_prefix), - &trie_output.into_sorted_ref(), + &trie_output.to_sorted_ref(), )?; warn!( target: "engine::invalid_block_hooks::witness", @@ -396,7 +395,7 @@ where impl InvalidBlockHook for InvalidBlockWitnessHook where - P: StateProviderFactory + Send + Sync + 'static, + P: StateProviderFactory + 'static, E: ConfigureEvm + 'static, { fn on_invalid_block( @@ -682,12 +681,12 @@ mod tests { // Mock Data let mut codes = BTreeMap::new(); - codes.insert(B256::from([1u8; 32]), Bytes::from("contract_code_1")); - codes.insert(B256::from([2u8; 32]), Bytes::from("contract_code_2")); + codes.insert(B256::repeat_byte(1u8), Bytes::from("contract_code_1")); + codes.insert(B256::repeat_byte(2u8), Bytes::from("contract_code_2")); let mut preimages = BTreeMap::new(); - preimages.insert(B256::from([3u8; 32]), Bytes::from("preimage_1")); - preimages.insert(B256::from([4u8; 32]), Bytes::from("preimage_2")); + preimages.insert(B256::repeat_byte(3u8), Bytes::from("preimage_1")); + preimages.insert(B256::repeat_byte(4u8), Bytes::from("preimage_2")); let hashed_state = reth_trie::HashedPostState::default(); @@ -738,7 +737,7 @@ mod tests { let mut modified_state = create_bundle_state(); // Modify the state to create a mismatch - let addr = Address::from([1u8; 20]); + let addr = Address::repeat_byte(1u8); if let Some(account) = modified_state.state.get_mut(&addr) && let Some(ref mut info) = account.info { @@ -771,11 +770,11 @@ mod tests { let mut account_nodes = HashMap::default(); let nibbles = Nibbles::from_nibbles_unchecked([0x1, 0x2, 0x3]); let branch_node = BranchNodeCompact::new( - 0b1010, // state_mask - 0b1010, // tree_mask - must be subset of state_mask - 0b1000, // hash_mask - vec![B256::from([1u8; 32])], // hashes - None, // root_hash + 0b1010, // state_mask + 0b1010, // tree_mask - must be subset of state_mask + 0b1000, // hash_mask + vec![B256::repeat_byte(1u8)], // hashes + None, // root_hash ); account_nodes.insert(nibbles, branch_node); @@ -806,7 +805,7 @@ mod tests { .unwrap(); let trie_updates = create_test_trie_updates(); - let original_root = B256::from([2u8; 32]); // Different from what will be computed + let original_root = B256::repeat_byte(2u8); // Different from what will be computed let block_prefix = "test_state_root_with_trie"; // Test with trie updates - this will likely produce warnings due to mock data diff --git a/crates/engine/primitives/Cargo.toml b/crates/engine/primitives/Cargo.toml index c1a55fed607..e2bbd083af3 100644 --- a/crates/engine/primitives/Cargo.toml +++ b/crates/engine/primitives/Cargo.toml @@ -38,6 +38,7 @@ futures = { workspace = true, optional = true } auto_impl.workspace = true serde.workspace = true thiserror.workspace = true +tracing = { workspace = true, optional = true } [dev-dependencies] alloy-primitives = { workspace = true, features = ["getrandom"] } @@ -63,6 +64,7 @@ std = [ "tokio", "serde/std", "thiserror/std", + "tracing/std", "reth-evm?/std", "alloy-rlp/std", ] diff --git a/crates/engine/primitives/src/config.rs b/crates/engine/primitives/src/config.rs index a1f1b5324d6..6a20055b73c 100644 --- a/crates/engine/primitives/src/config.rs +++ b/crates/engine/primitives/src/config.rs @@ -228,6 +228,8 @@ pub struct TreeConfig { /// When set, BAL storage slots are not read into the execution cache. BAL hashed-state /// streaming for parallel state-root computation is controlled separately. disable_bal_batch_io: bool, + /// Whether block execution reuses transaction prewarming results. + prewarm_reuse: bool, /// Whether to skip trie state-root computation during engine validation. /// /// This trusts the block header's state root. It is intended for experiments that measure @@ -285,6 +287,7 @@ impl Default for TreeConfig { disable_bal_parallel_execution: false, disable_bal_parallel_state_root: false, disable_bal_batch_io: false, + prewarm_reuse: false, skip_state_root: false, #[cfg(feature = "trie-debug")] proof_jitter: None, @@ -296,6 +299,10 @@ impl TreeConfig { /// Create engine tree configuration. /// /// The backfill run threshold is clamped to [`MAX_BACKFILL_RUN_THRESHOLD`]. + /// + /// # Panics + /// + /// Panics if `multiproof_chunk_size` is zero or the persistence thresholds are inconsistent. #[expect(clippy::too_many_arguments)] pub const fn new( persistence_threshold: u64, @@ -326,6 +333,7 @@ impl TreeConfig { share_execution_cache_with_payload_builder: bool, share_sparse_trie_with_payload_builder: bool, ) -> Self { + assert!(multiproof_chunk_size > 0, "multiproof chunk size must be non-zero"); let num_state_masking_blocks = if persistence_threshold == 0 { 0 } else { num_state_masking_blocks }; assert_backpressure_threshold_invariant( @@ -371,6 +379,7 @@ impl TreeConfig { disable_bal_parallel_execution: false, disable_bal_parallel_state_root: false, disable_bal_batch_io: false, + prewarm_reuse: false, skip_state_root: false, #[cfg(feature = "trie-debug")] proof_jitter: None, @@ -667,7 +676,12 @@ impl TreeConfig { } /// Setter for multiproof task chunk size for proof targets. + /// + /// # Panics + /// + /// Panics if `multiproof_chunk_size` is zero. pub const fn with_multiproof_chunk_size(mut self, multiproof_chunk_size: usize) -> Self { + assert!(multiproof_chunk_size > 0, "multiproof chunk size must be non-zero"); self.multiproof_chunk_size = multiproof_chunk_size; self } @@ -837,6 +851,17 @@ impl TreeConfig { self } + /// Returns whether block execution reuses transaction prewarming results. + pub const fn prewarm_reuse(&self) -> bool { + self.prewarm_reuse + } + + /// Setter for whether block execution reuses transaction prewarming results. + pub const fn with_prewarm_reuse(mut self, prewarm_reuse: bool) -> Self { + self.prewarm_reuse = prewarm_reuse; + self + } + /// Returns whether trie state-root computation is skipped during engine validation. pub const fn skip_state_root(&self) -> bool { self.skip_state_root @@ -889,26 +914,6 @@ mod tests { assert_eq!(config.block_buffer_limit(), 2000); } - #[test] - fn txpool_prewarming_is_disabled_by_default_and_can_be_enabled() { - assert!(!TreeConfig::default().txpool_prewarming()); - assert!(TreeConfig::default().with_txpool_prewarming(true).txpool_prewarming()); - } - - #[test] - fn state_root_task_requires_parallelism_without_overrides() { - assert!(TreeConfig::default().with_has_enough_parallelism(true).use_state_root_task()); - assert!(!TreeConfig::default().with_has_enough_parallelism(false).use_state_root_task()); - assert!(!TreeConfig::default() - .with_has_enough_parallelism(true) - .with_state_root_fallback(true) - .use_state_root_task()); - assert!(!TreeConfig::default() - .with_has_enough_parallelism(true) - .with_skip_state_root(true) - .use_state_root_task()); - } - #[test] #[should_panic( expected = "persistence_backpressure_threshold must be greater than persistence_threshold" @@ -920,14 +925,6 @@ mod tests { .with_persistence_backpressure_threshold(4); } - #[test] - fn default_persistence_settings() { - let config = TreeConfig::default(); - assert_eq!(config.persistence_threshold(), 50); - assert_eq!(config.num_state_masking_blocks(), 30); - assert_eq!(config.persistence_backpressure_threshold(), 100); - } - #[test] fn zero_persistence_threshold_disables_state_masking() { let config = TreeConfig::default().with_persistence_threshold(0); diff --git a/crates/engine/primitives/src/error.rs b/crates/engine/primitives/src/error.rs index 0062d47c751..692d7e225e0 100644 --- a/crates/engine/primitives/src/error.rs +++ b/crates/engine/primitives/src/error.rs @@ -2,6 +2,7 @@ use alloc::boxed::Box; use alloy_rpc_types_engine::ForkchoiceUpdateError; use reth_errors::{BlockExecutionError, BlockValidationError, ConsensusError, ProviderError}; use reth_execution_errors::InternalBlockExecutionError; +use reth_payload_primitives::PayloadBuilderError; /// Represents all error cases when handling a new payload. /// @@ -57,6 +58,10 @@ pub enum BeaconForkChoiceUpdateError { /// Thrown when the engine task is unavailable/stopped. #[error("beacon consensus engine task stopped")] EngineUnavailable, + /// Thrown when the forkchoice update was applied, but the payload job for the provided payload + /// attributes could not be started. + #[error("failed to start payload job: {0}")] + PayloadJob(#[source] PayloadBuilderError), /// An internal error occurred, not necessarily related to the update. #[error(transparent)] Internal(Box), diff --git a/crates/engine/primitives/src/forkchoice.rs b/crates/engine/primitives/src/forkchoice.rs index 533bbeb41bb..d8106cf38b1 100644 --- a/crates/engine/primitives/src/forkchoice.rs +++ b/crates/engine/primitives/src/forkchoice.rs @@ -105,9 +105,7 @@ impl ForkchoiceStateTracker { /// - Or the finalized hash for the latest valid forkchoice state is zero. #[inline] pub fn last_valid_finalized(&self) -> Option { - self.last_valid - .filter(|state| !state.finalized_block_hash.is_zero()) - .map(|state| state.finalized_block_hash) + self.last_valid.and_then(|state| state.state_finalized_hash()) } /// Returns the last received `ForkchoiceState` to which we need to sync. @@ -122,9 +120,7 @@ impl ForkchoiceStateTracker { /// - Or the finalized hash for the sync target forkchoice state is zero. #[inline] pub fn sync_target_finalized(&self) -> Option { - self.last_syncing - .filter(|state| !state.finalized_block_hash.is_zero()) - .map(|state| state.finalized_block_hash) + self.last_syncing.and_then(|state| state.state_finalized_hash()) } /// Returns true if no forkchoice state has been received yet. diff --git a/crates/engine/primitives/src/message.rs b/crates/engine/primitives/src/message.rs index 951f3196421..73fd6a16038 100644 --- a/crates/engine/primitives/src/message.rs +++ b/crates/engine/primitives/src/message.rs @@ -4,8 +4,8 @@ use crate::{ use alloy_eips::eip4895::Withdrawal; use alloy_primitives::{Bytes, B256}; use alloy_rpc_types_engine::{ - ExecutionData, ForkChoiceUpdateResult, ForkchoiceState, ForkchoiceUpdateError, - ForkchoiceUpdated, PayloadId, PayloadStatus, PayloadStatusEnum, + ExecutionData, ForkchoiceState, ForkchoiceUpdateError, ForkchoiceUpdated, PayloadId, + PayloadStatus, PayloadStatusEnum, }; use core::{ fmt::{self, Display}, @@ -19,14 +19,18 @@ use reth_payload_builder_primitives::PayloadBuilderError; use reth_payload_primitives::PayloadTypes; use std::time::{Duration, Instant}; use tokio::sync::{mpsc::UnboundedSender, oneshot}; +use tracing::Span; /// Type alias for backwards compat #[deprecated(note = "Use ConsensusEngineHandle instead")] pub type BeaconConsensusEngineHandle = ConsensusEngineHandle; +/// The result of a forkchoice update, or the error to return to the consensus client. +pub type OnForkChoiceUpdatedResult = Result; + /// Represents the outcome of forkchoice update. /// -/// This is a future that resolves to [`ForkChoiceUpdateResult`] +/// This is a future that resolves to [`OnForkChoiceUpdatedResult`] #[must_use = "futures do nothing unless you `.await` or poll them"] #[derive(Debug)] pub struct OnForkChoiceUpdated { @@ -36,7 +40,7 @@ pub struct OnForkChoiceUpdated { /// depending on the payload attributes, even if the forkchoice update itself is valid. forkchoice_status: ForkchoiceStatus, /// Returns the result of the forkchoice update. - fut: Either, PendingPayloadId>, + fut: Either, PendingPayloadId>, } // === impl OnForkChoiceUpdated === @@ -79,7 +83,9 @@ impl OnForkChoiceUpdated { pub fn invalid_state() -> Self { Self { forkchoice_status: ForkchoiceStatus::Invalid, - fut: Either::Left(futures::future::ready(Err(ForkchoiceUpdateError::InvalidState))), + fut: Either::Left(futures::future::ready(Err( + ForkchoiceUpdateError::InvalidState.into() + ))), } } @@ -88,7 +94,9 @@ impl OnForkChoiceUpdated { pub fn too_deep_reorg() -> Self { Self { forkchoice_status: ForkchoiceStatus::Invalid, - fut: Either::Left(futures::future::ready(Err(ForkchoiceUpdateError::TooDeepReorg))), + fut: Either::Left(futures::future::ready(Err( + ForkchoiceUpdateError::TooDeepReorg.into() + ))), } } @@ -99,12 +107,14 @@ impl OnForkChoiceUpdated { // This is valid because this is only reachable if the state and payload is valid forkchoice_status: ForkchoiceStatus::Valid, fut: Either::Left(futures::future::ready(Err( - ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes, + ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes.into(), ))), } } - /// If the forkchoice update was successful and no payload attributes were provided, this method + /// Creates a new instance of `OnForkChoiceUpdated` that resolves to the id of the payload job + /// started for the payload attributes, or to [`BeaconForkChoiceUpdateError::PayloadJob`] if the + /// job cannot be started. pub const fn updated_with_pending_payload_id( payload_status: PayloadStatus, pending_payload_id: oneshot::Receiver>, @@ -120,7 +130,7 @@ impl OnForkChoiceUpdated { } impl Future for OnForkChoiceUpdated { - type Output = ForkChoiceUpdateResult; + type Output = OnForkChoiceUpdatedResult; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { self.get_mut().fut.poll_unpin(cx) @@ -136,21 +146,17 @@ struct PendingPayloadId { } impl Future for PendingPayloadId { - type Output = ForkChoiceUpdateResult; + type Output = OnForkChoiceUpdatedResult; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); - let res = ready!(this.pending_payload_id.poll_unpin(cx)); - match res { - Ok(Ok(payload_id)) => Poll::Ready(Ok(ForkchoiceUpdated { - payload_status: this.payload_status.take().expect("Polled after completion"), - payload_id: Some(payload_id), - })), - Err(_) | Ok(Err(_)) => { - // failed to initiate a payload build job - Poll::Ready(Err(ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes)) - } - } + let payload_id = ready!(this.pending_payload_id.poll_unpin(cx)) + .unwrap_or_else(|err| Err(err.into())) + .map_err(BeaconForkChoiceUpdateError::PayloadJob)?; + Poll::Ready(Ok(ForkchoiceUpdated { + payload_status: this.payload_status.take().expect("Polled after completion"), + payload_id: Some(payload_id), + })) } } @@ -244,10 +250,16 @@ impl ExecutionPayload for BigBlockData { /// A message for the beacon engine from other components of the node (engine RPC API invoked by the /// consensus layer). +/// +/// The `cause` span carries in-process tracing context across the engine queue. Callers +/// constructing messages directly should capture [`Span::current`], or use [`Span::none`] when no +/// context exists. #[derive(Debug)] pub enum BeaconEngineMessage { /// Message with new payload. NewPayload { + /// The caller span that caused this message. + cause: Span, /// The execution payload received by Engine API. payload: Payload::ExecutionData, /// The sender for returning payload status result. @@ -260,6 +272,8 @@ pub enum BeaconEngineMessage { /// /// Returns detailed timing breakdown alongside the payload status. RethNewPayload { + /// The caller span that caused this message. + cause: Span, /// The execution payload received by Engine API. payload: Payload::ExecutionData, /// Whether to wait for in-flight persistence to complete before processing. @@ -273,6 +287,8 @@ pub enum BeaconEngineMessage { }, /// Message with updated forkchoice state. ForkchoiceUpdated { + /// The caller span that caused this message. + cause: Span, /// The updated forkchoice state. state: ForkchoiceState, /// The payload attributes for block building. @@ -344,7 +360,11 @@ where payload: Payload::ExecutionData, ) -> Result { let (tx, rx) = oneshot::channel(); - let _ = self.to_engine.send(BeaconEngineMessage::NewPayload { payload, tx }); + let _ = self.to_engine.send(BeaconEngineMessage::NewPayload { + cause: Span::current(), + payload, + tx, + }); rx.await.map_err(|_| BeaconOnNewPayloadError::EngineUnavailable)? } @@ -363,6 +383,7 @@ where ) -> Result<(PayloadStatus, NewPayloadTimings), BeaconOnNewPayloadError> { let (tx, rx) = oneshot::channel(); let _ = self.to_engine.send(BeaconEngineMessage::RethNewPayload { + cause: Span::current(), payload, wait_for_persistence, wait_for_caches, @@ -380,12 +401,11 @@ where state: ForkchoiceState, payload_attrs: Option, ) -> Result { - Ok(self - .send_fork_choice_updated(state, payload_attrs) + self.send_fork_choice_updated(state, payload_attrs) .map_err(|_| BeaconForkChoiceUpdateError::EngineUnavailable) .await? .map_err(BeaconForkChoiceUpdateError::internal)? - .await?) + .await } /// Sends a forkchoice update message to the beacon consensus engine and returns the receiver to @@ -397,6 +417,7 @@ where ) -> oneshot::Receiver> { let (tx, rx) = oneshot::channel(); let _ = self.to_engine.send(BeaconEngineMessage::ForkchoiceUpdated { + cause: Span::current(), state, payload_attrs, tx, @@ -404,3 +425,45 @@ where rx } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Resolves the forkchoice update for a payload job that answers with `result`, or whose sender + /// is dropped if `result` is `None`. + fn on_payload_job( + result: Option>, + ) -> OnForkChoiceUpdatedResult { + let (tx, rx) = oneshot::channel(); + match result { + Some(result) => tx.send(result).unwrap(), + None => drop(tx), + } + OnForkChoiceUpdated::updated_with_pending_payload_id( + PayloadStatus::from_status(PayloadStatusEnum::Valid), + rx, + ) + .now_or_never() + .expect("payload job result is ready") + } + + #[test] + fn payload_job_start_errors_are_returned() { + let id = PayloadId::new([1; 8]); + assert_eq!(on_payload_job(Some(Ok(id))).unwrap().payload_id, Some(id)); + + let err = on_payload_job(Some(Err(PayloadBuilderError::MissingParentHeader(B256::ZERO)))) + .unwrap_err(); + assert!(matches!( + err, + BeaconForkChoiceUpdateError::PayloadJob(PayloadBuilderError::MissingParentHeader(_)) + )); + + let err = on_payload_job(None).unwrap_err(); + assert!(matches!( + err, + BeaconForkChoiceUpdateError::PayloadJob(PayloadBuilderError::ChannelClosed) + )); + } +} diff --git a/crates/engine/tree/Cargo.toml b/crates/engine/tree/Cargo.toml index 102d5490ea1..211b86da740 100644 --- a/crates/engine/tree/Cargo.toml +++ b/crates/engine/tree/Cargo.toml @@ -54,13 +54,13 @@ futures.workspace = true parking_lot.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread", "sync", "macros"] } -moka = { workspace = true, features = ["sync"] } # metrics metrics.workspace = true reth-metrics = { workspace = true, features = ["common"] } # misc +dashmap.workspace = true indexmap.workspace = true schnellru.workspace = true rayon.workspace = true @@ -104,6 +104,7 @@ rand.workspace = true rand_08.workspace = true alloy-rpc-types-eth.workspace = true firehose-tracer.workspace = true +tracing-subscriber = { workspace = true, features = ["registry"] } [features] test-utils = [ diff --git a/crates/engine/tree/src/backfill.rs b/crates/engine/tree/src/backfill.rs index b96f9e25220..dca90c42bcb 100644 --- a/crates/engine/tree/src/backfill.rs +++ b/crates/engine/tree/src/backfill.rs @@ -7,6 +7,7 @@ //! //! These modes are mutually exclusive and the node can only be in one mode at a time. +use alloy_primitives::B256; use futures::FutureExt; use reth_provider::providers::ProviderNodeTypes; use reth_stages_api::{ControlFlow, Pipeline, PipelineError, PipelineTarget, PipelineWithResult}; @@ -66,6 +67,12 @@ pub trait BackfillSync: Send { pub enum BackfillAction { /// Start backfilling with the given target. Start(PipelineTarget), + /// Moves the target of a running backfill to the new forkchoice head, without starting another + /// run. It may arrive when no run is active, in which case it should be ignored. + UpdateTarget(B256), + /// Reports the new finalized block, which a backfill may anchor irreversible work to. + /// Backfills that use finality retain it while idle so the next run can use it. + UpdateFinalized(B256), } /// The events that can be emitted on backfill sync. @@ -187,6 +194,8 @@ impl BackfillSync for PipelineSync { fn on_action(&mut self, event: BackfillAction) { match event { BackfillAction::Start(target) => self.set_pipeline_sync_target(target), + // Ordinary backfill finishes its current range before the engine re-evaluates FCU. + BackfillAction::UpdateTarget(_) | BackfillAction::UpdateFinalized(_) => {} } } @@ -235,20 +244,36 @@ impl PipelineState { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{insert_headers_into_client, TestPipelineBuilder}; + use crate::{ + chain::{ChainHandler, HandlerEvent}, + download::{BlockDownloader, DownloadAction, DownloadOutcome}, + engine::{EngineHandler, EngineRequestHandler, FromEngine, RequestHandlerEvent}, + test_utils::{insert_headers_into_client, TestPipelineBuilder}, + }; use alloy_consensus::Header; use alloy_eips::eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M; use alloy_primitives::{BlockNumber, B256}; use assert_matches::assert_matches; use futures::poll; use reth_chainspec::{ChainSpecBuilder, MAINNET}; + use reth_ethereum_primitives::EthPrimitives; use reth_network_p2p::test_utils::TestFullBlockClient; - use reth_primitives_traits::SealedHeader; + use reth_primitives_traits::{NodePrimitives, SealedHeader}; use reth_provider::test_utils::MockNodeTypesWithDB; use reth_stages::ExecOutput; use reth_stages_api::StageCheckpoint; use reth_tasks::Runtime; - use std::{collections::VecDeque, future::poll_fn, sync::Arc}; + use std::{ + collections::VecDeque, + future::poll_fn, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::Waker, + }; + + type TestBlock = ::Block; struct TestHarness { pipeline_sync: PipelineSync, @@ -267,10 +292,9 @@ mod tests { // force the pipeline to be "done" after `pipeline_done_after` blocks let pipeline = TestPipelineBuilder::new() - .with_pipeline_exec_outputs(VecDeque::from([Ok(ExecOutput { - checkpoint: StageCheckpoint::new(BlockNumber::from(pipeline_done_after)), - done: true, - })])) + .with_pipeline_exec_outputs(VecDeque::from([Ok(ExecOutput::done( + StageCheckpoint::new(BlockNumber::from(pipeline_done_after)), + ))])) .build(chain_spec); let pipeline_sync = PipelineSync::new(pipeline, Runtime::test()); @@ -289,6 +313,41 @@ mod tests { } } + // Emits one queued backfill action per poll. + struct QueuedActions(VecDeque); + + impl EngineRequestHandler for QueuedActions { + type Event = (); + type Request = (); + type Block = TestBlock; + + fn on_event(&mut self, _event: FromEngine) {} + + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.0.pop_front().map_or(Poll::Pending, |action| { + Poll::Ready(RequestHandlerEvent::HandlerEvent(HandlerEvent::BackfillAction(action))) + }) + } + } + + // Counts how often in-flight downloads are cleared. + #[derive(Default)] + struct CountedClears(Arc); + + impl BlockDownloader for CountedClears { + type Block = TestBlock; + + fn on_action(&mut self, action: DownloadAction) { + if matches!(action, DownloadAction::Clear) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + } + #[tokio::test] async fn pipeline_started_and_finished() { const TOTAL_BLOCKS: usize = 10; @@ -319,4 +378,40 @@ mod tests { assert_matches!(result, Ok(control_flow) => assert_eq!(control_flow, ControlFlow::Continue { block_number: PIPELINE_DONE_AFTER })); }); } + + #[tokio::test] + async fn target_updates_do_not_start_the_pipeline() { + let TestHarness { mut pipeline_sync, tip } = TestHarness::new(10, 5); + + pipeline_sync.on_action(BackfillAction::UpdateTarget(tip)); + + assert_matches!(poll!(poll_fn(|cx| pipeline_sync.poll(cx))), Poll::Pending); + assert!(pipeline_sync.is_pipeline_idle()); + } + + #[test] + fn only_a_new_backfill_run_clears_downloads() { + let head = B256::repeat_byte(1); + let actions = VecDeque::from([ + BackfillAction::UpdateTarget(head), + BackfillAction::Start(PipelineTarget::Sync(head)), + ]); + let downloader = CountedClears::default(); + let clears = Arc::clone(&downloader.0); + let mut handler = + EngineHandler::new(QueuedActions(actions), downloader, futures::stream::empty::<()>()); + let mut cx = Context::from_waker(Waker::noop()); + + assert!(matches!( + ChainHandler::poll(&mut handler, &mut cx), + Poll::Ready(HandlerEvent::BackfillAction(BackfillAction::UpdateTarget(_))) + )); + assert_eq!(clears.load(Ordering::Relaxed), 0); + + assert!(matches!( + ChainHandler::poll(&mut handler, &mut cx), + Poll::Ready(HandlerEvent::BackfillAction(BackfillAction::Start(_))) + )); + assert_eq!(clears.load(Ordering::Relaxed), 1); + } } diff --git a/crates/engine/tree/src/chain.rs b/crates/engine/tree/src/chain.rs index 35f04176a43..63c93f93493 100644 --- a/crates/engine/tree/src/chain.rs +++ b/crates/engine/tree/src/chain.rs @@ -228,8 +228,8 @@ pub enum FromOrchestrator { /// Gracefully terminate the engine service. /// /// When this variant is received, the engine will persist all remaining in-memory blocks - /// to disk before shutting down. Once persistence is complete, a signal is sent through - /// the oneshot channel to notify the caller. + /// to disk before shutting down. Once persistence is complete and the engine has shut down, + /// a signal is sent through the oneshot channel to notify the caller. Terminate { /// Channel to signal termination completion. tx: tokio::sync::oneshot::Sender<()>, diff --git a/crates/engine/tree/src/engine.rs b/crates/engine/tree/src/engine.rs index 4217cf447f7..f4b642e6ae4 100644 --- a/crates/engine/tree/src/engine.rs +++ b/crates/engine/tree/src/engine.rs @@ -87,7 +87,10 @@ where return match ev { HandlerEvent::BackfillAction(target) => { // bubble up backfill sync request - self.downloader.on_action(DownloadAction::Clear); + // Only a new run, not a target update, makes downloads stale. + if matches!(target, BackfillAction::Start(_)) { + self.downloader.on_action(DownloadAction::Clear); + } Poll::Ready(HandlerEvent::BackfillAction(target)) } HandlerEvent::Event(ev) => { diff --git a/crates/engine/tree/src/launch.rs b/crates/engine/tree/src/launch.rs index 24297608bb1..45e42f039d6 100644 --- a/crates/engine/tree/src/launch.rs +++ b/crates/engine/tree/src/launch.rs @@ -1,11 +1,12 @@ //! Engine orchestrator launch helper. //! -//! Provides [`EngineOrchestratorBuilder`](crate::launch::EngineOrchestratorBuilder) which wires +//! Provides [`EngineOrchestratorBuilder`] which wires //! together all engine components and builds a -//! [`ChainOrchestrator`](crate::chain::ChainOrchestrator) ready to be polled as a `Stream`. +//! [`ChainOrchestrator`] ready to be polled as a `Stream`, with the staged pipeline or a +//! caller-supplied [`BackfillSync`] as backfill. use crate::{ - backfill::PipelineSync, + backfill::{BackfillSync, PipelineSync}, chain::ChainOrchestrator, download::BasicBlockDownloader, engine::{EngineApiKind, EngineApiRequest, EngineApiRequestHandler, EngineHandler}, @@ -27,7 +28,7 @@ use reth_prune::PrunerWithFactory; use reth_stages_api::{MetricEventsSender, Pipeline}; use reth_storage_overlay::OverlayManager; use reth_tasks::Runtime; -use std::sync::Arc; +use std::{convert::Infallible, sync::Arc}; /// The [`ChainOrchestrator`] built by [`EngineOrchestratorBuilder`]. pub type EngineOrchestrator = ChainOrchestrator< @@ -39,6 +40,10 @@ pub type EngineOrchestrator = ChainOrchestrator< B, >; +// An [`EngineOrchestrator`], or the error its backfill failed to build with. +type EngineOrchestratorResult = + Result, E>; + /// Components needed to build the engine [`ChainOrchestrator`] that drives the chain forward. /// /// [`build`](Self::build) spawns and wires together the following components: @@ -110,6 +115,20 @@ where pub fn build( self, ) -> EngineOrchestrator> { + let Ok(orchestrator) = self.build_with_backfill(|pipeline, runtime| { + Ok::<_, Infallible>(PipelineSync::new(pipeline, runtime)) + }); + orchestrator + } + + /// Like [`Self::build`], backfilling through the [`BackfillSync`] `backfill` makes from the + /// staged [`Pipeline`] and its runtime, instead of [`PipelineSync`]. + /// + /// The backfill is built before any engine service is spawned, so its error aborts the build. + pub fn build_with_backfill( + self, + backfill: impl FnOnce(Pipeline, Runtime) -> Result, + ) -> EngineOrchestratorResult { let Self { engine_kind, consensus, @@ -129,6 +148,7 @@ where runtime, } = self; + let backfill_sync = backfill(pipeline, pipeline_task_spawner)?; let downloader = BasicBlockDownloader::new(client, consensus.clone()); let persistence_handle = @@ -153,8 +173,6 @@ where let engine_handler = EngineApiRequestHandler::new(to_tree_tx, from_tree); let handler = EngineHandler::new(engine_handler, downloader, incoming_requests); - let backfill_sync = PipelineSync::new(pipeline, pipeline_task_spawner); - - ChainOrchestrator::new(handler, backfill_sync) + Ok(ChainOrchestrator::new(handler, backfill_sync)) } } diff --git a/crates/engine/tree/src/persistence.rs b/crates/engine/tree/src/persistence.rs index f466dad914e..f19afa6b48b 100644 --- a/crates/engine/tree/src/persistence.rs +++ b/crates/engine/tree/src/persistence.rs @@ -413,7 +413,7 @@ impl Drop for ServiceGuard { mod tests { use super::*; use alloy_eips::NumHash; - use alloy_primitives::{BlockHash, BlockNumber, Bytes, B256, U256}; + use alloy_primitives::{Address, BlockHash, BlockNumber, Bytes, B256, U256}; use reth_chain_state::{test_utils::TestBlockBuilder, ExecutedBlock}; use reth_db_common::init::init_genesis; use reth_exex_types::FinishedExExHeight; @@ -648,7 +648,7 @@ mod tests { reth_tracing::init_test_tracing(); let provider_factory = create_test_provider_factory(); - let tracked_addr = alloy_primitives::Address::from([0xBE; 20]); + let tracked_addr = Address::repeat_byte(0xBE); // Phase 1: Establish baseline history for blocks 0..20. let rocksdb = provider_factory.rocksdb_provider(); diff --git a/crates/engine/tree/src/tree/block_buffer.rs b/crates/engine/tree/src/tree/block_buffer.rs index c0e917eb613..afb607515fc 100644 --- a/crates/engine/tree/src/tree/block_buffer.rs +++ b/crates/engine/tree/src/tree/block_buffer.rs @@ -1,10 +1,13 @@ use crate::tree::metrics::BlockBufferMetrics; use alloy_consensus::BlockHeader; -use alloy_primitives::{BlockHash, BlockNumber}; +use alloy_primitives::{ + map::{hash_map::Entry, B256Map, B256Set, FbBuildHasher}, + BlockHash, BlockNumber, +}; use indexmap::IndexSet; use reth_network_p2p::full_block::SealedBlockWithAccessList; use reth_primitives_traits::{Block, SealedBlock}; -use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, VecDeque}; /// Contains the tree of pending blocks that cannot be executed due to missing parent. /// It allows to store unconnected blocks for potential future inclusion. @@ -20,14 +23,14 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; #[derive(Debug)] pub struct BlockBuffer { /// All blocks in the buffer stored by their block hash. - pub(crate) blocks: HashMap>, + pub(crate) blocks: B256Map>, /// Map of any parent block hash (even the ones not currently in the buffer) /// to the buffered children. /// Allows connecting buffered blocks by parent. - pub(crate) parent_to_child: HashMap>, + pub(crate) parent_to_child: B256Map>>, /// `BTreeMap` tracking the earliest blocks by block number. /// Used for removal of old blocks that precede finalization. - pub(crate) earliest_blocks: BTreeMap>, + pub(crate) earliest_blocks: BTreeMap, /// FIFO queue tracking block insertion order for eviction. /// When the buffer reaches its capacity limit, the oldest block is evicted first. pub(crate) block_queue: VecDeque, @@ -69,14 +72,14 @@ impl BlockBuffer { let hash = block.hash(); match self.blocks.entry(hash) { - std::collections::hash_map::Entry::Occupied(mut entry) => { + Entry::Occupied(mut entry) => { // a duplicate that includes access list data is preferred over one without if entry.get().data().is_none() && block.data().is_some() { entry.insert(block); } return } - std::collections::hash_map::Entry::Vacant(entry) => { + Entry::Vacant(entry) => { self.parent_to_child.entry(block.parent_hash()).or_default().insert(hash); self.earliest_blocks.entry(block.number()).or_default().insert(hash); entry.insert(block); diff --git a/crates/engine/tree/src/tree/metrics.rs b/crates/engine/tree/src/tree/metrics.rs index 3532f1b2c29..8fd21ce5b9a 100644 --- a/crates/engine/tree/src/tree/metrics.rs +++ b/crates/engine/tree/src/tree/metrics.rs @@ -1,4 +1,4 @@ -use crate::tree::TreeOutcome; +use crate::tree::{payload_processor::reuse::ReuseMiss, TreeOutcome}; use alloy_rpc_types_engine::{PayloadStatus, PayloadStatusEnum}; use reth_engine_primitives::{ForkchoiceStatus, InsertBlockProcessingError, OnForkChoiceUpdated}; use reth_errors::ProviderError; @@ -39,6 +39,8 @@ pub struct EngineApiMetrics { pub(crate) execution_gas_buckets: ExecutionGasBucketMetrics, /// Gas-bucketed block validation sub-phase metrics. pub(crate) block_validation_gas_buckets: BlockValidationGasBucketMetrics, + /// Metrics for reusing transaction prewarming results. + pub(crate) prewarm_reuse: PrewarmReuseMetrics, } impl EngineApiMetrics { @@ -62,7 +64,7 @@ impl EngineApiMetrics { self.executor.execution_duration.set(execution_secs); // Update the metrics for the number of accounts, storage slots and bytecodes - let accounts = output.state.state.len(); + let accounts = output.state.len(); let storage_slots = output.state.state.values().map(|account| account.storage.len()).sum::(); let bytecodes = output.state.contracts.len(); @@ -114,6 +116,46 @@ impl EngineApiMetrics { } } +/// Metrics for reusing transaction prewarming results during block execution. +#[derive(Metrics)] +#[metrics(scope = "sync.prewarm.reuse")] +pub(crate) struct PrewarmReuseMetrics { + /// Transactions committed from prewarm results. + reused_transactions: Counter, + /// Transactions executed because prewarming had not finished them. + missing_transactions: Counter, + /// Transactions executed because a prewarm result was unusable or failed reuse checks. + stale_transactions: Counter, +} + +impl PrewarmReuseMetrics { + /// Records the reuse outcomes of a block. + pub(crate) fn record(&self, stats: PrewarmReuseStats) { + self.reused_transactions.increment(stats.reused); + self.missing_transactions.increment(stats.missing); + self.stale_transactions.increment(stats.stale); + } +} + +/// Prewarm result reuse outcomes of a block. +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct PrewarmReuseStats { + reused: u64, + missing: u64, + stale: u64, +} + +impl PrewarmReuseStats { + /// Records the reuse outcome of a transaction. + pub(crate) const fn record(&mut self, outcome: Result<(), ReuseMiss>) { + match outcome { + Ok(()) => self.reused += 1, + Err(ReuseMiss::Missing) => self.missing += 1, + Err(ReuseMiss::Stale) => self.stale += 1, + } + } +} + /// Metrics for the entire blockchain tree #[derive(Metrics)] #[metrics(scope = "blockchain_tree")] diff --git a/crates/engine/tree/src/tree/mod.rs b/crates/engine/tree/src/tree/mod.rs index 95dd09c4791..0abee61c0a1 100644 --- a/crates/engine/tree/src/tree/mod.rs +++ b/crates/engine/tree/src/tree/mod.rs @@ -40,7 +40,7 @@ use reth_revm::database::StateProviderDatabase; use reth_stages_api::ControlFlow; use reth_storage_overlay::OverlayManager; use reth_tasks::{spawn_os_thread, utils::increase_thread_priority}; -use reth_trie::ComputedTrieData; +use reth_trie::{HashedPostState, KeccakKeyHasher, SortedTrieData}; use revm::interpreter::debug_unreachable; use state::TreeState; use std::{ @@ -68,7 +68,6 @@ mod metrics; pub mod payload_processor; pub mod payload_validator; mod persistence_state; -pub mod precompile_cache; pub mod state_root_strategy; #[cfg(test)] mod tests; @@ -85,8 +84,9 @@ pub use payload_validator::{BasicEngineValidator, EngineValidator}; pub use persistence_state::PersistenceState; pub use reth_engine_primitives::TreeConfig; pub use reth_execution_cache::{ - CachedStateCacheMetrics, CachedStateMetrics, CachedStateMetricsSource, CachedStateProvider, - ExecutionCache, PayloadExecutionCache, SavedCache, TxPoolPrewarmCacheSnapshot, + precompile_cache, CachedStateCacheMetrics, CachedStateMetrics, CachedStateMetricsSource, + CachedStateProvider, ExecutionCache, PayloadExecutionCache, SavedCache, + TxPoolPrewarmCacheSnapshot, }; pub use txpool_prewarm::{ Source as TxPoolPrewarmSource, Transaction as TxPoolPrewarmTransaction, @@ -323,11 +323,11 @@ where runtime: reth_tasks::Runtime, } -impl std::fmt::Debug +impl std::fmt::Debug for EngineApiTreeHandler where N: NodePrimitives, - C: Debug + ConfigureEvm, + C: ConfigureEvm, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("EngineApiTreeHandler") @@ -518,6 +518,14 @@ where /// /// This will block the current thread and process incoming messages. pub fn run(mut self) { + let terminated = self.run_until_exit(); + drop(self); + if let Some(tx) = terminated { + let _ = tx.send(()); + } + } + + fn run_until_exit(&mut self) -> Option> { loop { // Each iteration has three phases: // @@ -547,14 +555,14 @@ where Ok(true) => { if let Err(err) = self.advance_persistence() { error!(target: "engine::tree", %err, "Advancing persistence failed"); - return + return None } continue; } Ok(false) => {} Err(err) => { error!(target: "engine::tree", %err, "Polling persistence failed"); - return + return None } } @@ -573,24 +581,24 @@ where LoopEvent::EngineMessage(msg) => { debug!(target: "engine::tree", %msg, "received new engine message"); match self.on_engine_message(msg) { - Ok(ops::ControlFlow::Break(())) => return, + Ok(ops::ControlFlow::Break(tx)) => return Some(tx), Ok(ops::ControlFlow::Continue(())) => {} Err(fatal) => { error!(target: "engine::tree", %fatal, "insert block fatal error"); - return + return None } } } LoopEvent::PersistenceComplete { result, start_time } => { if let Err(err) = self.on_persistence_complete(result, start_time) { error!(target: "engine::tree", %err, "Persistence complete handling failed"); - return + return None } } LoopEvent::PayloadBuildFinished => {} LoopEvent::Disconnected => { error!(target: "engine::tree", "Channel disconnected"); - return + return None } } @@ -600,7 +608,7 @@ where // - After persistence completion: we can now persist more blocks if needed if let Err(err) = self.advance_persistence() { error!(target: "engine::tree", %err, "Advancing persistence failed"); - return + return None } } } @@ -1202,6 +1210,15 @@ where } if !self.backfill_sync_state.is_idle() { + // Forward the head and finalized block, since a long-running backfill such as snap + // must follow them. A run awaiting revalidation has not started and reads the latest + // forkchoice when it does. + if self.backfill_sync_state.is_pending() || self.backfill_sync_state.is_active() { + self.send_event(EngineApiEvent::BackfillAction(BackfillAction::UpdateTarget( + state.head_block_hash, + ))); + self.forward_finalized(state.finalized_block_hash); + } // We can only process new forkchoice updates if the pipeline is idle, since it requires // exclusive access to the database trace!(target: "engine::tree", "Pipeline is syncing, skipping forkchoice update"); @@ -1460,20 +1477,6 @@ where Ok(()) } - /// Finishes termination by persisting all remaining blocks and signaling completion. - /// - /// This blocks until all persistence is complete. Always signals completion, - /// even if an error occurs. - fn finish_termination( - &mut self, - pending_termination: oneshot::Sender<()>, - ) -> Result<(), AdvancePersistenceError> { - trace!(target: "engine::tree", "finishing termination, persisting remaining blocks"); - let result = self.persist_until_complete(); - let _ = pending_termination.send(()); - result - } - /// Persists all remaining blocks until none are left. fn persist_until_complete(&mut self) -> Result<(), AdvancePersistenceError> { loop { @@ -1576,11 +1579,12 @@ where /// Handles a message from the engine. /// - /// Returns `ControlFlow::Break(())` if the engine should terminate. + /// Returns `ControlFlow::Break` with the sender of the termination request if the engine + /// should terminate. fn on_engine_message( &mut self, msg: FromEngine, N::Block>, - ) -> Result, InsertBlockFatalError> { + ) -> Result>, InsertBlockFatalError> { match msg { FromEngine::Event(event) => match event { FromOrchestrator::BackfillSyncStarted => { @@ -1592,10 +1596,11 @@ where } FromOrchestrator::Terminate { tx } => { debug!(target: "engine::tree", "received terminate request"); - if let Err(err) = self.finish_termination(tx) { + trace!(target: "engine::tree", "finishing termination, persisting remaining blocks"); + if let Err(err) = self.persist_until_complete() { error!(target: "engine::tree", %err, "Termination failed"); } - return Ok(ops::ControlFlow::Break(())) + return Ok(ops::ControlFlow::Break(tx)) } }, FromEngine::Request(request) => { @@ -1644,7 +1649,13 @@ where } EngineApiRequest::Beacon(request) => { match request { - BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx } => { + BeaconEngineMessage::ForkchoiceUpdated { + cause, + state, + payload_attrs, + tx, + } => { + let _cause = cause.enter(); let has_attrs = payload_attrs.is_some(); let start = Instant::now(); @@ -1687,7 +1698,8 @@ where warn!(target: "engine::tree", ?state, elapsed=?start.elapsed(), "Failed to deliver forkchoiceUpdated response, receiver dropped (request cancelled): {err:?}"); } } - BeaconEngineMessage::NewPayload { payload, tx } => { + BeaconEngineMessage::NewPayload { cause, payload, tx } => { + let _cause = cause.enter(); let start = Instant::now(); let gas_used = payload.gas_used(); let num_hash = payload.num_hash(); @@ -1717,12 +1729,14 @@ where self.on_maybe_tree_event(maybe_event)?; } BeaconEngineMessage::RethNewPayload { + cause, payload, wait_for_persistence, wait_for_caches, tx, enqueued_at, } => { + let _cause = cause.enter(); debug!( target: "engine::tree", wait_for_persistence, @@ -2201,10 +2215,24 @@ where ); self.backfill_sync_state = BackfillSyncState::Pending; self.metrics.engine.pipeline_runs.increment(1); + // Forkchoice updates only forward finality to a running backfill, so the run starting here + // gets the latest one up front. + if let Some(state) = self.state.forkchoice_state_tracker.latest_state() { + self.forward_finalized(state.finalized_block_hash); + } debug!(target: "engine::tree", "emitting backfill action event"); self.send_event(EngineApiEvent::BackfillAction(action)); } + // Forwards `finalized` to the backfill unless the forkchoice leaves it unset. + fn forward_finalized(&self, finalized: B256) { + if !finalized.is_zero() { + self.send_event(EngineApiEvent::BackfillAction(BackfillAction::UpdateFinalized( + finalized, + ))); + } + } + /// Sends an event to the orchestrator. fn send_event(&self, event: EngineApiEvent) { let _ = self.outgoing.send(event).inspect_err( @@ -2383,11 +2411,15 @@ where let bundle_state = execution_output.state(); // `get_state` can return an in-memory execution outcome that retains destruction statuses. // Hashing it requires the parent provider to expand a pre-existing destroyed account's - // storage into zero-valued slots. - let hashed_state = self - .provider - .state_by_block_hash(block.parent_hash())? - .hashed_post_state(bundle_state)?; + // storage into zero-valued slots. Genesis has no parent state, and no account existed + // before it to be destroyed. + let hashed_state = if block.parent_hash().is_zero() { + HashedPostState::from_bundle_state::(bundle_state.state()) + } else { + self.provider + .state_by_block_hash(block.parent_hash())? + .hashed_post_state(bundle_state)? + }; debug!( target: "engine::tree", @@ -2403,7 +2435,7 @@ where let sorted_hashed_state = Arc::new(hashed_state.into_sorted()); let sorted_trie_updates = Arc::new(trie_updates); - let trie_data = ComputedTrieData::new(sorted_hashed_state, sorted_trie_updates); + let trie_data = SortedTrieData::new(sorted_hashed_state, sorted_trie_updates); let execution_output = Arc::new(BlockExecutionOutput { state: execution_output.bundle, @@ -2667,12 +2699,12 @@ where /// and block body itself. fn validate_block(&self, block: &SealedBlock) -> Result<(), ConsensusError> { if let Err(e) = self.consensus.validate_header(block.sealed_header()) { - error!(target: "engine::tree", ?block, "Failed to validate header {}: {e}", block.hash()); + error!(target: "engine::tree", block = ?block.block_with_parent(), %e, "Failed to validate header"); return Err(e) } if let Err(e) = self.consensus.validate_block_pre_execution(block) { - error!(target: "engine::tree", ?block, "Failed to validate block {}: {e}", block.hash()); + error!(target: "engine::tree", block = ?block.block_with_parent(), %e, "Failed to validate block"); return Err(e) } diff --git a/crates/engine/tree/src/tree/payload_processor/bal/execute.rs b/crates/engine/tree/src/tree/payload_processor/bal/execute.rs index 90cca4e3b73..f150709bd61 100644 --- a/crates/engine/tree/src/tree/payload_processor/bal/execute.rs +++ b/crates/engine/tree/src/tree/payload_processor/bal/execute.rs @@ -42,7 +42,7 @@ use reth_primitives_traits::ReceiptTy; use reth_provider::BlockExecutionOutput; use reth_tasks::Runtime; use revm::{ - context::{result::ResultAndState, Block}, + context::{result::ResultAndState, Block, Cfg}, database::{states::bundle_state::BundleRetention, State}, state::bal::Bal as RevmBal, }; @@ -78,7 +78,6 @@ where Err: core::error::Error + Send + Sync + 'static, DB: Database + Send + 'a, MakeDb: Fn(bool) -> Result + Sync + 'a, - ReceiptTy: Clone, { let worker_pool = runtime.bal_streaming_pool(); let worker_count = worker_pool.current_num_threads().max(1).min(transaction_count); @@ -121,14 +120,13 @@ where Err: core::error::Error + Send + Sync + 'static, DB: Database + Send + 'scope, MakeDb: Fn(bool) -> Result + Sync + 'scope, - ReceiptTy: Clone, { let bal = input_bal.as_bal(); let input_bal_revm = convert_alloy_to_revm_bal(bal)?; let block_gas_limit = evm_env.block_env.gas_limit(); let enable_amsterdam_eip8037 = evm_env.cfg_env.enable_amsterdam_eip8037; - let tx_gas_limit_cap = evm_env.cfg_env.tx_gas_limit_cap; + let tx_gas_limit_cap = evm_env.cfg_env.tx_gas_limit_cap(); let mut canonical_state = State::builder() .with_database(make_db(false)?) .with_bundle_update() @@ -289,7 +287,7 @@ impl AbortGuard { struct BlockGasTracker { block_gas_limit: u64, enable_amsterdam_eip8037: bool, - tx_gas_limit_cap: Option, + tx_gas_limit_cap: u64, cumulative_tx_gas_used: u64, block_regular_gas_used: u64, block_state_gas_used: u64, @@ -299,7 +297,7 @@ impl BlockGasTracker { const fn new( block_gas_limit: u64, enable_amsterdam_eip8037: bool, - tx_gas_limit_cap: Option, + tx_gas_limit_cap: u64, ) -> Self { Self { block_gas_limit, @@ -334,8 +332,7 @@ impl BlockGasTracker { self.cumulative_tx_gas_used }; let block_available_gas = self.block_gas_limit.saturating_sub(block_gas_used); - let tx_min_gas_limit = - self.tx_gas_limit_cap.map_or(tx_gas_limit, |cap| tx_gas_limit.min(cap)); + let tx_min_gas_limit = tx_gas_limit.min(self.tx_gas_limit_cap); if tx_min_gas_limit > block_available_gas { return Err(BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas { @@ -372,9 +369,10 @@ impl BlockGasTracker { #[cfg(test)] mod tests { + use super::*; use crate::tree::error::{InsertBlockErrorKind, InsertBlockValidationError}; - use alloy_consensus::{BlockHeader, Header}; + use alloy_consensus::{BlockHeader, Header, TxLegacy}; use alloy_eip7928::{ bal::Bal as AlloyBal, AccountChanges, BlockAccessIndex, BlockAccessList, CodeChange, }; @@ -383,16 +381,17 @@ mod tests { eip4788::{BEACON_ROOTS_ADDRESS, BEACON_ROOTS_CODE}, eip7002::{WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_PREDEPLOY_CODE}, }; - use alloy_primitives::{keccak256, B256, U256}; - use reth_ethereum_primitives::{Block, BlockBody, Receipt, TransactionSigned}; + use alloy_primitives::{Bytes, TxKind, B256, U256}; + use reth_chainspec::MAINNET; + use reth_ethereum_primitives::{Block, BlockBody, Receipt, Transaction, TransactionSigned}; use reth_evm_ethereum::EthEvmConfig; - use reth_primitives_traits::{Block as _, Recovered, SealedBlock}; - use reth_revm::db::BundleState; - use reth_tasks::Runtime; - use revm::{ - database::{CacheDB, EmptyDB}, - state::{AccountInfo, Bytecode}, + use reth_primitives_traits::{ + crypto::secp256k1::public_key_to_address, Block as _, Recovered, SealedBlock, }; + use reth_revm::db::{BundleState, InMemoryDB}; + use reth_tasks::Runtime; + use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; + use revm::state::{AccountInfo, Bytecode}; use std::convert::Infallible; /// Wraps a `BlockAccessList` into an `Arc` by RLP-encoding the BAL. @@ -405,34 +404,21 @@ mod tests { /// Builds an in-memory canonical DB pre-populated with the post-Cancun system contracts /// that `apply_pre_execution_changes` calls: beacon roots (EIP-4788), withdrawal requests /// (EIP-7002), and historical block hashes (EIP-2935). - fn system_contracts_db() -> CacheDB { - let mut db = CacheDB::::new(Default::default()); + fn system_contracts_db() -> InMemoryDB { + let mut db = InMemoryDB::default(); db.insert_account_info( BEACON_ROOTS_ADDRESS, - AccountInfo { - nonce: 1, - code_hash: keccak256(BEACON_ROOTS_CODE.clone()), - code: Some(Bytecode::new_raw(BEACON_ROOTS_CODE.clone())), - ..Default::default() - }, + AccountInfo::from_bytecode(Bytecode::new_raw(BEACON_ROOTS_CODE.clone())), ); db.insert_account_info( WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, - AccountInfo { - nonce: 1, - code_hash: keccak256(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone()), - code: Some(Bytecode::new_raw(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone())), - ..Default::default() - }, + AccountInfo::from_bytecode(Bytecode::new_raw( + WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone(), + )), ); db.insert_account_info( HISTORY_STORAGE_ADDRESS, - AccountInfo { - nonce: 1, - code_hash: keccak256(HISTORY_STORAGE_CODE.clone()), - code: Some(Bytecode::new_raw(HISTORY_STORAGE_CODE.clone())), - ..Default::default() - }, + AccountInfo::from_bytecode(Bytecode::new_raw(HISTORY_STORAGE_CODE.clone())), ); db } @@ -549,9 +535,7 @@ mod tests { } } - fn db_factory( - db: CacheDB, - ) -> impl Fn() -> Result, BalExecutionError> + Sync { + fn db_factory(db: InMemoryDB) -> impl Fn() -> Result + Sync { move || Ok(db.clone()) } @@ -613,7 +597,7 @@ mod tests { } /// Inserts `AccountInfo { nonce: 0, balance }` for `addr` into the canonical DB. - fn insert_funded(db: &mut CacheDB, addr: alloy_primitives::Address, balance: U256) { + fn insert_funded(db: &mut InMemoryDB, addr: alloy_primitives::Address, balance: U256) { db.insert_account_info( addr, AccountInfo { balance, code_hash: B256::ZERO, code: None, ..Default::default() }, @@ -624,7 +608,7 @@ mod tests { /// composed BAL. Used to build the reference BAL for happy-path multi-tx tests. fn reference_bal_for_block( evm_config: &EthEvmConfig, - mut db: CacheDB, + mut db: InMemoryDB, block: &SealedBlock, txs: Vec, ) -> BlockAccessList @@ -664,15 +648,8 @@ mod tests { // 3. Build the reference BAL by running the block through a canonical executor with // `with_bal_builder`. // 4. Feed that BAL into `execute_block` and assert 2 receipts + no rejections. - use alloy_consensus::TxLegacy; - use alloy_primitives::TxKind; - use reth_chainspec::MAINNET; - use reth_ethereum_primitives::Transaction; - use reth_primitives_traits::crypto::secp256k1::public_key_to_address; - use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; - let evm_config = EthEvmConfig::mainnet(); - let carol: alloy_primitives::Address = alloy_primitives::Address::from([0xCA; 20]); + let carol: alloy_primitives::Address = Address::repeat_byte(0xCA); let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); // Generate keypairs + derive sender addresses. @@ -790,7 +767,7 @@ mod tests { /// (a) capture the composed BAL for the BAL-path input and (b) pull the bundle out after. fn run_serial_path( evm_config: &EthEvmConfig, - canonical_db: CacheDB, + canonical_db: InMemoryDB, block: &SealedBlock, txs: &[Recovered], ) -> (ShadowOutput, BlockAccessList) { @@ -834,7 +811,7 @@ mod tests { /// Shadow harness. Runs the block through both paths; asserts byte-equal outputs. fn assert_shadow_equal( evm_config: EthEvmConfig, - canonical_db_template: CacheDB, + canonical_db_template: InMemoryDB, block_header_only: SealedBlock, txs: Vec>, ) { @@ -893,15 +870,8 @@ mod tests { fn shadow_multi_value_transfer() { // Two senders → same recipient. Byte-equal across paths means: worker-produced // diffs commit identically to a directly-executed serial path. - use alloy_consensus::TxLegacy; - use alloy_primitives::TxKind; - use reth_chainspec::MAINNET; - use reth_ethereum_primitives::Transaction; - use reth_primitives_traits::crypto::secp256k1::public_key_to_address; - use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; - let evm_config = EthEvmConfig::mainnet(); - let carol: alloy_primitives::Address = alloy_primitives::Address::from([0xCA; 20]); + let carol: alloy_primitives::Address = Address::repeat_byte(0xCA); let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); let alice_kp = generate_key(&mut rng()); @@ -939,16 +909,8 @@ mod tests { // Each worker sees an empty block, so both transactions fit individually. The ordered // commit loop must still reject tx2 because tx1's committed gas leaves too little // block gas for tx2's gas limit. - use alloy_consensus::TxLegacy; - use alloy_evm::block::BlockValidationError; - use alloy_primitives::TxKind; - use reth_chainspec::MAINNET; - use reth_ethereum_primitives::Transaction; - use reth_primitives_traits::crypto::secp256k1::public_key_to_address; - use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; - let evm_config = EthEvmConfig::mainnet(); - let carol: alloy_primitives::Address = alloy_primitives::Address::from([0xCA; 20]); + let carol: alloy_primitives::Address = Address::repeat_byte(0xCA); let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); let block_gas_limit = 1_000_000; let tx_gas_limit = 990_000; @@ -1012,27 +974,15 @@ mod tests { } } - /// Two funded senders each transferring to a fresh recipient, plus the reference BAL of a - /// block containing only the covered subset of those transfers. - fn two_transfers_with_reference_bal( - evm_config: &EthEvmConfig, - tx_gas_limit: u64, - bal_covers_first: bool, - ) -> ( - CacheDB, - BlockAccessList, - Recovered, - Recovered, - ) { - use alloy_consensus::TxLegacy; - use alloy_primitives::TxKind; - use reth_chainspec::MAINNET; - use reth_ethereum_primitives::Transaction; - use reth_primitives_traits::crypto::secp256k1::public_key_to_address; - use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; - - let recipient = alloy_primitives::Address::from([0xCA; 20]); - let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); + /// Two funded senders each transferring to the same recipient, which is funded only if + /// `fund_recipient` is set. + fn two_transfers( + tx1_gas_limit: u64, + tx2_gas_limit: u64, + fund_recipient: bool, + ) -> (InMemoryDB, Recovered, Recovered) { + let recipient = Address::repeat_byte(0xCA); + let balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); let alice_kp = generate_key(&mut rng()); let alice = public_key_to_address(alice_kp.public_key()); @@ -1040,27 +990,45 @@ mod tests { let bob = public_key_to_address(bob_kp.public_key()); let mut pre_block_db = system_contracts_db(); - insert_funded(&mut pre_block_db, alice, sender_balance); - insert_funded(&mut pre_block_db, bob, sender_balance); + insert_funded(&mut pre_block_db, alice, balance); + insert_funded(&mut pre_block_db, bob, balance); + if fund_recipient { + insert_funded(&mut pre_block_db, recipient, balance); + } let chain_id = MAINNET.chain.id(); - let make_tx = |kp, value| { + let make_tx = |kp, gas_limit, value| { sign_tx_with_key_pair( kp, Transaction::Legacy(TxLegacy { chain_id: Some(chain_id), nonce: 0, gas_price: 1, - gas_limit: tx_gas_limit, + gas_limit, to: TxKind::Call(recipient), value: U256::from(value), input: Default::default(), }), ) }; - let tx1 = Recovered::new_unchecked(make_tx(alice_kp, 100u64), alice); - let tx2 = Recovered::new_unchecked(make_tx(bob_kp, 200u64), bob); + let tx1 = Recovered::new_unchecked(make_tx(alice_kp, tx1_gas_limit, 100u64), alice); + let tx2 = Recovered::new_unchecked(make_tx(bob_kp, tx2_gas_limit, 200u64), bob); + (pre_block_db, tx1, tx2) + } + /// [`two_transfers`] to a fresh recipient, plus the reference BAL of a block containing only + /// the covered subset of those transfers. + fn two_transfers_with_reference_bal( + evm_config: &EthEvmConfig, + tx_gas_limit: u64, + bal_covers_first: bool, + ) -> ( + InMemoryDB, + BlockAccessList, + Recovered, + Recovered, + ) { + let (pre_block_db, tx1, tx2) = two_transfers(tx_gas_limit, tx_gas_limit, false); let reference_block = empty_amsterdam_block(B256::ZERO); let covered = if bal_covers_first { vec![tx1.clone()] } else { vec![] }; let reference_bal = @@ -1274,6 +1242,54 @@ mod tests { } } + #[test] + fn unset_tx_gas_limit_cap_matches_serial_execution() { + let evm_config = EthEvmConfig::new(Arc::new( + reth_chainspec::ChainSpecBuilder::mainnet().amsterdam_activated().build(), + )); + let block_gas_limit = 20_000_000; + // The second gas limit exceeds the regular gas left after the first transfer, but its + // regular part, capped at the EIP-7825 default, fits. The funded recipient keeps both + // transfers off the state-gas budget. + let (pre_block_db, tx1, tx2) = two_transfers(100_000, 19_990_000, true); + let reference_bal = reference_bal_for_block( + &evm_config, + pre_block_db.clone(), + &empty_amsterdam_block_with_gas_limit(B256::ZERO, block_gas_limit), + vec![tx1.clone(), tx2.clone()], + ); + let block = empty_amsterdam_block_with_gas_limit( + compute_block_access_list_hash(&reference_bal), + block_gas_limit, + ); + let mut evm_env = evm_config.evm_env(block.header()).unwrap(); + // An unset cap means the spec default. + evm_env.cfg_env.tx_gas_limit_cap = None; + let ctx = evm_config.context_for_block(&block).unwrap(); + + let mut state = State::builder().with_database(pre_block_db.clone()).build(); + let evm = evm_config.evm_with_env(&mut state, evm_env.clone()); + let mut serial = evm_config.create_executor_with_state(evm, ctx.clone()); + serial.apply_pre_execution_changes().unwrap(); + serial.execute_transaction(tx1.clone()).unwrap(); + serial.execute_transaction(tx2.clone()).unwrap(); + + let (receipt_tx, _receipt_rx) = crossbeam_channel::unbounded(); + let (output, ..) = execute_block( + &Runtime::test(), + &evm_config, + &|_: bool| Ok(pre_block_db.clone()), + to_arc_decoded(reference_bal), + evm_env, + ctx, + 2, + tx_stream(vec![tx1, tx2]), + receipt_tx, + ) + .unwrap(); + assert_eq!(output.receipts, serial.receipts()); + } + #[test] fn shadow_tx_with_revert() { // A tx that reverts in a deployed contract. Both paths must produce identical receipts @@ -1281,16 +1297,8 @@ mod tests { // // Deploys `0x60006000fd` (PUSH1 0 PUSH1 0 REVERT) at `revert_contract`. Sender calls // it; the call reverts; fees + nonce still apply. - use alloy_consensus::TxLegacy; - use alloy_primitives::{keccak256, Bytes, TxKind}; - use reth_chainspec::MAINNET; - use reth_ethereum_primitives::Transaction; - use reth_primitives_traits::crypto::secp256k1::public_key_to_address; - use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; - let evm_config = EthEvmConfig::mainnet(); - let revert_contract: alloy_primitives::Address = - alloy_primitives::Address::from([0xDE; 20]); + let revert_contract: alloy_primitives::Address = Address::repeat_byte(0xDE); let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); let alice_kp = generate_key(&mut rng()); @@ -1298,17 +1306,11 @@ mod tests { // Deploy the revert contract bytecode. let revert_code: Bytes = Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd]); - let code_hash = keccak256(&revert_code); let mut db = system_contracts_db(); insert_funded(&mut db, alice, sender_balance); db.insert_account_info( revert_contract, - AccountInfo { - nonce: 1, - code_hash, - code: Some(Bytecode::new_raw(revert_code)), - ..Default::default() - }, + AccountInfo::from_bytecode(Bytecode::new_raw(revert_code)), ); let tx = Recovered::new_unchecked( @@ -1337,16 +1339,8 @@ mod tests { // a diff produced by a worker EVM. // // Bytecode: PUSH1 0x42, PUSH1 0x00, SSTORE, STOP → `0x60 0x42 0x60 0x00 0x55 0x00`. - use alloy_consensus::TxLegacy; - use alloy_primitives::{keccak256, Bytes, TxKind}; - use reth_chainspec::MAINNET; - use reth_ethereum_primitives::Transaction; - use reth_primitives_traits::crypto::secp256k1::public_key_to_address; - use reth_testing_utils::generators::{generate_key, rng, sign_tx_with_key_pair}; - let evm_config = EthEvmConfig::mainnet(); - let sstore_contract: alloy_primitives::Address = - alloy_primitives::Address::from([0x55; 20]); + let sstore_contract: alloy_primitives::Address = Address::repeat_byte(0x55); let sender_balance = U256::from(alloy_consensus::constants::ETH_TO_WEI); let alice_kp = generate_key(&mut rng()); @@ -1354,17 +1348,11 @@ mod tests { // Deploy the SSTORE contract. let sstore_code: Bytes = Bytes::from_static(&[0x60, 0x42, 0x60, 0x00, 0x55, 0x00]); - let code_hash = keccak256(&sstore_code); let mut db = system_contracts_db(); insert_funded(&mut db, alice, sender_balance); db.insert_account_info( sstore_contract, - AccountInfo { - nonce: 1, - code_hash, - code: Some(Bytecode::new_raw(sstore_code)), - ..Default::default() - }, + AccountInfo::from_bytecode(Bytecode::new_raw(sstore_code)), ); let tx = Recovered::new_unchecked( @@ -1400,7 +1388,7 @@ mod tests { assert!(!real_bal.is_empty(), "reference BAL must be non-empty"); // Tamper: append a phantom address not accessed during execution. - let phantom = alloy_primitives::Address::from([0xFF; 20]); + let phantom = Address::repeat_byte(0xFF); let mut tampered_entries: Vec = real_bal; tampered_entries.push(AccountChanges::new(phantom)); let tampered_bal: alloy_eip7928::bal::Bal = alloy_eip7928::bal::Bal::new(tampered_entries); @@ -1441,7 +1429,7 @@ mod tests { let evm_config = EthEvmConfig::mainnet(); let block = empty_amsterdam_block(B256::ZERO); - let failing_make_db = || -> Result, BalExecutionError> { + let failing_make_db = || -> Result { Err(reth_provider::ProviderError::BestBlockNotFound.into()) }; @@ -1530,7 +1518,7 @@ mod tests { ); // Non-Amsterdam: block_available_gas = 1_000_000 - 600_000 = 400_000 → reject 500_000. - let mut non_amsterdam = BlockGasTracker::new(block_gas_limit, false, None); + let mut non_amsterdam = BlockGasTracker::new(block_gas_limit, false, u64::MAX); non_amsterdam.record_result(&fake_result); assert!( non_amsterdam.validate_tx_limit(second_tx_gas_limit).is_err(), @@ -1538,7 +1526,7 @@ mod tests { ); // Amsterdam: both regular and state budgets have 700_000 left → accept 500_000. - let mut amsterdam = BlockGasTracker::new(block_gas_limit, true, None); + let mut amsterdam = BlockGasTracker::new(block_gas_limit, true, u64::MAX); amsterdam.record_result(&fake_result); assert!( amsterdam.validate_tx_limit(second_tx_gas_limit).is_ok(), @@ -1576,7 +1564,7 @@ mod tests { // Regular budget is full (block_regular_gas_used = 0) but the state budget has // only 400_000 left → reject 500_000. - let mut amsterdam = BlockGasTracker::new(block_gas_limit, true, None); + let mut amsterdam = BlockGasTracker::new(block_gas_limit, true, u64::MAX); amsterdam.record_result(&fake_result); assert!( amsterdam.validate_tx_limit(second_tx_gas_limit).is_err(), @@ -1589,7 +1577,7 @@ mod tests { // With a cap of 400_000 the capped regular check passes, but the full 500_000 // limit still counts against the state budget → reject. - let mut capped = BlockGasTracker::new(block_gas_limit, true, Some(400_000)); + let mut capped = BlockGasTracker::new(block_gas_limit, true, 400_000); capped.record_result(&fake_result); assert!( capped.validate_tx_limit(second_tx_gas_limit).is_err(), @@ -1614,7 +1602,7 @@ mod tests { // Case 1: fresh block, no prior gas consumed. // tx_min_gas_limit = TX_GAS_LIMIT_CAP (16_777_216) ≤ block_available_gas (30M) → Ok. - let tracker = BlockGasTracker::new(block_gas_limit, false, Some(TX_GAS_LIMIT_CAP)); + let tracker = BlockGasTracker::new(block_gas_limit, false, TX_GAS_LIMIT_CAP); assert!( tracker.validate_tx_limit(oversized).is_ok(), "oversized tx must pass when capped limit fits in block gas", @@ -1635,7 +1623,7 @@ mod tests { EvmState::default(), ); - let mut tracker = BlockGasTracker::new(block_gas_limit, false, Some(TX_GAS_LIMIT_CAP)); + let mut tracker = BlockGasTracker::new(block_gas_limit, false, TX_GAS_LIMIT_CAP); tracker.record_result(&fake_result); assert!( tracker.validate_tx_limit(oversized).is_err(), diff --git a/crates/engine/tree/src/tree/payload_processor/bal/ordered_outputs.rs b/crates/engine/tree/src/tree/payload_processor/bal/ordered_outputs.rs index 544c0e6eb4a..2621b2d7a62 100644 --- a/crates/engine/tree/src/tree/payload_processor/bal/ordered_outputs.rs +++ b/crates/engine/tree/src/tree/payload_processor/bal/ordered_outputs.rs @@ -176,44 +176,6 @@ mod tests { assert!(outputs.next().is_none()); } - #[test] - fn defers_indexed_execution_errors_to_their_slot() { - let (tx, rx) = crossbeam_channel::unbounded(); - tx.send(Err(BalWorkerError::Execution { - tx_index: 1, - tx_gas_limit: 42, - source: alloy_evm::block::BlockExecutionError::msg("bal miss"), - })) - .unwrap(); - tx.send(Ok(output(0, 0))).unwrap(); - drop(tx); - - let mut outputs = ordered_worker_outputs(&rx, 2); - - assert_eq!(outputs.next().expect("first item").expect("first output").result, 0); - expect_err_contains(outputs.next().expect("second item"), "transaction 1: bal miss"); - assert!(outputs.next().is_none()); - } - - #[test] - fn continues_past_indexed_execution_errors() { - let (tx, rx) = crossbeam_channel::unbounded(); - tx.send(Err(BalWorkerError::Execution { - tx_index: 0, - tx_gas_limit: 42, - source: alloy_evm::block::BlockExecutionError::msg("bal miss"), - })) - .unwrap(); - tx.send(Ok(output(1, 10))).unwrap(); - drop(tx); - - let mut outputs = ordered_worker_outputs(&rx, 2); - - expect_err_contains(outputs.next().expect("first item"), "transaction 0: bal miss"); - assert_eq!(outputs.next().expect("second item").expect("second output").result, 10); - assert!(outputs.next().is_none()); - } - #[test] fn orders_recovery_and_execution_errors_by_transaction_index() { let (tx, rx) = crossbeam_channel::unbounded(); diff --git a/crates/engine/tree/src/tree/payload_processor/mod.rs b/crates/engine/tree/src/tree/payload_processor/mod.rs index f3170406683..65f58658c87 100644 --- a/crates/engine/tree/src/tree/payload_processor/mod.rs +++ b/crates/engine/tree/src/tree/payload_processor/mod.rs @@ -2,7 +2,10 @@ use super::precompile_cache::PrecompileCacheMap; use crate::tree::{ - payload_processor::prewarm::{PrewarmCacheTask, PrewarmContext, PrewarmMode, PrewarmTaskEvent}, + payload_processor::{ + prewarm::{PrewarmCacheTask, PrewarmContext, PrewarmMode, PrewarmTaskEvent}, + reuse::{cfg_reuses_prewarm_results, PrewarmResults}, + }, CachedStateCacheMetrics, CachedStateMetrics, CachedStateMetricsSource, ExecutionCache, ExecutionEnv, PayloadExecutionCache, SavedCache, TreeConfig, }; @@ -14,7 +17,8 @@ use rayon::prelude::*; use reth_evm::{ block::ExecutableTxParts, execute::{ExecutableTxFor, WithTxEnv}, - ConfigureEvm, ConvertTx, ExecutableTxIterator, ExecutableTxTuple, SpecFor, TxEnvFor, + ConfigureEvm, ConvertTx, ExecutableTxIterator, ExecutableTxTuple, HaltReasonFor, SpecFor, + TxEnvFor, }; use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ @@ -29,7 +33,7 @@ pub use reth_trie_parallel::{ state_root_task::{ evm_state_to_hashed_post_state, PayloadStateRootHandle, StateAccessHint, StateRootComputeOutcome, StateRootHandle, StateRootHintStream, StateRootMessage, - StateRootSink, StateRootTaskCancelGuard, StateRootUpdateHook, StateRootUpdateStream, + StateRootTaskCancelGuard, StateRootUpdateHook, StateRootUpdateStream, }, }; use std::{ @@ -39,12 +43,13 @@ use std::{ mpsc, Arc, OnceLock, }, }; -use tracing::{debug, instrument, trace, warn, Span}; +use tracing::{debug, debug_span, instrument, trace, trace_span, warn, Span}; pub mod bal; pub mod bal_prewarm_pool; pub mod prewarm; pub mod receipt_root_task; +pub(crate) mod reuse; /// Blocks with fewer transactions than this skip prewarming, since the fixed overhead of spawning /// prewarm workers exceeds the execution time saved. @@ -57,6 +62,7 @@ type IteratorPayloadHandle = PayloadHandle< IteratorTx, ::Error, <::Primitives as NodePrimitives>::Receipt, + HaltReasonFor, >; type IteratorPrewarmTxReceiver = @@ -108,6 +114,8 @@ where disable_bal_parallel_state_root: bool, /// Whether BAL state prefetching during prewarm is disabled. disable_bal_batch_io: bool, + /// Whether block execution may reuse transaction prewarming results. + reuse_prewarm_results: bool, /// Dedicated blocking pool for warming the BAL read-set, created lazily on the first BAL block /// (see [`Self::bal_prewarm_pool`]). Its threads exit when the processor is dropped. bal_prewarm_pool: OnceLock>, @@ -139,6 +147,7 @@ where .then(CachedStateCacheMetrics::default), disable_bal_parallel_state_root: config.disable_bal_parallel_state_root(), disable_bal_batch_io: config.disable_bal_batch_io(), + reuse_prewarm_results: config.prewarm_reuse(), bal_prewarm_pool: OnceLock::new(), } } @@ -263,6 +272,7 @@ where let (prewarm_tx, prewarm_rx) = prewarm_transactions.then(|| mpsc::sync_channel(transaction_count)).unzip(); let (execute_tx, execute_rx) = crossbeam_channel::bounded(transaction_count); + let parent_span = Span::current(); if transaction_count == 0 { // Empty block — nothing to do. @@ -275,6 +285,9 @@ where "using sequential sig recovery for small block" ); self.executor.spawn_blocking_named("tx-iterator", move || { + // Restore the parent even when the worker span is filtered out. + let _parent = parent_span.enter(); + let _span = debug_span!(target: "engine::tree::payload_processor", "tx_iterator", transaction_count).entered(); let (transactions, convert) = transactions.into_parts(); convert_serial( transactions.into_iter(), @@ -288,15 +301,25 @@ where // to prewarming and execution. let executor = self.executor.clone(); self.executor.spawn_blocking_named("tx-iterator", move || { + let _parent = parent_span.enter(); + let _span = debug_span!(target: "engine::tree::payload_processor", "tx_iterator", transaction_count).entered(); let (transactions, convert) = transactions.into_parts(); if parallel_bal_execution { // With BALs, we don't care about the order of transactions in execution and // prewarming, so we don't have to use `for_each_ordered_in`. + let parent_span = Span::current(); executor.cpu_pool().install(|| { + let _parent = parent_span.enter(); + let span = debug_span!(target: "engine::tree::payload_processor", "convert_transactions").or_current(); + let _entered = span.enter(); let _ = transactions .into_par_iter() .enumerate() + // Feed the ordered commit loop before recovering distant transactions. + .by_exponential_blocks() .try_for_each(|(idx, tx)| { + let _parent = span.enter(); + let _span = trace_span!(target: "engine::tree::payload_processor", "convert_transaction", idx).entered(); let tx = convert.convert(tx).map(WithTxEnv::new); if let (Some(prewarm_tx), Ok(tx)) = (&prewarm_tx, &tx) { let _ = prewarm_tx.send((idx, tx.clone())); @@ -336,7 +359,11 @@ where // Without BALs, we need to preserve the initial order of transactions. // Process exponentially increasing windows to make sure that first transactions are prioritized. + let parent_span = Span::current(); executor.cpu_pool().install(move || { + let _parent = parent_span.enter(); + let span = debug_span!(target: "engine::tree::payload_processor", "convert_transactions").or_current(); + let _entered = span.enter(); loop { let chunk = iter .by_ref() @@ -352,6 +379,8 @@ where .into_par_iter() .map(|(i, tx)| { let idx = i + prefetch; + let _parent = span.enter(); + let _span = trace_span!(target: "engine::tree::payload_processor", "convert_transaction", idx).entered(); let tx = convert.convert(tx).map(WithTxEnv::new); (idx, tx) }) @@ -386,13 +415,13 @@ where &self, env: ExecutionEnv, transactions: Option< - mpsc::Receiver<(usize, impl ExecutableTxFor + Clone + Send + 'static)>, + mpsc::Receiver<(usize, impl ExecutableTxFor + Clone + Send + Sync + 'static)>, >, state_provider_factory: OverlayStateProviderFactory, hint_stream: Option, hashed_update_stream: Option, parallel_bal_execution: bool, - ) -> CacheTaskHandle<::Receipt> + ) -> CacheTaskHandle<::Receipt, HaltReasonFor> where P: DatabaseProviderFactory + Clone + 'static, P::Provider: BlockNumReader @@ -418,6 +447,16 @@ where }; let saved_cache = self.disable_state_cache.not().then(|| self.cache_for(env.parent_hash)); + // Reused results replace whole transaction executions, which is only equivalent for + // configurations that opt in, without block access lists and EIP-8037 multidimensional + // block gas. + let prewarm_results = (self.reuse_prewarm_results && + matches!(mode, PrewarmMode::Transactions { .. }) && + env.decoded_bal.is_none() && + cfg_reuses_prewarm_results(&env.evm_env.cfg_env) && + self.evm_config.reuses_prewarm_results()) + .then(|| Arc::new(PrewarmResults::new(env.transaction_count))); + let executed_tx_index = Arc::new(AtomicUsize::new(0)); // configure prewarming let prewarm_ctx = PrewarmContext { @@ -435,6 +474,7 @@ where precompile_cache_map: self.precompile_cache_map.clone(), disable_bal_parallel_state_root: self.disable_bal_parallel_state_root, disable_bal_batch_io: self.disable_bal_batch_io, + prewarm_results: prewarm_results.clone(), }; let (prewarm_task, to_prewarm_task) = @@ -450,6 +490,7 @@ where saved_cache, to_prewarm_task: Some(to_prewarm_task), executed_tx_index, + prewarm_results, cache_metrics: self.cache_metrics.clone(), } } @@ -543,6 +584,9 @@ where C: ConvertTx, { for (idx, raw_tx) in iter.enumerate() { + let _span = + trace_span!(target: "engine::tree::payload_processor", "convert_transaction", idx) + .entered(); let tx = convert.convert(raw_tx); let failed = tx.is_err(); let tx = tx.map(WithTxEnv::new); @@ -560,17 +604,18 @@ where /// Handle to all the spawned tasks. /// /// Generic over `R` (receipt type) to allow sharing `Arc>` with the -/// caching task without cloning the expensive `BundleState`. +/// caching task without cloning the expensive `BundleState`, and over `H` (halt reason) of the +/// prewarm results that execution may reuse. #[derive(Debug)] -pub struct PayloadHandle { - prewarm_handle: CacheTaskHandle, +pub struct PayloadHandle { + prewarm_handle: CacheTaskHandle, /// Stream of block transactions and their indices in the block. transactions: IndexedTxReceiver, /// Span for tracing _span: Span, } -impl PayloadHandle { +impl PayloadHandle { /// Returns a clone of the caches used by prewarming pub fn caches(&self) -> Option { self.prewarm_handle.saved_cache.as_ref().map(|cache| cache.cache().clone()) @@ -589,6 +634,11 @@ impl PayloadHandle { &self.prewarm_handle.executed_tx_index } + /// Returns the transaction prewarming results that execution may reuse, if enabled. + pub(crate) fn prewarm_results(&self) -> Option>> { + self.prewarm_handle.prewarm_results.clone() + } + /// Terminates the pre-warming transaction processing. /// /// Note: This does not terminate the task yet. @@ -624,9 +674,10 @@ impl PayloadHandle { /// Access to the spawned [`PrewarmCacheTask`]. /// /// Generic over `R` (receipt type) to allow sharing `Arc>` with the -/// prewarm task without cloning the expensive `BundleState`. +/// prewarm task without cloning the expensive `BundleState`, and over `H` (halt reason) of the +/// prewarm results that execution may reuse. #[derive(Debug)] -pub struct CacheTaskHandle { +pub struct CacheTaskHandle { /// The shared cache the task operates with. saved_cache: Option, /// Channel to the spawned prewarm task if any @@ -634,11 +685,13 @@ pub struct CacheTaskHandle { /// Shared counter tracking the next transaction index to be executed by the main execution /// loop. Prewarm workers skip transactions below this index. executed_tx_index: Arc, + /// Transaction prewarming results that execution may reuse. + prewarm_results: Option>>, /// Metrics for the execution cache. cache_metrics: Option, } -impl CacheTaskHandle { +impl CacheTaskHandle { /// Terminates the pre-warming transaction processing. /// /// Note: This does not terminate the task yet. @@ -669,7 +722,7 @@ impl CacheTaskHandle { } } -impl Drop for CacheTaskHandle { +impl Drop for CacheTaskHandle { fn drop(&mut self) { // Ensure we always terminate on drop - send None without needing Send + Sync bounds if let Some(tx) = self.to_prewarm_task.take() { @@ -683,6 +736,7 @@ impl Drop for CacheTaskHandle { #[cfg(test)] mod tests { + use super::*; use crate::tree::{ payload_processor::PayloadProcessor, precompile_cache::PrecompileCacheMap, ExecutionCache, PayloadExecutionCache, SavedCache, TreeConfig, @@ -690,12 +744,23 @@ mod tests { use alloy_consensus::constants::KECCAK_EMPTY; use alloy_eips::eip1898::{BlockNumHash, BlockWithParent}; use alloy_primitives::{Address, B256, U256}; + use proptest::{ + prelude::any, + test_runner::{Config, TestRunner}, + }; use reth_chainspec::ChainSpec; use reth_evm_ethereum::EthEvmConfig; use reth_execution_cache::CachedStatus; use reth_revm::db::BundleState; use revm::state::AccountInfo; - use std::sync::{atomic::Ordering, Arc}; + use std::{ + collections::HashSet, + process::Command, + sync::{atomic::Ordering, Arc, Mutex}, + }; + use tracing_subscriber::{ + filter::LevelFilter, layer::SubscriberExt, registry::LookupSpan, Registry, + }; type TestTx = reth_evm::execute::WithTxEnv< reth_evm::TxEnvFor, @@ -836,10 +901,11 @@ mod tests { false, ); let handle = super::PayloadHandle { - prewarm_handle: super::CacheTaskHandle::<()> { + prewarm_handle: super::CacheTaskHandle::<(), ()> { saved_cache: None, to_prewarm_task: None, executed_tx_index: Default::default(), + prewarm_results: None, cache_metrics: None, }, transactions: receiver, @@ -869,7 +935,7 @@ mod tests { #[test] fn execution_cache_allows_single_checkout() { let execution_cache = PayloadExecutionCache::default(); - let hash = B256::from([1u8; 32]); + let hash = B256::repeat_byte(1u8); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash))); @@ -888,7 +954,7 @@ mod tests { #[test] fn execution_cache_checkout_releases_on_drop() { let execution_cache = PayloadExecutionCache::default(); - let hash = B256::from([2u8; 32]); + let hash = B256::repeat_byte(2u8); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash))); @@ -905,13 +971,13 @@ mod tests { #[test] fn execution_cache_mismatch_parent_clears_and_returns() { let execution_cache = PayloadExecutionCache::default(); - let hash = B256::from([3u8; 32]); + let hash = B256::repeat_byte(3u8); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(hash))); // When the parent hash doesn't match (fork block), the cache is cleared, // hash updated on the original, and clone returned for reuse - let different_hash = B256::from([4u8; 32]); + let different_hash = B256::repeat_byte(4u8); let cache = execution_cache.get_cache_for(different_hash); assert!(cache.is_some(), "cache should be returned for reuse after clearing"); @@ -926,7 +992,7 @@ mod tests { #[test] fn execution_cache_update_after_release_succeeds() { let execution_cache = PayloadExecutionCache::default(); - let initial = B256::from([5u8; 32]); + let initial = B256::repeat_byte(5u8); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(initial))); @@ -935,7 +1001,7 @@ mod tests { drop(guard); - let updated = B256::from([6u8; 32]); + let updated = B256::repeat_byte(6u8); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(updated))); let new_checkout = execution_cache.get_cache_for(updated); @@ -951,8 +1017,8 @@ mod tests { PrecompileCacheMap::default(), ); - let parent_hash = B256::from([1u8; 32]); - let block_hash = B256::from([10u8; 32]); + let parent_hash = B256::repeat_byte(1u8); + let block_hash = B256::repeat_byte(10u8); let block_with_parent = BlockWithParent { block: BlockNumHash { hash: block_hash, number: 1 }, parent: parent_hash, @@ -981,14 +1047,14 @@ mod tests { ); // Setup: populate cache with block 1 - let block1_hash = B256::from([1u8; 32]); + let block1_hash = B256::repeat_byte(1u8); payload_processor .execution_cache .update_with_guard(|slot| *slot = Some(make_saved_cache(block1_hash))); // Try to insert block 3 with wrong parent (should skip and keep block 1's cache) - let wrong_parent = B256::from([99u8; 32]); - let block3_hash = B256::from([3u8; 32]); + let wrong_parent = B256::repeat_byte(99u8); + let block3_hash = B256::repeat_byte(3u8); let block_with_parent = BlockWithParent { block: BlockNumHash { hash: block3_hash, number: 3 }, parent: wrong_parent, @@ -1015,7 +1081,7 @@ mod tests { PrecompileCacheMap::default(), ); - let parent_hash = B256::from([1u8; 32]); + let parent_hash = B256::repeat_byte(1u8); payload_processor .execution_cache .update_with_guard(|slot| *slot = Some(make_saved_cache(parent_hash))); @@ -1045,7 +1111,7 @@ mod tests { // Make parent match the cached slot so we bypass the parent-mismatch guard and exercise // the in-use guard specifically. let block_with_parent = BlockWithParent { - block: BlockNumHash { hash: B256::from([2u8; 32]), number: 2 }, + block: BlockNumHash { hash: B256::repeat_byte(2u8), number: 2 }, parent: parent_hash, }; @@ -1080,12 +1146,12 @@ mod tests { let execution_cache = PayloadExecutionCache::default(); // Canonical chain at block 4. - let block4_hash = B256::from([4u8; 32]); + let block4_hash = B256::repeat_byte(4u8); execution_cache.update_with_guard(|slot| *slot = Some(make_saved_cache(block4_hash))); // Fork block arrives with parent = block 2. Prewarm task checks out the cache. // This simulates PrewarmCacheTask receiving a SavedCache clone from get_cache_for. - let fork_parent = B256::from([2u8; 32]); + let fork_parent = B256::repeat_byte(2u8); let prewarm_cache = execution_cache.get_cache_for(fork_parent); assert!(prewarm_cache.is_some(), "prewarm should obtain cache for fork block"); let prewarm_cache = prewarm_cache.unwrap(); @@ -1093,8 +1159,8 @@ mod tests { // Prewarm populates cache with fork-specific state (ancestor data for block 2). // Since ExecutionCache uses Arc, this data is shared with the stored original. - let fork_addr = Address::from([0xBB; 20]); - let fork_key = B256::from([0xCC; 32]); + let fork_addr = Address::repeat_byte(0xBB); + let fork_key = B256::repeat_byte(0xCC); prewarm_cache.cache().insert_storage(fork_addr, fork_key, Some(U256::from(999))); // While prewarm holds the clone, the cache handle count > 1 so the cache is in use. @@ -1121,4 +1187,118 @@ mod tests { "cache must carry the canonical parent hash, not the fork parent" ); } + + #[test] + fn transaction_conversion_has_child_spans() { + // Worker threads use the global subscriber. Isolate it from other tests and run each + // filter level in a fresh process so filtered child spans also exercise context fallback. + const LEVEL_ENV: &str = "RETH_TEST_CONVERSION_SPAN_LEVEL"; + let Ok(level) = std::env::var(LEVEL_ENV) else { + for level in ["trace", "debug", "info"] { + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "tree::payload_processor::tests::transaction_conversion_has_child_spans", + "--nocapture", + ]) + .env(LEVEL_ENV, level) + .output() + .unwrap(); + assert!( + output.status.success(), + "{level}: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + return; + }; + let level = level.parse::().unwrap(); + tracing::subscriber::set_global_default(tracing_subscriber::registry().with(level)) + .unwrap(); + let processor = test_processor(); + + // Exercise both sides of the sequential cutoff and the first parallel window, as well + // as randomly sized blocks. BAL execution bypasses the sequential prefetch/windowing. + for count in [0, 1, 29, 30, 68, 69] { + for bal in [false, true] { + assert_conversion_spans(&processor, count, bal, level); + } + } + TestRunner::new(Config::with_cases(32)) + .run(&(0..200usize, any::()), |(count, bal)| { + assert_conversion_spans(&processor, count, bal, level); + Ok(()) + }) + .unwrap(); + } + + fn assert_conversion_spans( + processor: &PayloadProcessor, + count: usize, + bal: bool, + level: LevelFilter, + ) { + let request = tracing::info_span!(parent: None, "request"); + let _entered = request.enter(); + // Retain the spans so registry IDs cannot be recycled between conversions. + let observed = Arc::new(Mutex::new(Vec::new())); + let converted = observed.clone(); + let (_, receiver) = processor.spawn_tx_iterator( + ((0..count).collect::>(), move |idx| { + converted.lock().unwrap().push((idx, Span::current())); + Ok::<_, std::io::Error>(converted_tx()) + }), + count, + bal, + false, + ); + let mut indices = Vec::new(); + loop { + match receiver.recv_timeout(std::time::Duration::from_secs(10)) { + Ok((idx, tx)) => { + assert!(tx.is_ok()); + indices.push(idx); + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, + Err(err) => panic!("conversion did not terminate: {err}"), + } + } + if bal { + indices.sort_unstable(); + } + assert_eq!(indices, (0..count).collect::>()); + let observed = observed.lock().unwrap(); + assert_eq!(observed.len(), count); + let mut transaction_ids = HashSet::new(); + for (idx, span) in observed.iter() { + let scope = span + .with_subscriber(|(id, dispatch)| { + dispatch + .downcast_ref::() + .unwrap() + .span(id) + .unwrap() + .scope() + .from_root() + .map(|span| (span.id(), span.name())) + .collect::>() + }) + .expect("conversion lost its request context"); + assert_eq!(Some(scope[0].0.clone()), request.id()); + let mut expected = vec!["request"]; + if level >= LevelFilter::DEBUG { + expected.push("spawn_tx_iterator"); + expected.push("tx_iterator"); + if count >= 30 && (bal || *idx >= 4) { + expected.push("convert_transactions"); + } + } + if level == LevelFilter::TRACE { + expected.push("convert_transaction"); + assert!(transaction_ids.insert(span.id().unwrap()), "reused a transaction span"); + } + assert_eq!(scope.iter().map(|(_, name)| *name).collect::>(), expected); + } + } } diff --git a/crates/engine/tree/src/tree/payload_processor/prewarm.rs b/crates/engine/tree/src/tree/payload_processor/prewarm.rs index c4a289d6a7d..706a34d3781 100644 --- a/crates/engine/tree/src/tree/payload_processor/prewarm.rs +++ b/crates/engine/tree/src/tree/payload_processor/prewarm.rs @@ -10,8 +10,14 @@ //! and one for actual execution (executed sequentially) //! 2. Prewarming tasks execute transactions in parallel using shared caches //! 3. When actual block execution happens, it benefits from the warmed cache +//! 4. Block execution commits prewarm results whose reads are still valid instead of executing the +//! transactions again -use super::{bal_prewarm_pool::BalPrewarmPool, StateRootHintStream, StateRootUpdateStream}; +use super::{ + bal_prewarm_pool::BalPrewarmPool, + reuse::{BalanceTracker, PoolTx, PrewarmResults, RecordingDb, SpeculativeTx}, + StateRootHintStream, StateRootUpdateStream, +}; use crate::tree::{ precompile_cache::{CachedPrecompile, PrecompileCacheMap}, CachedStateCacheMetrics, CachedStateMetrics, CachedStateProvider, ExecutionEnv, @@ -23,7 +29,9 @@ use alloy_eips::eip4895::Withdrawal; use alloy_primitives::keccak256; use metrics::{Counter, Gauge, Histogram}; use rayon::prelude::*; -use reth_evm::{execute::ExecutableTxFor, ConfigureEvm, Evm, EvmFor, RecoveredTx, SpecFor}; +use reth_evm::{ + execute::ExecutableTxFor, ConfigureEvm, Evm, EvmFor, HaltReasonFor, RecoveredTx, SpecFor, +}; use reth_metrics::Metrics; use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; use reth_provider::{ @@ -36,14 +44,28 @@ use reth_revm::database::StateProviderDatabase; use reth_storage_overlay::OverlayStateProviderFactory; use reth_tasks::{pool::WorkerPool, Runtime}; use reth_trie_common::MultiProofTargetsV2; -use std::sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - mpsc::{self, channel, Receiver, Sender}, - Arc, +use revm::{context::Transaction as _, state::EvmState}; +use std::{ + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc::{self, channel, Receiver, Sender}, + Arc, + }, + time::Duration, }; use tokio::sync::oneshot; use tracing::{debug, debug_span, instrument, trace, trace_span, warn, Span}; +/// Maximum number of consecutive rounds that execute stale prewarm results again. +const MAX_REFRESH_ROUNDS: usize = 4; + +/// Number of transactions ahead of block execution that refreshing checks after block execution +/// published actual writes. +const REFRESH_WINDOW: usize = 64; + +/// How often refreshing checks whether block execution published actual writes. +const REFRESH_POLL_INTERVAL: Duration = Duration::from_micros(50); + /// Determines the prewarming mode: transaction-based, BAL-based, or skipped. /// /// Each variant carries the state-root capability its producers use, so the capability dies @@ -138,7 +160,7 @@ where actions_tx: Sender>, state_root_hint_stream: Option, ) where - Tx: ExecutableTxFor + Send + 'static, + Tx: ExecutableTxFor + Clone + Send + Sync + 'static, { let executor = self.executor.clone(); let ctx = self.ctx.clone(); @@ -157,6 +179,8 @@ where let mut tx_count = 0usize; let state_root_hint_stream = state_root_hint_stream.as_ref(); + // Transactions to execute again when their results become stale. + let mut refresh_txs = Vec::new(); pool.in_place_scope(|s| { s.spawn(|_| { pool.init::>(|_| ctx.evm_for_ctx()); @@ -177,6 +201,20 @@ where } tx_count += 1; + if ctx.prewarm_results.is_some() { + refresh_txs.push((index, tx.clone())); + } + // Seed results before queueing, so that they are ready as soon as possible. + let seeded = if let Some(results) = &ctx.prewarm_results && + let Some(seeds) = &ctx.env.pool_seeds + { + results.seed(index, tx.tx().tx_hash(), seeds) + } else { + None + }; + if seeded.is_some() { + ctx.metrics.seeded_transactions.increment(1); + } let parent_span = Span::current(); s.spawn(move |_| { let _enter = trace_span!( @@ -186,7 +224,7 @@ where i = index, ) .entered(); - Self::transact_worker(ctx, index, tx, state_root_hint_stream); + Self::transact_worker(ctx, index, tx, seeded, state_root_hint_stream); }); } @@ -200,6 +238,10 @@ where } }); + if let Some(results) = &ctx.prewarm_results { + Self::refresh_results(ctx, pool, results, &refresh_txs); + } + // All tasks are done — clear per-thread EVM state for the next block. pool.clear(); @@ -208,7 +250,72 @@ where }); } - /// Executes a single prewarm transaction on the current pool thread's EVM. + /// Executes transactions again whose results became stale, because the predicted writes of + /// earlier transactions changed after they were executed. + /// + /// Each round checks the transactions that block execution has not reached yet, in block + /// order, on all pool threads. Rounds repeat while they find stale results, then wait for + /// block execution to publish actual writes that differ from the predicted ones, until + /// prewarming stops. + fn refresh_results( + ctx: &PrewarmContext, + pool: &WorkerPool, + results: &PrewarmResults>, + txs: &[(usize, Tx)], + ) where + Tx: ExecutableTxFor + Clone + Send + Sync, + { + let mut rounds = 0; + // Rounds after block execution published writes only check the transactions it reaches + // soon, the others are checked in later rounds. + let mut window_len = usize::MAX; + while !ctx.should_stop() { + let generation = results.predicted().generation(); + // Skip the transaction block execution is executing and the one it reaches next. + let next = ctx.executed_tx_index.load(Ordering::Relaxed) + 1; + let start = txs.partition_point(|(index, _)| *index <= next); + if start == txs.len() { + break + } + let window = &txs[start..txs.len().min(start.saturating_add(window_len))]; + // Checking is cheap, so only stale transactions are handed to the pool. + let stale = window + .iter() + .filter(|(index, _)| results.needs_refresh(*index)) + .collect::>(); + let refreshed = !stale.is_empty(); + if refreshed { + let cursor = AtomicUsize::new(0); + pool.in_place_scope(|s| { + for _ in 0..pool.current_num_threads().min(stale.len()) { + s.spawn(|_| { + while let Some((index, tx)) = + stale.get(cursor.fetch_add(1, Ordering::Relaxed)) + { + if ctx.should_stop() { + break + } + Self::transact_worker(ctx, *index, tx.clone(), None, None); + } + }); + } + }); + } + + rounds += 1; + if refreshed && rounds < MAX_REFRESH_ROUNDS { + continue + } + rounds = 0; + window_len = REFRESH_WINDOW; + while results.predicted().generation() == generation && !ctx.should_stop() { + std::thread::sleep(REFRESH_POLL_INTERVAL); + } + } + } + + /// Executes a single prewarm transaction on the current pool thread's EVM, or only loads the + /// reads of its `seeded` pool transaction result. /// /// Lazily initialises per-thread [`PrewarmEvmState`] via /// [`get_or_init`](reth_tasks::pool::Worker::get_or_init) on first access. @@ -216,17 +323,12 @@ where ctx: &PrewarmContext, index: usize, tx: Tx, + seeded: Option>>>, state_root_hint_stream: Option<&StateRootHintStream>, ) where Tx: ExecutableTxFor, { WorkerPool::with_worker_mut(|worker| { - let Some(evm) = - worker.get_or_init::>(|| ctx.evm_for_ctx()).as_mut() - else { - return; - }; - if ctx.should_stop() { return; } @@ -236,10 +338,56 @@ where return; } + let state = worker.get_or_init::>(|| ctx.evm_for_ctx()); + // The pool is shared across blocks, so this thread may still hold the EVM of a + // previous prewarm run that has not finished yet. Runs of payloads that claim the same + // block hash may use different block environments, so the hash cannot tell them apart. + if !Arc::ptr_eq(&state.run, &ctx.terminate_execution) { + *state = ctx.evm_for_ctx(); + } + let Some(evm) = state.evm.as_mut() else { return }; + let start = Instant::now(); let (tx_env, tx) = tx.into_parts(); - let res = match evm.transact(tx_env) { + let send_hints = |state: &EvmState| { + if index > 0 && + let Some(state_root_hint_stream) = state_root_hint_stream + { + let (targets, storage_targets) = MultiProofTargetsV2::from_state(state); + ctx.metrics.prefetch_storage_targets.record(storage_targets as f64); + state_root_hint_stream.on_access_hint(targets.into()); + } + }; + + if let Some(seeded) = seeded { + // Block execution loads the reads to validate the result. + if let Err(err) = seeded.load_reads(evm.db_mut()) { + trace!( + target: "engine::tree::payload_processor::prewarm", + %err, + tx_hash=%tx.tx().tx_hash(), + "Failed to load reads of seeded pool transaction", + ); + } + send_hints(seeded.state()); + return; + } + + // Prewarming disables nonce and balance checks, so a reusable result must also show + // that the caller would have passed them. + let speculative = SpeculativeTx { + caller: *tx.signer(), + nonce: tx_env.nonce(), + max_balance_spending: tx_env.max_balance_spending(), + }; + if let Some(results) = &ctx.prewarm_results { + evm.db_mut().start_recording(Arc::clone(results.predicted()), index); + evm.inspector_mut().clear(); + } + let res = evm.transact(tx_env); + let reads = evm.db_mut().take_reads(); + let res = match res { Ok(res) => res, Err(err) => { trace!( @@ -250,6 +398,11 @@ where "Error when executing prewarm transaction", ); ctx.metrics.transaction_errors.increment(1); + if let Some(results) = &ctx.prewarm_results && + let Some(reads) = reads + { + results.reject(index, reads); + } return; } }; @@ -259,12 +412,13 @@ where return; } - if index > 0 { - let (targets, storage_targets) = MultiProofTargetsV2::from_state(res.state); - ctx.metrics.prefetch_storage_targets.record(storage_targets as f64); - if let Some(state_root_hint_stream) = state_root_hint_stream { - state_root_hint_stream.on_access_hint(targets.into()); - } + send_hints(&res.state); + + if let Some(results) = &ctx.prewarm_results && + let Some(reads) = reads && + index >= ctx.executed_tx_index.load(Ordering::Relaxed) + { + results.store(index, res, reads, evm.inspector_mut(), speculative); } ctx.metrics.total_runtime.record(start.elapsed()); @@ -473,7 +627,7 @@ where )] pub fn run(self, mode: PrewarmMode, actions_tx: Sender>) where - Tx: ExecutableTxFor + Send + 'static, + Tx: ExecutableTxFor + Clone + Send + Sync + 'static, { // Spawn execution tasks based on mode. The state-root capabilities arrive inside the // mode and move into the spawned producers, so they die with the producers instead of @@ -577,11 +731,20 @@ where pub disable_bal_parallel_state_root: bool, /// Whether BAL state prefetching during prewarm is disabled. pub disable_bal_batch_io: bool, + /// Results of transaction prewarming that block execution may reuse, `None` if disabled. + pub(crate) prewarm_results: Option>>>, } /// Per-thread EVM state initialised by [`PrewarmContext::evm_for_ctx`] and stored in /// [`WorkerPool`] workers via [`Worker::get_or_init`](reth_tasks::pool::Worker::get_or_init). -type PrewarmEvmState = Option>>; +struct PrewarmEvmState { + /// Termination flag of the prewarm run that created the EVM, which identifies the run. + run: Arc, + /// The EVM, `None` if its state provider could not be created. + evm: Option< + EvmFor>, BalanceTracker>, + >, +} impl PrewarmContext where @@ -597,7 +760,7 @@ where + 'static, Evm: ConfigureEvm + 'static, { - /// Creates a per-thread EVM for prewarming. + /// Creates a per-thread EVM for prewarming, tagged with the prewarm run it belongs to. #[instrument(level = "debug", target = "engine::tree::payload_processor::prewarm", skip_all)] fn evm_for_ctx(&self) -> PrewarmEvmState { let mut state_provider = match self.provider.database_provider_ro() { @@ -608,7 +771,7 @@ where %err, "Failed to build state provider in prewarm thread" ); - return None + return PrewarmEvmState { run: Arc::clone(&self.terminate_execution), evm: None } } }; @@ -635,7 +798,19 @@ where // create a new executor and disable nonce checks in the env let spec_id = *evm_env.spec_id(); - let mut evm = self.evm_config.evm_with_env(state_provider, evm_env); + // Balances only need to be tracked to record reads for result reuse, and JIT-compiled + // code would skip the inspector hooks that track them. + let reuse = self.prewarm_results.is_some(); + let mut evm_config = self.evm_config.clone(); + if reuse { + evm_config = evm_config.with_jit_support_enabled(false); + } + let mut evm = evm_config.evm_with_env_and_inspector( + RecordingDb::new(state_provider), + evm_env, + BalanceTracker::default(), + ); + evm.set_inspector_enabled(reuse); if !self.precompile_cache_disabled { // Only cache pure precompiles to avoid issues with stateful precompiles @@ -649,7 +824,7 @@ where }); } - Some(evm) + PrewarmEvmState { run: Arc::clone(&self.terminate_execution), evm: Some(evm) } } /// Returns `true` if prewarming should stop. @@ -781,19 +956,85 @@ fn multiproof_targets_from_withdrawals(withdrawals: &[Withdrawal]) -> MultiProof } } +/// The events the pre-warm task can handle. +/// +/// Generic over `R` (receipt type) to allow sharing `Arc>` with the main +/// execution path without cloning the expensive `BundleState`. +#[derive(Debug)] +pub enum PrewarmTaskEvent { + /// Signals the prewarm workers to stop executing further transactions. + /// + /// This only sets the termination flag the workers poll; the task keeps running to save the + /// cache. Sent once the authoritative execution no longer needs prewarming, so the workers do + /// not race ahead on transactions that will never be used. + TerminateTransactionExecution, + /// Tears the whole task down: stops the workers, optionally saves the warmed cache from the + /// final output, and exits. + /// + /// Sent when execution completed successfully (carrying the output to save) or when the task + /// handle is dropped (carrying no output, e.g. after an execution error). Handling this event + /// also stops the workers, since a teardown may arrive without a preceding + /// [`TerminateTransactionExecution`](Self::TerminateTransactionExecution). + Terminate { + /// The final execution outcome, or `None` when the task is torn down without one (e.g. a + /// dropped handle). Using `Arc` allows sharing with the main execution path without + /// cloning the expensive `BundleState`. + execution_outcome: Option>>, + /// Receiver for the block validation result. + /// + /// Cache saving is racing the state root validation. We optimistically construct the + /// updated cache but only save it once we know the block is valid. + valid_block_rx: mpsc::Receiver<()>, + }, + /// Emitted by the worker-dispatch side once every dispatched transaction has finished or been + /// cancelled, reporting how many were executed. + FinishedTxExecution { + /// Number of transactions executed + executed_transactions: usize, + }, +} + +/// Metrics for transactions prewarming. +#[derive(Metrics, Clone)] +#[metrics(scope = "sync.prewarm")] +pub struct PrewarmMetrics { + /// The number of transactions to prewarm + pub(crate) transactions: Gauge, + /// A histogram of the number of transactions to prewarm + pub(crate) transactions_histogram: Histogram, + /// A histogram of duration per transaction prewarming + pub(crate) total_runtime: Histogram, + /// A histogram of EVM execution duration per transaction prewarming + pub(crate) execution_duration: Histogram, + /// A histogram for prefetch targets per transaction prewarming + pub(crate) prefetch_storage_targets: Histogram, + /// Time spent in `save_cache`, including dropping its removed `SavedCache` values. + /// Excludes any later freeing of cache contents by other `ExecutionCache` clones. + pub(crate) cache_saving_duration: Gauge, + /// Counter for transaction execution errors during prewarming + pub(crate) transaction_errors: Counter, + /// Counter for transactions seeded with results of pool transactions instead of being + /// executed. + pub(crate) seeded_transactions: Counter, + /// A histogram of BAL slot iteration duration during prefetching + pub(crate) bal_slot_iteration_duration: Histogram, +} + #[cfg(test)] mod tests { + use super::*; - use alloy_consensus::transaction::Recovered; - use alloy_eip7928::{AccountChanges, BalanceChange, BlockAccessIndex}; - use alloy_primitives::{address, B256, U256}; + use alloy_consensus::{transaction::Recovered, TxLegacy}; + use alloy_eips::eip7702::constants::EIP7702_CLEARED_DELEGATION; + use alloy_primitives::{Address, Signature, TxKind, B256, U256}; use reth_chainspec::ChainSpec; use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_evm::{execute::WithTxEnv, TxEnvFor}; use reth_evm_ethereum::EthEvmConfig; - use reth_primitives_traits::Account; use reth_provider::test_utils::MockEthProvider; + use reth_stages_api::{StageCheckpoint, StageId}; use reth_storage_overlay::OverlayManager; + use revm::database::EmptyDB; #[test] fn terminate_event_stops_transaction_execution() { @@ -816,6 +1057,7 @@ mod tests { precompile_cache_map: PrecompileCacheMap::default(), disable_bal_parallel_state_root: false, disable_bal_batch_io: false, + prewarm_results: None, }; let (task, actions_tx) = PrewarmCacheTask::new(Runtime::test(), PayloadExecutionCache::default(), ctx); @@ -834,6 +1076,67 @@ mod tests { assert!(terminate_execution.load(Ordering::Relaxed)); } + #[test] + fn rebuilds_evm_for_each_prewarm_run() { + let caller = Address::repeat_byte(1); + let provider = MockEthProvider::default(); + provider.enable_database_provider(); + provider.add_header(B256::ZERO, Default::default()); + provider.add_stage_checkpoint(StageId::Finish, StageCheckpoint::new(0)); + let tx = Recovered::new_unchecked( + TransactionSigned::new_unhashed( + // Free, so that the empty caller can pay for it. + TxLegacy { + gas_limit: 21_000, + to: TxKind::Call(Address::repeat_byte(2)), + ..Default::default() + } + .into(), + Signature::test_signature(), + ), + caller, + ); + let pool = WorkerPool::new(1, "prewarm-test"); + // Payloads that claim the same block hash may still use different block environments. + let prewarm = |beneficiary| { + let mut env = ExecutionEnv::::test_default(); + env.evm_env.block_env.beneficiary = beneficiary; + let results = Arc::new(PrewarmResults::new(1)); + let ctx = PrewarmContext { + env, + evm_config: EthEvmConfig::new(Arc::new(ChainSpec::default())), + saved_cache: None, + provider: OverlayStateProviderFactory::new( + provider.clone(), + OverlayManager::default().overlay_builder(B256::ZERO), + ), + bal_prewarm_pool: None, + metrics: PrewarmMetrics::default(), + cache_metrics: None, + cache_state_metrics: None, + terminate_execution: Arc::new(AtomicBool::new(false)), + executed_tx_index: Arc::new(AtomicUsize::new(0)), + precompile_cache_disabled: false, + precompile_cache_map: PrecompileCacheMap::default(), + disable_bal_parallel_state_root: false, + disable_bal_batch_io: false, + prewarm_results: Some(Arc::clone(&results)), + }; + let tx = WithTxEnv { tx_env: ctx.evm_config.tx_env(&tx), tx: Arc::new(tx.clone()) }; + pool.install_fn(|| { + PrewarmCacheTask::::transact_worker( + &ctx, 0, tx, None, None, + ) + }); + results.take_valid(0, &mut EmptyDB::default()).unwrap().unwrap().state + }; + + assert!(prewarm(Address::repeat_byte(0xa)).contains_key(&Address::repeat_byte(0xa))); + let state = prewarm(Address::repeat_byte(0xb)); + assert!(state.contains_key(&Address::repeat_byte(0xb))); + assert!(!state.contains_key(&Address::repeat_byte(0xa))); + } + fn test_prewarm_context( saved_cache: SavedCache, saving_duration: Gauge, @@ -859,6 +1162,7 @@ mod tests { precompile_cache_map: PrecompileCacheMap::default(), disable_bal_parallel_state_root: false, disable_bal_batch_io: false, + prewarm_results: None, } } @@ -907,7 +1211,7 @@ mod tests { let runtime = Runtime::test(); let execution_cache = PayloadExecutionCache::default(); let saved = SavedCache::new(B256::repeat_byte(1), crate::tree::ExecutionCache::new(1_000)); - let address = address!("0000000000000000000000000000000000000001"); + let address = Address::with_last_byte(1); saved.cache().insert_storage(address, B256::ZERO, Some(U256::from(7))); execution_cache.update_with_guard(|slot| *slot = Some(saved.clone())); // Keep the drop worker occupied: a queued SavedCache would delay cache reuse. @@ -969,7 +1273,7 @@ mod tests { impl AsRef<[u8]> for CacheDropProbe { fn as_ref(&self) -> &[u8] { - &[0xef, 0x01, 0x00, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + &EIP7702_CLEARED_DELEGATION } } @@ -1025,10 +1329,11 @@ mod tests { let (task, actions_tx) = PrewarmCacheTask::new(runtime.clone(), execution_cache.clone(), ctx); let mut payload = PayloadHandle { - prewarm_handle: CacheTaskHandle { + prewarm_handle: CacheTaskHandle::<_, ()> { saved_cache: task.ctx.saved_cache.clone(), to_prewarm_task: Some(actions_tx.clone()), executed_tx_index: task.ctx.executed_tx_index.clone(), + prewarm_results: None, cache_metrics: None, }, transactions: crossbeam_channel::never::<(usize, Result<(), ()>)>(), @@ -1102,7 +1407,7 @@ mod tests { if insert_error { // Modified accounts without current info are rejected by insert_state. state.state.insert( - address!("0000000000000000000000000000000000000001"), + Address::with_last_byte(1), BundleAccount::new(None, None, Default::default(), AccountStatus::Changed), ); } @@ -1161,94 +1466,4 @@ mod tests { assert_save_cache_drops_removed_caches(CacheSlot::Empty, valid, insert_error); } } - - #[test] - fn bal_read_only_account_does_not_change_state_root() { - let changes = AccountChanges::new(address!("0000000000000000000000000000000000000001")) - .with_storage_read(U256::from(1)); - - assert!(!changes.account_info().changes_state_root(&changes)); - } - - #[test] - #[allow(clippy::needless_update)] - fn bal_account_uses_existing_fields_only_when_missing() { - let changes = AccountChanges::new(address!("0000000000000000000000000000000000000001")) - .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::from(10))); - let info = changes.account_info(); - - assert!(!info.is_complete()); - let mut account = Account { - balance: U256::from(1), - nonce: 3, - bytecode_hash: Some(B256::repeat_byte(0xaa)), - ..Default::default() - }; - account.apply_bal_info(info); - - assert_eq!(account.balance, U256::from(10)); - assert_eq!(account.nonce, 3); - assert_eq!(account.bytecode_hash, Some(B256::repeat_byte(0xaa))); - } -} - -/// The events the pre-warm task can handle. -/// -/// Generic over `R` (receipt type) to allow sharing `Arc>` with the main -/// execution path without cloning the expensive `BundleState`. -#[derive(Debug)] -pub enum PrewarmTaskEvent { - /// Signals the prewarm workers to stop executing further transactions. - /// - /// This only sets the termination flag the workers poll; the task keeps running to save the - /// cache. Sent once the authoritative execution no longer needs prewarming, so the workers do - /// not race ahead on transactions that will never be used. - TerminateTransactionExecution, - /// Tears the whole task down: stops the workers, optionally saves the warmed cache from the - /// final output, and exits. - /// - /// Sent when execution completed successfully (carrying the output to save) or when the task - /// handle is dropped (carrying no output, e.g. after an execution error). Handling this event - /// also stops the workers, since a teardown may arrive without a preceding - /// [`TerminateTransactionExecution`](Self::TerminateTransactionExecution). - Terminate { - /// The final execution outcome, or `None` when the task is torn down without one (e.g. a - /// dropped handle). Using `Arc` allows sharing with the main execution path without - /// cloning the expensive `BundleState`. - execution_outcome: Option>>, - /// Receiver for the block validation result. - /// - /// Cache saving is racing the state root validation. We optimistically construct the - /// updated cache but only save it once we know the block is valid. - valid_block_rx: mpsc::Receiver<()>, - }, - /// Emitted by the worker-dispatch side once every dispatched transaction has finished or been - /// cancelled, reporting how many were executed. - FinishedTxExecution { - /// Number of transactions executed - executed_transactions: usize, - }, -} - -/// Metrics for transactions prewarming. -#[derive(Metrics, Clone)] -#[metrics(scope = "sync.prewarm")] -pub struct PrewarmMetrics { - /// The number of transactions to prewarm - pub(crate) transactions: Gauge, - /// A histogram of the number of transactions to prewarm - pub(crate) transactions_histogram: Histogram, - /// A histogram of duration per transaction prewarming - pub(crate) total_runtime: Histogram, - /// A histogram of EVM execution duration per transaction prewarming - pub(crate) execution_duration: Histogram, - /// A histogram for prefetch targets per transaction prewarming - pub(crate) prefetch_storage_targets: Histogram, - /// Time spent in `save_cache`, including dropping its removed `SavedCache` values. - /// Excludes any later freeing of cache contents by other `ExecutionCache` clones. - pub(crate) cache_saving_duration: Gauge, - /// Counter for transaction execution errors during prewarming - pub(crate) transaction_errors: Counter, - /// A histogram of BAL slot iteration duration during prefetching - pub(crate) bal_slot_iteration_duration: Histogram, } diff --git a/crates/engine/tree/src/tree/payload_processor/receipt_root_task.rs b/crates/engine/tree/src/tree/payload_processor/receipt_root_task.rs index 263b61693ee..e7fadb08c98 100644 --- a/crates/engine/tree/src/tree/payload_processor/receipt_root_task.rs +++ b/crates/engine/tree/src/tree/payload_processor/receipt_root_task.rs @@ -133,9 +133,10 @@ impl ReceiptRootTaskHandle { #[cfg(test)] mod tests { + use super::*; use alloy_consensus::{proofs::calculate_receipt_root, TxReceipt}; - use alloy_primitives::{b256, hex, Address, Bytes, Log}; + use alloy_primitives::{b256, Address, Bytes, Log}; use crossbeam_channel::bounded; use reth_ethereum_primitives::{Receipt, TxType}; @@ -205,10 +206,7 @@ mod tests { root, b256!("0x61353b4fb714dc1fccacbf7eafc4273e62f3d1eed716fe41b2a0cd2e12c63ebc") ); - assert_eq!( - bloom, - Bloom::from(hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")) - ); + assert_eq!(bloom, Bloom::ZERO); } #[tokio::test] diff --git a/crates/engine/tree/src/tree/payload_processor/reuse.rs b/crates/engine/tree/src/tree/payload_processor/reuse.rs new file mode 100644 index 00000000000..0914f93d32a --- /dev/null +++ b/crates/engine/tree/src/tree/payload_processor/reuse.rs @@ -0,0 +1,1794 @@ +//! Reuse of speculative prewarm execution results. +//! +//! Prewarm workers execute block transactions in parallel against the parent state and record +//! every account and storage value a transaction reads from the database. When sequential +//! execution reaches a transaction whose recorded reads still match the current state, it commits +//! the prewarm result instead of executing the transaction again. Missing or stale results fall +//! back to normal execution, which never waits for prewarming. +//! +//! Most conflicts between transactions are balance changes, such as fee credits to the block +//! beneficiary or value transfers to popular contracts. A transaction that never observes an +//! account's balance produces the same result for any balance, apart from the balance itself, so +//! such balances may differ and the transaction's balance change is applied to the current balance +//! instead. Balances are observed by `BALANCE`, `SELFBALANCE`, `SELFDESTRUCT` and failed value +//! transfers. Successful value transfers and gas payments only require enough starting balance +//! to cover the largest cumulative debit recorded during speculation. +//! +//! Prewarming also seeds results of pool transactions that the txpool prewarm worker executed on +//! top of the parent before the block arrived, see [`PoolSeeds`]. Their block environment is +//! predicted, so they must not depend on fields that turn out to differ, and their fee credit to +//! the beneficiary is applied to the actual beneficiary when the result is reused. + +use alloy_consensus::InMemorySize; +use alloy_primitives::{ + address, + map::{B256Map, DefaultHashBuilder, Entry}, + Address, B256, U256, +}; +use dashmap::DashMap; +use parking_lot::{Mutex, RwLock}; +use revm::{ + bytecode::opcode, + context::{ + result::{HaltReason, InvalidTransaction, ResultAndState}, + Block, CfgEnv, ContextTr, JournalTr, + }, + inspector::JournalExt, + interpreter::{ + instructions::utility::IntoAddress, interpreter_types::Jumps, CallInputs, CallOutcome, + CreateInputs, CreateOutcome, InstructionResult, Interpreter, SuccessOrHalt, + }, + primitives::{StorageKey, StorageValue, KECCAK_EMPTY}, + state::{Account, AccountInfo, Bytecode, EvmState, EvmStorageSlot}, + Database, Inspector, +}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; + +/// Balances above this are compared exactly, so that applying balance changes cannot overflow. +const MAX_REBASED_BALANCE: U256 = U256::from_limbs([u64::MAX, u64::MAX, 0, 0]); + +/// Beneficiary of speculative pool transaction executions. +/// +/// Results of transactions that access it are discarded, so its value only affects how many are. +pub(crate) const POOL_BENEFICIARY: Address = address!("0x00000000000000000000000000000000000beef0"); + +/// Maximum estimated number of bytes held by [`PoolResults`]. +const MAX_POOL_RESULT_BYTES: usize = 64 << 20; + +/// Returns whether blocks executed with `cfg_env` may reuse prewarm results, which excludes +/// EIP-8037 multidimensional block gas. +pub(crate) const fn cfg_reuses_prewarm_results(cfg_env: &CfgEnv) -> bool { + !cfg_env.enable_amsterdam_eip8037 +} + +/// Speculative prewarm results of a block's transactions, indexed by their position in the block. +#[derive(Debug)] +pub(crate) struct PrewarmResults { + slots: Box<[Mutex>]>, + /// Writes predicted by the stored results. + predicted: Arc, +} + +/// Prewarm state of a transaction in [`PrewarmResults`]. +#[derive(Debug)] +enum ResultSlot { + /// Prewarming has not executed the transaction. + Empty, + /// A result that may be reused. + Ready(Box>), + /// A result that cannot be reused, with the reads it depends on. + Unusable(ReadSet), + /// Block execution executed the transaction or reused its result, which are published as + /// its actual writes. + Executed, +} + +impl PrewarmResults { + /// Creates empty result slots for `transaction_count` transactions. + pub(crate) fn new(transaction_count: usize) -> Self { + Self { + slots: (0..transaction_count).map(|_| Mutex::new(ResultSlot::Empty)).collect(), + predicted: Arc::default(), + } + } + + /// Returns the writes predicted by the stored results. + pub(super) const fn predicted(&self) -> &Arc { + &self.predicted + } + + /// Stores the prewarm result of the transaction at `index` and publishes its writes if + /// [`ReadSet::finish`] accepts its reads, otherwise marks it as unusable. + pub(super) fn store( + &self, + index: usize, + result: ResultAndState, + reads: ReadSet, + balances: &mut BalanceTracker, + tx: SpeculativeTx, + ) { + match reads.finish(balances, tx) { + Ok(reads) => self.set(index, |predicted| { + predicted.publish(index, &result.state); + ResultSlot::Ready(Box::new(PrewarmedTx { result, reads, credit: None })) + }), + Err(reads) => self.reject(index, reads), + } + } + + /// Stores the result of the pool transaction with `hash` as the prewarm result of the + /// transaction at `index` and publishes its writes, unless prewarming already executed it or + /// the result depends on the block environment fields that `seeds` differ in. + /// + /// Returns the pool transaction if it was stored. + pub(super) fn seed( + &self, + index: usize, + hash: &B256, + seeds: &PoolSeeds, + ) -> Option>> + where + H: Clone, + { + let tx = seeds.results.get(hash)?; + // The speculative execution treated the actual beneficiary as a cold account. + if tx.reads.block_env.intersects(seeds.differs) || + tx.reads.position(seeds.beneficiary).is_some() + { + return None + } + let mut slot = self.slots.get(index)?.lock(); + if !matches!(*slot, ResultSlot::Empty) { + return None + } + self.predicted.publish(index, &tx.result.state); + *slot = ResultSlot::Ready(Box::new(PrewarmedTx { + result: tx.result.clone(), + reads: tx.reads.clone(), + credit: Some(BeneficiaryCredit { beneficiary: seeds.beneficiary, amount: tx.credit }), + })); + Some(tx) + } + + /// Marks the result of the transaction at `index` as unusable. + pub(super) fn reject(&self, index: usize, mut reads: ReadSet) { + // Rejection may depend on any balance, such as the caller's. + for read in &mut reads.accounts { + read.balance = BalanceDependency::Exact; + } + self.set(index, |_| ResultSlot::Unusable(reads)); + } + + /// Replaces the predicted writes of the transaction at `index` with the writes of its actual + /// execution by block execution. + pub(crate) fn publish_executed(&self, index: usize, state: &EvmState) { + let Some(slot) = self.slots.get(index) else { return }; + let mut slot = slot.lock(); + // Overwrite the predicted writes in place, then retract those the execution did not make. + self.predicted.publish(index, state); + if let ResultSlot::Ready(previous) = core::mem::replace(&mut *slot, ResultSlot::Executed) { + self.predicted.retract_unwritten(index, &previous.result.state, state); + } + self.predicted.generation.fetch_add(1, Ordering::Relaxed); + } + + /// Replaces the slot of the transaction at `index`, retracting the writes of its previous + /// result, unless block execution already published its actual writes. + fn set(&self, index: usize, f: impl FnOnce(&PredictedState) -> ResultSlot) { + let Some(slot) = self.slots.get(index) else { return }; + let mut slot = slot.lock(); + match &*slot { + ResultSlot::Ready(previous) => self.predicted.retract(index, &previous.result.state), + ResultSlot::Executed => return, + ResultSlot::Empty | ResultSlot::Unusable(_) => {} + } + *slot = f(&self.predicted); + } + + /// Returns whether the transaction at `index` should be executed again, because prewarming + /// skipped it or its reads no longer match the predicted state. + pub(super) fn needs_refresh(&self, index: usize) -> bool { + let Some(slot) = self.slots.get(index) else { return false }; + let slot = slot.lock(); + let reads = match &*slot { + ResultSlot::Empty => return true, + ResultSlot::Ready(tx) => &tx.reads, + ResultSlot::Unusable(reads) => reads, + ResultSlot::Executed => return false, + }; + !reads.matches(&self.predicted, index) + } + + /// Takes the prewarm result of the transaction at `index` if it is ready and its reads match + /// `db`. + /// + /// The returned result has its balance changes applied to the current balances. Stale results, + /// and results whose reads fail, stay in their slot unchanged and are freed with `self`. + pub(crate) fn take_valid( + &self, + index: usize, + db: &mut DB, + ) -> Result, ReuseMiss>, DB::Error> { + let Some(slot) = self.slots.get(index) else { return Ok(Err(ReuseMiss::Missing)) }; + let mut slot = slot.lock(); + let mut tx = match core::mem::replace(&mut *slot, ResultSlot::Executed) { + ResultSlot::Ready(tx) => tx, + ResultSlot::Unusable(reads) => { + *slot = ResultSlot::Unusable(reads); + return Ok(Err(ReuseMiss::Stale)) + } + previous @ (ResultSlot::Empty | ResultSlot::Executed) => { + *slot = previous; + return Ok(Err(ReuseMiss::Missing)) + } + }; + let rebased = match tx.validate(db) { + Ok(Some(rebased)) => rebased, + result => { + *slot = ResultSlot::Ready(tx); + return result.map(|_| Err(ReuseMiss::Stale)) + } + }; + // The result stays published as the prediction of this transaction's writes. + if rebased { + // Later transactions read the rebased balances. + self.predicted.publish_accounts(index, &tx.result.state); + self.predicted.generation.fetch_add(1, Ordering::Relaxed); + } + Ok(Ok(tx.result)) + } +} + +/// Reason a prewarm result could not be reused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReuseMiss { + /// Prewarming had not finished the transaction. + Missing, + /// The speculative result was unusable or failed a reuse eligibility check. + Stale, +} + +/// A speculative transaction result and the database reads it depends on. +#[derive(Debug)] +struct PrewarmedTx { + result: ResultAndState, + reads: ReadSet, + /// The fee credit to apply to the block beneficiary, for results of pool transactions. + credit: Option, +} + +/// A fee credit to the block beneficiary that a speculative result does not contain. +#[derive(Debug)] +struct BeneficiaryCredit { + beneficiary: Address, + amount: U256, +} + +impl PrewarmedTx { + /// Returns whether all reads match `db`, applying the result's balance changes to the current + /// balances of accounts whose balance differs and its credit to the beneficiary, and whether + /// any account changed. + /// + /// The result is left unchanged unless this returns `Some`, so that it can be validated again. + fn validate(&mut self, db: &mut DB) -> Result, DB::Error> { + for read in &self.reads.storage { + if db.storage(read.address, read.slot)? != read.value { + return Ok(None) + } + } + let mut rebased = Vec::new(); + for read in &self.reads.accounts { + let current = db.basic(read.address)?; + match read.compare(current.as_ref()) { + AccountMatch::Same => {} + AccountMatch::Rebase { speculative, current } => { + if let Some(account) = self.result.state.get(&read.address) { + // All balances are bounded, and the execution never leaves less than the + // difference, so this neither overflows nor underflows. + let Some(balance) = account + .info + .balance + .checked_add(current) + .and_then(|balance| balance.checked_sub(speculative)) + else { + return Ok(None) + }; + rebased.push(Rebase { address: read.address, balance, original: current }); + } + } + AccountMatch::Differs => return Ok(None), + } + } + let credited = match &self.credit { + Some(credit) => { + // The beneficiary is not in the result, since the transaction did not read it. + // Like execution, load it and touch it even if the credit is zero. A missing + // beneficiary would be loaded as not existing, which is not worth supporting. + let Some(info) = db.basic(credit.beneficiary)? else { return Ok(None) }; + let mut account = Account::from(info); + // Execution leaves the balance unchanged on overflow. + let Some(balance) = account.info.balance.checked_add(credit.amount) else { + return Ok(None) + }; + account.info.balance = balance; + account.mark_touch(); + Some((credit.beneficiary, account)) + } + None => None, + }; + for rebase in &rebased { + if let Some(account) = self.result.state.get_mut(&rebase.address) { + account.info.balance = rebase.balance; + // The state root task only streams accounts whose info differs from the original. + account.original_info_mut().balance = rebase.original; + } + } + let changed = !rebased.is_empty() || credited.is_some(); + self.result.state.extend(credited); + Ok(Some(changed)) + } +} + +/// A balance of a speculative result applied to the current balance. +#[derive(Debug)] +struct Rebase { + address: Address, + /// The balance after the transaction. + balance: U256, + /// The balance before the transaction. + original: U256, +} + +/// How a speculative execution depends on an account's balance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BalanceDependency { + /// The balance was only credited or debited, so it may differ. + None, + /// The account paid for gas or sent value, so the balance must be at least this large. + AtLeast(U256), + /// The balance was observed, so it must match. + Exact, +} + +/// An account a speculative execution read. +#[derive(Debug, Clone)] +struct AccountRead { + address: Address, + /// The account without its bytecode, `None` if it did not exist. + info: Option, + balance: BalanceDependency, + /// Whether the account was read from the predicted state. + predicted: bool, +} + +impl AccountRead { + /// Compares the read account with `current`. + fn compare(&self, current: Option<&AccountInfo>) -> AccountMatch { + let (speculative, current) = match (&self.info, current) { + (Some(speculative), Some(current)) => (speculative, current), + (None, None) => return AccountMatch::Same, + _ => return AccountMatch::Differs, + }; + if speculative.balance == current.balance { + return if speculative == current { AccountMatch::Same } else { AccountMatch::Differs } + } + + let allowed = match self.balance { + BalanceDependency::None => true, + BalanceDependency::AtLeast(min) => current.balance >= min, + BalanceDependency::Exact => false, + }; + // A zero balance can make the account empty, which changes gas costs and EIP-161 state + // clearing. + if !allowed || + speculative.balance.is_zero() || + current.balance.is_zero() || + current.balance > MAX_REBASED_BALANCE || + (AccountInfo { balance: speculative.balance, ..current.copy_without_code() }) != + *speculative + { + return AccountMatch::Differs + } + AccountMatch::Rebase { speculative: speculative.balance, current: current.balance } + } +} + +/// How a read account compares with its current state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AccountMatch { + /// The account is unchanged. + Same, + /// Only the balance changed, so the result's balance change must be applied to the current + /// balance. + Rebase { speculative: U256, current: U256 }, + /// The account differs in a way the execution may depend on. + Differs, +} + +/// A storage slot a speculative execution read. +#[derive(Debug, Clone)] +struct StorageRead { + address: Address, + slot: StorageKey, + value: StorageValue, + /// Whether the slot was read from the predicted state. + predicted: bool, +} + +/// Database reads of a speculative transaction execution. +#[derive(Debug, Default, Clone)] +pub(crate) struct ReadSet { + accounts: Vec, + storage: Vec, + /// Whether execution loaded bytecode that is unknown to the database. + unknown_code: bool, + /// Block environment fields the execution observed. + block_env: BlockEnvFields, +} + +impl ReadSet { + /// Classifies how the execution depends on the balance of each account it read. + /// + /// Speculation disables nonce and balance checks, so this fails unless `caller` had the + /// transaction's `nonce` and could pay `max_balance_spending`, i.e. unless execution with + /// these checks enabled would have accepted the transaction. + fn finish(mut self, balances: &mut BalanceTracker, tx: SpeculativeTx) -> Result { + let SpeculativeTx { caller, nonce, max_balance_spending } = tx; + // Balance changes are applied once per account. + self.accounts.sort_unstable_by_key(|read| read.address); + self.accounts.dedup_by_key(|read| read.address); + self.block_env = balances.block_env; + + let Ok(caller_index) = self.accounts.binary_search_by_key(&caller, |read| read.address) + else { + return Err(self) + }; + let caller_info = &self.accounts[caller_index].info; + let (caller_nonce, caller_balance) = + caller_info.as_ref().map_or((0, U256::ZERO), |info| (info.nonce, info.balance)); + let Ok(max_balance_spending) = max_balance_spending else { return Err(self) }; + // Speculation still rejects transactions with the largest nonce, in revm's `validate_env`. + if self.unknown_code || caller_nonce != nonce || caller_balance < max_balance_spending { + return Err(self) + } + + // Rebasing only bounded balances keeps every balance during execution far from overflow, + // so no value transfer can overflow in one execution but not the other. + let bounded = self + .accounts + .iter() + .all(|read| read.info.as_ref().is_none_or(|info| info.balance <= MAX_REBASED_BALANCE)); + let paid = caller_balance - max_balance_spending; + // Sorted, so that each account is looked up in logarithmic time and the smallest balance + // a sender had left comes first. + let BalanceTracker { observed, senders, .. } = balances; + observed.sort_unstable(); + senders.sort_unstable(); + for read in &mut self.accounts { + // A sender needs enough balance to leave the same smallest balance, which is the + // balance after paying for gas for the caller. + let sent = senders + .get(senders.partition_point(|(address, _)| *address < read.address)) + .filter(|(address, _)| *address == read.address) + .map(|(_, left)| *left); + let left = sent.into_iter().chain((read.address == caller).then_some(paid)).min(); + read.balance = if !bounded || observed.binary_search(&read.address).is_ok() { + BalanceDependency::Exact + } else if let Some(left) = left { + let balance = read.info.as_ref().map_or(U256::ZERO, |info| info.balance); + BalanceDependency::AtLeast(balance.saturating_sub(left)) + } else { + BalanceDependency::None + }; + } + Ok(self) + } + + /// Returns the position of the read of `address` in the sorted account reads. + fn position(&self, address: Address) -> Option { + self.accounts.binary_search_by_key(&address, |read| read.address).ok() + } + + /// Returns whether executing the transaction at `index` against `predicted` would read the + /// same values, as far as the execution depends on them. + fn matches(&self, predicted: &PredictedState, index: usize) -> bool { + self.accounts.iter().all(|read| match predicted.account(read.address, index) { + Some(account) => read.compare(account.as_ref()) != AccountMatch::Differs, + None => !read.predicted, + }) && self.storage.iter().all(|read| { + match predicted.storage(read.address, read.slot, index) { + Some(value) => value == read.value, + None => !read.predicted, + } + }) + } +} + +/// The fields of a speculatively executed transaction that its validity checks depend on. +#[derive(Debug)] +pub(crate) struct SpeculativeTx { + /// The transaction's sender. + pub(crate) caller: Address, + /// The transaction's nonce. + pub(crate) nonce: u64, + /// The most the caller may spend, or an error if it overflows. + pub(crate) max_balance_spending: Result, +} + +/// Writes of a key, sorted by the index of the transaction that made them. +type Writes = Vec<(usize, T)>; + +/// State writes predicted by speculative results, by transaction index. +/// +/// Speculative executions read the latest write predicted before their transaction, so that +/// transactions depending on earlier ones in the block can still be reused. +#[derive(Debug, Default)] +pub(super) struct PredictedState { + /// Accounts, `None` if destroyed. + accounts: DashMap>, DefaultHashBuilder>, + storage: DashMap<(Address, StorageKey), Writes, DefaultHashBuilder>, + /// Transactions that cleared an account's storage by creating or destroying it. + cleared: DashMap, DefaultHashBuilder>, + /// Bytecode deployed by predicted writes. + code: DashMap, + /// Number of actual execution or rebased account updates published by block execution. + generation: AtomicUsize, +} + +impl PredictedState { + /// Returns the number of actual execution or rebased account updates published by block + /// execution. + pub(super) fn generation(&self) -> usize { + self.generation.load(Ordering::Relaxed) + } + + /// Returns the latest account write predicted before the transaction at `index`. + fn account(&self, address: Address, index: usize) -> Option> { + let writes = self.accounts.get(&address)?; + latest(&writes, index).map(|(_, account)| account.clone()) + } + + /// Returns the latest storage value predicted before the transaction at `index`. + fn storage(&self, address: Address, slot: StorageKey, index: usize) -> Option { + let cleared = self.cleared.get(&address).and_then(|w| latest(&w, index).map(|(i, _)| *i)); + let written = self.storage.get(&(address, slot)).and_then(|w| latest(&w, index).copied()); + match (written, cleared) { + (Some((written, value)), Some(cleared)) if written >= cleared => Some(value), + (_, Some(_)) => Some(StorageValue::ZERO), + (written, None) => written.map(|(_, value)| value), + } + } + + /// Returns predicted bytecode by its hash. + fn code(&self, code_hash: B256) -> Option { + self.code.get(&code_hash).map(|code| code.clone()) + } + + /// Publishes the writes of the transaction at `index`. + fn publish(&self, index: usize, state: &EvmState) { + self.update(index, state, true); + for account in state.values() { + if account.is_touched() && + account.is_created() && + let Some(code) = &account.info.code + { + self.code.insert(account.info.code_hash, code.clone()); + } + } + } + + /// Publishes the account writes of the transaction at `index`. + fn publish_accounts(&self, index: usize, state: &EvmState) { + for (address, account) in state { + if account.is_touched() && !account.is_selfdestructed() && !account.info.is_empty() { + write( + &mut self.accounts.entry(*address).or_default(), + index, + Some(Some(account.info.copy_without_code())), + ); + } + } + } + + /// Retracts the writes of the transaction at `index` that were published from `state`. + fn retract(&self, index: usize, state: &EvmState) { + self.update(index, state, false); + } + + /// Retracts the writes of the transaction at `index` that were published from `previous` but + /// that `state` does not make. + fn retract_unwritten(&self, index: usize, previous: &EvmState, state: &EvmState) { + for (address, account) in previous { + if !account.is_touched() { + continue + } + let Some(written) = state.get(address).filter(|account| account.is_touched()) else { + self.update(index, [(address, account)], false); + continue + }; + let clears = |account: &Account| { + account.is_selfdestructed() || account.info.is_empty() || account.is_created() + }; + if clears(account) && !clears(written) { + write::<()>(&mut self.cleared.entry(*address).or_default(), index, None); + } + // Storage of destroyed accounts is not published. + let destroyed = written.is_selfdestructed() || written.info.is_empty(); + for (slot, _) in account.changed_storage_slots() { + if destroyed || !written.storage.get(slot).is_some_and(|value| value.is_changed()) { + write::( + &mut self.storage.entry((*address, *slot)).or_default(), + index, + None, + ); + } + } + } + } + + /// Publishes or retracts the writes of the transaction at `index` to every key that `state` + /// writes. + fn update<'a>( + &self, + index: usize, + state: impl IntoIterator, + publish: bool, + ) { + for (address, account) in state { + if !account.is_touched() { + continue + } + // Touched empty accounts are removed by EIP-161 state clearing. + let destroyed = account.is_selfdestructed() || account.info.is_empty(); + if destroyed || account.is_created() { + write(&mut self.cleared.entry(*address).or_default(), index, publish.then_some(())); + } + // Created bytecode is kept separately, the rest is known to the database. + let info = publish.then(|| (!destroyed).then(|| account.info.copy_without_code())); + write(&mut self.accounts.entry(*address).or_default(), index, info); + if destroyed { + continue + } + for (slot, value) in account.changed_storage_slots() { + write( + &mut self.storage.entry((*address, *slot)).or_default(), + index, + publish.then_some(value.present_value), + ); + } + } + } +} + +/// Sets the write of the transaction at `index`, or removes it if `value` is `None`. +fn write(writes: &mut Writes, index: usize, value: Option) { + match (writes.binary_search_by_key(&index, |(i, _)| *i), value) { + (Ok(i), Some(value)) => writes[i].1 = value, + (Err(i), Some(value)) => writes.insert(i, (index, value)), + (Ok(i), None) => { + writes.remove(i); + } + (Err(_), None) => {} + } +} + +/// Returns the latest write before the transaction at `index`. +fn latest(writes: &[(usize, T)], index: usize) -> Option<&(usize, T)> { + writes[..writes.partition_point(|(i, _)| *i < index)].last() +} + +/// Block environment fields that speculative executions may observe and that may differ from the +/// actual block environment. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct BlockEnvFields { + /// The beneficiary, observed by `COINBASE`, by accessing its account, or possibly by running + /// out of gas. + pub(crate) beneficiary: bool, + /// The timestamp, observed by `TIMESTAMP`. + pub(crate) timestamp: bool, + /// The prevrandao, observed by `PREVRANDAO`. + pub(crate) prevrandao: bool, + /// The gas limit, observed by `GASLIMIT`. + pub(crate) gas_limit: bool, +} + +impl BlockEnvFields { + /// Returns whether any field is set in both `self` and `other`. + const fn intersects(self, other: Self) -> bool { + (self.beneficiary && other.beneficiary) || + (self.timestamp && other.timestamp) || + (self.prevrandao && other.prevrandao) || + (self.gas_limit && other.gas_limit) + } +} + +/// Results of pool transactions executed on top of a block's parent in a predicted block +/// environment with [`POOL_BENEFICIARY`] as the beneficiary, by transaction hash. +#[derive(Debug)] +pub(crate) struct PoolResults { + stored: RwLock>, +} + +impl Default for PoolResults { + fn default() -> Self { + Self { stored: RwLock::new(StoredPoolTxs { txs: B256Map::default(), bytes: 0 }) } + } +} + +/// Results of pool transactions stored in [`PoolResults`]. +#[derive(Debug)] +struct StoredPoolTxs { + txs: B256Map>>, + /// Estimated number of bytes held by `txs`. + bytes: usize, +} + +impl PoolResults { + /// Stores the result of the pool transaction with `hash` if [`ReadSet::finish`] accepts its + /// reads, unless it may have accessed the beneficiary, a result is already stored for `hash`, + /// or the stored results would hold more than [`MAX_POOL_RESULT_BYTES`]. + /// + /// Returns whether the result was stored. + pub(crate) fn store( + &self, + hash: B256, + mut result: ResultAndState, + reads: ReadSet, + balances: &mut BalanceTracker, + tx: SpeculativeTx, + ) -> bool { + if balances.block_env.beneficiary { + return false + } + let Ok(mut reads) = reads.finish(balances, tx) else { return false }; + + // The beneficiary was only loaded to credit fees, which are credited to the actual + // beneficiary instead. Crediting fees always loads and touches it, even for zero fees. + let Some(beneficiary) = result.state.remove(&POOL_BENEFICIARY) else { return false }; + let Some(index) = reads.position(POOL_BENEFICIARY) else { return false }; + let speculative = reads.accounts.remove(index).info.map_or(U256::ZERO, |info| info.balance); + // A bounded balance cannot overflow when credited, which would leave it unchanged. + if speculative > MAX_REBASED_BALANCE { + return false + } + let Some(credit) = beneficiary.info.balance.checked_sub(speculative) else { return false }; + + let bytes = retained_bytes(&result, &reads); + let mut stored = self.stored.write(); + let total = stored.bytes + bytes; + if total > MAX_POOL_RESULT_BYTES { + return false + } + let Entry::Vacant(entry) = stored.txs.entry(hash) else { return false }; + entry.insert(Arc::new(PoolTx { result, reads, credit })); + stored.bytes = total; + true + } + + /// Returns the result of the pool transaction with `hash`. + pub(crate) fn get(&self, hash: &B256) -> Option>> { + self.stored.read().txs.get(hash).cloned() + } + + /// Returns whether no result is stored. + pub(crate) fn is_empty(&self) -> bool { + self.stored.read().txs.is_empty() + } +} + +/// Returns the estimated number of bytes that storing `result` and `reads` holds. +fn retained_bytes(result: &ResultAndState, reads: &ReadSet) -> usize { + let accounts = result.state.values().map(|account| { + // Other bytecode is shared with the database. + let code = account.info.code.as_ref().filter(|_| account.is_created()); + account.storage.capacity() * size_of::<(StorageKey, EvmStorageSlot)>() + + code.map_or(0, Bytecode::len) + }); + result.state.capacity() * size_of::<(Address, Account)>() + + reads.accounts.capacity() * size_of::() + + reads.storage.capacity() * size_of::() + + result.result.output().map_or(0, |output| output.len()) + + result.result.logs().iter().map(InMemorySize::size).sum::() + + accounts.sum::() +} + +/// A speculative result of a pool transaction without its fee credit to the beneficiary. +#[derive(Debug)] +pub(crate) struct PoolTx { + result: ResultAndState, + reads: ReadSet, + /// The fee credit to the beneficiary. + credit: U256, +} + +impl PoolTx { + /// Returns the state changes of the transaction. + pub(super) const fn state(&self) -> &EvmState { + &self.result.state + } + + /// Loads every read of the transaction from `db`. + pub(super) fn load_reads(&self, db: &mut DB) -> Result<(), DB::Error> { + for read in &self.reads.accounts { + db.basic(read.address)?; + } + for read in &self.reads.storage { + db.storage(read.address, read.slot)?; + } + Ok(()) + } +} + +/// Results of pool transactions that may seed the [`PrewarmResults`] of a block on top of the +/// parent they were executed on. +#[derive(Debug, Clone)] +pub(crate) struct PoolSeeds { + results: Arc>, + /// The beneficiary of the block. + beneficiary: Address, + /// Block environment fields that differ from the predicted environment. + differs: BlockEnvFields, +} + +impl PoolSeeds { + /// Creates seeds for a block with `beneficiary` whose environment differs from the predicted + /// environment of `results` in `differs`. + pub(crate) const fn new( + results: Arc>, + beneficiary: Address, + differs: BlockEnvFields, + ) -> Self { + Self { results, beneficiary, differs } + } +} + +/// Database wrapper that records reads for [`PrewarmResults`]. +#[derive(Debug)] +pub(crate) struct RecordingDb { + inner: DB, + /// Reads of the current transaction, `None` if recording is disabled. + reads: Option, + /// The predicted state, and the index of the current transaction. + predicted: Option<(Arc, usize)>, +} + +impl RecordingDb { + /// Wraps `inner` without recording. + pub(crate) const fn new(inner: DB) -> Self { + Self { inner, reads: None, predicted: None } + } + + /// Starts recording the reads of the transaction at `index`, which reads `predicted` writes + /// of earlier transactions. + pub(super) fn start_recording(&mut self, predicted: Arc, index: usize) { + self.reads = Some(ReadSet::default()); + self.predicted = Some((predicted, index)); + } + + /// Starts recording the reads of a transaction that only reads the wrapped database. + pub(crate) fn start_recording_inner(&mut self) { + self.reads = Some(ReadSet::default()); + self.predicted = None; + } + + /// Stops recording and returns the reads of the current transaction. + pub(crate) fn take_reads(&mut self) -> Option { + self.predicted = None; + self.reads.take() + } +} + +impl Database for RecordingDb { + type Error = DB::Error; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + let predicted = + self.predicted.as_ref().and_then(|(state, index)| state.account(address, *index)); + let is_predicted = predicted.is_some(); + let account = match predicted { + Some(account) => account, + None => self.inner.basic(address)?, + }; + + if let Some(reads) = &mut self.reads { + reads.accounts.push(AccountRead { + address, + info: account.as_ref().map(AccountInfo::copy_without_code), + balance: BalanceDependency::None, + predicted: is_predicted, + }); + } + Ok(account) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + if let Some(code) = self.predicted.as_ref().and_then(|(state, _)| state.code(code_hash)) { + return Ok(code) + } + let code = self.inner.code_by_hash(code_hash)?; + // The database returns empty bytecode for unknown hashes. + if code.is_empty() && + code_hash != KECCAK_EMPTY && + let Some(reads) = &mut self.reads + { + reads.unknown_code = true; + } + Ok(code) + } + + fn storage( + &mut self, + address: Address, + index: StorageKey, + ) -> Result { + let predicted = self + .predicted + .as_ref() + .and_then(|(state, tx_index)| state.storage(address, index, *tx_index)); + let is_predicted = predicted.is_some(); + let value = match predicted { + Some(value) => value, + None => self.inner.storage(address, index)?, + }; + if let Some(reads) = &mut self.reads { + reads.storage.push(StorageRead { + address, + slot: index, + value, + predicted: is_predicted, + }); + } + Ok(value) + } + + fn block_hash(&mut self, number: u64) -> Result { + self.inner.block_hash(number) + } +} + +/// Inspector that tracks which balances and block environment fields a transaction observes and +/// which accounts send value. +#[derive(Debug, Default)] +pub(crate) struct BalanceTracker { + /// Accounts whose balance was observed. + observed: Vec
, + /// Accounts that sent value, with the balance they had left after each send. + senders: Vec<(Address, U256)>, + /// Block environment fields that were observed. + block_env: BlockEnvFields, +} + +impl BalanceTracker { + /// Clears the tracked accounts for a new transaction. + pub(crate) fn clear(&mut self) { + self.observed.clear(); + self.senders.clear(); + self.block_env = BlockEnvFields::default(); + } + + /// Records whether the transaction may have accessed the beneficiary when a frame ends. + /// + /// The beneficiary is only loaded after all frames ended to credit fees, unless the + /// transaction accessed it. Loaded accounts stay loaded when frames revert. A frame that ran + /// out of gas may have skipped loading a cold account, such as an actual beneficiary that is + /// warm in the block. + fn frame_end>( + &mut self, + context: &CTX, + result: InstructionResult, + ) { + let out_of_gas = matches!( + SuccessOrHalt::::from(result), + SuccessOrHalt::Halt(HaltReason::OutOfGas(_)) + ); + let journal = context.journal_ref(); + self.block_env.beneficiary |= out_of_gas || + (journal.depth() == 0 && + journal.evm_state().contains_key(&context.block().beneficiary())); + } + + /// Records that `sender` sends `value`, or observes its balance if it cannot. + fn send>>( + &mut self, + context: &CTX, + sender: Address, + value: U256, + ) { + // The sender of a frame is always loaded. + let left = context + .journal_ref() + .evm_state() + .get(&sender) + .and_then(|account| account.info.balance.checked_sub(value)); + match left { + Some(left) => self.senders.push((sender, left)), + None => self.observed.push(sender), + } + } +} + +impl + ContextTr>> + Inspector for BalanceTracker +{ + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + let address = match interp.bytecode.opcode() { + opcode::BALANCE => { + let Ok(word) = interp.stack.peek(0) else { return }; + word.into_address() + } + opcode::SELFBALANCE | opcode::SELFDESTRUCT => interp.input.target_address, + opcode::COINBASE => { + self.block_env.beneficiary = true; + return + } + opcode::TIMESTAMP => { + self.block_env.timestamp = true; + return + } + opcode::DIFFICULTY => { + self.block_env.prevrandao = true; + return + } + opcode::GASLIMIT => { + self.block_env.gas_limit = true; + return + } + _ => return, + }; + // Skip repeated observations, such as in a loop. + if self.observed.last() != Some(&address) { + self.observed.push(address); + } + } + + fn call(&mut self, context: &mut CTX, inputs: &mut CallInputs) -> Option { + if let Some(value) = inputs.transfer_value().filter(|value| !value.is_zero()) { + self.send(context, inputs.transfer_from(), value); + } + None + } + + fn call_end(&mut self, context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if *outcome.instruction_result() == InstructionResult::OutOfFunds { + self.observed.push(inputs.transfer_from()); + } + self.frame_end(context, *outcome.instruction_result()); + } + + fn create(&mut self, context: &mut CTX, inputs: &mut CreateInputs) -> Option { + if !inputs.value().is_zero() { + self.send(context, inputs.caller(), inputs.value()); + } + None + } + + fn create_end( + &mut self, + context: &mut CTX, + inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if *outcome.instruction_result() == InstructionResult::OutOfFunds { + self.observed.push(inputs.caller()); + } + self.frame_end(context, *outcome.instruction_result()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{transaction::Recovered, TxEip1559, TxLegacy}; + use alloy_evm::{ + block::{BlockExecutor, TxResult}, + eth::EthBlockExecutionCtx, + Evm, EvmEnv, + }; + use alloy_primitives::{bytes, Bytes, Signature, TxKind}; + use reth_errors::ProviderError; + use reth_ethereum_primitives::{Receipt, Transaction, TransactionSigned}; + use reth_evm::{ConfigureEvm, EvmFor, HaltReasonFor}; + use reth_evm_ethereum::EthEvmConfig; + use revm::{ + context::{BlockEnv, CfgEnv, Transaction as _}, + database::{states::bundle_state::BundleRetention, BundleState, CacheDB, EmptyDB, State}, + primitives::hardfork::SpecId, + }; + + const BENEFICIARY: Address = Address::repeat_byte(0xbe); + /// Does nothing. + const NOOP: Address = Address::repeat_byte(0xc0); + /// Increments slot 0. + const COUNTER: Address = Address::repeat_byte(0xc1); + /// Stores the beneficiary balance in slot 0. + const BENEFICIARY_BALANCE_READER: Address = Address::repeat_byte(0xc2); + /// Stores its own balance in slot 0. + const SELF_BALANCE_READER: Address = Address::repeat_byte(0xc3); + /// Sends 5 wei to [`NOOP`]. + const FORWARDER: Address = Address::repeat_byte(0xc4); + /// Has a balance above [`MAX_REBASED_BALANCE`]. + const RICH: Address = Address::repeat_byte(0xc5); + /// Exists with a zero balance. + const ZERO_BALANCE: Address = Address::repeat_byte(0xc6); + /// Has a code hash whose bytecode the database does not know. + const UNKNOWN_CODE: Address = Address::repeat_byte(0xc7); + /// Stores the timestamp in slot 0. + const TIMESTAMP_READER: Address = Address::repeat_byte(0xc8); + /// Stores the gas limit in slot 0. + const GAS_LIMIT_READER: Address = Address::repeat_byte(0xc9); + /// Stores the beneficiary in slot 0. + const BENEFICIARY_READER: Address = Address::repeat_byte(0xca); + /// Calls [`POOL_BENEFICIARY`]. + const POOL_BENEFICIARY_CALLER: Address = Address::repeat_byte(0xcb); + /// Reads the balance of [`BENEFICIARY`] by its address. + const BENEFICIARY_BALANCE_BY_ADDRESS: Address = Address::repeat_byte(0xcc); + /// Stores the prevrandao in slot 0. + const PREVRANDAO_READER: Address = Address::repeat_byte(0xcd); + /// Has the largest balance. + const SATURATED: Address = Address::repeat_byte(0xce); + /// Index of a sender with the largest nonce. + const EXHAUSTED_SENDER: u8 = 5; + const GAS_PRICE: u128 = 2; + + type Results = PrewarmResults>; + + fn evm_env(prewarm: bool) -> EvmEnv { + let mut cfg_env = CfgEnv::new_with_spec(SpecId::CANCUN).with_chain_id(1); + cfg_env.disable_nonce_check = prewarm; + cfg_env.disable_balance_check = prewarm; + let block_env = BlockEnv { + number: U256::from(1), + beneficiary: BENEFICIARY, + basefee: 1, + gas_limit: 30_000_000, + ..Default::default() + }; + EvmEnv { cfg_env, block_env } + } + + fn sender(i: u8) -> Address { + Address::repeat_byte(i) + } + + fn tx(from: u8, nonce: u64, to: Address, value: u64) -> Recovered { + tx_with_gas_limit(from, nonce, to, value, 100_000) + } + + fn tx_with_gas_limit( + from: u8, + nonce: u64, + to: Address, + value: u64, + gas_limit: u64, + ) -> Recovered { + let tx = Transaction::Legacy(TxLegacy { + chain_id: Some(1), + nonce, + gas_price: GAS_PRICE, + gas_limit, + to: TxKind::Call(to), + value: U256::from(value), + input: Bytes::new(), + }); + Recovered::new_unchecked( + TransactionSigned::new_unhashed(tx, Signature::test_signature()), + sender(from), + ) + } + + fn parent_state() -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + // The exhausted sender is funded separately. + for i in [1, 2, 3, 4, 6, 7] { + db.insert_account_info( + sender(i), + AccountInfo { balance: U256::from(10u64.pow(18)), ..Default::default() }, + ); + } + db.insert_account_info( + sender(EXHAUSTED_SENDER), + AccountInfo { + balance: U256::from(10u64.pow(18)), + nonce: u64::MAX, + ..Default::default() + }, + ); + db.insert_account_info( + BENEFICIARY, + AccountInfo { balance: U256::from(1), ..Default::default() }, + ); + db.insert_account_info(RICH, AccountInfo { balance: U256::MAX >> 1, ..Default::default() }); + db.insert_account_info(SATURATED, AccountInfo { balance: U256::MAX, ..Default::default() }); + db.insert_account_info(ZERO_BALANCE, AccountInfo { nonce: 1, ..Default::default() }); + db.insert_account_info( + UNKNOWN_CODE, + AccountInfo { code_hash: B256::repeat_byte(0xab), code: None, ..Default::default() }, + ); + for (address, code) in [ + (NOOP, bytes!("00")), + (COUNTER, bytes!("5f546001015f5500")), + (BENEFICIARY_BALANCE_READER, bytes!("41315f5500")), + (SELF_BALANCE_READER, bytes!("475f5500")), + (FORWARDER, bytes!("5f5f5f5f600573c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c05af100")), + (TIMESTAMP_READER, bytes!("425f5500")), + (GAS_LIMIT_READER, bytes!("455f5500")), + (BENEFICIARY_READER, bytes!("415f5500")), + ( + POOL_BENEFICIARY_CALLER, + bytes!("5f5f5f5f5f7300000000000000000000000000000000000beef05af100"), + ), + ( + BENEFICIARY_BALANCE_BY_ADDRESS, + bytes!("73bebebebebebebebebebebebebebebebebebebebe3100"), + ), + (PREVRANDAO_READER, bytes!("445f5500")), + ] { + let code = Bytecode::new_raw(code); + db.insert_account_info( + address, + AccountInfo { + balance: U256::from(12), + code_hash: code.hash_slow(), + code: Some(code), + ..Default::default() + }, + ); + } + db + } + + /// Speculative execution of `txs`, as by prewarm workers. + struct Prewarm<'a> { + txs: &'a [Recovered], + results: Results, + evm: EvmFor>, BalanceTracker>, + } + + impl<'a> Prewarm<'a> { + /// Executes the transactions at the indices in `order`, then refreshes stale results + /// among them in block order. + fn new(txs: &'a [Recovered], order: &[usize]) -> Self { + let evm = EthEvmConfig::mainnet().evm_with_env_and_inspector( + RecordingDb::new(parent_state()), + evm_env(true), + BalanceTracker::default(), + ); + let mut prewarm = Self { txs, results: Results::new(txs.len()), evm }; + for &index in order { + prewarm.run(index); + } + let mut refresh = order.to_vec(); + refresh.sort_unstable(); + for index in refresh { + if prewarm.results.needs_refresh(index) { + prewarm.run(index); + } + } + prewarm + } + + fn run(&mut self, index: usize) { + let tx = &self.txs[index]; + let tx_env = EthEvmConfig::mainnet().tx_env(tx); + let speculative = SpeculativeTx { + caller: tx.signer(), + nonce: tx_env.nonce, + max_balance_spending: tx_env.max_balance_spending(), + }; + self.evm.db_mut().start_recording(Arc::clone(self.results.predicted()), index); + self.evm.inspector_mut().clear(); + let result = self.evm.transact(tx_env); + let reads = self.evm.db_mut().take_reads().unwrap(); + match result { + Ok(result) => { + self.results.store(index, result, reads, self.evm.inspector_mut(), speculative) + } + Err(_) => self.results.reject(index, reads), + } + } + + /// Refreshes stale or missing results of transactions after `index`. + fn refresh_after(&mut self, index: usize) { + for index in index + 1..self.txs.len() { + if self.results.needs_refresh(index) { + self.run(index); + } + } + } + } + + /// Executes `txs` sequentially, committing valid prewarm results instead of executing. + /// + /// If `publish` is set, prewarm results are refreshed against the writes of executed + /// transactions. + fn execute( + txs: &[Recovered], + mut prewarm: Option<&mut Prewarm<'_>>, + publish: bool, + beneficiary: Address, + ) -> Execution { + let config = EthEvmConfig::mainnet(); + let mut state = State::builder().with_database(parent_state()).with_bundle_update().build(); + let mut evm_env = evm_env(false); + evm_env.block_env.beneficiary = beneficiary; + let evm = config.evm_with_env(&mut state, evm_env); + let ctx = EthBlockExecutionCtx { + parent_hash: B256::ZERO, + parent_beacon_block_root: None, + ommers: &[], + withdrawals: None, + extra_data: Bytes::new(), + tx_count_hint: None, + slot_number: None, + }; + let mut executor = config.create_executor(evm, ctx); + let mut outcomes = Vec::new(); + let mut changes = Vec::new(); + for (index, tx) in txs.iter().enumerate() { + let Some(prewarm) = prewarm.as_deref_mut() else { + executor + .execute_transaction_with_result_closure(tx, |result| { + changes.push(changed_accounts(&result.result().state)) + }) + .unwrap(); + outcomes.push(Err(ReuseMiss::Missing)); + continue + }; + let outcome = + match prewarm.results.take_valid(index, executor.evm_mut().db_mut()).unwrap() { + Ok(result) => { + changes.push(changed_accounts(&result.state)); + executor.commit_transaction( + config.prewarm_tx_result(tx.inner(), result).unwrap(), + ); + Ok(()) + } + Err(miss) => { + executor + .execute_transaction_with_result_closure(tx, |result| { + changes.push(changed_accounts(&result.result().state)); + if publish { + prewarm.results.publish_executed(index, &result.result().state) + } + }) + .unwrap(); + if publish { + prewarm.refresh_after(index); + } + Err(miss) + } + }; + outcomes.push(outcome); + } + let receipts = executor.receipts().to_vec(); + drop(executor); + state.merge_transitions(BundleRetention::Reverts); + Execution { receipts, bundle: state.take_bundle(), changes, outcomes } + } + + /// Output of [`execute`]. + struct Execution { + receipts: Vec, + bundle: BundleState, + /// Accounts each transaction changed, as the state root task sees them. + changes: Vec>, + outcomes: Vec>, + } + + /// Returns the accounts in `state` whose info differs from their original info, which the + /// state root task streams as updates. + fn changed_accounts(state: &EvmState) -> Vec<(Address, AccountInfo)> { + let mut changed = state + .iter() + .filter(|(_, account)| account.is_touched() && account.info != account.original_info()) + .map(|(address, account)| (*address, account.info.copy_without_code())) + .collect::>(); + changed.sort_unstable_by_key(|(address, _)| *address); + changed + } + + /// Database that fails every read. + struct FailingDb; + + impl Database for FailingDb { + type Error = ProviderError; + + fn basic(&mut self, _address: Address) -> Result, Self::Error> { + Err(ProviderError::BestBlockNotFound) + } + + fn code_by_hash(&mut self, _code_hash: B256) -> Result { + Err(ProviderError::BestBlockNotFound) + } + + fn storage( + &mut self, + _address: Address, + _index: StorageKey, + ) -> Result { + Err(ProviderError::BestBlockNotFound) + } + + fn block_hash(&mut self, _number: u64) -> Result { + Err(ProviderError::BestBlockNotFound) + } + } + + /// Returns whether the prewarm result of the first transaction can be reused, for + /// transactions that block execution would reject. + fn reusable(txs: &[Recovered]) -> Result<(), ReuseMiss> { + let prewarm = Prewarm::new(txs, &[0]); + let mut state = State::builder().with_database(parent_state()).build(); + prewarm.results.take_valid(0, &mut state).unwrap().map(drop) + } + + /// Asserts that reusing prewarm results produces the same output as executing every + /// transaction, and returns the reuse outcomes. + fn reuse(txs: &[Recovered]) -> Vec> { + reuse_in(txs, &(0..txs.len()).collect::>(), true) + } + + /// Like [`reuse`], prewarming only the transactions at the indices in `order`, and refreshing + /// results against the writes of executed transactions if `publish` is set. + fn reuse_in( + txs: &[Recovered], + order: &[usize], + publish: bool, + ) -> Vec> { + let reused = execute(txs, Some(&mut Prewarm::new(txs, order)), publish, BENEFICIARY); + let expected = execute(txs, None, false, BENEFICIARY); + assert_eq!(reused.receipts, expected.receipts); + assert_eq!(reused.bundle, expected.bundle); + assert_eq!(reused.changes, expected.changes); + reused.outcomes + } + + /// Executes `txs` as pool transactions on top of the parent state, as by the txpool prewarm + /// worker, in a block environment that differs from the actual one in the timestamp and + /// prevrandao. + fn pool_results( + txs: &[Recovered], + ) -> PoolResults> { + let mut evm_env = evm_env(true); + evm_env.block_env.beneficiary = POOL_BENEFICIARY; + evm_env.block_env.timestamp = U256::from(12); + evm_env.block_env.prevrandao = Some(B256::repeat_byte(1)); + let mut evm = EthEvmConfig::mainnet().evm_with_env_and_inspector( + RecordingDb::new(parent_state()), + evm_env, + BalanceTracker::default(), + ); + let results = PoolResults::default(); + for tx in txs { + let tx_env = EthEvmConfig::mainnet().tx_env(tx); + let speculative = SpeculativeTx { + caller: tx.signer(), + nonce: tx_env.nonce, + max_balance_spending: tx_env.max_balance_spending(), + }; + evm.db_mut().start_recording_inner(); + evm.inspector_mut().clear(); + let result = evm.transact(tx_env).unwrap(); + let reads = evm.db_mut().take_reads().unwrap(); + results.store(*tx.tx_hash(), result, reads, evm.inspector_mut(), speculative); + } + results + } + + /// Asserts that reusing results of the `pool` transactions produces the same output as + /// executing every transaction in a block with `beneficiary`, and returns the reuse outcomes. + fn reuse_pool( + txs: &[Recovered], + pool: &[Recovered], + beneficiary: Address, + ) -> Vec> { + let differs = BlockEnvFields { timestamp: true, prevrandao: true, ..Default::default() }; + reuse_pool_with(txs, pool, beneficiary, differs) + } + + /// Like [`reuse_pool`], for a block environment that differs from the predicted one in + /// `differs`. + fn reuse_pool_with( + txs: &[Recovered], + pool: &[Recovered], + beneficiary: Address, + differs: BlockEnvFields, + ) -> Vec> { + let seeds = PoolSeeds::new(Arc::new(pool_results(pool)), beneficiary, differs); + let mut prewarm = Prewarm::new(txs, &[]); + for (index, tx) in txs.iter().enumerate() { + prewarm.results.seed(index, tx.tx_hash(), &seeds); + } + let reused = execute(txs, Some(&mut prewarm), false, beneficiary); + let expected = execute(txs, None, false, beneficiary); + assert_eq!(reused.receipts, expected.receipts); + assert_eq!(reused.bundle, expected.bundle); + assert_eq!(reused.changes, expected.changes); + reused.outcomes + } + + #[test] + fn reuses_independent_transactions() { + let txs = [tx(1, 0, sender(2), 1), tx(3, 0, Address::repeat_byte(0xaa), 1)]; + assert_eq!(reuse(&txs), [Ok(()), Ok(())]); + } + + #[test] + fn rebases_unobserved_balances() { + // The last transaction's caller and recipient were credited by earlier ones. + let txs = [tx(1, 0, NOOP, 1), tx(1, 1, sender(2), 1), tx(2, 0, NOOP, 1)]; + assert_eq!(reuse(&txs), [Ok(()), Ok(()), Ok(())]); + } + + #[test] + fn reuses_predicted_writes() { + let txs = [tx(1, 0, COUNTER, 1), tx(2, 0, COUNTER, 1), tx(1, 1, COUNTER, 1)]; + assert_eq!(reuse(&txs), [Ok(()), Ok(()), Ok(())]); + // Results executed before the transactions they depend on are executed again. + assert_eq!(reuse_in(&txs, &[2, 1, 0], true), [Ok(()), Ok(()), Ok(())]); + } + + #[test] + fn refreshes_against_executed_writes() { + let txs = [tx(1, 0, COUNTER, 1), tx(2, 0, COUNTER, 1), tx(1, 1, sender(3), 1)]; + assert_eq!(reuse_in(&txs, &[1, 2], true), [Err(ReuseMiss::Missing), Ok(()), Ok(())]); + } + + #[test] + fn rebases_original_balances() { + // The first transaction leaves the forwarder 5 wei more, and the second forwards 5 wei, so + // its rebased balance equals its speculative balance before the second transaction. + let txs = [tx(1, 0, FORWARDER, 10), tx(2, 0, FORWARDER, 0)]; + assert_eq!(reuse_in(&txs, &[1], false), [Err(ReuseMiss::Missing), Ok(())]); + } + + #[test] + fn rejects_stale_storage() { + let txs = [tx(1, 0, COUNTER, 1), tx(2, 0, COUNTER, 1)]; + assert_eq!(reuse_in(&txs, &[1], false), [Err(ReuseMiss::Missing), Err(ReuseMiss::Stale)]); + } + + #[test] + fn rejects_stale_callers() { + let txs = [tx(1, 0, sender(2), 1), tx(1, 1, sender(3), 1)]; + assert_eq!(reuse_in(&txs, &[1], false), [Err(ReuseMiss::Missing), Err(ReuseMiss::Stale)]); + } + + #[test] + fn rejects_observed_balances() { + let txs = [ + tx(1, 0, SELF_BALANCE_READER, 1), + tx(2, 0, SELF_BALANCE_READER, 1), + tx(3, 0, BENEFICIARY_BALANCE_READER, 1), + ]; + assert_eq!( + reuse_in(&txs, &[1, 2], false), + [Err(ReuseMiss::Missing), Err(ReuseMiss::Stale), Err(ReuseMiss::Stale)] + ); + } + + #[test] + fn rejects_lower_sender_balances() { + let txs = [tx(1, 0, FORWARDER, 0), tx(2, 0, FORWARDER, 0), tx(3, 0, FORWARDER, 0)]; + // The forwarder can send twice from its speculative balance, but not three times. + assert_eq!( + reuse_in(&txs, &[1, 2], false), + [Err(ReuseMiss::Missing), Ok(()), Err(ReuseMiss::Stale)] + ); + } + + #[test] + fn reuses_pool_results() { + // The last transaction's caller was credited by the first one. + let txs = [tx(1, 0, sender(2), 1), tx(3, 0, COUNTER, 1), tx(2, 0, GAS_LIMIT_READER, 1)]; + assert_eq!(reuse_pool(&txs, &txs, BENEFICIARY), [Ok(()), Ok(()), Ok(())]); + // Transactions missing from the pool are executed. + assert_eq!( + reuse_pool(&txs, &txs[1..], BENEFICIARY), + [Err(ReuseMiss::Missing), Ok(()), Ok(())] + ); + } + + #[test] + fn rejects_stale_pool_results() { + // Test transactions are hashed without their sender. + let txs = [tx(1, 0, COUNTER, 1), tx(2, 0, COUNTER, 2)]; + assert_eq!(reuse_pool(&txs, &txs, BENEFICIARY), [Ok(()), Err(ReuseMiss::Stale)]); + // The beneficiary must exist to be credited. + let missing = Address::repeat_byte(0xbf); + assert_eq!(reuse_pool(&txs, &txs, missing), [Err(ReuseMiss::Stale), Err(ReuseMiss::Stale)]); + } + + #[test] + fn rejects_pool_results_observing_block_env() { + let txs = [ + tx(1, 0, TIMESTAMP_READER, 1), + tx(2, 0, BENEFICIARY_READER, 1), + tx(3, 0, POOL_BENEFICIARY, 1), + tx(4, 0, POOL_BENEFICIARY_CALLER, 1), + tx(6, 0, BENEFICIARY, 1), + tx(7, 0, PREVRANDAO_READER, 1), + ]; + assert_eq!(reuse_pool(&txs, &txs, BENEFICIARY), [Err(ReuseMiss::Missing); 6]); + + let txs = [tx(1, 0, GAS_LIMIT_READER, 1)]; + let differs = BlockEnvFields { gas_limit: true, ..Default::default() }; + assert_eq!(reuse_pool_with(&txs, &txs, BENEFICIARY, differs), [Err(ReuseMiss::Missing)]); + } + + #[test] + fn seeds_only_unexecuted_transactions() { + let txs = [tx(1, 0, COUNTER, 1)]; + let differs = BlockEnvFields::default(); + let seeds = PoolSeeds::new(Arc::new(pool_results(&txs)), BENEFICIARY, differs); + assert!(Prewarm::new(&txs, &[]).results.seed(0, txs[0].tx_hash(), &seeds).is_some()); + assert!(Prewarm::new(&txs, &[0]).results.seed(0, txs[0].tx_hash(), &seeds).is_none()); + } + + #[test] + fn rejects_pool_results_overflowing_the_beneficiary() { + let txs = [tx(1, 0, COUNTER, 1)]; + assert_eq!(reuse_pool(&txs, &txs, SATURATED), [Err(ReuseMiss::Stale)]); + } + + /// Stores the recorded result of `tx` in `results` again under `hash`, after `modify` changed + /// it and the read of the pool beneficiary. + fn store_again( + results: &PoolResults>, + tx: &Recovered, + hash: B256, + modify: impl FnOnce(&mut ResultAndState>, &mut AccountRead), + ) -> bool { + let recorded = results.get(tx.tx_hash()).unwrap(); + let mut result = recorded.result.clone(); + result.state.insert(POOL_BENEFICIARY, Account::default()); + let mut beneficiary = AccountRead { + address: POOL_BENEFICIARY, + info: None, + balance: BalanceDependency::None, + predicted: false, + }; + modify(&mut result, &mut beneficiary); + let mut reads = recorded.reads.clone(); + reads.accounts.push(beneficiary); + let tx_env = EthEvmConfig::mainnet().tx_env(tx); + let speculative = SpeculativeTx { + caller: tx.signer(), + nonce: tx_env.nonce, + max_balance_spending: tx_env.max_balance_spending(), + }; + results.store(hash, result, reads, &mut BalanceTracker::default(), speculative) + } + + #[test] + fn bounds_pool_results() { + let txs = [tx(1, 0, COUNTER, 1)]; + let results = pool_results(&txs); + assert!(store_again(&results, &txs[0], B256::ZERO, |_, _| {})); + // Deployed code counts towards the bound. + assert!(!store_again(&results, &txs[0], B256::with_last_byte(1), |result, _| { + let account = result.state.get_mut(&COUNTER).unwrap(); + account.mark_created(); + account.info.code = Some(Bytecode::new_raw(vec![0; MAX_POOL_RESULT_BYTES].into())); + })); + } + + #[test] + fn rejects_recorded_pool_hashes() { + let txs = [tx(1, 0, COUNTER, 1)]; + let results = pool_results(&txs); + assert!(!store_again(&results, &txs[0], *txs[0].tx_hash(), |_, _| {})); + assert!(store_again(&results, &txs[0], B256::ZERO, |_, _| {})); + } + + #[test] + fn rejects_saturated_pool_beneficiaries() { + let txs = [tx(1, 0, COUNTER, 1)]; + let results = pool_results(&txs); + // Crediting a saturated balance leaves it unchanged. + assert!(!store_again(&results, &txs[0], B256::ZERO, |result, beneficiary| { + beneficiary.info = Some(AccountInfo { balance: U256::MAX, ..Default::default() }); + result.state.get_mut(&POOL_BENEFICIARY).unwrap().info.balance = U256::MAX; + })); + assert!(store_again(&results, &txs[0], B256::ZERO, |_, _| {})); + } + + #[test] + fn rejects_pool_results_running_out_of_gas() { + // Enough gas to read the balance of the warm beneficiary in the block, but not of the cold + // one during speculation. + let txs = [tx_with_gas_limit(1, 0, BENEFICIARY_BALANCE_BY_ADDRESS, 0, 21_500)]; + assert_eq!(reuse_pool(&txs, &txs, BENEFICIARY), [Err(ReuseMiss::Missing)]); + } + + #[test] + fn rejects_invalid_transactions() { + assert_eq!(reusable(&[tx(1, 0, NOOP, 0)]), Ok(())); + // The caller cannot pay for the value. + assert_eq!(reusable(&[tx(1, 0, NOOP, 10u64.pow(19))]), Err(ReuseMiss::Stale)); + // The caller's nonce differs. + assert_eq!(reusable(&[tx(1, 1, NOOP, 0)]), Err(ReuseMiss::Stale)); + // The caller's nonce cannot be incremented. + assert_eq!(reusable(&[tx(EXHAUSTED_SENDER, u64::MAX, NOOP, 0)]), Err(ReuseMiss::Stale)); + // Speculative execution fails. + assert_eq!( + reusable(&[tx_with_gas_limit(1, 0, NOOP, 0, 40_000_000)]), + Err(ReuseMiss::Stale) + ); + // The most the caller may spend overflows, though the effective fee does not. + let overflowing = Transaction::Eip1559(TxEip1559 { + chain_id: 1, + max_fee_per_gas: u128::MAX, + gas_limit: 100_000, + to: TxKind::Call(NOOP), + ..Default::default() + }); + let overflowing = Recovered::new_unchecked( + TransactionSigned::new_unhashed(overflowing, Signature::test_signature()), + sender(1), + ); + assert_eq!(reusable(&[overflowing]), Err(ReuseMiss::Stale)); + } + + #[test] + fn keeps_stale_results_unchanged() { + let txs = [tx(1, 0, NOOP, 0)]; + let prewarm = Prewarm::new(&txs, &[0]); + // The caller's balance can be rebased, but the callee changed. + let mut db = parent_state(); + db.insert_account_info( + sender(1), + AccountInfo { balance: U256::from(2 * 10u64.pow(18)), ..Default::default() }, + ); + let mut noop = db.basic(NOOP).unwrap().unwrap(); + noop.nonce = 1; + db.insert_account_info(NOOP, noop); + let mut stale = State::builder().with_database(db).build(); + assert_eq!( + prewarm.results.take_valid(0, &mut stale).unwrap().map(drop), + Err(ReuseMiss::Stale) + ); + + let mut state = State::builder().with_database(parent_state()).build(); + let result = prewarm.results.take_valid(0, &mut state).unwrap().unwrap(); + let fee = U256::from(21_000 * GAS_PRICE); + assert_eq!(result.state[&sender(1)].info.balance, U256::from(10u64.pow(18)) - fee); + } + + #[test] + fn keeps_results_on_database_errors() { + let txs = [tx(1, 0, NOOP, 0)]; + let prewarm = Prewarm::new(&txs, &[0]); + assert!(matches!( + prewarm.results.take_valid(0, &mut FailingDb), + Err(ProviderError::BestBlockNotFound) + )); + let mut state = State::builder().with_database(parent_state()).build(); + assert_eq!(prewarm.results.take_valid(0, &mut state).unwrap().map(drop), Ok(())); + } + + #[test] + fn rejects_unknown_code() { + assert_eq!(reuse_in(&[tx(1, 0, UNKNOWN_CODE, 0)], &[0], false), [Err(ReuseMiss::Stale)]); + } + + #[test] + fn rejects_rebasing_unbounded_or_zero_balances() { + let rebased = [tx(1, 0, sender(4), 1), tx(2, 0, sender(4), 1)]; + assert_eq!(reuse_in(&rebased, &[1], false), [Err(ReuseMiss::Missing), Ok(())]); + for txs in [ + [tx(1, 0, RICH, 1), tx(2, 0, RICH, 1)], + [tx(1, 0, ZERO_BALANCE, 1), tx(2, 0, ZERO_BALANCE, 1)], + // Any unbounded balance read makes every balance exact. + [tx(1, 0, sender(4), 1), tx(4, 0, RICH, 1)], + ] { + assert_eq!( + reuse_in(&txs, &[1], false), + [Err(ReuseMiss::Missing), Err(ReuseMiss::Stale)] + ); + } + } +} diff --git a/crates/engine/tree/src/tree/payload_validator.rs b/crates/engine/tree/src/tree/payload_validator.rs index 862318046c3..a293aae5d88 100644 --- a/crates/engine/tree/src/tree/payload_validator.rs +++ b/crates/engine/tree/src/tree/payload_validator.rs @@ -100,14 +100,18 @@ use crate::tree::{ BlockAccessListDecodeError, InsertBlockError, InsertBlockErrorKind, InsertPayloadError, }, instrumented_state::{InstrumentedStateProvider, StateProviderMetrics, StateProviderStats}, - payload_processor::PayloadProcessor, + metrics::PrewarmReuseStats, + payload_processor::{ + reuse::{PrewarmResults, ReuseMiss}, + PayloadProcessor, + }, precompile_cache::{CachedPrecompile, CachedPrecompileMetrics, PrecompileCacheMap}, txpool_prewarm, types::{InsertPayloadResult, ValidationOutput}, CacheWaitDurations, CachedStateProvider, EngineApiMetrics, EngineApiTreeState, ExecutionEnv, PayloadHandle, StateProviderDatabase, TreeConfig, WaitForCaches, }; -use alloy_consensus::transaction::{Either, TxHashRef}; +use alloy_consensus::{transaction::Either, Transaction as _, TxReceipt as _}; use alloy_eip7928::{ bal::{Bal, DecodedBal}, BlockAccessList, @@ -128,7 +132,6 @@ use crate::tree::{ StateRootUpdateStream, }, }; -use alloy_consensus::constants::KECCAK_EMPTY; use alloy_primitives::Address; use reth_chain_state::{CanonicalInMemoryState, ExecutedBlock, ExecutionTimingStats}; use reth_consensus::{ConsensusError, FullConsensus, ReceiptRootBloom}; @@ -137,8 +140,10 @@ use reth_engine_primitives::{ }; use reth_errors::{BlockExecutionError, BlockValidationError, ProviderResult}; use reth_evm::{ - block::BlockExecutor, execute::ExecutableTxFor, ConfigureEvm, EvmEnvFor, ExecutionCtxFor, - OnStateHook, SpecFor, + block::{BlockExecutor, TxResult}, + execute::ExecutableTxFor, + ConfigureEvm, EvmEnvFor, ExecutionCtxFor, HaltReasonFor, OnStateHook, SpecFor, + TxExecutionResultFor, }; use reth_execution_cache::{CacheFillMode, CacheStats}; use reth_execution_types::DecodedRevmBal; @@ -153,19 +158,18 @@ use reth_primitives_traits::{ RecoveredBlock, SealedBlock, SealedHeader, SignerRecoverable, TxTy, }; use reth_provider::{ - BlockExecutionOutput, BlockHashReader, BlockReader, ChangeSetReader, DatabaseProviderFactory, - DatabaseProviderROFactory, EvmStateProvider, EvmStateProviderBox, HashedPostStateProvider, - HistoryReader, ProviderError, PruneCheckpointReader, StageCheckpointReader, StateProvider, - StateProviderFactory, StateReader, StateRootProvider, StorageChangeSetReader, - StorageSettingsCache, + BlockExecutionOutput, BlockReader, ChangeSetReader, DatabaseProviderFactory, + DatabaseProviderROFactory, EvmStateProvider, EvmStateProviderBox, HistoryReader, ProviderError, + PruneCheckpointReader, StageCheckpointReader, StateProvider, StateProviderFactory, StateReader, + StorageChangeSetReader, StorageSettingsCache, }; use reth_revm::db::{states::bundle_state::BundleRetention, BundleAccount, State}; use reth_storage_overlay::{OverlayManager, OverlayStateProviderFactory}; -use reth_trie::{ - hashed_cursor::HashedCursorFactory, trie_cursor::TrieCursorFactory, updates::TrieUpdates, - HashedPostState, KeccakKeyHasher, LazyTrieData, +use reth_trie::{updates::TrieUpdates, HashedPostState, KeccakKeyHasher, LazyTrieData}; +use revm::{ + context::Block as _, + state::{bal::Bal as RevmBal, AccountInfo}, }; -use revm::state::bal::Bal as RevmBal; use std::{ sync::{ atomic::{AtomicUsize, Ordering}, @@ -313,7 +317,6 @@ where N: NodePrimitives, P: DatabaseProviderFactory< Provider: BlockReader - + BlockHashReader + StageCheckpointReader + PruneCheckpointReader + ChangeSetReader @@ -327,15 +330,6 @@ where + StateReader + Clone + 'static, - OverlayStateProviderFactory: DatabaseProviderROFactory< - Provider: TrieCursorFactory - + HashedCursorFactory - + HashedPostStateProvider - + StateRootProvider - + StateProvider - + Send, - > + Clone - + 'static, Evm: ConfigureEvm + 'static, { /// Creates a new `TreePayloadValidator`. @@ -394,6 +388,7 @@ where &self.runtime, Arc::new(source), self.evm_config.clone(), + self.evm_config.reuses_prewarm_results() && self.config.prewarm_reuse(), )); self } @@ -582,6 +577,10 @@ where let evm_env = debug_span!(target: "engine::tree::payload_validator", "evm_env") .in_scope(|| self.evm_env_for(&input)) .map_err(NewPayloadError::other)?; + let pool_seeds = self + .txpool_prewarm + .as_ref() + .and_then(|prewarmer| prewarmer.seeds(parent_hash, &evm_env)); // Extract the decoded BAL, if present. Undecodable block access list bytes invalidate the // payload. @@ -607,6 +606,7 @@ where withdrawals: input.withdrawals().map(|w| w.to_vec()), decoded_bal: decoded_bal.as_ref().map(Arc::clone), txpool_snapshot: txpool_snapshot.clone(), + pool_seeds, }; // Get an iterator over the transactions in the payload @@ -818,10 +818,12 @@ where // (keccak256 hashing of all changed addresses and storage slots). let hashed_state_output = output.clone(); let mut hashed_state_rx = state_root_job.take_hashed_state_rx(); + let parent_span = Span::current(); let mut hashed_state: LazyHashedPostState = self.runtime.spawn_blocking_named("hash-post-state", move || { let _span = debug_span!( target: "engine::tree::payload_validator", + parent: parent_span, "hashed_post_state", ) .entered(); @@ -1036,7 +1038,7 @@ where }; if let Err(e) = consensus.validate_header(block.sealed_header()) { - error!(target: "engine::tree::payload_validator", ?block, "Failed to validate header {}: {e}", block.hash()); + error!(target: "engine::tree::payload_validator", block = ?block.block_with_parent(), %e, "Failed to validate header"); return Err(InsertBlockError::consensus_error(e, block).into()) } @@ -1044,7 +1046,7 @@ where let _enter = debug_span!(target: "engine::tree::payload_validator", "validate_header_against_parent").entered(); if let Err(e) = consensus.validate_header_against_parent(block.sealed_header(), &parent) { - warn!(target: "engine::tree::payload_validator", ?block, "Failed to validate header {} against parent: {e}", block.hash()); + warn!(target: "engine::tree::payload_validator", block = ?block.block_with_parent(), %e, "Failed to validate header against parent"); return Err(InsertBlockError::consensus_error(e, block).into()) } drop(_enter); @@ -1052,7 +1054,7 @@ where if let Err(e) = consensus.validate_block_pre_execution_with_tx_root(&block, None) { - error!(target: "engine::tree::payload_validator", ?block, "Failed to validate block {}: {e}", block.hash()); + error!(target: "engine::tree::payload_validator", block = ?block.block_with_parent(), %e, "Failed to validate block"); return Err(InsertBlockError::consensus_error(e, block).into()) } @@ -1124,7 +1126,7 @@ where state_provider: S, env: ExecutionEnv, input: &BlockOrPayload, - handle: &mut PayloadHandle, Err, N::Receipt>, + handle: &mut PayloadHandle, Err, N::Receipt, HaltReasonFor>, state_hook: Option>, ) -> Result< (BlockExecutionOutput, Vec
, ReceiptRootReceiver, Option), @@ -1182,6 +1184,8 @@ where let transaction_count = input.transaction_count(); let (receipt_tx, result_rx) = self.spawn_receipt_root_task(transaction_count); let executed_tx_index = Arc::clone(handle.executed_tx_index()); + // Reads made to validate prewarm results would be recorded in the built BAL. + let prewarm_results = handle.prewarm_results().filter(|_| !has_bal); executor.evm_mut().db_mut().set_state_hook(state_hook); let execution_start = Instant::now(); @@ -1194,8 +1198,13 @@ where &receipt_tx, &executed_tx_index, has_bal, + prewarm_results.as_deref(), )?; drop(receipt_tx); + // Free stale results off the execution path. + if let Some(prewarm_results) = prewarm_results { + self.runtime.spawn_drop(prewarm_results); + } // Finish execution and get the result let post_exec_start = Instant::now(); @@ -1252,7 +1261,7 @@ where state_provider: EvmStateProviderBox, env: ExecutionEnv, input: &BlockOrPayload, - handle: &mut PayloadHandle, Err, N::Receipt>, + handle: &mut PayloadHandle, Err, N::Receipt, HaltReasonFor>, state_hook: Option>, tracer: &mut reth_firehose::FirehoseBlockTracer, ) -> Result< @@ -1330,7 +1339,8 @@ where let execution_start = Instant::now(); - // Execute all transactions and finalize + // Execute all transactions and finalize. Prewarm results are never reused here: committing + // one skips execution, so the tracer would not see that transaction. let (executor, senders) = self.execute_transactions( executor, transaction_count, @@ -1338,6 +1348,7 @@ where &receipt_tx, &executed_tx_index, has_bal, + None, )?; drop(receipt_tx); @@ -1403,7 +1414,7 @@ where &self, env: ExecutionEnv, input: &BlockOrPayload, - handle: &PayloadHandle, + handle: &PayloadHandle>, make_state_provider: &MakeStateProvider, ) -> Result< (BlockExecutionOutput, Vec
, ReceiptRootReceiver, Option), @@ -1487,7 +1498,8 @@ where /// - Collecting transaction senders for later use /// /// Returns the executor (for finalization) and the collected senders. - fn execute_transactions<'a, E, Tx, InnerTx, Err, DB>( + #[expect(clippy::too_many_arguments)] + fn execute_transactions<'a, E, Tx, Err, DB>( &self, mut executor: E, transaction_count: usize, @@ -1495,15 +1507,20 @@ where receipt_tx: &crossbeam_channel::Sender>, executed_tx_index: &AtomicUsize, has_bal: bool, + prewarm_results: Option<&PrewarmResults>>, ) -> Result<(E, Vec
), BlockExecutionError> where - E: BlockExecutor>>, - Tx: alloy_evm::block::ExecutableTx + alloy_evm::RecoveredTx, - InnerTx: TxHashRef, + E: BlockExecutor< + Receipt = N::Receipt, + Result = TxExecutionResultFor, + Evm: alloy_evm::Evm, HaltReason = HaltReasonFor>, + >, + Tx: alloy_evm::block::ExecutableTx + alloy_evm::RecoveredTx>, DB: revm::Database + 'a, Err: core::error::Error + Send + Sync + 'static, { let mut senders = Vec::with_capacity(transaction_count); + let mut reuse_stats = PrewarmReuseStats::default(); // Apply pre-execution changes (e.g., beacon root update) let pre_exec_start = Instant::now(); @@ -1532,7 +1549,7 @@ where self.metrics.record_transaction_wait(wait_start.elapsed()); let tx = tx_result.map_err(BlockValidationError::other)?; - let tx_signer = *>::signer(&tx); + let tx_signer = *>>::signer(&tx); senders.push(tx_signer); @@ -1549,7 +1566,27 @@ where } let tx_start = Instant::now(); - executor.execute_transaction(tx)?; + let index = senders.len() - 1; + match prewarm_results { + Some(results) => { + let outcome = self.commit_prewarm_result( + &mut executor, + results, + index, + >>::tx(&tx), + )?; + if outcome.is_err() { + // Later prewarm results are refreshed against the actual writes. + executor.execute_transaction_with_result_closure(tx, |result| { + results.publish_executed(index, &result.result().state) + })?; + } + reuse_stats.record(outcome); + } + None => { + executor.execute_transaction(tx)?; + } + } self.metrics.record_transaction_execution(tx_start.elapsed()); // advance the shared counter so prewarm workers skip already-executed txs @@ -1572,9 +1609,52 @@ where drop(exec_span); + if prewarm_results.is_some() { + self.metrics.prewarm_reuse.record(reuse_stats); + } + Ok((executor, senders)) } + /// Commits the prewarm result of the transaction at `index` instead of executing it, if the + /// result is ready and its reads match the current state. + fn commit_prewarm_result<'a, E, DB>( + &self, + executor: &mut E, + results: &PrewarmResults>, + index: usize, + tx: &TxTy, + ) -> Result, BlockExecutionError> + where + E: BlockExecutor< + Receipt = N::Receipt, + Result = TxExecutionResultFor, + Evm: alloy_evm::Evm, HaltReason = HaltReasonFor>, + >, + DB: revm::Database + 'a, + { + // Skipping execution bypasses the block executor's available gas check. Ignoring the + // transaction gas limit cap makes this check stricter; normal execution handles the rest. + let block = executor.evm().block(); + let block_gas_used = executor.receipts().last().map_or(0, |r| r.cumulative_gas_used()); + if tx.gas_limit() > block.gas_limit().saturating_sub(block_gas_used) { + return Ok(Err(ReuseMiss::Stale)) + } + + let result = match results + .take_valid(index, executor.evm_mut().db_mut()) + .map_err(BlockExecutionError::other)? + { + Ok(result) => result, + Err(miss) => return Ok(Err(miss)), + }; + let Some(result) = self.evm_config.prewarm_tx_result(tx, result) else { + return Ok(Err(ReuseMiss::Stale)) + }; + executor.commit_transaction(result); + Ok(Ok(())) + } + /// Validates the block after execution. /// /// This performs: @@ -1667,6 +1747,7 @@ where impl ExecutableTxFor + use, impl core::error::Error + Send + Sync + 'static + use, N::Receipt, + HaltReasonFor, >, InsertBlockErrorKind, > { @@ -1816,10 +1897,10 @@ where // Record sizes of the computed trie data block_validation_metrics .hashed_post_state_size - .record(computed.sorted.hashed_state.total_len() as f64); + .record(computed.hashed_state.total_len() as f64); block_validation_metrics .trie_updates_sorted_size - .record(computed.sorted.trie_updates.total_len() as f64); + .record(computed.trie_updates.total_len() as f64); }; // Spawn task that computes trie data asynchronously. @@ -1843,7 +1924,7 @@ where let code_bytes_read = provider_stats.total_code_fetched_bytes(); // Write stats from BundleState (final state changes) - let accounts_changed = output.state.state.len(); + let accounts_changed = output.state.len(); let accounts_deleted = output.state.state.values().filter(|acc| acc.was_destroyed()).count(); let storage_slots_changed = @@ -1860,12 +1941,9 @@ where // Helper: check if account represents a new contract deployment let is_new_deployment = |acc: &BundleAccount| -> bool { - let has_code_now = acc.info.as_ref().is_some_and(|info| info.code_hash != KECCAK_EMPTY); - let had_no_code_before = acc - .original_info - .as_ref() - .map(|info| info.code_hash == KECCAK_EMPTY) - .unwrap_or(true); + let has_code_now = acc.info.as_ref().is_some_and(|info| !info.is_empty_code_hash()); + let had_no_code_before = + acc.original_info.as_ref().is_none_or(AccountInfo::is_empty_code_hash); has_code_now && had_no_code_before }; @@ -1882,9 +1960,7 @@ where .collect(); let code_bytes_written: usize = unique_new_code_hashes .iter() - .filter_map(|hash| { - output.state.contracts.get(hash).map(|bytecode| bytecode.original_bytes().len()) - }) + .filter_map(|hash| output.state.contracts.get(hash).map(|bytecode| bytecode.len())) .sum(); // Total time spent fetching state during execution @@ -1910,12 +1986,10 @@ where .original_info .as_ref() .and_then(|info| info.code.as_ref()) - .map(|bytecode| bytecode.is_eip7702()) - .unwrap_or(false); + .is_some_and(|bytecode| bytecode.is_eip7702()); // Check if current code is empty (delegation cleared) - let code_now_empty = - acc.info.as_ref().map(|info| info.code_hash == KECCAK_EMPTY).unwrap_or(false); + let code_now_empty = acc.info.as_ref().is_some_and(AccountInfo::is_empty_code_hash); original_was_eip7702 && code_now_empty }) @@ -2062,7 +2136,6 @@ impl EngineValidator for BasicEngineValidator: DatabaseProviderROFactory< - Provider: TrieCursorFactory - + HashedCursorFactory - + HashedPostStateProvider - + StateRootProvider - + StateProvider - + Send, - > + Clone - + 'static, N: NodePrimitives, V: PayloadValidator + Clone, Evm: ConfigureEngineEvm + 'static, @@ -2159,9 +2223,10 @@ where return } }; - // Warming reuses the head block's own environment rather than predicting the next - // block's: attribute-level accuracy (timestamp, basefee) doesn't matter for collecting - // state reads, and the worker disables the fee checks a stale basefee would trip. + // Unless results are recorded, warming reuses the head block's own environment rather + // than predicting the next block's: attribute-level accuracy (timestamp, basefee) doesn't + // matter for collecting state reads, and the worker disables the fee checks a stale + // basefee would trip. let evm_env = match self.evm_config.evm_env(parent.header()) { Ok(evm_env) => evm_env, Err(err) => { @@ -2189,7 +2254,7 @@ where return } }; - txpool_prewarm.start(parent.hash(), evm_env, state_provider_factory) + txpool_prewarm.start(&parent, evm_env, state_provider_factory) } fn payload_builder_resources( diff --git a/crates/engine/tree/src/tree/state_root_strategy/mod.rs b/crates/engine/tree/src/tree/state_root_strategy/mod.rs index aefd61b06b1..a066ac4e868 100644 --- a/crates/engine/tree/src/tree/state_root_strategy/mod.rs +++ b/crates/engine/tree/src/tree/state_root_strategy/mod.rs @@ -58,8 +58,8 @@ mod sparse_trie; use self::sparse_trie::{SparseTrieCacheTask, SparseTrieTaskMetrics}; use crate::tree::{metrics::BlockValidationMetrics, EngineApiTreeState, ExecutionEnv, TreeConfig}; use alloy_primitives::B256; -use crossbeam_channel::{Receiver as CrossbeamReceiver, Sender as CrossbeamSender}; -use reth_chain_state::{ExecutedBlock, PreservedSparseTrie}; +use crossbeam_channel::Receiver as CrossbeamReceiver; +use reth_chain_state::{ExecutedBlock, PreservedSparseTrie, SparseTrieWithStorageRoots}; use reth_errors::ProviderResult; use reth_evm::{ConfigureEvm, OnStateHook}; use reth_primitives_traits::{ @@ -72,17 +72,16 @@ use reth_provider::{ }; use reth_storage_overlay::{OverlayManager, OverlayStateProviderFactory}; use reth_tasks::utils::increase_thread_priority; -use reth_trie::{ - hashed_cursor::HashedCursorFactory, trie_cursor::TrieCursorFactory, updates::TrieUpdates, - HashedPostState, +use reth_trie::{updates::TrieUpdates, HashedPostState}; +use reth_trie_parallel::proof_task::{ + metrics::ProofWorkerIdleMetrics, ProofTaskCtx, ProofWorkerCounts, ProofWorkerHandle, }; -use reth_trie_parallel::proof_task::{ProofResultMessage, ProofTaskCtx, ProofWorkerHandle}; pub use reth_trie_parallel::{ error::StateRootTaskError, state_root_task::{ evm_state_to_hashed_post_state, PayloadStateRootHandle, StateAccessHint, StateRootComputeOutcome, StateRootHandle, StateRootHintStream, StateRootMessage, - StateRootSink, StateRootTaskCancelGuard, StateRootUpdateHook, StateRootUpdateStream, + StateRootTaskCancelGuard, StateRootUpdateHook, StateRootUpdateStream, }, }; use reth_trie_sparse::{ @@ -469,6 +468,7 @@ type SerialFallbackRx = mpsc::Receiver, + transaction_count: Option, + gas_used: Option, + ) -> usize { + let count = if let Some(configured_threads) = configured_threads { + configured_threads + } else { + match (transaction_count, gas_used) { + (Some(count), _) if count <= Self::SMALL_BLOCK_PROOF_WORKER_TX_THRESHOLD => { + pool_threads / 4 + } + (_, Some(gas)) if gas >= Self::LARGE_BLOCK_PROOF_WORKER_GAS_THRESHOLD => { + pool_threads + } + _ => pool_threads / 2, + } + }; + + // Pools can be smaller than two threads, and a pinned count can be zero; a block without + // workers stalls the proofs. + if count == 0 { + 1 + } else { + count + } + } + /// Spawns the default state-root computation pipeline. /// /// The authoritative update capability taken from the returned handle must be dropped or /// explicitly finished after execution so the task observes the end of the update stream. - /// An unknown transaction count uses the full proof-worker pool. #[instrument(level = "debug", target = "engine::tree::payload_processor", skip_all)] fn spawn_state_root( &self, executor: &reth_tasks::Runtime, - overlay_manager: &OverlayManager, - multiproof_provider_factory: F, + multiproof_provider_factory: OverlayStateProviderFactory, options: StateRootTaskOptions<'_, N>, - ) -> StateRootHandle + ) -> (StateRootHandle, mpsc::Receiver) where N: NodePrimitives, - F: DatabaseProviderROFactory - + Clone - + Send - + Sync + F: DatabaseProviderFactory + Clone + 'static, + F::Provider: BlockNumReader + + PruneCheckpointReader + + StageCheckpointReader + + ChangeSetReader + + StorageChangeSetReader + + StorageSettingsCache + 'static, { let StateRootTaskOptions { parent_header, preserved_sparse_trie, transaction_count, + gas_used, config, pending_sparse_trie_prune_blocks, } = options; let (updates_tx, from_multi_proof) = crossbeam_channel::unbounded(); let (cancel_guard, cancel_rx) = StateRootTaskCancelGuard::channel(); - let (proof_result_tx, proof_result_rx) = - crossbeam_channel::unbounded::(); - - let task_ctx = ProofTaskCtx::new(multiproof_provider_factory); - #[cfg(feature = "trie-debug")] - let task_ctx = task_ctx.with_proof_jitter(config.proof_jitter()); - let halve_workers = transaction_count - .is_some_and(|count| count <= Self::SMALL_BLOCK_PROOF_WORKER_TX_THRESHOLD); - let proof_handle = - ProofWorkerHandle::new(executor, task_ctx, halve_workers, proof_result_tx.clone()); + let worker_counts = ProofWorkerCounts::new( + Self::proof_worker_count( + executor.proof_storage_worker_pool().num_threads(), + executor.proof_storage_worker_threads_override(), + transaction_count, + gas_used, + ), + Self::proof_worker_count( + executor.proof_account_worker_pool().num_threads(), + executor.proof_account_worker_threads_override(), + transaction_count, + gas_used, + ), + ); let (state_root_tx, state_root_rx) = mpsc::channel(); + let (pending_trie_tx, pending_trie_rx) = mpsc::channel(); let (hashed_state_tx, hashed_state_rx) = mpsc::channel(); let parent_state_root = parent_header.state_root(); self.spawn_sparse_trie_task( executor, - overlay_manager, - proof_handle, - proof_result_tx, - proof_result_rx, + multiproof_provider_factory, state_root_tx, hashed_state_tx, from_multi_proof, @@ -540,7 +584,11 @@ impl DefaultStateRootStrategy { SparseTrieTaskOptions { parent_header, preserved_sparse_trie, + pending_trie_tx, chunk_size: config.multiproof_chunk_size(), + worker_counts, + #[cfg(feature = "trie-debug")] + proof_jitter: config.proof_jitter(), pending_sparse_trie_prune_blocks: if config.disable_sparse_trie_cache_pruning() { None } else { @@ -549,38 +597,52 @@ impl DefaultStateRootStrategy { }, ); - StateRootHandle::new( - parent_state_root, - updates_tx, - cancel_guard, - state_root_rx, - hashed_state_rx, + ( + StateRootHandle::new( + parent_state_root, + updates_tx, + cancel_guard, + state_root_rx, + hashed_state_rx, + ), + pending_trie_rx, ) } - /// Spawns the sparse-trie task and preserves its trie for the next state-root job. + /// Spawns the sparse-trie task and hands off its pending trie for later publication. #[expect(clippy::too_many_arguments)] - fn spawn_sparse_trie_task( + fn spawn_sparse_trie_task( &self, executor: &reth_tasks::Runtime, - overlay_manager: &OverlayManager, - proof_worker_handle: ProofWorkerHandle, - proof_result_tx: CrossbeamSender, - proof_result_rx: CrossbeamReceiver, + multiproof_provider_factory: OverlayStateProviderFactory, state_root_tx: mpsc::Sender>, hashed_state_tx: mpsc::Sender>, from_multi_proof: CrossbeamReceiver, cancel_rx: CrossbeamReceiver<()>, options: SparseTrieTaskOptions, - ) { + ) where + N: NodePrimitives, + F: DatabaseProviderFactory + Clone + 'static, + F::Provider: BlockNumReader + + PruneCheckpointReader + + StageCheckpointReader + + ChangeSetReader + + StorageChangeSetReader + + StorageSettingsCache + + 'static, + { let SparseTrieTaskOptions { parent_header, preserved_sparse_trie, + pending_trie_tx, chunk_size, + worker_counts, + #[cfg(feature = "trie-debug")] + proof_jitter, pending_sparse_trie_prune_blocks, } = options; - let overlay_manager = overlay_manager.clone(); let trie_metrics = self.metrics.clone(); + let worker_metrics = self.proof_worker_metrics.clone(); let executor = executor.clone(); let parent_span = Span::current(); @@ -590,12 +652,10 @@ impl DefaultStateRootStrategy { let parent_hash = parent_header.hash(); let parent_state_root = parent_header.state_root(); let new_epoch = TrieNodeEpoch::new(parent_header.number().saturating_add(1)); - let prune_before = - sparse_trie_prune_before(pending_sparse_trie_prune_blocks.as_deref(), new_epoch); let _enter = debug_span!( target: "engine::tree::payload_processor", - parent: parent_span, + parent: &parent_span, "sparse_trie_task" ) .entered(); @@ -615,39 +675,57 @@ impl DefaultStateRootStrategy { let mut sparse_trie_anchor_hash = parent_hash; let mut reused_preserved_sparse_trie = false; + let mut known_storage_roots = Default::default(); let sparse_state_trie = match preserved_sparse_trie { Some(preserved) => { let start = Instant::now(); let preserved_anchor_hash = preserved.anchor_hash(); - let preserved = preserved.into_trie_for(parent_state_root); + let preserved = preserved.into_trie_for(parent_hash); trie_metrics .sparse_trie_cache_wait_duration_histogram .record(start.elapsed().as_secs_f64()); match preserved { - Ok(Some(trie)) => { + Ok(Some(SparseTrieWithStorageRoots { trie, storage_roots })) => { + known_storage_roots = storage_roots; sparse_trie_anchor_hash = preserved_anchor_hash; reused_preserved_sparse_trie = true; trie } Ok(None) => new_sparse_state_trie(), Err(err) => { - let _ = - state_root_tx.send(Err(StateRootTaskError::Other(err.to_string()))); + let _ = state_root_tx.send(Err(StateRootTaskError::Other(err.into()))); return; } } } None => new_sparse_state_trie(), }; + let prune_target = sparse_trie_prune_target( + sparse_trie_anchor_hash, + reused_preserved_sparse_trie, + parent_header.num_hash(), + pending_sparse_trie_prune_blocks.as_deref(), + ); + let factory = if reused_preserved_sparse_trie { + multiproof_provider_factory.with_skip_overlay_for_reused_sparse_trie( + sparse_trie_anchor_hash, + known_storage_roots, + ) + } else { + multiproof_provider_factory + }; + let task_ctx = ProofTaskCtx::new(factory); + #[cfg(feature = "trie-debug")] + let task_ctx = task_ctx.with_proof_jitter(proof_jitter); + let proof_worker_handle = + ProofWorkerHandle::new(&executor, task_ctx, worker_counts, worker_metrics); let mut task = SparseTrieCacheTask::new_with_trie( &executor, from_multi_proof, cancel_rx, hashed_state_tx, proof_worker_handle, - proof_result_tx, - proof_result_rx, trie_metrics.clone(), sparse_state_trie, parent_state_root, @@ -656,51 +734,38 @@ impl DefaultStateRootStrategy { ); let result = task.run(); - let task_result = result.as_ref().ok().cloned(); - - // Publish a handle before sending the result so the next block can inspect the - // state root immediately while the trie is finalized for reuse below. - let pending_trie = if let Some(result) = &task_result { - let preserved_anchor_hash = published_sparse_trie_anchor_hash( - sparse_trie_anchor_hash, - reused_preserved_sparse_trie, - pending_sparse_trie_prune_blocks.as_deref(), - ); - let (preserved, completer) = - PreservedSparseTrie::pending(result.state_root, preserved_anchor_hash); - overlay_manager.store_sparse_trie(preserved); - Some(completer) + // Send the pending handle before the result so acceptance never waits for pruning. + let trie_completer = if let Ok(outcome) = &result { + let preserved_anchor_hash = + prune_target.map_or(sparse_trie_anchor_hash, |(_, anchor_hash)| anchor_hash); + let (tx, rx) = mpsc::channel(); + let pending = PendingSparseTrie { + state_root: outcome.state_root, + anchor_hash: preserved_anchor_hash, + trie: rx, + }; + pending_trie_tx.send(pending).is_ok().then_some(tx) } else { - overlay_manager.clear_sparse_trie(); None }; if state_root_tx.send(result).is_err() { - // A continuation task can take the pending trie during the narrow window between - // publishing it and detecting the abandoned receiver here. Returning drops the - // completer, so the taker wakes with `ProducerDropped` and its state-root consumer - // falls back to serial computation. No partially finalized trie is exposed; the - // worst case is a redundant fallback. debug!( target: "engine::tree::payload_processor", - "State root receiver dropped, dropping trie" + "State root receiver dropped" ); - let (trie, deferred) = task.into_cleared_trie(); - overlay_manager.clear_sparse_trie(); - executor.spawn_drop(trie); - executor.spawn_drop(deferred); - return; } + drop(pending_trie_tx); let _enter = debug_span!(target: "engine::tree::payload_processor", "preserve").entered(); - let mut trie_to_drop = None; - let deferred = if task_result.is_some() { - let pending_trie = - pending_trie.expect("pending trie is created for successful task result"); + let (trie_to_drop, deferred) = if let Some(completer) = trie_completer { + // Complete every handed-off handle, even if the root receiver was abandoned: + // a builder may have accepted the matching root through another strategy. let start = Instant::now(); let (mut trie, deferred) = task.into_trie_for_reuse(); - if let Some(prune_before) = prune_before { + let storage_roots = trie.storage_roots(new_epoch); + if let Some((prune_before, _)) = prune_target { let prune_start = Instant::now(); trie.prune(prune_before); trie_metrics @@ -713,18 +778,20 @@ impl DefaultStateRootStrategy { trie_metrics .sparse_trie_retained_storage_tries .set(trie.retained_storage_tries_count() as f64); - if let Err(trie) = pending_trie.complete(trie) { - trie_to_drop = Some(trie); - } - deferred + ( + completer + .send(SparseTrieWithStorageRoots { trie, storage_roots }) + .err() + .map(|err| err.0.trie), + deferred, + ) } else { debug!( target: "engine::tree::payload_processor", - "State root computation failed, dropping trie" + "State root task has no reusable trie to publish, dropping trie" ); let (trie, deferred) = task.into_cleared_trie(); - trie_to_drop = Some(trie); - deferred + (Some(trie), deferred) }; if let Some(trie) = trie_to_drop { executor.spawn_drop(trie); @@ -734,10 +801,41 @@ impl DefaultStateRootStrategy { } } +/// A computed trie whose finalization and pruning may still be running. +#[derive(Debug)] +struct PendingSparseTrie { + state_root: B256, + anchor_hash: B256, + trie: mpsc::Receiver, +} + +fn publish_sparse_trie( + pending_trie_rx: &mpsc::Receiver, + overlay_manager: &OverlayManager, + block_hash: B256, + state_root: B256, +) { + // Validation has already received the root. A builder using another strategy may finish + // before the sparse task; it must not wait for that task just to populate the cache. + if let Ok(pending) = pending_trie_rx.try_recv() && + pending.state_root == state_root + { + overlay_manager.store_sparse_trie(PreservedSparseTrie::pending( + pending.trie, + block_hash, + pending.anchor_hash, + )); + } +} + struct SparseTrieTaskOptions { parent_header: SealedHeader, preserved_sparse_trie: Option, + pending_trie_tx: mpsc::Sender, chunk_size: usize, + worker_counts: ProofWorkerCounts, + #[cfg(feature = "trie-debug")] + proof_jitter: Option, /// `None` disables pruning. `Some(Vec::new())` prunes nodes older than the current block. pending_sparse_trie_prune_blocks: Option>>, } @@ -746,47 +844,31 @@ struct StateRootTaskOptions<'a, N: NodePrimitives> { parent_header: SealedHeader, preserved_sparse_trie: Option, transaction_count: Option, + /// Gas the block claims to use, taken from the payload before execution. + gas_used: Option, config: &'a TreeConfig, pending_sparse_trie_prune_blocks: Option>>, } -fn sparse_trie_prune_before( - pending_sparse_trie_prune_blocks: Option<&[ExecutedBlock]>, - new_epoch: TrieNodeEpoch, -) -> Option { - // The parent chain is ordered newest to oldest. An empty chain means the block being - // calculated is the only in-memory block whose trie nodes need to be retained. - match pending_sparse_trie_prune_blocks { - None => None, - Some([]) => Some(new_epoch), - Some([.., oldest]) => Some(TrieNodeEpoch::new(oldest.recovered_block().number())), - } -} - -fn published_sparse_trie_anchor_hash( - sparse_trie_anchor_hash: B256, +fn sparse_trie_prune_target( + current_anchor: B256, reused_preserved_sparse_trie: bool, - pending_sparse_trie_prune_blocks: Option<&[ExecutedBlock]>, -) -> B256 { - if !reused_preserved_sparse_trie { - return sparse_trie_anchor_hash - } - - let Some(prune_blocks) = pending_sparse_trie_prune_blocks else { - return sparse_trie_anchor_hash + parent: alloy_eips::BlockNumHash, + pending_blocks: Option<&[ExecutedBlock]>, +) -> Option<(TrieNodeEpoch, B256)> { + let blocks = pending_blocks?; + let Some(oldest) = blocks.last() else { + // Pruning the now-durable parent moves the proof source forward with it. + return Some((TrieNodeEpoch::new(parent.number.saturating_add(1)), parent.hash)) }; - let Some(oldest_prune_block) = prune_blocks.last() else { return sparse_trie_anchor_hash }; - - // Prune blocks contain the complete in-memory parent chain from newest to oldest, with the - // oldest block's parent being the persisted tip. A fresh trie can be anchored to an in-memory - // block ahead of that tip. If that anchor is still in the prune range, publishing the - // persisted tip as the new anchor would expand the trie's claimed coverage backwards even - // though pruning cannot reveal those paths. - if prune_blocks.iter().any(|block| block.recovered_block().hash() == sparse_trie_anchor_hash) { - return sparse_trie_anchor_hash - } - - oldest_prune_block.recovered_block().parent_hash() + let anchor = if !reused_preserved_sparse_trie || + blocks.iter().any(|block| block.recovered_block().hash() == current_anchor) + { + current_anchor + } else { + oldest.recovered_block().parent_hash() + }; + Some((TrieNodeEpoch::new(oldest.recovered_block().number()), anchor)) } impl StateRootStrategy for DefaultStateRootStrategy @@ -800,14 +882,6 @@ where + StorageChangeSetReader + StorageSettingsCache + 'static, - OverlayStateProviderFactory: DatabaseProviderROFactory< - Provider: TrieCursorFactory - + HashedCursorFactory - + HashedPostStateProvider - + StateRootProvider - + Send, - > + Clone - + 'static, Evm: ConfigureEvm + 'static, { fn prepare( @@ -840,24 +914,14 @@ where } = ctx; let preserved_sparse_trie = overlay_manager.take_sparse_trie(); - let proof_state_provider_factory = if let Some(anchor_hash) = preserved_sparse_trie - .as_ref() - .filter(|trie| trie.state_root() == env.parent_state_root) - .map(|trie| trie.anchor_hash()) - { - state_provider_factory.clone().with_skip_overlay_for_reused_sparse_trie(anchor_hash) - } else { - state_provider_factory.clone() - }; - - let mut handle = self.spawn_state_root( + let (mut handle, pending_trie_rx) = self.spawn_state_root( executor, - overlay_manager, - proof_state_provider_factory, + state_provider_factory.clone(), StateRootTaskOptions { parent_header: parent_header.clone(), preserved_sparse_trie, transaction_count: Some(env.transaction_count), + gas_used: Some(env.gas_used), config, pending_sparse_trie_prune_blocks, }, @@ -879,6 +943,8 @@ where let mut prepared = PreparedStateRootJob::new( Box::new(SparseTrieStateRootJob { + pending_trie_rx, + overlay_manager: overlay_manager.clone(), handle, state_provider_factory, executor: executor.clone(), @@ -912,34 +978,28 @@ where } let pending_sparse_trie_prune_blocks = ctx.take_sparse_trie_prune_blocks(); - let parent_state_root = ctx.parent_state_root(); let parent_header = SealedHeader::new(ctx.parent_header().clone(), ctx.parent_hash()); let preserved_sparse_trie = ctx.overlay_manager.take_sparse_trie(); - let proof_state_provider_factory = if let Some(anchor_hash) = preserved_sparse_trie - .as_ref() - .filter(|trie| trie.state_root() == parent_state_root) - .map(|trie| trie.anchor_hash()) - { - ctx.state_provider_factory.clone().with_skip_overlay_for_reused_sparse_trie(anchor_hash) - } else { - ctx.state_provider_factory.clone() - }; - Ok(Some( - self.spawn_state_root( - ctx.executor, - ctx.overlay_manager, - proof_state_provider_factory, - StateRootTaskOptions { - parent_header, - preserved_sparse_trie, - // Tx count unknown at FCU time (block built incrementally): full proof workers. - transaction_count: None, - config: ctx.config, - pending_sparse_trie_prune_blocks, - }, - ) - .into_payload_state_root_handle(), - )) + let (handle, pending_trie_rx) = self.spawn_state_root( + ctx.executor, + ctx.state_provider_factory.clone(), + StateRootTaskOptions { + parent_header, + preserved_sparse_trie, + // Block built incrementally, so neither the tx count nor the gas is known at FCU + // time: the payload builder uses half of each proof pool. + transaction_count: None, + gas_used: None, + config: ctx.config, + pending_sparse_trie_prune_blocks, + }, + ); + let overlay_manager = ctx.overlay_manager.clone(); + Ok(Some(handle.into_payload_state_root_handle().with_on_payload_built( + move |block_hash, state_root| { + publish_sparse_trie(&pending_trie_rx, &overlay_manager, block_hash, state_root); + }, + ))) } } @@ -977,7 +1037,6 @@ where + StorageChangeSetReader + StorageSettingsCache + 'static, - OverlayStateProviderFactory: DatabaseProviderROFactory, { fn name(&self) -> &'static str { "synchronous" @@ -998,6 +1057,8 @@ where #[derive(Debug)] struct SparseTrieStateRootJob { + pending_trie_rx: mpsc::Receiver, + overlay_manager: OverlayManager, handle: StateRootHandle, state_provider_factory: OverlayStateProviderFactory, executor: reth_tasks::Runtime, @@ -1017,23 +1078,14 @@ where + StorageChangeSetReader + StorageSettingsCache + 'static, - OverlayStateProviderFactory: DatabaseProviderROFactory< - Provider: TrieCursorFactory - + HashedCursorFactory - + HashedPostStateProvider - + StateRootProvider - + Send, - > + Clone - + 'static, { fn serial_fallback( - executor: &reth_tasks::Runtime, - state_provider_factory: OverlayStateProviderFactory, + &self, output: Arc>, ) -> ProviderResult { - let provider = state_provider_factory.database_provider_ro()?; + let provider = self.state_provider_factory.database_provider_ro()?; let (fallback_tx, fallback_rx) = mpsc::channel(); - executor.spawn_blocking_named("serial-root", move || { + self.executor.spawn_blocking_named("serial-root", move || { let result = (|| { let hashed_state = Arc::new(provider.hashed_post_state(&output.state)?); let (root, updates) = @@ -1073,8 +1125,14 @@ where output: &BlockExecutionOutput, outcome: StateRootComputeOutcome, ) -> ProviderResult { - let outcome = self.sparse_outcome(block, output, outcome); + let outcome = self.sparse_outcome(output, outcome); if outcome.state_root == block.header().state_root() { + publish_sparse_trie( + &self.pending_trie_rx, + &self.overlay_manager, + block.hash(), + outcome.state_root, + ); return Ok(outcome) } warn!( @@ -1088,7 +1146,6 @@ where fn sparse_outcome( &self, - _block: &RecoveredBlock, output: &BlockExecutionOutput, outcome: StateRootComputeOutcome, ) -> StateRootJobOutcome { @@ -1118,14 +1175,6 @@ where + StorageChangeSetReader + StorageSettingsCache + 'static, - OverlayStateProviderFactory: DatabaseProviderROFactory< - Provider: TrieCursorFactory - + HashedCursorFactory - + HashedPostStateProvider - + StateRootProvider - + Send, - > + Clone - + 'static, { fn name(&self) -> &'static str { "sparse-trie" @@ -1153,35 +1202,29 @@ where Ok(Ok(outcome)) => return self.verified_sparse_outcome(block, &output, outcome), Ok(Err(err)) => { debug!(target: "engine::tree::state_root_strategy", %err, "State root task failed, falling back to serial root"); - Self::serial_fallback( - &self.executor, - self.state_provider_factory.clone(), - output.clone(), - )? + self.serial_fallback(output.clone())? } Err(RecvTimeoutError::Timeout) => { warn!(target: "engine::tree::state_root_strategy", ?timeout, "State root task timed out, racing serial fallback"); self.metrics.state_root_task_timeout_total.increment(1); - Self::serial_fallback( - &self.executor, - self.state_provider_factory.clone(), - output.clone(), - )? + self.serial_fallback(output.clone())? } Err(RecvTimeoutError::Disconnected) => { debug!(target: "engine::tree::state_root_strategy", "State root task dropped, falling back to serial root"); - Self::serial_fallback( - &self.executor, - self.state_provider_factory.clone(), - output.clone(), - )? + self.serial_fallback(output.clone())? } }; loop { if let Ok(Ok(outcome)) = task_rx.try_recv() { - let outcome = self.sparse_outcome(block, &output, outcome); + let outcome = self.sparse_outcome(&output, outcome); if outcome.state_root == block.header().state_root() { + publish_sparse_trie( + &self.pending_trie_rx, + &self.overlay_manager, + block.hash(), + outcome.state_root, + ); return Ok(outcome) } // A wrong task root falls through to the serial fallback already racing below. @@ -1228,12 +1271,6 @@ where + StorageChangeSetReader + StorageSettingsCache + 'static, - OverlayStateProviderFactory: DatabaseProviderROFactory< - Provider: TrieCursorFactory - + HashedCursorFactory - + HashedPostStateProvider - + StateRootProvider, - >, { debug!(target: "engine::tree::state_root_strategy", "Comparing trie updates with serial computation"); @@ -1307,57 +1344,282 @@ mod tests { use revm::state::{AccountInfo, AccountStatus, EvmState, EvmStorageSlot, TransactionId}; #[test] - fn sparse_trie_prune_before_uses_requested_range() { - let new_epoch = TrieNodeEpoch::new(10); - assert_eq!(sparse_trie_prune_before::(None, new_epoch), None); + fn proof_worker_count_scales_with_block_gas() { + let pool_threads = 64; + let count = |transaction_count, gas_used| { + DefaultStateRootStrategy::proof_worker_count( + pool_threads, + None, + transaction_count, + gas_used, + ) + }; + + // Small blocks use a quarter of the pool, whatever their gas. + assert_eq!(count(Some(30), Some(1_000_000)), 16); + assert_eq!(count(Some(30), Some(300_000_000)), 16); + + // Gas heavy blocks use the full pool. + assert_eq!(count(Some(1_000), Some(100_000_000)), 64); + assert_eq!(count(Some(1_000), Some(300_000_000)), 64); + + // Everything else, including a block whose size is not known yet, uses half the pool. + assert_eq!(count(Some(31), Some(99_999_999)), 32); + assert_eq!(count(Some(1_000), Some(15_000_000)), 32); + assert_eq!(count(None, None), 32); + + // An explicitly configured count is used verbatim for every block. + for (transaction_count, gas_used) in + [(Some(30), Some(1_000_000)), (Some(1_000), Some(300_000_000)), (None, None)] + { + assert_eq!( + DefaultStateRootStrategy::proof_worker_count( + 48, + Some(48), + transaction_count, + gas_used + ), + 48 + ); + } + + // Pools smaller than two threads, and a pinned count of zero, still get a worker. + assert_eq!( + DefaultStateRootStrategy::proof_worker_count(1, None, Some(1), Some(1_000_000)), + 1 + ); + assert_eq!(DefaultStateRootStrategy::proof_worker_count(1, None, None, None), 1); + assert_eq!(DefaultStateRootStrategy::proof_worker_count(1, Some(0), None, None), 1); + } + + #[test] + fn finish_publishes_only_accepted_sparse_trie() { + let factory = create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let genesis_hash = init_genesis(&factory).unwrap(); + let provider = BlockchainProvider::new(factory).unwrap(); + let runtime = reth_tasks::Runtime::test(); + + for timeout in [None, Some(Duration::from_secs(1))] { + for (finish, matching_root) in [(false, true), (true, true), (true, false)] { + let overlay_manager = OverlayManager::::default(); + let block = RecoveredBlock::new_unhashed( + reth_ethereum_primitives::Block { + header: alloy_consensus::Header { + state_root: B256::with_last_byte(1), + ..Default::default() + }, + ..Default::default() + }, + Vec::new(), + ); + let (completer, trie) = mpsc::channel(); + let (pending_trie_tx, pending_trie_rx) = mpsc::channel(); + pending_trie_tx + .send(PendingSparseTrie { + state_root: if matching_root { block.state_root } else { B256::ZERO }, + anchor_hash: genesis_hash, + trie, + }) + .unwrap(); + let (result_tx, result_rx) = mpsc::channel(); + result_tx + .send(Ok(StateRootComputeOutcome { + state_root: if matching_root { block.state_root } else { B256::ZERO }, + trie_updates: Default::default(), + hashed_state: Default::default(), + })) + .unwrap(); + let mut job = SparseTrieStateRootJob { + pending_trie_rx, + overlay_manager: overlay_manager.clone(), + handle: StateRootHandle::new( + B256::ZERO, + crossbeam_channel::unbounded().0, + StateRootTaskCancelGuard::channel().0, + result_rx, + mpsc::channel().1, + ), + state_provider_factory: OverlayStateProviderFactory::new( + provider.clone(), + overlay_manager.overlay_builder(genesis_hash), + ), + executor: runtime.clone(), + timeout, + compare_trie_updates: false, + metrics: Default::default(), + }; + assert!(overlay_manager.take_sparse_trie().is_none()); + if finish { + job.finish( + &block, + Arc::new(BlockExecutionOutput::default()), + &LazyHashedPostState::ready(Default::default()), + ) + .unwrap(); + } + drop(job); + let preserved = overlay_manager.take_sparse_trie(); + assert_eq!(preserved.is_some(), finish && matching_root); + // Publication must finish before trie finalization does. + assert_eq!( + completer.send(SparseTrieWithStorageRoots::default()).is_ok(), + finish && matching_root + ); + if let Some(preserved) = preserved { + assert!(preserved.into_trie_for(block.hash()).unwrap().is_some()); + } + } + } + } + + #[test] + fn failed_parent_trie_closes_handoff_without_publishing() { + let factory = create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let genesis_hash = init_genesis(&factory).unwrap(); + let runtime = reth_tasks::Runtime::test(); + { + let (completer, trie) = mpsc::channel(); + let preserved = PreservedSparseTrie::pending(trie, genesis_hash, B256::ZERO); + drop(completer); + let (mut handle, pending_trie_rx) = DefaultStateRootStrategy::default() + .spawn_state_root( + &runtime, + OverlayStateProviderFactory::new( + factory, + OverlayManager::::default().overlay_builder(genesis_hash), + ), + StateRootTaskOptions:: { + parent_header: SealedHeader::new(Default::default(), genesis_hash), + preserved_sparse_trie: Some(preserved), + transaction_count: None, + gas_used: None, + config: &TreeConfig::default(), + pending_sparse_trie_prune_blocks: None, + }, + ); + assert!(handle.state_root().is_err()); + assert!(matches!( + pending_trie_rx.recv_timeout(Duration::from_secs(5)), + Err(RecvTimeoutError::Disconnected) + )); + } + for name in ["sparse-trie", "storage-workers", "account-workers"] { + runtime.spawn_blocking_named(name, || {}).get(); + } + } + + #[test] + fn publication_does_not_wait_for_worker() { + let (pending_trie_tx, pending_trie_rx) = mpsc::channel(); + let overlay_manager = OverlayManager::::default(); + let (done_tx, done_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + publish_sparse_trie(&pending_trie_rx, &overlay_manager, B256::ZERO, B256::ZERO); + assert!(overlay_manager.take_sparse_trie().is_none()); + done_tx.send(()).unwrap(); + }); + let result = done_rx.recv_timeout(Duration::from_secs(5)); + drop(pending_trie_tx); + worker.join().unwrap(); + result.expect("publication should not wait for the root task"); + } + + #[test] + fn dropped_root_receiver_still_completes_pending_trie() { + let factory = create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let genesis_hash = init_genesis(&factory).unwrap(); + let runtime = reth_tasks::Runtime::test(); + let (mut handle, pending_trie_rx) = DefaultStateRootStrategy::default().spawn_state_root( + &runtime, + OverlayStateProviderFactory::new( + factory, + OverlayManager::::default().overlay_builder(genesis_hash), + ), + StateRootTaskOptions:: { + parent_header: SealedHeader::new(Default::default(), genesis_hash), + preserved_sparse_trie: None, + transaction_count: None, + gas_used: None, + config: &TreeConfig::default(), + pending_sparse_trie_prune_blocks: None, + }, + ); + drop(handle.take_state_root_rx()); + drop(handle.take_execution_hook()); + let pending = pending_trie_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(pending.anchor_hash, genesis_hash); + pending + .trie + .recv_timeout(Duration::from_secs(5)) + .expect("handed-off trie must be completed"); + drop(handle); + for name in ["sparse-trie", "trie-hashing", "storage-workers", "account-workers", "drop"] { + runtime.spawn_blocking_named(name, || {}).get(); + } + } + + #[test] + fn sparse_trie_prune_target_uses_requested_range() { + let parent = alloy_eips::BlockNumHash { hash: B256::with_last_byte(9), number: 9 }; assert_eq!( - sparse_trie_prune_before::(Some(&[]), new_epoch), - Some(new_epoch) + sparse_trie_prune_target::(B256::ZERO, false, parent, None), + None + ); + assert_eq!( + sparse_trie_prune_target::(B256::ZERO, false, parent, Some(&[])), + Some((TrieNodeEpoch::new(10), parent.hash)) ); let mut blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(7..10).collect(); blocks.reverse(); - assert_eq!(sparse_trie_prune_before(Some(&blocks), new_epoch), Some(TrieNodeEpoch::new(7))); + let anchor = blocks[0].recovered_block().hash(); + assert_eq!( + sparse_trie_prune_target(anchor, true, parent, Some(&blocks)), + Some((TrieNodeEpoch::new(7), anchor)) + ); } #[test] - fn published_sparse_trie_anchor_advances_to_prune_anchor() { + fn sparse_trie_prune_target_advances_to_prune_anchor() { let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..5).collect(); let reused_anchor_hash = blocks[0].recovered_block().hash(); let expected_prune_anchor = blocks[1].recovered_block().hash(); + let parent = blocks[3].recovered_block().num_hash(); let prune_blocks: Vec<_> = blocks.into_iter().skip(2).rev().collect(); assert_eq!( - published_sparse_trie_anchor_hash(reused_anchor_hash, true, Some(&prune_blocks)), - expected_prune_anchor + sparse_trie_prune_target(reused_anchor_hash, true, parent, Some(&prune_blocks),), + Some((TrieNodeEpoch::new(3), expected_prune_anchor)) ); } #[test] - fn published_sparse_trie_anchor_does_not_move_backwards_when_anchor_is_in_prune_range() { - let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..5).collect(); - let reused_anchor_hash = blocks[2].recovered_block().hash(); - let mut prune_blocks = blocks; - prune_blocks.reverse(); - let prune_anchor = prune_blocks.last().unwrap().recovered_block().parent_hash(); + fn sparse_trie_prune_target_keeps_parent_for_fresh_trie() { + let mut blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect(); + blocks.reverse(); + let parent_hash = B256::with_last_byte(0xaa); - assert_ne!(reused_anchor_hash, prune_anchor); assert_eq!( - published_sparse_trie_anchor_hash(reused_anchor_hash, true, Some(&prune_blocks)), - reused_anchor_hash + sparse_trie_prune_target( + parent_hash, + false, + blocks[0].recovered_block().num_hash(), + Some(&blocks), + ), + Some((TrieNodeEpoch::new(1), parent_hash)) ); } #[test] - fn published_sparse_trie_anchor_keeps_parent_for_fresh_trie() { - let mut blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..3).collect(); - blocks.reverse(); - let parent_hash = B256::with_last_byte(0xaa); + fn sparse_trie_prune_target_advances_reused_anchor_when_parent_becomes_durable() { + let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..4).collect(); + let old_anchor = blocks[1].recovered_block().hash(); + let parent = blocks[2].recovered_block().num_hash(); assert_eq!( - published_sparse_trie_anchor_hash(parent_hash, false, Some(&blocks)), - parent_hash + sparse_trie_prune_target::(old_anchor, true, parent, Some(&[])), + Some((TrieNodeEpoch::new(4), parent.hash)) ); } @@ -1407,7 +1669,7 @@ mod tests { } #[test] - fn state_root_task_matches_serial_root() { + fn payload_builder_trie_matches_serial_root_and_can_be_reused() { reth_tracing::init_test_tracing(); let factory = create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); @@ -1453,33 +1715,104 @@ mod tests { } let provider_factory = BlockchainProvider::new(factory).unwrap(); - let env: ExecutionEnv = ExecutionEnv::test_default(); let runtime = reth_tasks::Runtime::test(); let overlay_manager = OverlayManager::::default(); - let mut state_root_handle = DefaultStateRootStrategy::default().spawn_state_root( - &runtime, - &overlay_manager, - OverlayStateProviderFactory::new( - provider_factory, - overlay_manager.overlay_builder(genesis_hash), - ), - StateRootTaskOptions { - parent_header: SealedHeader::new(Default::default(), genesis_hash), - preserved_sparse_trie: None, - transaction_count: Some(env.transaction_count), - config: &TreeConfig::default(), - pending_sparse_trie_prune_blocks: None, - }, + let config = TreeConfig::default() + .with_has_enough_parallelism(true) + .with_share_sparse_trie_with_payload_builder(true); + let mut state = EngineApiTreeState::new( + 10, + 10, + config.invalid_header_hit_eviction_threshold(), + alloy_eips::BlockNumHash::new(0, genesis_hash), + crate::engine::EngineApiKind::Ethereum, + overlay_manager.clone(), ); - - let mut state_hook = state_root_handle.take_execution_hook(); - for update in state_updates { - state_hook.on_state(update); + let mut root_from_regular = state_root(accumulated_state.clone()); + let mut preserved: Option = None; + for (number, (reuse, return_payload, matching_root)) in [ + (false, false, true), + (false, true, false), + (false, true, true), + (true, true, true), + (true, false, true), + ] + .into_iter() + .enumerate() + { + assert_eq!(preserved.is_some(), reuse); + let parent_hash = + preserved.as_ref().map_or(genesis_hash, PreservedSparseTrie::block_hash); + let block_hash = B256::with_last_byte(number as u8 + 1); + if let Some(preserved) = preserved.take() { + overlay_manager.store_sparse_trie(preserved); + } + let parent_header = alloy_consensus::Header { + number: number as u64, + state_root: if reuse { root_from_regular } else { B256::ZERO }, + ..Default::default() + }; + let mut state_root_handle = >::prepare_payload_builder( + &DefaultStateRootStrategy::default(), + PayloadStateRootJobContext::new( + &runtime, + &overlay_manager, + parent_hash, + &parent_header, + 1, + &mut state, + OverlayStateProviderFactory::new( + provider_factory.clone(), + overlay_manager.overlay_builder(genesis_hash), + ), + &config, + ), + ) + .unwrap() + .unwrap(); + let on_payload_built = state_root_handle.take_on_payload_built().unwrap(); + let mut state_hook = state_root_handle.take_state_hook(); + if reuse { + let mut update = state_updates.last().unwrap().clone(); + for (address, account) in &mut update { + account.info.nonce += number as u64; + accumulated_state.get_mut(address).unwrap().0 = + Account::from_revm_account(account); + } + state_hook.on_state(update); + root_from_regular = state_root(accumulated_state.clone()); + } else { + for update in &state_updates { + state_hook.on_state(update.clone()); + } + } + drop(state_hook); + + let root_from_task = state_root_handle.state_root().expect("task failed").state_root; + assert_eq!(root_from_task, root_from_regular); + assert!(overlay_manager.take_sparse_trie().is_none()); + drop(state_root_handle); + if return_payload { + on_payload_built( + block_hash, + if matching_root { root_from_task } else { B256::ZERO }, + ); + } else { + drop(on_payload_built); + } + preserved = overlay_manager.take_sparse_trie(); + assert_eq!(preserved.is_some(), return_payload && matching_root); + if let Some(preserved) = &preserved { + assert_eq!(preserved.block_hash(), block_hash); + assert_eq!(preserved.anchor_hash(), genesis_hash); + } + } + for name in ["sparse-trie", "trie-hashing", "storage-workers", "account-workers", "drop"] { + runtime.spawn_blocking_named(name, || {}).get(); } - drop(state_hook); - - let root_from_task = state_root_handle.state_root().expect("task failed").state_root; - let root_from_regular = state_root(accumulated_state); - assert_eq!(root_from_task, root_from_regular); } } diff --git a/crates/engine/tree/src/tree/state_root_strategy/sparse_trie.rs b/crates/engine/tree/src/tree/state_root_strategy/sparse_trie.rs index bcf4ea14121..f383e6bbe9a 100644 --- a/crates/engine/tree/src/tree/state_root_strategy/sparse_trie.rs +++ b/crates/engine/tree/src/tree/state_root_strategy/sparse_trie.rs @@ -24,10 +24,7 @@ use reth_trie::{ use reth_trie_common::{MultiProofTargetsV2, ProofTrieNodeV2, ProofV2Target, ProofV2TargetParent}; use reth_trie_parallel::{ error::StateRootTaskError, - proof_task::{ - AccountMultiproofInput, ProofResultContext, ProofResultMessage, ProofResultSender, - ProofWorkerHandle, - }, + proof_task::{ProofWorkerHandle, ProofWorkerResult}, }; use reth_trie_sparse::{ errors::{ @@ -52,11 +49,11 @@ use tracing::{debug, debug_span, error, instrument, trace_span}; /// receive order. `HashedState` supplies changed values; `PrefetchProofs` supplies /// [`LeafUpdate::Touched`] hints that must not overwrite changed values. The task buffers these /// leaf updates before applying them in batches. -/// 2. **Proof workers:** blinded paths become [`MultiProofTargetsV2`] requests dispatched through -/// [`ProofWorkerHandle`]. Each request carries a [`ProofResultContext`] with a sender for -/// [`Self::proof_result_rx`]. The task coalesces returned [`ProofResultMessage`]s, reveals -/// account proofs locally, and queues storage proofs on the corresponding address' -/// [`StorageTrieState`]. +/// 2. **Proof workers:** blinded paths become [`MultiProofTargetsV2`] chunks, which +/// [`ProofWorkerHandle::dispatch`] splits into independent account and per-address storage jobs. +/// The task coalesces the [`ProofWorkerResult`]s returned through +/// [`ProofWorkerHandle::results`], reveals account proofs locally, and queues storage proofs on +/// the corresponding address' [`StorageTrieState`]. /// 3. **Storage jobs:** [`Self::spawn_storage_jobs`] moves each [`StorageTrieWork`] into a rayon /// closure. A pass reveals queued proofs, applies unblocked leaves, and hashes the storage trie /// once its pending leaves are drained. It sends [`StorageJobMessage::Done`] via @@ -120,10 +117,6 @@ use tracing::{debug, debug_span, error, instrument, trace_span}; /// this task's thread. During draining, pending updates with no remaining source of progress /// produce [`StateRootTaskError::Stalled`]. pub(super) struct SparseTrieCacheTask { - /// Sender for proof results. - proof_result_tx: ProofResultSender, - /// Receiver for proof results directly from workers. - proof_result_rx: CrossbeamReceiver, /// Receives updates from execution and prewarming. updates: CrossbeamReceiver, /// Fires (by disconnecting) when the consumer drops its cancel guard, meaning nobody is @@ -188,8 +181,8 @@ pub(super) struct SparseTrieCacheTask, final_hashed_state_tx: std::sync::mpsc::Sender>, proof_worker_handle: ProofWorkerHandle, - proof_result_tx: ProofResultSender, - proof_result_rx: CrossbeamReceiver, metrics: SparseTrieTaskMetrics, trie: SparseStateTrie, parent_state_root: B256, @@ -254,8 +245,6 @@ where }); Self { - proof_result_tx, - proof_result_rx, updates: hashed_state_rx, cancel_rx, proof_worker_handle, @@ -277,7 +266,7 @@ where storage_cache_hits: 0, storage_cache_misses: 0, pending_targets: Default::default(), - in_flight_proof_batches: 0, + in_flight_proof_jobs: 0, storage: Default::default(), storage_to_drop: Default::default(), storage_in_flight: 0, @@ -396,14 +385,14 @@ where .record(wake.duration_since(t)); let update = message.map_err(|_| StateRootTaskError::Other( - "updates channel disconnected before state root calculation".to_string(), + "updates channel disconnected before state root calculation".into(), ))?; if let Some(hashed_state) = self.on_message(update) { finalized_hashed_state = Some(hashed_state); } self.pending_updates += 1; } - recv(self.proof_result_rx) -> message => { + recv(self.proof_worker_handle.results()) -> message => { let wake = Instant::now(); total_idle_time += wake.duration_since(idle_start); self.metrics @@ -412,9 +401,9 @@ where t = wake; let Ok(result) = message else { - unreachable!("we own the sender half") + unreachable!("proof worker results never disconnect while the handle is alive") }; - self.on_proof_results(result, &mut t)?; + self.on_proof_worker_result(result, &mut t)?; }, recv(self.storage_done_rx) -> message => { let wake = Instant::now(); @@ -442,7 +431,7 @@ where while !done { let mut t = Instant::now(); crossbeam_channel::select_biased! { - recv(self.proof_result_rx) -> message => { + recv(self.proof_worker_handle.results()) -> message => { let wake = Instant::now(); total_idle_time += wake.duration_since(idle_start); self.metrics @@ -451,9 +440,9 @@ where t = wake; let Ok(result) = message else { - unreachable!("we own the sender half") + unreachable!("proof worker results never disconnect while the handle is alive") }; - self.on_proof_results(result, &mut t)?; + self.on_proof_worker_result(result, &mut t)?; }, recv(self.storage_done_rx) -> message => { let wake = Instant::now(); @@ -497,11 +486,7 @@ where // the unchanged root node. (self.parent_state_root, TrieUpdates::default()) } - Err(err) => { - return Err(StateRootTaskError::Other(format!( - "could not calculate state root: {err:?}" - ))) - } + Err(err) => return Err(StateRootTaskError::Other(err.into())), }; let end = Instant::now(); @@ -525,17 +510,16 @@ where }) } - /// Handles a received proof result: coalesces everything already queued, reveals the + /// Handles a received proof worker result: coalesces everything already queued, reveals the /// proof in the trie, and records timing metrics. - fn on_proof_results( + fn on_proof_worker_result( &mut self, - message: ProofResultMessage, + result: ProofWorkerResult, t: &mut Instant, ) -> Result<(), StateRootTaskError> { - let mut result = self.on_proof_result_message(message)?; - while let Ok(next) = self.proof_result_rx.try_recv() { - let res = self.on_proof_result_message(next)?; - result.extend(res); + let mut proofs = self.complete_proof_job(result)?; + while let Ok(next) = self.proof_worker_handle.results().try_recv() { + proofs.extend(self.complete_proof_job(next)?); } let phase_end = Instant::now(); @@ -544,7 +528,7 @@ where .record(phase_end.duration_since(*t)); *t = phase_end; - self.on_proof_result(result)?; + self.on_proofs(proofs)?; self.metrics.sparse_trie_reveal_multiproof_duration_histogram.record(t.elapsed()); Ok(()) } @@ -560,7 +544,7 @@ where let updates_queued = !self.finished_state_updates && !self.updates.is_empty(); - if !updates_queued && self.proof_result_rx.is_empty() { + if !updates_queued && self.proof_worker_handle.results().is_empty() { // If we don't have any pending messages, we can spend some time on computing // storage roots and promoting account updates. self.dispatch_pending_targets()?; @@ -578,7 +562,7 @@ where // If there's still no pending updates spend some time pre-computing the account // trie upper hashes - if self.proof_result_rx.is_empty() { + if self.proof_worker_handle.results().is_empty() { self.trie.calculate_subtries(self.new_epoch); } } else if !updates_queued { @@ -696,17 +680,16 @@ where self.final_hashed_state.extend(hashed_state_update); } - fn on_proof_result(&mut self, result: DecodedMultiProofV2) -> Result<(), StateRootTaskError> { - self.reveal_proof_result(result) - .map_err(|e| StateRootTaskError::Other(format!("could not reveal multiproof: {e:?}"))) + fn on_proofs(&mut self, proofs: DecodedMultiProofV2) -> Result<(), StateRootTaskError> { + self.reveal_proof_result(proofs).map_err(|e| StateRootTaskError::Other(e.into())) } /// Reveals a proof batch. /// /// The account trie is revealed here because it can never be checked out. Storage proof nodes /// are queued on their address' entry and revealed by the job that runs for it next. - fn reveal_proof_result(&mut self, result: DecodedMultiProofV2) -> SparseStateTrieResult<()> { - let DecodedMultiProofV2 { account_proofs, storage_proofs } = result; + fn reveal_proof_result(&mut self, proofs: DecodedMultiProofV2) -> SparseStateTrieResult<()> { + let DecodedMultiProofV2 { account_proofs, storage_proofs } = proofs; self.queue_storage_proofs(storage_proofs); // Get the workers going before spending this thread on the account trie. @@ -743,17 +726,19 @@ where }) } - fn on_proof_result_message( + /// Accounts for a received proof job result and returns its proofs. + fn complete_proof_job( &mut self, - message: ProofResultMessage, + result: ProofWorkerResult, ) -> Result { - let result = message.result?; + let proofs = result?; debug_assert!( - self.in_flight_proof_batches > 0, - "received proof result without an in-flight proof batch" + self.in_flight_proof_jobs > 0, + "received proof result without an in-flight proof job" ); - self.in_flight_proof_batches = self.in_flight_proof_batches.saturating_sub(1); - Ok(result) + // Saturates so that a broken invariant in release builds still lets the stall check fire. + self.in_flight_proof_jobs = self.in_flight_proof_jobs.saturating_sub(1); + Ok(proofs) } /// Applies buffered input and runs ready storage work, including work buffered while a job @@ -1148,20 +1133,13 @@ where return; } - match self.proof_worker_handle.dispatch_account_multiproof(AccountMultiproofInput { - targets: proof_targets, - proof_result_sender: ProofResultContext::new( - self.proof_result_tx.clone(), - HashedPostState::default(), - Instant::now(), - ), - }) { - Ok(()) => { - self.in_flight_proof_batches += 1; - } + // A chunk becomes several jobs, and an address whose slots span chunks gets one + // storage job per chunk. Every job returns its own result. + match self.proof_worker_handle.dispatch(proof_targets) { + Ok(jobs) => self.in_flight_proof_jobs += jobs, Err(e) => { - error!("failed to dispatch account multiproof: {e:?}"); - dispatch_error = Some(StateRootTaskError::ProofDispatch(e)); + error!("failed to dispatch proofs: {e}"); + dispatch_error = Some(StateRootTaskError::Other(Box::new(e))); } } }, @@ -1201,8 +1179,8 @@ where !updates_queued && self.pending_updates == 0 && self.pending_targets.is_empty() && - self.in_flight_proof_batches == 0 && - self.proof_result_rx.is_empty() && + self.in_flight_proof_jobs == 0 && + self.proof_worker_handle.results().is_empty() && self.storage_in_flight == 0 && !self.has_ready_storage_work() && self.has_pending_sparse_trie_updates() @@ -1673,12 +1651,14 @@ enum SparseTrieTaskMessage { #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{keccak256, Address, B256, U256}; + use alloy_primitives::{B256, U256}; use reth_db_common::init::init_genesis; use reth_provider::test_utils::create_test_provider_factory; use reth_storage_overlay::{OverlayManager, OverlayStateProviderFactory}; - use reth_trie_common::{ExtensionNode, LeafNode, Nibbles, RlpNode, TrieNodeV2}; - use reth_trie_parallel::proof_task::ProofTaskCtx; + use reth_trie_common::{ + BranchNodeV2, ExtensionNode, LeafNode, Nibbles, RlpNode, TrieMask, TrieNodeV2, + }; + use reth_trie_parallel::proof_task::{ProofTaskCtx, ProofWorkerCounts}; use reth_trie_sparse::ArenaParallelSparseTrie; fn drain_sparse_trie_tasks(runtime: &Runtime) { @@ -1698,12 +1678,11 @@ mod tests { OverlayManager::::default() .overlay_builder(anchor_hash), ); - let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded(); let proof_worker_handle = ProofWorkerHandle::new( runtime, ProofTaskCtx::new(state_provider_factory), - false, - proof_result_tx.clone(), + ProofWorkerCounts::full(runtime), + Default::default(), ); let (updates_tx, updates_rx) = crossbeam_channel::unbounded(); let (cancel_guard, cancel_rx) = crossbeam_channel::bounded(0); @@ -1713,8 +1692,6 @@ mod tests { cancel_rx, std::sync::mpsc::channel().0, proof_worker_handle, - proof_result_tx, - proof_result_rx, SparseTrieTaskMetrics::default(), trie, EMPTY_ROOT_HASH, @@ -1778,56 +1755,6 @@ mod tests { work.trie.root(TrieNodeEpoch::new(1)).expect("storage trie must be revealed") } - #[test] - fn test_run_hashing_task_hashed_state_update_forwards() { - let (updates_tx, updates_rx) = crossbeam_channel::unbounded(); - let (hashed_state_tx, hashed_state_rx) = crossbeam_channel::unbounded(); - - let address = keccak256(Address::random()); - let slot = keccak256(U256::from(42).to_be_bytes::<32>()); - let value = U256::from(999); - - let mut hashed_state = HashedPostState::default(); - hashed_state.accounts.insert( - address, - Some(Account { balance: U256::from(100), nonce: 1, ..Default::default() }), - ); - let mut storage = reth_trie::HashedStorage::default(); - storage.storage.insert(slot, value); - hashed_state.storages.insert(address, storage); - - let expected_state = hashed_state.clone(); - - let handle = std::thread::spawn(move || { - SparseTrieCacheTask::::run_hashing_task( - updates_rx, - hashed_state_tx, - SparseTrieTaskMetrics::default(), - ); - }); - - updates_tx.send(StateRootMessage::HashedStateUpdate(hashed_state)).unwrap(); - updates_tx.send(StateRootMessage::FinishedStateUpdates).unwrap(); - drop(updates_tx); - - let SparseTrieTaskMessage::HashedState(received) = hashed_state_rx.recv().unwrap() else { - panic!("expected HashedState message"); - }; - - let account = received.accounts.get(&address).unwrap().as_ref().unwrap(); - assert_eq!(account.balance, expected_state.accounts[&address].as_ref().unwrap().balance); - assert_eq!(account.nonce, expected_state.accounts[&address].as_ref().unwrap().nonce); - - let storage = received.storages.get(&address).unwrap(); - assert_eq!(*storage.storage.get(&slot).unwrap(), value); - - let second = hashed_state_rx.recv().unwrap(); - assert!(matches!(second, SparseTrieTaskMessage::FinishedStateUpdates)); - - assert!(hashed_state_rx.recv().is_err()); - handle.join().unwrap(); - } - #[test] fn test_encode_account_leaf_value_deletion_and_empty_root_is_empty() { let mut account_rlp_buf = vec![0xAB]; @@ -1855,11 +1782,11 @@ mod tests { #[test] #[allow(clippy::needless_update)] fn test_encode_account_leaf_value_non_empty_account_is_rlp() { - let storage_root = B256::from([0x99; 32]); + let storage_root = B256::repeat_byte(0x99); let account = Some(Account { nonce: 7, balance: U256::from(42), - bytecode_hash: Some(B256::from([0xAA; 32])), + bytecode_hash: Some(B256::repeat_byte(0xAA)), ..Default::default() }); let mut account_rlp_buf = vec![0x00, 0x01]; @@ -1918,7 +1845,7 @@ mod tests { )), masks: None, }; - task.on_proof_result(DecodedMultiProofV2 { + task.on_proofs(DecodedMultiProofV2 { storage_proofs: B256Map::from_iter([(address, vec![leaf])]), ..Default::default() }) @@ -2009,7 +1936,7 @@ mod tests { state.storages.entry(address).or_default().storage.extend([ (removed_slot, U256::from(8)), (changed_slot, U256::from(9)), - (new_slot, U256::from(1)), + (new_slot, U256::ONE), (sibling_slot, U256::from(2)), ]); task.on_hashed_state_update(state); @@ -2046,7 +1973,7 @@ mod tests { // Deliver the requested parent proof directly so the interleaving is deterministic. let (targets, _) = task.pending_targets.take(); assert_eq!(targets.storage_targets[&address].len(), 4); - task.on_proof_result(DecodedMultiProofV2 { + task.on_proofs(DecodedMultiProofV2 { storage_proofs: B256Map::from_iter([(address, vec![parent_leaf.clone()])]), ..Default::default() }) @@ -2056,7 +1983,7 @@ mod tests { let expected_root = reth_trie_common::root::storage_root_unsorted([ (changed_slot, U256::from(11)), - (new_slot, U256::from(1)), + (new_slot, U256::ONE), (sibling_slot, U256::from(2)), ]); assert_eq!( @@ -2072,7 +1999,7 @@ mod tests { let mut final_updates = B256Map::from_iter([ (removed_slot, LeafUpdate::Changed(Vec::new())), (changed_slot, LeafUpdate::Changed(alloy_rlp::encode(U256::from(11)))), - (new_slot, LeafUpdate::Changed(alloy_rlp::encode(U256::from(1)))), + (new_slot, LeafUpdate::Changed(alloy_rlp::encode(U256::ONE))), (sibling_slot, LeafUpdate::Changed(alloy_rlp::encode(U256::from(2)))), ]); serial @@ -2105,7 +2032,7 @@ mod tests { task.run_ready_storage_work().unwrap(); assert_eq!(task.storage_cache_misses, 1); - task.on_proof_result(DecodedMultiProofV2 { + task.on_proofs(DecodedMultiProofV2 { storage_proofs: B256Map::from_iter([( B256::repeat_byte(0x33), vec![ProofTrieNodeV2::empty()], @@ -2116,7 +2043,7 @@ mod tests { assert_eq!(task.storage_cache_misses, 1, "unrelated reveals cannot unblock this trie"); assert_eq!(storage_slot_value(&task, &address, &slot), None); - task.on_proof_result(DecodedMultiProofV2 { + task.on_proofs(DecodedMultiProofV2 { storage_proofs: B256Map::from_iter([(address, vec![ProofTrieNodeV2::empty()])]), ..Default::default() }) @@ -2164,13 +2091,12 @@ mod tests { let StorageTrieState::Idle(work) = task.storage_trie_state_mut(address) else { unreachable!() }; - // An unfused extension root is unsupported and deterministically panics in set_root. work.queue_proofs(&mut vec![ProofTrieNodeV2 { path: Nibbles::default(), - node: TrieNodeV2::Extension(ExtensionNode::new( - Nibbles::from_nibbles([1]), - RlpNode::word_rlp(&B256::ZERO), - )), + node: TrieNodeV2::Branch(BranchNodeV2 { + state_mask: TrieMask::from(1), + ..Default::default() + }), masks: None, }]); let work = check_out_storage(&mut task, address); @@ -2190,6 +2116,39 @@ mod tests { drain_sparse_trie_tasks(&runtime); } + #[test] + fn unsupported_storage_root_errors_are_returned_to_the_task() { + let runtime = Runtime::test(); + let (mut task, updates_tx, _cancel_guard) = test_task(&runtime, SparseStateTrie::default()); + let address = B256::repeat_byte(0x11); + let StorageTrieState::Idle(work) = task.storage_trie_state_mut(address) else { + unreachable!() + }; + work.queue_proofs(&mut vec![ProofTrieNodeV2 { + path: Nibbles::default(), + node: TrieNodeV2::Extension(ExtensionNode::new( + Nibbles::from_nibbles([1]), + RlpNode::word_rlp(&B256::ZERO), + )), + masks: None, + }]); + let work = check_out_storage(&mut task, address); + task.spawn_storage_jobs(vec![StorageTrieJob { address, work }]); + + let message = receive_storage_job(&task); + assert!(matches!(&message, StorageJobMessage::Done(done) if done.address == address)); + let error = task.on_storage_job_message(message).unwrap_err(); + assert!( + matches!(error.kind(), SparseTrieErrorKind::Reveal { path, .. } if path.is_empty()) + ); + assert_eq!(task.storage_in_flight, 0); + assert!(matches!(task.storage.get(&address), Some(StorageTrieState::Idle(_)))); + + drop(updates_tx); + drop(task); + drain_sparse_trie_tasks(&runtime); + } + #[test] fn large_storage_batches_run_off_thread() { let runtime = Runtime::test(); @@ -2222,8 +2181,7 @@ mod tests { }) .collect(); - task.on_proof_result(DecodedMultiProofV2 { account_proofs: Vec::new(), storage_proofs }) - .unwrap(); + task.on_proofs(DecodedMultiProofV2 { account_proofs: Vec::new(), storage_proofs }).unwrap(); assert_eq!(task.storage_in_flight, leaves.len()); assert!(task.has_pending_sparse_trie_updates(), "completion must wait for the reveals"); @@ -2262,10 +2220,7 @@ mod tests { }; let storage = (0..4u8) .map(|slot| { - ( - B256::repeat_byte(0x40 + index * 4 + slot), - U256::from(slot) + U256::from(1), - ) + (B256::repeat_byte(0x40 + index * 4 + slot), U256::from(slot) + U256::ONE) }) .collect::>(); (address, account, storage) @@ -2306,12 +2261,11 @@ mod tests { OverlayManager::::default() .overlay_builder(anchor_hash), ); - let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded(); let proof_worker_handle = ProofWorkerHandle::new( &runtime, ProofTaskCtx::new(state_provider_factory), - false, - proof_result_tx.clone(), + ProofWorkerCounts::full(&runtime), + Default::default(), ); let default_trie = RevealableSparseTrie::blind_from(ArenaParallelSparseTrie::default()); @@ -2320,7 +2274,7 @@ mod tests { .with_default_storage_trie(default_trie) .with_updates(true); - let parent_state_root = B256::from([0x55; 32]); + let parent_state_root = B256::repeat_byte(0x55); let (updates_tx, updates_rx) = crossbeam_channel::unbounded(); let (_cancel_guard, cancel_rx) = crossbeam_channel::bounded::<()>(0); let mut task = SparseTrieCacheTask::new_with_trie( @@ -2329,8 +2283,6 @@ mod tests { cancel_rx, std::sync::mpsc::channel().0, proof_worker_handle, - proof_result_tx, - proof_result_rx, SparseTrieTaskMetrics::default(), trie, parent_state_root, @@ -2355,10 +2307,10 @@ mod tests { task.pending_updates += 1; assert!(!task.make_progress().unwrap()); if index + 1 < INITIAL_UPDATE_BATCH_SIZE { - assert_eq!(task.in_flight_proof_batches, 0); + assert_eq!(task.in_flight_proof_jobs, 0); } } - assert!(task.in_flight_proof_batches > 0, "proof work must start before the queue drains"); + assert!(task.in_flight_proof_jobs > 0, "proof work must start before the queue drains"); assert_eq!(task.pending_updates, 0); // A second batch remains buffered; the early flush must not become a permanent small @@ -2390,12 +2342,11 @@ mod tests { OverlayManager::::default() .overlay_builder(anchor_hash), ); - let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded(); let proof_worker_handle = ProofWorkerHandle::new( &runtime, ProofTaskCtx::new(state_provider_factory), - false, - proof_result_tx.clone(), + ProofWorkerCounts::full(&runtime), + Default::default(), ); let default_trie = RevealableSparseTrie::blind_from(ArenaParallelSparseTrie::default()); @@ -2404,7 +2355,7 @@ mod tests { .with_default_storage_trie(default_trie) .with_updates(true); - let parent_state_root = B256::from([0x55; 32]); + let parent_state_root = B256::repeat_byte(0x55); let (updates_tx, updates_rx) = crossbeam_channel::unbounded(); let (_cancel_guard, cancel_rx) = crossbeam_channel::bounded::<()>(0); let mut task = SparseTrieCacheTask::new_with_trie( @@ -2413,8 +2364,6 @@ mod tests { cancel_rx, std::sync::mpsc::channel().0, proof_worker_handle, - proof_result_tx, - proof_result_rx, SparseTrieTaskMetrics::default(), trie, parent_state_root, @@ -2445,12 +2394,11 @@ mod tests { OverlayManager::::default() .overlay_builder(anchor_hash), ); - let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded(); let proof_worker_handle = ProofWorkerHandle::new( &runtime, ProofTaskCtx::new(state_provider_factory), - false, - proof_result_tx.clone(), + ProofWorkerCounts::full(&runtime), + Default::default(), ); let default_trie = RevealableSparseTrie::blind_from(ArenaParallelSparseTrie::default()); @@ -2467,21 +2415,19 @@ mod tests { cancel_rx, std::sync::mpsc::channel().0, proof_worker_handle, - proof_result_tx, - proof_result_rx, SparseTrieTaskMetrics::default(), trie, - B256::from([0x55; 32]), + B256::repeat_byte(0x55), TrieNodeEpoch::UNMODIFIED, 1, ); drop(updates_tx); - let account = B256::from([0x11; 32]); - let slot = B256::from([0x22; 32]); - let account_target = B256::from([0x33; 32]); - let storage_target = B256::from([0x44; 32]); + let account = B256::repeat_byte(0x11); + let slot = B256::repeat_byte(0x22); + let account_target = B256::repeat_byte(0x33); + let storage_target = B256::repeat_byte(0x44); task.finished_state_updates = true; task.account_updates.insert(account, LeafUpdate::Touched); @@ -2495,18 +2441,14 @@ mod tests { work.updated = true; work.pending.insert(slot, LeafUpdate::Touched); work.fetched.insert(storage_target, ProofV2TargetParent::new(11)); - task.in_flight_proof_batches = 1; + task.in_flight_proof_jobs = 1; assert!(task.ensure_not_stalled(false).is_ok()); - let result = ProofResultMessage { - result: Ok(DecodedMultiProofV2::default()), - elapsed: std::time::Duration::ZERO, - state: HashedPostState::default(), - }; - task.on_proof_result_message(result).expect("proof result should be ok"); + let result = Ok(DecodedMultiProofV2::default()); + task.complete_proof_job(result).expect("proof result should be ok"); - assert_eq!(task.in_flight_proof_batches, 0); + assert_eq!(task.in_flight_proof_jobs, 0); let error = task.ensure_not_stalled(false).expect_err("task should be stalled"); assert!(matches!(error, StateRootTaskError::Stalled)); let error = error.to_string(); @@ -2526,59 +2468,6 @@ mod tests { drain_sparse_trie_tasks(&runtime); } - #[test] - fn run_errors_when_cancel_guard_drops_before_updates_finish() { - let runtime = reth_tasks::Runtime::test(); - let provider_factory = create_test_provider_factory(); - let anchor_hash = init_genesis(&provider_factory).expect("failed to initialize genesis"); - let state_provider_factory = OverlayStateProviderFactory::new( - provider_factory, - OverlayManager::::default() - .overlay_builder(anchor_hash), - ); - let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded(); - let proof_worker_handle = ProofWorkerHandle::new( - &runtime, - ProofTaskCtx::new(state_provider_factory), - false, - proof_result_tx.clone(), - ); - - let default_trie = RevealableSparseTrie::blind_from(ArenaParallelSparseTrie::default()); - let trie = SparseStateTrie::default() - .with_accounts_trie(default_trie.clone()) - .with_default_storage_trie(default_trie) - .with_updates(true); - - let (updates_tx, updates_rx) = crossbeam_channel::unbounded(); - let (cancel_guard, cancel_rx) = crossbeam_channel::bounded::<()>(0); - let mut task = SparseTrieCacheTask::new_with_trie( - &runtime, - updates_rx, - cancel_rx, - std::sync::mpsc::channel().0, - proof_worker_handle, - proof_result_tx, - proof_result_rx, - SparseTrieTaskMetrics::default(), - trie, - B256::from([0x55; 32]), - TrieNodeEpoch::UNMODIFIED, - 1, - ); - - // The consumer abandons the computation. The updates channel is still open (no finish - // marker was sent), so without the cancel signal the task would wait forever. - drop(cancel_guard); - - let error = task.run().expect_err("canceled task must return an error"); - assert!(matches!(error, StateRootTaskError::Canceled)); - - drop(updates_tx); - drop(task); - drain_sparse_trie_tasks(&runtime); - } - #[test] fn run_ignores_hints_queued_after_updates_finish() { let runtime = reth_tasks::Runtime::test(); @@ -2589,12 +2478,11 @@ mod tests { OverlayManager::::default() .overlay_builder(anchor_hash), ); - let (proof_result_tx, proof_result_rx) = crossbeam_channel::unbounded(); let proof_worker_handle = ProofWorkerHandle::new( &runtime, ProofTaskCtx::new(state_provider_factory), - false, - proof_result_tx.clone(), + ProofWorkerCounts::full(&runtime), + Default::default(), ); let default_trie = RevealableSparseTrie::blind_from(ArenaParallelSparseTrie::default()); @@ -2611,11 +2499,9 @@ mod tests { cancel_rx, std::sync::mpsc::channel().0, proof_worker_handle, - proof_result_tx, - proof_result_rx, SparseTrieTaskMetrics::default(), trie, - B256::from([0x55; 32]), + B256::repeat_byte(0x55), TrieNodeEpoch::UNMODIFIED, 1, ); diff --git a/crates/engine/tree/src/tree/tests.rs b/crates/engine/tree/src/tree/tests.rs index de102c4710e..79d7804cb60 100644 --- a/crates/engine/tree/src/tree/tests.rs +++ b/crates/engine/tree/src/tree/tests.rs @@ -22,10 +22,12 @@ use alloy_rpc_types_engine::{ ForkchoiceUpdateError, }; use assert_matches::assert_matches; +use futures::FutureExt as _; use reth_chain_state::test_utils::TestBlockBuilder; use reth_chainspec::{ChainSpec, HOLESKY, MAINNET}; use reth_engine_primitives::{ - EngineApiValidator, ForkchoiceStatus, NoopInvalidBlockHook, DEFAULT_BACKFILL_RUN_THRESHOLD, + BeaconForkChoiceUpdateError, ConsensusEngineHandle, EngineApiValidator, ForkchoiceStatus, + NoopInvalidBlockHook, DEFAULT_BACKFILL_RUN_THRESHOLD, }; use reth_ethereum_consensus::EthBeaconConsensus; use reth_ethereum_engine_primitives::{EthEngineTypes, EthPayloadAttributes}; @@ -37,7 +39,7 @@ use reth_provider::{ test_utils::MockEthProvider, BalStoreHandle, HeaderProvider, InMemoryBalStore, RawBal, }; use reth_tasks::spawn_os_thread; -use reth_trie_common::ComputedTrieData; +use reth_trie_common::SortedTrieData; use revm::state::bal::Bal as RevmBal; use std::{ collections::BTreeMap, @@ -49,6 +51,8 @@ use std::{ time::Duration, }; use tokio::sync::oneshot; +use tracing::{span::Attributes, Id, Subscriber}; +use tracing_subscriber::{layer::Context, prelude::*, registry::LookupSpan, Layer}; /// Wraps blocks as if they had been downloaded without any access list data. fn downloaded_blocks( @@ -351,6 +355,7 @@ impl TestHarness { .tree .on_engine_message(FromEngine::Request( BeaconEngineMessage::ForkchoiceUpdated { + cause: tracing::Span::none(), state: fcu_state, payload_attrs: None, tx, @@ -401,6 +406,21 @@ impl TestHarness { self.provider.extend_blocks(block_data); } + + // Consumes the latest finalized block, which the tree forwards ahead of every backfill start. + fn expect_finalized_forwarded(&mut self) { + let finalized = + self.tree.state.forkchoice_state_tracker.latest_state().unwrap().finalized_block_hash; + let event = self.from_tree_rx.try_recv().unwrap(); + assert!( + matches!( + event, + EngineApiEvent::BackfillAction(BackfillAction::UpdateFinalized(hash)) + if hash == finalized + ), + "expected finalized update, got {event:#?}" + ); + } } /// Simplified test metrics for validation calls @@ -642,18 +662,6 @@ fn on_new_persisted_block_queues_sparse_trie_prune_request() { assert!(test_harness.tree.state.pending_sparse_trie_prune()); } -#[test] -fn on_new_persisted_block_queues_sparse_trie_prune_with_in_memory_blocks() { - let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..4).collect(); - let mut test_harness = TestHarness::new(MAINNET.clone()).with_blocks(blocks.clone()); - let persisted = blocks[0].recovered_block().num_hash(); - test_harness.tree.persistence_state.finish(persisted, persisted); - - test_harness.tree.on_new_persisted_block().unwrap(); - - assert!(test_harness.tree.state.pending_sparse_trie_prune()); -} - #[test] fn on_new_persisted_block_skips_sparse_trie_prune_when_state_root_task_disabled() { let blocks: Vec<_> = TestBlockBuilder::eth().get_executed_blocks(1..4).collect(); @@ -737,6 +745,7 @@ fn backfill_action_waits_while_payload_build_is_active() { test_harness.tree.advance_persistence().unwrap(); assert!(test_harness.tree.backfill_sync_state.is_pending()); + test_harness.expect_finalized_forwarded(); let EngineApiEvent::BackfillAction(emitted_action) = test_harness.from_tree_rx.try_recv().unwrap() else { @@ -745,6 +754,42 @@ fn backfill_action_waits_while_payload_build_is_active() { assert_eq!(emitted_action, action); } +#[test] +fn forkchoice_notifies_active_backfill_of_a_new_head() { + let mut harness = TestHarness::with_config(MAINNET.clone(), TreeConfig::default()); + harness.tree.backfill_sync_state = BackfillSyncState::Active; + let head = B256::repeat_byte(0x42); + let finalized = B256::repeat_byte(0x41); + let state = ForkchoiceState { + head_block_hash: head, + safe_block_hash: B256::ZERO, + finalized_block_hash: finalized, + }; + assert!(harness.tree.validate_forkchoice_state(state).unwrap().is_some()); + assert!(matches!( + harness.from_tree_rx.try_recv().unwrap(), + EngineApiEvent::BackfillAction(BackfillAction::UpdateTarget(target)) if target == head + )); + assert!(matches!( + harness.from_tree_rx.try_recv().unwrap(), + EngineApiEvent::BackfillAction(BackfillAction::UpdateFinalized(hash)) if hash == finalized + )); + assert!(harness.tree.backfill_sync_state.is_active()); +} + +#[test] +fn forkchoice_does_not_notify_a_backfill_awaiting_revalidation() { + let mut harness = TestHarness::with_config(MAINNET.clone(), TreeConfig::default()); + harness.tree.backfill_sync_state = BackfillSyncState::PendingRevalidation; + let state = ForkchoiceState { + head_block_hash: B256::repeat_byte(0x42), + safe_block_hash: B256::ZERO, + finalized_block_hash: B256::ZERO, + }; + assert!(harness.tree.validate_forkchoice_state(state).unwrap().is_some()); + assert!(harness.from_tree_rx.try_recv().is_err()); +} + fn deferred_backfill_harness() -> (TestHarness, Vec, BackfillAction) { let all_blocks: Vec<_> = TestBlockBuilder::eth() .get_executed_blocks(1..DEFAULT_BACKFILL_RUN_THRESHOLD + 10) @@ -812,6 +857,7 @@ fn backfill_action_catches_up_state_trie_before_starting_pipeline() { test_harness.tree.advance_persistence().unwrap(); assert!(test_harness.tree.backfill_sync_state.is_pending()); + test_harness.expect_finalized_forwarded(); let emitted = test_harness.from_tree_rx.try_recv().unwrap(); let EngineApiEvent::BackfillAction(emitted_action) = emitted else { panic!("expected backfill action, got {emitted:?}") @@ -876,6 +922,7 @@ fn deferred_backfill_uses_latest_sync_target() { test_harness.tree.advance_persistence().unwrap(); + test_harness.expect_finalized_forwarded(); let emitted = test_harness.from_tree_rx.try_recv().unwrap(); let EngineApiEvent::BackfillAction(BackfillAction::Start(target)) = emitted else { panic!("expected backfill action, got {emitted:?}") @@ -923,6 +970,7 @@ fn backfill_request_is_preserved_while_persistence_is_in_flight() { test_harness.tree.advance_persistence().unwrap(); assert!(test_harness.tree.backfill_sync_state.is_pending()); + test_harness.expect_finalized_forwarded(); let emitted = test_harness.from_tree_rx.try_recv().unwrap(); let EngineApiEvent::BackfillAction(emitted_action) = emitted else { panic!("expected backfill action, got {emitted:?}") @@ -930,6 +978,29 @@ fn backfill_request_is_preserved_while_persistence_is_in_flight() { assert_eq!(emitted_action, action); } +#[test] +fn a_backfill_starts_with_the_latest_finalized_block() { + let mut harness = TestHarness::with_config(MAINNET.clone(), TreeConfig::default()); + let finalized = B256::repeat_byte(0x41); + let state = ForkchoiceState { + head_block_hash: B256::repeat_byte(0x42), + safe_block_hash: B256::ZERO, + finalized_block_hash: finalized, + }; + harness.tree.state.forkchoice_state_tracker.set_latest(state, ForkchoiceStatus::Syncing); + + harness.tree.dispatch_backfill_action(BackfillAction::Start(B256::ZERO.into())); + + assert!(matches!( + harness.from_tree_rx.try_recv().unwrap(), + EngineApiEvent::BackfillAction(BackfillAction::UpdateFinalized(hash)) if hash == finalized + )); + assert!(matches!( + harness.from_tree_rx.try_recv().unwrap(), + EngineApiEvent::BackfillAction(BackfillAction::Start(_)) + )); +} + #[test] fn configured_persistence_suppression_tracks_payload_job_lifetime() { let config = TreeConfig::default() @@ -1037,6 +1108,7 @@ async fn test_engine_request_during_backfill() { .tree .on_engine_message(FromEngine::Request( BeaconEngineMessage::ForkchoiceUpdated { + cause: tracing::Span::none(), state: ForkchoiceState { head_block_hash: B256::random(), safe_block_hash: B256::random(), @@ -1157,6 +1229,7 @@ async fn test_holesky_payload() { .tree .on_engine_message(FromEngine::Request( BeaconEngineMessage::NewPayload { + cause: tracing::Span::none(), payload: ExecutionData { payload: payload.clone().into(), sidecar: ExecutionPayloadSidecar::none(), @@ -1192,6 +1265,7 @@ fn test_backpressure_waits_for_persistence_before_reading_incoming() { .to_tree_tx .send(FromEngine::Request( BeaconEngineMessage::ForkchoiceUpdated { + cause: tracing::Span::none(), state: ForkchoiceState { head_block_hash: B256::random(), safe_block_hash: B256::random(), @@ -1415,7 +1489,7 @@ fn test_tree_state_on_new_head_deep_fork() { let chain_a = test_block_builder.create_fork(&last_block, 10); let chain_b = test_block_builder.create_fork(&last_block, 10); - let empty_trie_data = ComputedTrieData::default; + let empty_trie_data = SortedTrieData::default; for block in &chain_a { test_harness.tree.state.tree_state.insert_executed(ExecutedBlock::new( @@ -1739,6 +1813,7 @@ async fn test_fcu_with_canonical_ancestor_updates_latest_block() { .tree .on_engine_message(FromEngine::Request( BeaconEngineMessage::ForkchoiceUpdated { + cause: tracing::Span::none(), state: ForkchoiceState { head_block_hash: ancestor_block.hash(), safe_block_hash: B256::ZERO, @@ -1822,7 +1897,10 @@ async fn test_fcu_with_canonical_ancestor_below_finalized_is_rejected() { .await .unwrap_err(); - assert_matches!(err, ForkchoiceUpdateError::TooDeepReorg); + assert_matches!( + err, + BeaconForkChoiceUpdateError::ForkchoiceUpdateError(ForkchoiceUpdateError::TooDeepReorg) + ); } // no payload build is started and the canonical head remains untouched @@ -2408,7 +2486,7 @@ mod check_invalid_ancestors_tests { let unsealed_block = block.unseal(); // Create payload with wrong hash (this makes it malformed) - let wrong_hash = B256::from([0xff; 32]); + let wrong_hash = B256::repeat_byte(0xff); ExecutionData { payload: ExecutionPayloadV1::from_block_unchecked(wrong_hash, &unsealed_block).into(), @@ -3118,8 +3196,8 @@ fn test_backfill_reaching_sync_target_head_updates_finalized() { #[test] fn test_backfill_target_hash_eth_returns_finalized() { let test_harness = TestHarness::new(MAINNET.clone()); - let head = B256::from([0xAA; 32]); - let finalized = B256::from([0xBB; 32]); + let head = B256::repeat_byte(0xAA); + let finalized = B256::repeat_byte(0xBB); let state = ForkchoiceState { head_block_hash: head, safe_block_hash: B256::ZERO, @@ -3132,7 +3210,7 @@ fn test_backfill_target_hash_eth_returns_finalized() { #[test] fn test_backfill_target_hash_eth_returns_zero_finalized() { let test_harness = TestHarness::new(MAINNET.clone()); - let head = B256::from([0xAA; 32]); + let head = B256::repeat_byte(0xAA); let state = ForkchoiceState { head_block_hash: head, safe_block_hash: B256::ZERO, @@ -3146,8 +3224,8 @@ fn test_backfill_target_hash_eth_returns_zero_finalized() { fn test_backfill_target_hash_opstack_returns_head() { let mut test_harness = TestHarness::new(MAINNET.clone()); test_harness.tree.engine_kind = EngineApiKind::OpStack; - let head = B256::from([0xAA; 32]); - let finalized = B256::from([0xBB; 32]); + let head = B256::repeat_byte(0xAA); + let finalized = B256::repeat_byte(0xBB); let state = ForkchoiceState { head_block_hash: head, safe_block_hash: B256::ZERO, @@ -3187,8 +3265,8 @@ fn test_on_disconnected_downloaded_block_opstack_targets_head() { let mut test_harness = TestHarness::new(MAINNET.clone()); test_harness.tree.engine_kind = EngineApiKind::OpStack; - let head_hash = B256::from([0xAA; 32]); - let finalized_hash = B256::from([0xBB; 32]); + let head_hash = B256::repeat_byte(0xAA); + let finalized_hash = B256::repeat_byte(0xBB); test_harness.tree.state.forkchoice_state_tracker.set_latest( ForkchoiceState { head_block_hash: head_hash, @@ -3200,9 +3278,9 @@ fn test_on_disconnected_downloaded_block_opstack_targets_head() { let canonical_head = BlockNumHash::new(0, B256::ZERO); let downloaded_block = - BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 100, B256::from([0xCC; 32])); + BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 100, B256::repeat_byte(0xCC)); let missing_parent = - BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 99, B256::from([0xDD; 32])); + BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 99, B256::repeat_byte(0xDD)); let event = test_harness.tree.on_disconnected_downloaded_block( downloaded_block, @@ -3228,8 +3306,8 @@ fn test_on_disconnected_downloaded_block_opstack_targets_head() { fn test_on_disconnected_downloaded_block_eth_targets_finalized() { let mut test_harness = TestHarness::new(MAINNET.clone()); - let head_hash = B256::from([0xAA; 32]); - let finalized_hash = B256::from([0xBB; 32]); + let head_hash = B256::repeat_byte(0xAA); + let finalized_hash = B256::repeat_byte(0xBB); test_harness.tree.state.forkchoice_state_tracker.set_latest( ForkchoiceState { head_block_hash: head_hash, @@ -3241,9 +3319,9 @@ fn test_on_disconnected_downloaded_block_eth_targets_finalized() { let canonical_head = BlockNumHash::new(0, B256::ZERO); let downloaded_block = - BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 100, B256::from([0xCC; 32])); + BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 100, B256::repeat_byte(0xCC)); let missing_parent = - BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 99, B256::from([0xDD; 32])); + BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 99, B256::repeat_byte(0xDD)); let event = test_harness.tree.on_disconnected_downloaded_block( downloaded_block, @@ -3269,7 +3347,7 @@ fn test_on_disconnected_downloaded_block_eth_targets_finalized() { fn test_on_disconnected_downloaded_block_eth_zero_finalized_targets_head() { let mut test_harness = TestHarness::new(MAINNET.clone()); - let head_hash = B256::from([0xAA; 32]); + let head_hash = B256::repeat_byte(0xAA); test_harness.tree.state.forkchoice_state_tracker.set_latest( ForkchoiceState { head_block_hash: head_hash, @@ -3281,9 +3359,9 @@ fn test_on_disconnected_downloaded_block_eth_zero_finalized_targets_head() { let canonical_head = BlockNumHash::new(0, B256::ZERO); let downloaded_block = - BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 100, B256::from([0xCC; 32])); + BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 100, B256::repeat_byte(0xCC)); let missing_parent = - BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 99, B256::from([0xDD; 32])); + BlockNumHash::new(DEFAULT_BACKFILL_RUN_THRESHOLD + 99, B256::repeat_byte(0xDD)); let event = test_harness.tree.on_disconnected_downloaded_block( downloaded_block, @@ -3336,7 +3414,7 @@ async fn assert_post_backfill_recheck_uses_threshold( // Place the buffered hash in the FCU slot the helper picks for this chain type, and put // an unrelated hash (not in buffer) in the other slot to keep the two slots distinct. - let other_hash = B256::from([0xFF; 32]); + let other_hash = B256::repeat_byte(0xFF); let (head_block_hash, finalized_block_hash) = if engine_kind.is_opstack() { (target_hash, other_hash) } else { @@ -3356,6 +3434,9 @@ async fn assert_post_backfill_recheck_uses_threshold( }); let _ = test_harness.tree.on_engine_message(FromEngine::Event(backfill_finished)).unwrap(); + if expect_backfill { + test_harness.expect_finalized_forwarded(); + } let event = test_harness.from_tree_rx.recv().await.unwrap(); match event { EngineApiEvent::BackfillAction(BackfillAction::Start(emitted_target)) @@ -3402,7 +3483,7 @@ async fn test_on_backfill_sync_finished_resumes_live_sync_with_higher_threshold( #[test] fn test_backfill_threshold_above_header_limit_triggers_backfill() { let mut test_harness = TestHarness::new(MAINNET.clone()); - let target_hash = B256::from([0xAA; 32]); + let target_hash = B256::repeat_byte(0xAA); test_harness.tree.state.forkchoice_state_tracker.set_latest( ForkchoiceState { head_block_hash: target_hash, @@ -3412,11 +3493,11 @@ fn test_backfill_threshold_above_header_limit_triggers_backfill() { ForkchoiceStatus::Syncing, ); let local_tip = BlockNumHash::new(10, B256::ZERO); - let parent_hash = B256::from([0xBB; 32]); + let parent_hash = B256::repeat_byte(0xBB); test_harness.tree.config = TreeConfig::default().with_backfill_run_threshold(2048); for gap in [1024, 1025] { let missing_parent = BlockNumHash::new(local_tip.number + gap, parent_hash); - let downloaded = BlockNumHash::new(missing_parent.number + 1, B256::from([0xCC; 32])); + let downloaded = BlockNumHash::new(missing_parent.number + 1, B256::repeat_byte(0xCC)); let event = test_harness.tree.on_disconnected_downloaded_block( downloaded, missing_parent, @@ -3452,7 +3533,7 @@ fn test_forkchoice_rejects_stale_persisted_prefix_hash() { ExecutedBlock::new( Arc::new(block), Arc::new(BlockExecutionOutput::default()), - ComputedTrieData::default(), + SortedTrieData::default(), ) }) .collect(); @@ -3490,7 +3571,7 @@ async fn assert_fcu_back_to_reorged_out_head_with_pending_disk_reorg(sibling_len ExecutedBlock::new( Arc::new(block), Arc::new(BlockExecutionOutput::default()), - ComputedTrieData::default(), + SortedTrieData::default(), ) }) .collect(); @@ -3529,3 +3610,67 @@ async fn test_fcu_back_to_reorged_out_head_with_pending_disk_reorg() { async fn test_fcu_back_to_reorged_out_head_above_shorter_branch_with_pending_disk_reorg() { assert_fcu_back_to_reorged_out_head_with_pending_disk_reorg(1).await; } + +proptest::proptest! { + // Each case constructs a full engine harness. + #![proptest_config(proptest::test_runner::Config::with_cases(32))] + + #[test] + fn engine_messages_keep_sender_parent_on_another_thread( + requests in proptest::collection::vec((0..2usize, 0..3u8), 1..16), + ) { + let parents = EngineSpanParents::default(); + let dispatch = tracing::Dispatch::new(tracing_subscriber::registry().with(parents.clone())); + tracing::dispatcher::with_default(&dispatch, || { + let (tx, mut rx) = unbounded_channel(); + let engine = ConsensusEngineHandle::::new(tx); + let callers = [tracing::info_span!("view_a"), tracing::info_span!("view_b")]; + let payload = || ExecutionData { + payload: ExecutionPayloadV1::from_block_slow(&Block::default()).into(), + sidecar: ExecutionPayloadSidecar::none(), + }; + for &(caller, kind) in &requests { + callers[caller].in_scope(|| match kind { + 0 => assert!(engine.new_payload(payload()).now_or_never().is_none()), + 1 => assert!(engine + .fork_choice_updated(ForkchoiceState::default(), None) + .now_or_never() + .is_none()), + _ => assert!(engine + .reth_new_payload(payload(), false, false) + .now_or_never() + .is_none()), + }); + } + let messages = (0..requests.len()).map(|_| rx.try_recv().unwrap()).collect::>(); + let expected = requests.iter().map(|&(caller, _)| callers[caller].id()).collect::>(); + std::thread::scope(|scope| { + scope.spawn(|| { + tracing::dispatcher::with_default(&dispatch, || { + let mut harness = TestHarness::new(MAINNET.clone()); + let unrelated = tracing::info_span!("worker_background"); + let _entered = unrelated.enter(); + for message in messages { + let _ = harness.tree.on_engine_message( + FromEngine::Request(EngineApiRequest::Beacon(message)), + ).unwrap(); + assert_eq!(tracing::Span::current().id(), unrelated.id()); + } + }); + }).join().unwrap(); + }); + assert_eq!(*parents.0.lock().unwrap(), expected); + }); + } +} + +#[derive(Clone, Default)] +struct EngineSpanParents(Arc>>>); + +impl LookupSpan<'a>> Layer for EngineSpanParents { + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + if matches!(attrs.metadata().name(), "on_new_payload" | "on_forkchoice_updated") { + self.0.lock().unwrap().push(ctx.span(id).unwrap().parent().map(|parent| parent.id())); + } + } +} diff --git a/crates/engine/tree/src/tree/txpool_prewarm/control.rs b/crates/engine/tree/src/tree/txpool_prewarm/control.rs index da71c87c97c..a235c748a57 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/control.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/control.rs @@ -124,17 +124,6 @@ mod tests { )); } - #[test] - fn pause_queues_without_waiting_and_resumes_on_drop() { - let (control, receiver) = control(); - // Nothing reads the channel, so this returning at all proves pause does not block. - let guard = control.pause(); - assert!(matches!(receiver.try_recv(), Ok(Command::Pause))); - - drop(guard); - assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); - } - #[test] fn pause_guard_does_not_retain_control() { let (control, _receiver) = control(); @@ -159,12 +148,4 @@ mod tests { drop(second); assert!(matches!(receiver.try_recv(), Ok(Command::Resume))); } - - #[test] - fn dropping_control_disconnects_worker() { - let (control, receiver) = control(); - drop(control); - - assert!(receiver.recv().is_err()); - } } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs index 5032d7851c4..84f2b93d0ad 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/mod.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/mod.rs @@ -1,20 +1,31 @@ //! Txpool-driven state prewarming and immutable snapshot publication. +//! +//! If block execution reuses prewarm results, the worker executes pool transactions in a predicted +//! environment of the next block and records their results, which prewarming of that block may +//! reuse, see [`PoolResults`]. mod control; mod worker; use self::control::Control; -use crate::tree::TxPoolPrewarmCacheSnapshot; +use crate::tree::{ + payload_processor::reuse::{ + cfg_reuses_prewarm_results, BlockEnvFields, PoolResults, PoolSeeds, POOL_BENEFICIARY, + }, + TxPoolPrewarmCacheSnapshot, +}; use alloy_consensus::transaction::Recovered; use alloy_primitives::{Address, B256}; -use reth_evm::{ConfigureEvm, EvmEnvFor}; -use reth_primitives_traits::{NodePrimitives, TxTy}; +use parking_lot::RwLock; +use reth_evm::{ConfigureEvm, EvmEnv, EvmEnvFor, HaltReasonFor}; +use reth_primitives_traits::{NodePrimitives, SealedHeader, TxTy}; use reth_provider::{ BlockNumReader, ChangeSetReader, DatabaseProviderFactory, DatabaseProviderROFactory, PruneCheckpointReader, StageCheckpointReader, StateProvider, StorageChangeSetReader, StorageSettingsCache, }; use reth_storage_overlay::OverlayStateProviderFactory; +use revm::context::Block; use std::{fmt::Debug, sync::Arc}; /// Coordinates a long-lived worker and the latest completed immutable snapshot. @@ -24,6 +35,13 @@ where Evm: ConfigureEvm, { control: Arc>>, + /// Predicts the environment of the next block. + evm_config: Evm, + /// Whether to record results of pool transactions in the predicted environment of the next + /// block. + record_results: bool, + /// The parent, predicted environment and results of the recorded speculation, if any. + speculation: RwLock>>, } impl Debug for Handle @@ -52,17 +70,22 @@ where { /// Spawns the long-lived worker, which owns its mutable read cache and starts a fresh one for /// each new head. + /// + /// If `record_results` is set, the worker records results of pool transactions that + /// prewarming may reuse. pub(crate) fn spawn( runtime: &reth_tasks::Runtime, source: Arc>, evm_config: Evm, + record_results: bool, ) -> Self { let (control, commands) = Control::new(); let publication = control.publication(); + let worker_evm_config = evm_config.clone(); runtime.spawn_critical_os_thread("txpool-prewarm", "txpool prewarm worker", async move { - worker::Worker::new(commands, publication, source, evm_config).run() + worker::Worker::new(commands, publication, source, worker_evm_config).run() }); - Self { control } + Self { control, evm_config, record_results, speculation: RwLock::new(None) } } /// Pauses speculative work. @@ -83,15 +106,94 @@ where self.control.snapshot(parent_hash) } - /// Starts continuous warming for the latest canonical head. + /// Starts continuous warming for the latest canonical head `parent`. + /// + /// Results are recorded in the predicted environment of the next block if it is available and + /// the block may reuse them, otherwise warming uses the head block's own environment + /// `evm_env`. pub(crate) fn start( &self, - parent_hash: B256, + parent: &SealedHeader, evm_env: EvmEnvFor, state_provider_factory: OverlayStateProviderFactory, ) { - self.control.start(parent_hash, Job { evm_env, state_provider_factory }); + let mut speculation = self.speculation.write(); + // The worker keeps executed transactions of the same head, so it would not record them + // again. + if speculation.as_ref().is_none_or(|s| s.parent_hash != parent.hash()) { + *speculation = self + .record_results + .then(|| self.evm_config.speculative_next_evm_env(parent, POOL_BENEFICIARY)) + .flatten() + .filter(|evm_env| cfg_reuses_prewarm_results(&evm_env.cfg_env)) + .map(|evm_env| Speculation { + parent_hash: parent.hash(), + evm_env, + results: Arc::default(), + }); + } + let job = Job { + evm_env: speculation.as_ref().map_or(evm_env, |s| s.evm_env.clone()), + state_provider_factory, + results: speculation.as_ref().map(|s| Arc::clone(&s.results)), + }; + drop(speculation); + self.control.start(parent.hash(), job); + } + + /// Returns the recorded results of pool transactions on top of `parent_hash` that prewarming + /// of a block with `evm_env` may reuse, `None` if there are none. + pub(crate) fn seeds( + &self, + parent_hash: B256, + evm_env: &EvmEnvFor, + ) -> Option>> { + let speculation = self.speculation.read(); + let speculation = speculation + .as_ref() + .filter(|s| s.parent_hash == parent_hash && !s.results.is_empty())?; + let differs = env_differences(&speculation.evm_env, evm_env)?; + Some(PoolSeeds::new( + Arc::clone(&speculation.results), + evm_env.block_env.beneficiary(), + differs, + )) + } +} + +/// Results of pool transactions recorded on top of a parent in a predicted block environment. +struct Speculation { + parent_hash: B256, + evm_env: EvmEnvFor, + results: Arc>>, +} + +/// Returns the block environment fields that differ between the `predicted` environment and the +/// `actual` one, or `None` if they differ in a way that every execution may depend on. +fn env_differences( + predicted: &EvmEnv, + actual: &EvmEnv, +) -> Option { + let mut cfg_env = predicted.cfg_env.clone(); + // Results are recorded with these checks disabled and show that the checks would pass. + cfg_env.disable_nonce_check = actual.cfg_env.disable_nonce_check; + cfg_env.disable_balance_check = actual.cfg_env.disable_balance_check; + if cfg_env != actual.cfg_env { + return None } + let (predicted, actual) = (&predicted.block_env, &actual.block_env); + (predicted.number() == actual.number() && + predicted.basefee() == actual.basefee() && + predicted.difficulty() == actual.difficulty() && + predicted.blob_excess_gas_and_price() == actual.blob_excess_gas_and_price() && + predicted.slot_num() == actual.slot_num()) + .then(|| BlockEnvFields { + // Recorded results never access the beneficiary. + beneficiary: false, + timestamp: predicted.timestamp() != actual.timestamp(), + prevrandao: predicted.prevrandao() != actual.prevrandao(), + gas_limit: predicted.gas_limit() != actual.gas_limit(), + }) } /// A live, forward-only view of the pool's best transactions for one canonical parent. @@ -100,7 +202,7 @@ where /// iterator can yield transactions that become pending later. pub type Transactions = Box> + Send>; -/// A transaction selected from the txpool for cache-only prewarming. +/// A transaction selected from the txpool for prewarming. #[derive(Debug, Clone)] pub struct Transaction { /// Transaction hash. @@ -111,7 +213,7 @@ pub struct Transaction { pub transaction: Recovered>, } -/// Source of txpool transactions for best-effort cache prewarming. +/// Source of txpool transactions for best-effort prewarming. pub trait Source: Send + Sync + Debug { /// Opens a live best-transactions iterator for `parent_hash`. /// @@ -125,4 +227,130 @@ pub trait Source: Send + Sync + Debug { struct Job> { evm_env: EvmEnvFor, state_provider_factory: OverlayStateProviderFactory, + /// Where to record results of pool transactions, if `evm_env` is the predicted environment + /// of the next block. + results: Option>>>, +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::Header; + use alloy_primitives::U256; + use reth_chainspec::ChainSpecBuilder; + use reth_ethereum_primitives::EthPrimitives; + use reth_evm_ethereum::EthEvmConfig; + use reth_provider::test_utils::MockEthProvider; + use reth_storage_overlay::OverlayManager; + use reth_tasks::Runtime; + use revm::context::result::HaltReason; + + type TestHandle = Handle; + + /// A pool that never tracks the head. + #[derive(Debug)] + struct NoPool; + + impl Source for NoPool { + fn best_transactions(&self, _parent_hash: B256) -> Option> { + None + } + } + + fn start(handle: &TestHandle, parent: &SealedHeader) { + let state_provider_factory = OverlayStateProviderFactory::new( + MockEthProvider::default(), + OverlayManager::default().overlay_builder(parent.hash()), + ); + handle.start(parent, Default::default(), state_provider_factory); + } + + fn recorded(handle: &TestHandle) -> Option>> { + handle.speculation.read().as_ref().map(|s| Arc::clone(&s.results)) + } + + #[test] + fn keeps_results_of_the_same_head() { + let handle = + TestHandle::spawn(&Runtime::test(), Arc::new(NoPool), EthEvmConfig::mainnet(), true); + let parent = SealedHeader::seal_slow(Header::default()); + start(&handle, &parent); + let results = recorded(&handle).unwrap(); + start(&handle, &parent); + assert!(Arc::ptr_eq(&results, &recorded(&handle).unwrap())); + + start(&handle, &SealedHeader::seal_slow(Header { number: 1, ..Default::default() })); + assert!(!Arc::ptr_eq(&results, &recorded(&handle).unwrap())); + } + + #[test] + fn offers_no_seeds_without_results() { + let handle = + TestHandle::spawn(&Runtime::test(), Arc::new(NoPool), EthEvmConfig::mainnet(), true); + let parent = SealedHeader::seal_slow(Header::default()); + start(&handle, &parent); + let evm_env = handle.speculation.read().as_ref().unwrap().evm_env.clone(); + assert!(handle.seeds(parent.hash(), &evm_env).is_none()); + } + + #[test] + fn records_no_results_with_eip8037() { + let chain_spec = ChainSpecBuilder::mainnet().amsterdam_activated().build(); + let evm_config = EthEvmConfig::new(Arc::new(chain_spec)); + let handle = TestHandle::spawn(&Runtime::test(), Arc::new(NoPool), evm_config, true); + start(&handle, &SealedHeader::seal_slow(Header::default())); + assert!(recorded(&handle).is_none()); + } + + #[test] + fn predicted_env_differs_in_unobserved_fields() { + let config = EthEvmConfig::mainnet(); + // A half full parent without blobs keeps the base fee and excess blob gas. + let parent = Header { + number: 25_000_000, + timestamp: 1_800_000_000, + gas_limit: 60_000_000, + gas_used: 30_000_000, + base_fee_per_gas: Some(1_000_000_000), + excess_blob_gas: Some(0), + blob_gas_used: Some(0), + ..Default::default() + }; + let block = Header { + number: parent.number + 1, + timestamp: parent.timestamp + 12, + beneficiary: Address::repeat_byte(1), + mix_hash: B256::repeat_byte(2), + gas_limit: parent.gas_limit + 1, + ..parent.clone() + }; + let mut predicted = config + .speculative_next_evm_env(&SealedHeader::seal_slow(parent), POOL_BENEFICIARY) + .unwrap(); + // Recorded results disable the nonce check. + predicted.cfg_env.disable_nonce_check = true; + let mut actual = config.evm_env(&block).unwrap(); + assert_eq!( + env_differences(&predicted, &actual), + Some(BlockEnvFields { prevrandao: true, gas_limit: true, ..Default::default() }) + ); + + actual.block_env.timestamp += U256::from(12); + assert_eq!( + env_differences(&predicted, &actual), + Some(BlockEnvFields { + timestamp: true, + prevrandao: true, + gas_limit: true, + ..Default::default() + }) + ); + + let mut other_chain = actual.clone(); + other_chain.cfg_env.chain_id += 1; + assert_eq!(env_differences(&predicted, &other_chain), None); + + actual.block_env.basefee += 1; + assert_eq!(env_differences(&predicted, &actual), None); + } } diff --git a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs index 9a44cbf0d18..caea0e7fd15 100644 --- a/crates/engine/tree/src/tree/txpool_prewarm/worker.rs +++ b/crates/engine/tree/src/tree/txpool_prewarm/worker.rs @@ -1,8 +1,11 @@ use super::{ control::{Command, Publication}, - Job, Source, Transactions, + Job, Source, Transaction, Transactions, +}; +use crate::tree::{ + payload_processor::reuse::{BalanceTracker, RecordingDb, SpeculativeTx}, + StateProviderDatabase, TxPoolPrewarmCacheSnapshot as Snapshot, }; -use crate::tree::{StateProviderDatabase, TxPoolPrewarmCacheSnapshot as Snapshot}; use alloy_evm::Evm; use alloy_primitives::B256; use crossbeam_channel::{Receiver, RecvTimeoutError, TryRecvError}; @@ -15,6 +18,7 @@ use reth_provider::{ }; use reth_revm::{cached::CachedReads, db::State}; use reth_storage_overlay::OverlayStateProviderFactory; +use revm::context::Transaction as _; use std::{ sync::Arc, time::{Duration, Instant}, @@ -32,7 +36,8 @@ const HEAD_POLL_INTERVAL: Duration = Duration::from_millis(10); /// A long-lived loop that speculatively executes the pool's best transactions on top of the /// current canonical state, recording every state read in a [`CachedReads`]. Roughly every /// [`REFRESH_INTERVAL`] it publishes an immutable snapshot of that cache, which block validation -/// and payload building use to seed their own caches. +/// and payload building use to seed their own caches. If the job records results, it also stores +/// each transaction's result and reads, which block prewarming may reuse. /// /// The worker is driven by [`Command`]s: `Start` points it at a new parent state, and /// `Pause`/`Resume` bracket cache-sensitive work elsewhere. Commands are only applied between @@ -122,6 +127,7 @@ where debug!( target: "engine::tree::txpool_prewarm", ?parent_hash, + record_results = self.job.as_ref().is_some_and(|(_, job)| job.results.is_some()), "started txpool prewarming" ); } @@ -206,26 +212,59 @@ where state_provider.into_evm_state_provider(), ))) .build(); - // The environment is the head block's own, not a predicted next-block one, and execution - // is out of context by design: transaction viability is the pool's business, so nonce, - // balance and (one-block-stale) basefee checks must not gate which state gets warmed. + // Unless results are recorded, the environment is the head block's own, not a predicted + // next-block one, and execution is out of context by design: transaction viability is the + // pool's business, so nonce, balance and (one-block-stale) basefee checks must not gate + // which state gets warmed. Recorded results show that the nonce and balance checks pass. let mut evm_env = job.evm_env.clone(); evm_env.cfg_env.disable_nonce_check = true; evm_env.cfg_env.disable_balance_check = true; - evm_env.cfg_env.disable_base_fee = true; - let mut evm = self.evm_config.evm_with_env(&mut state, evm_env); + evm_env.cfg_env.disable_base_fee = job.results.is_none(); + let mut evm_config = self.evm_config.clone(); + if job.results.is_some() { + // JIT-compiled code would skip the inspector hooks that track observed balances. + evm_config = evm_config.with_jit_support_enabled(false); + } + let mut evm = evm_config.evm_with_env_and_inspector( + RecordingDb::new(&mut state), + evm_env, + BalanceTracker::default(), + ); + evm.set_inspector_enabled(job.results.is_some()); let deadline = Instant::now() + REFRESH_INTERVAL; while self.commands.is_empty() && Instant::now() < deadline { - let Some(transaction) = transactions.next() else { return BatchEnd::Rest }; - if let Err(err) = evm.transact(transaction.transaction) { - trace!( - target: "engine::tree::txpool_prewarm", - %err, - tx_hash = ?transaction.hash, - sender = %transaction.sender, - "speculative txpool transaction execution failed" - ); + let Some(Transaction { hash, sender, transaction }) = transactions.next() else { + return BatchEnd::Rest + }; + let tx_env = evm_config.tx_env(transaction); + let speculative = job.results.as_ref().map(|_| SpeculativeTx { + caller: sender, + nonce: tx_env.nonce(), + max_balance_spending: tx_env.max_balance_spending(), + }); + if speculative.is_some() { + evm.db_mut().start_recording_inner(); + evm.inspector_mut().clear(); + } + match evm.transact(tx_env) { + Ok(result) => { + if let Some(results) = &job.results && + let Some(speculative) = speculative && + let Some(reads) = evm.db_mut().take_reads() + { + results.store(hash, result, reads, evm.inspector_mut(), speculative); + } + } + Err(err) => { + trace!( + target: "engine::tree::txpool_prewarm", + %err, + tx_hash = ?hash, + %sender, + "speculative txpool transaction execution failed" + ); + } } } BatchEnd::GoAgain @@ -323,14 +362,17 @@ fn entry_counts(reads: &CachedReads) -> (usize, usize, usize) { #[cfg(test)] mod tests { use super::{super::Transaction as PoolTransaction, *}; + use crate::tree::payload_processor::reuse::{PoolResults, POOL_BENEFICIARY}; use alloy_consensus::{transaction::Recovered, Signed, TxLegacy}; - use alloy_primitives::{Address, Signature, TxKind, U256}; + use alloy_primitives::{Address, Signature, TxKind}; use crossbeam_channel::{unbounded, Sender}; use parking_lot::{Mutex, RwLock}; use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; + use reth_evm::EvmEnvFor; use reth_evm_ethereum::EthEvmConfig; use reth_provider::test_utils::MockEthProvider; use reth_stages_api::{StageCheckpoint, StageId}; + use revm::context::result::HaltReason; use std::{ collections::{HashMap, VecDeque}, sync::atomic::{AtomicUsize, Ordering}, @@ -376,16 +418,30 @@ mod tests { /// Points the worker at `parent_hash`, as [`Handle::start`](super::super::Handle) does. fn start(&self, parent_hash: B256) { + self.start_with(parent_hash, None); + } + + /// Points the worker at `parent_hash` and returns the results it records. + fn start_recording(&self, parent_hash: B256) -> Arc> { + let results = Arc::new(PoolResults::default()); + self.start_with(parent_hash, Some(Arc::clone(&results))); + results + } + + fn start_with(&self, parent_hash: B256, results: Option>>) { let provider = MockEthProvider::default(); provider.enable_database_provider(); provider.add_header(parent_hash, Default::default()); provider.add_stage_checkpoint(StageId::Finish, StageCheckpoint::new(0)); + let mut evm_env = EvmEnvFor::::default(); + evm_env.block_env.beneficiary = POOL_BENEFICIARY; let job = Job { - evm_env: Default::default(), + evm_env, state_provider_factory: OverlayStateProviderFactory::new( provider, reth_storage_overlay::OverlayManager::default().overlay_builder(parent_hash), ), + results, }; self.commands.send(Command::Start { parent_hash, job }).unwrap(); } @@ -468,13 +524,13 @@ mod tests { } } - /// A signed transfer to `recipient`. The signature is a dummy: the worker executes with the - /// attached sender and disabled nonce/balance checks, so it is never recovered or validated. + /// A signed zero-value transfer to `recipient`, which the unfunded sender can pay for. The + /// signature is a dummy: the worker executes with the attached sender, so it is never + /// recovered or validated. fn transfer(recipient: u8) -> PoolTransaction { let transaction = TxLegacy { gas_limit: 21_000, to: TxKind::Call(Address::repeat_byte(recipient)), - value: U256::from(1), ..Default::default() }; let hash = B256::repeat_byte(recipient); @@ -513,24 +569,13 @@ mod tests { } #[test] - fn pause_quiesces_the_worker_until_resume() { + fn records_results_of_pool_transactions() { let harness = Harness::spawn(); let parent_hash = B256::repeat_byte(0x01); - harness.start(parent_hash); + let results = harness.start_recording(parent_hash); harness.pool.push(parent_hash, transfer(0xB0)); - let before = harness.published_for(parent_hash).entry_counts(); - - harness.pause(); - harness.pool.push(parent_hash, transfer(0xB1)); - thread::sleep(REFRESH_INTERVAL * 2); - assert_eq!( - harness.published_entry_counts(), - Some(before), - "a paused worker must not publish" - ); - harness.resume(); - harness.published(|snapshot| snapshot.entry_counts() != before); + wait_until("the result is recorded", || results.get(&B256::repeat_byte(0xB0)).is_some()); } #[test] @@ -575,19 +620,6 @@ mod tests { assert_eq!(harness.pool.opened.load(Ordering::Relaxed), 2); } - #[test] - fn newest_start_wins() { - let harness = Harness::spawn(); - let stale = B256::repeat_byte(0x01); - let newest = B256::repeat_byte(0x02); - - harness.start(stale); - harness.start(newest); - harness.pool.push(newest, transfer(0xB0)); - - harness.published_for(newest); - } - #[test] fn shuts_down_when_control_is_dropped() { let harness = Harness::spawn(); diff --git a/crates/engine/tree/src/tree/types.rs b/crates/engine/tree/src/tree/types.rs index 9e11a88426a..75d237829a8 100644 --- a/crates/engine/tree/src/tree/types.rs +++ b/crates/engine/tree/src/tree/types.rs @@ -1,11 +1,11 @@ //! Shared types for blockchain tree validation. -use crate::tree::error::InsertPayloadError; +use crate::tree::{error::InsertPayloadError, payload_processor::reuse::PoolSeeds}; use alloy_eip7928::bal::DecodedBal; use alloy_eips::eip4895::Withdrawal; use alloy_primitives::B256; use reth_chain_state::{ExecutedBlock, ExecutionTimingStats}; -use reth_evm::{ConfigureEvm, EvmEnvFor}; +use reth_evm::{ConfigureEvm, EvmEnvFor, HaltReasonFor}; use reth_execution_cache::TxPoolPrewarmCacheSnapshot; use reth_primitives_traits::{BlockTy, NodePrimitives}; use std::sync::Arc; @@ -19,9 +19,7 @@ pub struct ExecutionEnv { pub hash: B256, /// Hash of the parent block. pub parent_hash: B256, - /// State root of the parent block. - /// Used for sparse trie continuation: if the preserved trie's anchor matches this, - /// the trie can be reused directly. + /// State root of the parent block, used as the base state for execution. pub parent_state_root: B256, /// Number of transactions in the block. /// Used to determine parallel worker count for prewarming. @@ -39,6 +37,12 @@ pub struct ExecutionEnv { /// /// Can be None if txpool prewarming is disabled or the snapshot is not ready for some reason. pub txpool_snapshot: Option, + /// Results of pool transactions executed on top of the parent state that prewarming may + /// reuse. + /// + /// None if txpool prewarming does not record results or the block environment differs from + /// the predicted one. + pub(crate) pool_seeds: Option>>, } impl ExecutionEnv @@ -58,6 +62,7 @@ where withdrawals: None, decoded_bal: None, txpool_snapshot: None, + pool_seeds: None, } } } diff --git a/crates/engine/tree/tests/e2e-testsuite/main.rs b/crates/engine/tree/tests/e2e-testsuite/main.rs index 55b88a3d212..2dec03894c7 100644 --- a/crates/engine/tree/tests/e2e-testsuite/main.rs +++ b/crates/engine/tree/tests/e2e-testsuite/main.rs @@ -764,6 +764,9 @@ async fn test_engine_tree_fcu_extends_canon_chain_v2_e2e() -> Result<()> { Ok(()) } +/// Number of blocks whose state and trie updates the database masks in the masked variant. +const DISK_REORG_STATE_MASKING_BLOCKS: u64 = 2; + /// Creates a 2-node setup for disk-level reorg testing. /// /// Uses unconnected nodes so fork blocks can be produced independently on Node 1 and then @@ -773,7 +776,10 @@ async fn test_engine_tree_fcu_extends_canon_chain_v2_e2e() -> Result<()> { /// Persistence starts once more than 7 canonical blocks are held in memory. Without a memory /// block buffer, each cycle persists up to the canonical head, so the persisted ranges only depend /// on the chain lengths and not on how fast persistence keeps up with block production. -fn disk_reorg_setup(storage_v2: bool) -> Setup { +/// +/// With `num_state_masking_blocks` set, each cycle leaves the state trie that many blocks below +/// the database tip. +fn disk_reorg_setup(storage_v2: bool, num_state_masking_blocks: u64) -> Setup { Setup::default() .with_chain_spec(test_chain_spec(EthereumHardfork::Cancun)) .with_network(NetworkSetup::multi_node_unconnected(2)) @@ -783,6 +789,7 @@ fn disk_reorg_setup(storage_v2: bool) -> Setup { .with_num_state_masking_blocks(0) .with_persistence_threshold(7) .with_memory_block_buffer_target(0) + .with_num_state_masking_blocks(num_state_masking_blocks) .with_has_enough_parallelism(true), ) } @@ -796,9 +803,9 @@ fn disk_reorg_setup(storage_v2: bool) -> Setup { /// 5. FCU to the fork tip on Node 0 makes `find_disk_reorg` detect the persisted main chain blocks /// 4-8, so `RemoveBlocksAbove(3)` unwinds them /// 6. Node 0 then persists fork blocks 4-11 -fn disk_reorg_test(storage_v2: bool) -> TestBuilder { +fn disk_reorg_test(storage_v2: bool, num_state_masking_blocks: u64) -> TestBuilder { TestBuilder::new() - .with_setup(disk_reorg_setup(storage_v2)) + .with_setup(disk_reorg_setup(storage_v2, num_state_masking_blocks)) .with_action(SelectActiveNode::new(0)) .with_action(ProduceBlocks::::new(3)) .with_action(MakeCanonical::new()) @@ -840,7 +847,7 @@ fn disk_reorg_test(storage_v2: bool) -> TestBuilder { #[tokio::test] async fn test_engine_tree_disk_reorg_v1_e2e() -> Result<()> { reth_tracing::init_test_tracing(); - disk_reorg_test(false).run::().await?; + disk_reorg_test(false, 0).run::().await?; Ok(()) } @@ -851,6 +858,51 @@ async fn test_engine_tree_disk_reorg_v1_e2e() -> Result<()> { #[tokio::test] async fn test_engine_tree_disk_reorg_v2_e2e() -> Result<()> { reth_tracing::init_test_tracing(); - disk_reorg_test(true).run::().await?; + disk_reorg_test(true, 0).run::().await?; + Ok(()) +} + +/// Verifies a disk-level reorg while the database's state trie is partial. +/// +/// The masked blocks - the ones between the state trie frontier and the database tip - only +/// exist in memory, and they sit on the branch the unwind is about to remove rather than on the +/// new canonical chain. The unwind therefore has to complete the trie at Finish from the chain of +/// the persisted tip, which is no longer canonical, before the fork can be persisted. +/// +/// Extending the fork afterwards validates every new payload against the unwound database, whose +/// state trie is masked again, and persists the extension on top of it. +#[tokio::test] +async fn test_engine_tree_disk_reorg_with_masked_state_trie_e2e() -> Result<()> { + reth_tracing::init_test_tracing(); + + disk_reorg_test(false, DISK_REORG_STATE_MASKING_BLOCKS) + // Node 1 extends the fork tip at block 11 by 8 blocks, which Node 0 must all accept as + // valid on top of the unwound database. + .with_action(SelectActiveNode::new(1)) + .with_action(ProduceBlocksLocally::::new(8)) + .with_action(MakeCanonical::with_active_node()) + .with_action(CaptureBlock::new("extended_fork_tip")) + .with_action( + SendNewPayloads::::new() + .with_source_node(1) + .with_target_node(0) + .with_start_block(12) + .with_total_blocks(8), + ) + .with_action( + SendForkchoiceUpdate::::new( + BlockReference::Tag("extended_fork_tip".into()), + BlockReference::Tag("extended_fork_tip".into()), + BlockReference::Tag("extended_fork_tip".into()), + ) + .with_expected_status(PayloadStatusEnum::Valid) + .with_node_idx(0), + ) + // the in-memory canonical chain now exceeds the persistence threshold again, so the + // extension is persisted after the unwound fork. + .with_action(WaitForPersistedChain::new(0, "extended_fork_tip")) + .run::() + .await?; + Ok(()) } diff --git a/crates/engine/tree/tests/firehose_live_tracing_prewarm_reuse.rs b/crates/engine/tree/tests/firehose_live_tracing_prewarm_reuse.rs new file mode 100644 index 00000000000..716c1e27be8 --- /dev/null +++ b/crates/engine/tree/tests/firehose_live_tracing_prewarm_reuse.rs @@ -0,0 +1,82 @@ +//! Regression test for Firehose tracing on the engine-tree live-block path with prewarm result +//! reuse enabled. +//! +//! With `--engine.prewarm-reuse`, upstream `execute_block` commits a transaction's speculative +//! prewarm result instead of executing it when the result's reads are still valid. A committed +//! result never runs through the EVM, so the Firehose inspector would not see the transaction. The +//! traced twin (`execute_and_trace_block`) must execute every transaction even when reuse is on. +//! +//! The node runs the stock `EthEvmConfig`, which opts into reuse, and the traced block carries +//! independent transfers from distinct senders, whose prewarm results stay valid. +//! +//! Like `firehose_live_tracing`, it lives in its own integration-test binary because it installs +//! a process-wide tracer. + +use alloy_primitives::Address; +use eyre::Result; +use reth_chainspec::{EthChainSpec, EthereumHardfork}; +use reth_e2e_test_utils::{ + transaction::TransactionTestContext, wallet::test_signer, E2ETestSetupExt, +}; +use reth_node_ethereum::EthereumNode; + +/// Block that carries the transfers. +const TRANSFER_BLOCK: u64 = 2; + +/// Number of distinct funded senders in the transfer block. +const SENDER_COUNT: u32 = 16; + +/// Gas used by a plain value transfer. +const TRANSFER_GAS: u64 = 21_000; + +#[tokio::test] +async fn live_payload_validation_traces_reused_prewarm_results() -> Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_tree_config_modifier(|config| { + config.with_has_enough_parallelism(true).with_prewarm_reuse(true) + }) + .build_single() + .await?; + + // Installed after node startup so the genesis block isn't emitted, only the live blocks. + let buffer = reth_firehose::init_tracer_with_buffer( + node.inner.chain_spec().chain().id(), + Some(0), // shanghai + Some(0), // cancun + Some(0), // prague + ); + + let chain_id = node.inner.chain_spec().chain().id(); + let mut senders = Vec::with_capacity(SENDER_COUNT as usize); + for number in 1..=TRANSFER_BLOCK { + if number == TRANSFER_BLOCK { + for index in 0..SENDER_COUNT { + let signer = test_signer(index); + senders.push(signer.address()); + let transfer = + TransactionTestContext::transfer_tx_bytes_with_nonce(chain_id, signer, 0).await; + node.rpc.inject_tx(transfer).await?; + } + } + node.advance_block().await?; + } + + let block = reth_firehose_tests::parse_fire_block_for(&buffer.get_bytes(), TRANSFER_BLOCK)?; + let mut traced_senders = block + .transaction_traces + .iter() + .map(|trx| Address::from_slice(&trx.from)) + .collect::>(); + traced_senders.sort_unstable(); + senders.sort_unstable(); + assert_eq!(traced_senders, senders, "every transfer is traced"); + + for trx in &block.transaction_traces { + assert_eq!(trx.gas_used, TRANSFER_GAS); + assert!(!trx.calls.is_empty(), "transaction {:?} was traced without executing", trx.hash); + } + + Ok(()) +} diff --git a/crates/engine/util/src/engine_store.rs b/crates/engine/util/src/engine_store.rs index 80bdd0a2a92..17ef7dffec0 100644 --- a/crates/engine/util/src/engine_store.rs +++ b/crates/engine/util/src/engine_store.rs @@ -62,7 +62,7 @@ impl EngineMessageStore { fs::create_dir_all(&self.path)?; // ensure that store path had been created let timestamp = received_at.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis(); match msg { - BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx: _tx } => { + BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx: _tx, .. } => { let filename = format!("{}-fcu-{}.json", timestamp, state.head_block_hash); fs::write( self.path.join(filename), @@ -139,7 +139,7 @@ where if let Some(msg) = &next && let Err(error) = this.store.on_message(msg, SystemTime::now()) { - error!(target: "engine::stream::store", ?msg, %error, "Error handling Engine API message"); + error!(target: "engine::stream::store", %msg, %error, "Error handling Engine API message"); } Poll::Ready(next) } diff --git a/crates/engine/util/src/reorg.rs b/crates/engine/util/src/reorg.rs index 5f31301aa5b..aa15ddf9712 100644 --- a/crates/engine/util/src/reorg.rs +++ b/crates/engine/util/src/reorg.rs @@ -144,7 +144,7 @@ where let next = ready!(this.stream.poll_next_unpin(cx)); let item = match (next, &this.last_forkchoice_state) { ( - Some(BeaconEngineMessage::NewPayload { payload, tx }), + Some(BeaconEngineMessage::NewPayload { cause, payload, tx }), Some(last_forkchoice_state), ) if this.forkchoice_states_forwarded > this.frequency && // Only enter reorg state if new payload attaches to current head. @@ -172,6 +172,7 @@ where // Forward the payload and attempt to create reorg on top of // the next one return Poll::Ready(Some(BeaconEngineMessage::NewPayload { + cause, payload, tx, })) @@ -192,14 +193,16 @@ where let queue = VecDeque::from([ // Current payload - BeaconEngineMessage::NewPayload { payload, tx }, + BeaconEngineMessage::NewPayload { cause: cause.clone(), payload, tx }, // Reorg payload BeaconEngineMessage::NewPayload { + cause: cause.clone(), payload: T::block_to_payload(reorg_block, encoded_bal), tx: reorg_payload_tx, }, // Reorg forkchoice state BeaconEngineMessage::ForkchoiceUpdated { + cause, state: reorg_forkchoice_state, payload_attrs: None, tx: reorg_fcu_tx, @@ -208,13 +211,21 @@ where *this.state = EngineReorgState::Reorg { queue }; continue } - (Some(BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx }), _) => { + ( + Some(BeaconEngineMessage::ForkchoiceUpdated { + cause, + state, + payload_attrs, + tx, + }), + _, + ) => { // Record last forkchoice state forwarded to the engine. // We do not care if it's valid since engine should be able to handle // reorgs that rely on invalid forkchoice state. *this.last_forkchoice_state = Some(state); *this.forkchoice_states_forwarded += 1; - Some(BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx }) + Some(BeaconEngineMessage::ForkchoiceUpdated { cause, state, payload_attrs, tx }) } (item, _) => item, }; diff --git a/crates/engine/util/src/skip_fcu.rs b/crates/engine/util/src/skip_fcu.rs index 2cdd44ffb58..b3f3b6aa8af 100644 --- a/crates/engine/util/src/skip_fcu.rs +++ b/crates/engine/util/src/skip_fcu.rs @@ -45,7 +45,12 @@ where loop { let next = ready!(this.stream.poll_next_unpin(cx)); let item = match next { - Some(BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx }) => { + Some(BeaconEngineMessage::ForkchoiceUpdated { + cause, + state, + payload_attrs, + tx, + }) => { if this.skipped < this.threshold { *this.skipped += 1; tracing::warn!(target: "engine::stream::skip_fcu", ?state, ?payload_attrs, threshold=this.threshold, skipped=this.skipped, "Skipping FCU"); @@ -53,7 +58,7 @@ where continue } *this.skipped = 0; - Some(BeaconEngineMessage::ForkchoiceUpdated { state, payload_attrs, tx }) + Some(BeaconEngineMessage::ForkchoiceUpdated { cause, state, payload_attrs, tx }) } next => next, }; diff --git a/crates/engine/util/src/skip_new_payload.rs b/crates/engine/util/src/skip_new_payload.rs index fa818f54538..1e71eac3503 100644 --- a/crates/engine/util/src/skip_new_payload.rs +++ b/crates/engine/util/src/skip_new_payload.rs @@ -41,14 +41,13 @@ where loop { let next = ready!(this.stream.poll_next_unpin(cx)); let item = match next { - Some(BeaconEngineMessage::NewPayload { payload, tx }) => { + Some(BeaconEngineMessage::NewPayload { cause, payload, tx }) => { if this.skipped < this.threshold { *this.skipped += 1; tracing::warn!( target: "engine::stream::skip_new_payload", block_number = payload.block_number(), block_hash = %payload.block_hash(), - ?payload, threshold=this.threshold, skipped=this.skipped, "Skipping new payload" ); @@ -56,7 +55,7 @@ where continue } *this.skipped = 0; - Some(BeaconEngineMessage::NewPayload { payload, tx }) + Some(BeaconEngineMessage::NewPayload { cause, payload, tx }) } next => next, }; diff --git a/crates/era-downloader/Cargo.toml b/crates/era-downloader/Cargo.toml index 99e88094606..c33e4e9e8bc 100644 --- a/crates/era-downloader/Cargo.toml +++ b/crates/era-downloader/Cargo.toml @@ -23,7 +23,7 @@ reqwest.workspace = true # async tokio.workspace = true -tokio.features = ["fs", "io-util", "macros"] +tokio.features = ["fs", "io-util", "macros", "sync"] futures-util.workspace = true # errors diff --git a/crates/era-downloader/src/client.rs b/crates/era-downloader/src/client.rs index c267d9aa59d..e89f7f632ee 100644 --- a/crates/era-downloader/src/client.rs +++ b/crates/era-downloader/src/client.rs @@ -4,11 +4,13 @@ use eyre::{eyre, OptionExt}; use futures_util::{stream::StreamExt, Stream, TryStreamExt}; use reqwest::{Client, IntoUrl, Url}; use reth_era::common::file_ops::EraFileType; +use reth_fs_util::FsPathError; use sha2::{Digest, Sha256}; -use std::{future::Future, path::Path, str::FromStr}; +use std::{collections::HashMap, future::Future, path::Path, str::FromStr, sync::Arc}; use tokio::{ fs::{self, File}, io::{self, AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWriteExt}, + sync::Mutex, try_join, }; @@ -46,6 +48,7 @@ pub struct EraClient { url: Url, folder: Box, era_type: EraFileType, + file_lists: Arc>>, } impl EraClient { @@ -57,7 +60,7 @@ impl EraClient { /// [`with_era_type`](Self::with_era_type) to override. pub fn new(client: Http, url: Url, folder: impl Into>) -> Self { let era_type = EraFileType::from_url(url.as_str()); - Self { client, url, folder: folder.into(), era_type } + Self { client, url, folder: folder.into(), era_type, file_lists: Default::default() } } /// Override the auto-detected [`EraFileType`]. @@ -193,7 +196,14 @@ impl EraClient { /// `folder`. /// For era files, checksum.txt file does not exist, so the checksum verification is /// skipped. + /// + /// A successful fetch replaces the filename and checksum snapshot shared with this client's + /// clones. Later lookups use that snapshot until the next successful fetch; changes made to + /// sidecar files externally are not observed until then. Failed refreshes retain the last + /// successful snapshot. Archive types have separate snapshots. Refreshes are serialized across + /// clones, and lookups wait until an ongoing refresh finishes. pub async fn fetch_file_list(&self) -> eyre::Result<()> { + let mut file_lists = self.file_lists.lock().await; let index_path = self.folder.to_path_buf().join(INDEX_HTML_FILE); let checksums_path = self.folder.to_path_buf().join(Self::CHECKSUMS); @@ -210,13 +220,30 @@ impl EraClient { } // Parse and extract era filenames from index.html - self.extract_era_filenames(&index_path).await?; - + let file_names = self.extract_era_filenames(&index_path).await?; + let checksums = if self.era_type.has_checksums() { + fs::read(&checksums_path) + .await + .map_err(|error| FsPathError::read(error, &checksums_path))? + .split_inclusive(|byte| *byte == b'\n') + .map(|line| { + let line = if let Some(line) = line.strip_suffix(b"\n") { + line.strip_suffix(b"\r").unwrap_or(line) + } else { + line + }; + line.to_vec() + }) + .collect() + } else { + Vec::new() + }; + file_lists.insert(self.era_type, FileList { file_names, checksums }); Ok(()) } /// Extracts ERA filenames from `index.html` and writes them to the index file - async fn extract_era_filenames(&self, index_path: &Path) -> eyre::Result<()> { + async fn extract_era_filenames(&self, index_path: &Path) -> eyre::Result> { let file = File::open(index_path).await?; let reader = io::BufReader::new(file); let mut lines = reader.lines(); @@ -224,16 +251,18 @@ impl EraClient { let path = self.folder.to_path_buf().join("index"); let file = File::create(&path).await?; let mut writer = io::BufWriter::new(file); + let mut file_names = Vec::new(); while let Some(line) = lines.next_line().await? { if let Some(era) = extract_era_filename(&line, self.era_type.extensions()) { writer.write_all(era.as_bytes()).await?; writer.write_all(b"\n").await?; + file_names.push(era.to_owned()); } } writer.flush().await?; - Ok(()) + Ok(file_names) } // Helper to download a file to a specified path @@ -250,6 +279,9 @@ impl EraClient { /// Returns ERA1/ERA file name that is ordered at `number`. pub async fn number_to_file_name(&self, number: usize) -> eyre::Result> { + if let Some(file_list) = self.file_lists.lock().await.get(&self.era_type) { + return Ok(file_list.file_names.get(number).cloned()); + } let path = self.folder.to_path_buf().join("index"); let file = File::open(&path).await?; let reader = io::BufReader::new(file); @@ -319,6 +351,13 @@ impl EraClient { /// /// [file list]: Self::fetch_file_list async fn expected_checksum(&self, number: usize) -> eyre::Result> { + if let Some(file_list) = self.file_lists.lock().await.get(&self.era_type) { + let checksum = file_list + .checksums + .get(number) + .ok_or_else(|| eyre!("Missing hash for number {number}"))?; + return Ok(hex::decode(checksum)?); + } let file = File::open(self.folder.join(Self::CHECKSUMS)).await?; let reader = io::BufReader::new(file); let mut lines = reader.lines(); @@ -346,6 +385,13 @@ impl EraClient { } } +/// The filenames and raw checksum lines from one successful file-list fetch. +#[derive(Debug)] +struct FileList { + file_names: Vec, + checksums: Vec>, +} + /// Extracts an era filename ending in one of `extensions` from a single index line. /// /// `extensions` are tried in order; pass them longest-first so `.ere` never matches inside `.erae`. @@ -386,7 +432,13 @@ async fn checksum(mut reader: impl AsyncRead + Unpin) -> eyre::Result> { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; + use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + }; use test_case::test_case; impl EraClient { @@ -439,4 +491,168 @@ mod tests { let client = client.with_era_type(EraFileType::Era1); assert_eq!(client.era_type, EraFileType::Era1); } + + #[derive(Clone, Debug, Default)] + struct ListingClient { + index: Arc>, + checksums: Arc>>, + fail: Arc, + calls: Arc, + } + + impl HttpClient for ListingClient { + async fn get( + &self, + url: U, + ) -> eyre::Result> + Send + Sync + Unpin> { + self.calls.fetch_add(1, Ordering::Relaxed); + if self.fail.load(Ordering::Relaxed) { + return Err(eyre!("metadata fetch failed")); + } + let url = url.into_url()?; + let body = if url.path().ends_with("checksums.txt") { + self.checksums.lock().await.clone() + } else { + self.index.lock().await.clone().into_bytes() + }; + Ok(futures_util::stream::iter([Ok(Bytes::from(body))])) + } + } + + #[tokio::test] + async fn file_list_refresh_updates_clones_and_preserves_lazy_errors() { + let folder = tempfile::tempdir().unwrap(); + let http = ListingClient::default(); + *http.index.lock().await = "\n".to_owned(); + *http.checksums.lock().await = b"00\r\n\xff\n".to_vec(); + let client = EraClient::new( + http.clone(), + Url::parse("https://example.com/era1/").unwrap(), + folder.path(), + ); + let clone = client.clone(); + client.fetch_file_list().await.unwrap(); + assert_eq!( + clone.number_to_file_name(0).await.unwrap().as_deref(), + Some("mainnet-00000-original.era1") + ); + assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![0]); + assert_eq!( + clone.expected_checksum(1).await.unwrap_err().downcast_ref::(), + Some(&hex::FromHexError::OddLength) + ); + + *http.index.lock().await = + "\n\n" + .to_owned(); + *http.checksums.lock().await = b"02\n03\n".to_vec(); + http.fail.store(true, Ordering::Relaxed); + assert_eq!( + client.fetch_file_list().await.unwrap_err().to_string(), + "metadata fetch failed" + ); + assert_eq!( + clone.number_to_file_name(0).await.unwrap().as_deref(), + Some("mainnet-00000-original.era1") + ); + assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![0]); + + http.fail.store(false, Ordering::Relaxed); + client.fetch_file_list().await.unwrap(); + assert_eq!( + clone.number_to_file_name(0).await.unwrap().as_deref(), + Some("mainnet-00000-revised-name.era1") + ); + assert_eq!( + clone.number_to_file_name(1).await.unwrap().as_deref(), + Some("mainnet-00001-next.era1") + ); + assert_eq!(clone.number_to_file_name(2).await.unwrap(), None); + assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![2]); + assert_eq!(clone.expected_checksum(1).await.unwrap(), vec![3]); + assert_eq!( + clone.expected_checksum(2).await.unwrap_err().to_string(), + "Missing hash for number 2" + ); + } + + #[tokio::test] + async fn changing_archive_type_uses_a_separate_file_list() { + let folder = tempfile::tempdir().unwrap(); + let http = ListingClient::default(); + *http.index.lock().await = "\n".to_owned(); + *http.checksums.lock().await = b"00\n".to_vec(); + let client = EraClient::new( + http.clone(), + Url::parse("https://example.com/era1/").unwrap(), + folder.path(), + ); + client.fetch_file_list().await.unwrap(); + let era_client = client.clone().with_era_type(EraFileType::Era); + *http.index.lock().await = "\n".to_owned(); + era_client.fetch_file_list().await.unwrap(); + assert_eq!( + era_client.number_to_file_name(0).await.unwrap().as_deref(), + Some("mainnet-00000-consensus.era") + ); + assert_eq!( + client.number_to_file_name(0).await.unwrap().as_deref(), + Some("mainnet-00000-original.era1") + ); + assert_eq!(client.expected_checksum(0).await.unwrap(), vec![0]); + } + + #[tokio::test] + async fn lookups_without_fetch_use_existing_sidecars() { + let folder = tempfile::tempdir().unwrap(); + reth_fs_util::write(folder.path().join("index"), b"first\nsecond-longer-name\n").unwrap(); + reth_fs_util::write(folder.path().join(EraClient::::CHECKSUMS), b"00\n01\n") + .unwrap(); + let client = EraClient::new( + Client::new(), + Url::parse("https://example.com/era1/").unwrap(), + folder.path(), + ); + assert_eq!( + client.number_to_file_name(1).await.unwrap().as_deref(), + Some("second-longer-name") + ); + assert_eq!(client.number_to_file_name(2).await.unwrap(), None); + assert_eq!(client.expected_checksum(1).await.unwrap(), vec![1]); + assert_eq!( + client.expected_checksum(2).await.unwrap_err().to_string(), + "Missing hash for number 2" + ); + } + + #[tokio::test] + async fn concurrent_refreshes_do_not_interleave_sidecars() { + let folder = tempfile::tempdir().unwrap(); + let http = ListingClient::default(); + *http.index.lock().await = "\n".to_owned(); + *http.checksums.lock().await = b"00\n".to_vec(); + let client = EraClient::new( + http.clone(), + Url::parse("https://example.com/era1/").unwrap(), + folder.path(), + ); + let clone = client.clone(); + let index_guard = http.index.lock().await; + let mut first = Box::pin(client.fetch_file_list()); + assert!(futures::poll!(&mut first).is_pending()); + assert_eq!(http.calls.load(Ordering::Relaxed), 2); + let mut second = Box::pin(clone.fetch_file_list()); + assert!(futures::poll!(&mut second).is_pending()); + assert_eq!( + http.calls.load(Ordering::Relaxed), + 2, + "second refresh must wait before downloading sidecars" + ); + drop(index_guard); + let (first, second) = tokio::join!(first, second); + first.unwrap(); + second.unwrap(); + assert_eq!(http.calls.load(Ordering::Relaxed), 4); + assert_eq!(clone.expected_checksum(0).await.unwrap(), vec![0]); + } } diff --git a/crates/era-utils/src/export/ere.rs b/crates/era-utils/src/export/ere.rs index 737160c7c27..b13fcb42137 100644 --- a/crates/era-utils/src/export/ere.rs +++ b/crates/era-utils/src/export/ere.rs @@ -38,11 +38,11 @@ impl EraBlockWriter for Ere { // Total difficulty and the accumulator are pre-merge only: post-merge blocks have zero // difficulty and the accumulator is frozen at the merge. Difficulty drops to zero // monotonically, so the first block decides whether the file carries them at all. - let pre_merge = !blocks[0].header.difficulty().is_zero(); + let pre_merge = !blocks[0].header.is_zero_difficulty(); // Post-merge blocks are not part of any epoch accumulator, so a merge-spanning file builds // its accumulator from the pre-merge prefix only, while total difficulty is kept for every // block. - let pre_merge_count = blocks.partition_point(|b| !b.header.difficulty().is_zero()); + let pre_merge_count = blocks.partition_point(|b| !b.header.is_zero_difficulty()); let tuples = blocks .iter() @@ -234,7 +234,7 @@ mod tests { #[test] fn pre_merge_file_carries_total_difficulty_and_accumulator() { - let file = write_and_read(&export_blocks(3, U256::from(1))); + let file = write_and_read(&export_blocks(3, U256::ONE)); assert!(file.group.accumulator.is_some()); assert_eq!(file.group.index.component_count(), 4); @@ -253,9 +253,8 @@ mod tests { #[test] fn merge_spanning_file_excludes_post_merge_blocks_from_accumulator() { // Blocks 0-1 pre-merge (non-zero difficulty), blocks 2-3 post-merge (zero difficulty). - let blocks: Vec<_> = (0..4) - .map(|n| export_block(n, if n < 2 { U256::from(1) } else { U256::ZERO })) - .collect(); + let blocks: Vec<_> = + (0..4).map(|n| export_block(n, if n < 2 { U256::ONE } else { U256::ZERO })).collect(); let file = write_and_read(&blocks); // Total difficulty is kept for every block, so the component count stays at 4. diff --git a/crates/era-utils/src/history.rs b/crates/era-utils/src/history.rs index 618e88cf35a..924cee9bd37 100644 --- a/crates/era-utils/src/history.rs +++ b/crates/era-utils/src/history.rs @@ -1,5 +1,5 @@ use alloy_consensus::{ - proofs::calculate_receipt_root, BlockHeader, Eip658Value, ReceiptEnvelope, ReceiptWithBloom, + proofs::calculate_receipt_root, BlockHeader, ReceiptEnvelope, ReceiptWithBloom, RlpDecodableReceipt, TxReceipt, }; use alloy_primitives::{BlockHash, BlockNumber, Bloom, U256}; @@ -579,7 +579,7 @@ fn receipts_from_envelopes( envelopes: Vec, ) -> eyre::Result> { for envelope in &envelopes { - if matches!(envelope.status_or_post_state(), Eip658Value::PostState(_)) { + if envelope.status_or_post_state().is_post_state() { eyre::bail!( "block {number} has pre-Byzantium receipts, which commit to a post-state root \ rather than a success status and so cannot be represented by this node's receipt \ @@ -870,7 +870,9 @@ where #[cfg(test)] mod tests { use super::*; - use alloy_consensus::{Header, Receipt as RlpReceipt, ReceiptWithBloom, TxLegacy, TxType}; + use alloy_consensus::{ + Eip658Value, Header, Receipt as RlpReceipt, ReceiptWithBloom, TxLegacy, TxType, + }; use alloy_primitives::{Address, Bytes, Log, Signature, B256}; use reth_db_common::init::init_genesis; use reth_era::{ @@ -1427,39 +1429,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn process_writes_receipts_when_requested() { - let pf = create_test_provider_factory(); - init_genesis(&pf).unwrap(); - - let static_file_provider = pf.static_file_provider(); - let mut writer = static_file_provider.latest_writer(StaticFileSegment::Headers).unwrap(); - let mut receipts_writer = - static_file_provider.latest_writer(StaticFileSegment::Receipts).unwrap(); - let provider = pf.database_provider_rw().unwrap(); - let folder = tempdir().unwrap(); - let mut hash_collector = Collector::new(4096, Some(folder.path().to_owned())); - let meta = TestMeta { marked: Cell::new(false) }; - - let height = process::( - &meta, - &mut writer, - Some(&mut receipts_writer), - &provider, - &mut hash_collector, - 0..=1, - ImportPolicy { headers_tip: 0, is_receipt_verifiable: &|_| false }, - ) - .unwrap(); - receipts_writer.commit().unwrap(); - - assert_eq!(height, 1); - assert_eq!( - static_file_provider.get_highest_static_file_block(StaticFileSegment::Receipts), - Some(1) - ); - } - #[test] fn process_iter_errors_when_receipts_missing() { let pf = create_test_provider_factory(); @@ -1825,29 +1794,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn verify_receipts_rejects_tampered_contents() { - let receipts = vec![Receipt { - tx_type: TxType::Legacy, - success: true, - cumulative_gas_used: 21_000, - logs: vec![], - }]; - - // Commit the header to the receipts as decoded. - let with_bloom = receipts.iter().map(TxReceipt::with_bloom_ref).collect::>(); - let header = Header { - receipts_root: calculate_receipt_root(&with_bloom), - logs_bloom: with_bloom.iter().fold(Bloom::ZERO, |bloom, r| bloom | r.bloom_ref()), - ..Default::default() - }; - verify_receipts(&header, &receipts, true).unwrap(); - - // Same receipt count, different contents: the recomputed root no longer matches. - let tampered = vec![Receipt { cumulative_gas_used: 42_000, ..receipts[0].clone() }]; - assert!(verify_receipts(&header, &tampered, true).is_err()); - } - #[test] fn verify_receipts_checks_the_bloom_even_without_the_root() { let receipts = vec![Receipt { diff --git a/crates/era/src/era1/file.rs b/crates/era/src/era1/file.rs index e3b29d3f149..9bb38a0ec85 100644 --- a/crates/era/src/era1/file.rs +++ b/crates/era/src/era1/file.rs @@ -437,7 +437,7 @@ mod tests { blocks.push(create_test_block(block_num, 32)); } - let accumulator = Accumulator::new(B256::from([0xAA; 32])); + let accumulator = Accumulator::new(B256::repeat_byte(0xAA)); let mut offsets = Vec::with_capacity(block_count); for i in 0..block_count { diff --git a/crates/era/src/era1/types/execution.rs b/crates/era/src/era1/types/execution.rs index 08f1ca88ff9..5b3302a875f 100644 --- a/crates/era/src/era1/types/execution.rs +++ b/crates/era/src/era1/types/execution.rs @@ -70,18 +70,18 @@ //! `````` use crate::{ - common::decode::DecodeCompressedRlp, + common::{ + compression::{snappy_compress, snappy_decompress}, + decode::DecodeCompressedRlp, + }, e2s::{error::E2sError, types::Entry}, }; use alloy_consensus::{Block, BlockBody, Header}; use alloy_primitives::{B256, U256}; use alloy_rlp::{Decodable, Encodable}; use sha2::{Digest, Sha256}; -use snap::{read::FrameDecoder, write::FrameEncoder}; -use std::{ - io::{Read, Write}, - marker::PhantomData, -}; + +pub use crate::common::compression::SnappyRlpCodec; // Era1-specific constants /// `CompressedHeader` record type @@ -102,56 +102,6 @@ pub const ACCUMULATOR: [u8; 2] = [0x07, 0x00]; /// Maximum number of blocks in an Era1 file, limited by accumulator size pub const MAX_BLOCKS_PER_ERA1: usize = 8192; -/// Generic codec for Snappy-compressed RLP data -#[derive(Debug, Clone, Default)] -pub struct SnappyRlpCodec { - _phantom: PhantomData, -} - -impl SnappyRlpCodec { - /// Create a new codec for the given type - pub const fn new() -> Self { - Self { _phantom: PhantomData } - } -} - -impl SnappyRlpCodec { - /// Decode compressed data into the target type - pub fn decode(&self, compressed_data: &[u8]) -> Result { - let mut decoder = FrameDecoder::new(compressed_data); - let mut decompressed = Vec::new(); - Read::read_to_end(&mut decoder, &mut decompressed).map_err(|e| { - E2sError::SnappyDecompression(format!("Failed to decompress data: {e}")) - })?; - - let mut slice = decompressed.as_slice(); - T::decode(&mut slice).map_err(|e| E2sError::Rlp(format!("Failed to decode RLP data: {e}"))) - } -} - -impl SnappyRlpCodec { - /// Encode data into compressed format - pub fn encode(&self, data: &T) -> Result, E2sError> { - let mut rlp_data = Vec::new(); - data.encode(&mut rlp_data); - - let mut compressed = Vec::new(); - { - let mut encoder = FrameEncoder::new(&mut compressed); - - Write::write_all(&mut encoder, &rlp_data).map_err(|e| { - E2sError::SnappyCompression(format!("Failed to compress data: {e}")) - })?; - - encoder.flush().map_err(|e| { - E2sError::SnappyCompression(format!("Failed to flush encoder: {e}")) - })?; - } - - Ok(compressed) - } -} - /// Compressed block header using `snappyFramed(rlp(header))` #[derive(Debug, Clone)] pub struct CompressedHeader { @@ -167,30 +117,12 @@ impl CompressedHeader { /// Create from RLP-encoded header by compressing it with Snappy pub fn from_rlp(rlp_data: &[u8]) -> Result { - let mut compressed = Vec::new(); - { - let mut encoder = FrameEncoder::new(&mut compressed); - - Write::write_all(&mut encoder, rlp_data).map_err(|e| { - E2sError::SnappyCompression(format!("Failed to compress header: {e}")) - })?; - - encoder.flush().map_err(|e| { - E2sError::SnappyCompression(format!("Failed to flush encoder: {e}")) - })?; - } - Ok(Self { data: compressed }) + Ok(Self { data: snappy_compress(rlp_data)? }) } /// Decompress to get the original RLP-encoded header pub fn decompress(&self) -> Result, E2sError> { - let mut decoder = FrameDecoder::new(self.data.as_slice()); - let mut decompressed = Vec::new(); - Read::read_to_end(&mut decoder, &mut decompressed).map_err(|e| { - E2sError::SnappyDecompression(format!("Failed to decompress header: {e}")) - })?; - - Ok(decompressed) + snappy_decompress(&self.data) } /// Convert to an [`Entry`] @@ -248,30 +180,12 @@ impl CompressedBody { /// Create from RLP-encoded body by compressing it with Snappy pub fn from_rlp(rlp_data: &[u8]) -> Result { - let mut compressed = Vec::new(); - { - let mut encoder = FrameEncoder::new(&mut compressed); - - Write::write_all(&mut encoder, rlp_data).map_err(|e| { - E2sError::SnappyCompression(format!("Failed to compress body: {e}")) - })?; - - encoder.flush().map_err(|e| { - E2sError::SnappyCompression(format!("Failed to flush encoder: {e}")) - })?; - } - Ok(Self { data: compressed }) + Ok(Self { data: snappy_compress(rlp_data)? }) } /// Decompress to get the original RLP-encoded body pub fn decompress(&self) -> Result, E2sError> { - let mut decoder = FrameDecoder::new(self.data.as_slice()); - let mut decompressed = Vec::new(); - Read::read_to_end(&mut decoder, &mut decompressed).map_err(|e| { - E2sError::SnappyDecompression(format!("Failed to decompress body: {e}")) - })?; - - Ok(decompressed) + snappy_decompress(&self.data) } /// Convert to an [`Entry`] @@ -335,29 +249,12 @@ impl CompressedReceipts { /// Create from RLP-encoded receipts by compressing it with Snappy pub fn from_rlp(rlp_data: &[u8]) -> Result { - let mut compressed = Vec::new(); - { - let mut encoder = FrameEncoder::new(&mut compressed); - - Write::write_all(&mut encoder, rlp_data).map_err(|e| { - E2sError::SnappyCompression(format!("Failed to compress receipts: {e}")) - })?; - - encoder.flush().map_err(|e| { - E2sError::SnappyCompression(format!("Failed to flush encoder: {e}")) - })?; - } - Ok(Self { data: compressed }) + Ok(Self { data: snappy_compress(rlp_data)? }) } + /// Decompress to get the original RLP-encoded receipts pub fn decompress(&self) -> Result, E2sError> { - let mut decoder = FrameDecoder::new(self.data.as_slice()); - let mut decompressed = Vec::new(); - Read::read_to_end(&mut decoder, &mut decompressed).map_err(|e| { - E2sError::SnappyDecompression(format!("Failed to decompress receipts: {e}")) - })?; - - Ok(decompressed) + snappy_decompress(&self.data) } /// Convert to an [`Entry`] @@ -628,7 +525,7 @@ mod tests { use super::*; use crate::test_utils::{create_header, create_test_receipt, create_test_receipts}; use alloy_eips::eip4895::Withdrawals; - use alloy_primitives::{Bytes, U256}; + use alloy_primitives::{b256, Bytes, U256}; use reth_ethereum_primitives::{Receipt, TxType}; #[test] @@ -680,7 +577,7 @@ mod tests { fn test_total_difficulty_ssz_le_encoding() { // Verify that total-difficulty is encoded as SSZ uint256 (little-endian). // See https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md - let value = U256::from(1u64); + let value = U256::ONE; let td = TotalDifficulty::new(value); let entry = td.to_entry(); @@ -771,12 +668,12 @@ mod tests { fn test_accumulator_from_header_records_known_vectors() { // Known-answer vectors computed from the SSZ spec: // hash_tree_root(List[HeaderRecord, 8192]) - let expected_empty: B256 = - "4a8c3a07c8d23adc5bac61157555c3c784d53d9bc110c1370809bd23cd93777d".parse().unwrap(); - let expected_single_zero: B256 = - "81fd641249670887a731386e756a7a1538dc781b1b0bf016889045d350812817".parse().unwrap(); - let expected_single_nonzero: B256 = - "ada35c48d81117f4fd588554cd4c4752356336e84cb41106dea1ceb4cfac8799".parse().unwrap(); + let expected_empty = + b256!("4a8c3a07c8d23adc5bac61157555c3c784d53d9bc110c1370809bd23cd93777d"); + let expected_single_zero = + b256!("81fd641249670887a731386e756a7a1538dc781b1b0bf016889045d350812817"); + let expected_single_nonzero = + b256!("ada35c48d81117f4fd588554cd4c4752356336e84cb41106dea1ceb4cfac8799"); // Empty list let acc_empty = Accumulator::from_header_records(&[]).unwrap(); @@ -789,7 +686,7 @@ mod tests { // Single record with non-zero values let records2 = vec![HeaderRecord { - block_hash: B256::from([1u8; 32]), + block_hash: B256::repeat_byte(1u8), total_difficulty: U256::from(100u64), }]; let acc2 = Accumulator::from_header_records(&records2).unwrap(); @@ -834,4 +731,21 @@ mod tests { } } } + + #[test] + fn era1_decoders_reject_trailing_rlp() { + let compressed = snappy_compress(&[1, 2]).unwrap(); + let codec = SnappyRlpCodec::::new(); + for result in [ + codec.decode(&compressed), + CompressedHeader::new(compressed.clone()).decode::(), + CompressedBody::new(compressed.clone()).decode::(), + CompressedReceipts::new(compressed).decode::(), + ] { + assert_eq!( + result.unwrap_err().to_string(), + "RLP error: Trailing bytes after RLP value: 1 byte(s) remain" + ); + } + } } diff --git a/crates/era/src/ere/file.rs b/crates/era/src/ere/file.rs index 41d4e8da9e4..ca9dc0e183a 100644 --- a/crates/era/src/ere/file.rs +++ b/crates/era/src/ere/file.rs @@ -467,7 +467,7 @@ mod tests { let offsets = (0..(block_count as u64 * component_count) as i64).collect(); let index = DynamicBlockIndex::new(start_block, component_count, offsets); - let accumulator = with_accumulator.then(|| Accumulator::new(B256::from([0xAA; 32]))); + let accumulator = with_accumulator.then(|| Accumulator::new(B256::repeat_byte(0xAA))); let group = EreGroup::new(blocks, accumulator, index); let id = EreId::new(network, start_block, block_count as u32); @@ -748,8 +748,8 @@ mod tests { { let mut writer = E2StoreWriter::new(&mut buffer); writer.write_version().unwrap(); - writer.write_entry(&Accumulator::new(B256::from([0x11; 32])).to_entry()).unwrap(); - writer.write_entry(&Accumulator::new(B256::from([0x22; 32])).to_entry()).unwrap(); + writer.write_entry(&Accumulator::new(B256::repeat_byte(0x11)).to_entry()).unwrap(); + writer.write_entry(&Accumulator::new(B256::repeat_byte(0x22)).to_entry()).unwrap(); writer.write_entry(&DynamicBlockIndex::new(0, 2, Vec::new()).to_entry()).unwrap(); writer.flush().unwrap(); } diff --git a/crates/era/src/ere/types/execution.rs b/crates/era/src/ere/types/execution.rs index 4f8e853d4cc..da06052d28c 100644 --- a/crates/era/src/ere/types/execution.rs +++ b/crates/era/src/ere/types/execution.rs @@ -363,14 +363,15 @@ impl Proof { let proof_type = ProofType::from_byte(proof_type_byte) .ok_or_else(|| E2sError::Rlp(format!("Unknown proof type: {proof_type_byte}")))?; - let ssz_bytes = alloy_primitives::Bytes::decode(&mut payload) - .map_err(|e| E2sError::Rlp(format!("Failed to decode proof SSZ bytes: {e}")))?; + let ssz_bytes = alloy_rlp::Header::decode_bytes(&mut payload, false) + .map_err(|e| E2sError::Rlp(format!("Failed to decode proof SSZ bytes: {e}")))? + .to_vec(); if !payload.is_empty() { return Err(E2sError::Rlp("Unexpected extra items in Proof list".to_string())); } - Ok((proof_type, ssz_bytes.to_vec())) + Ok((proof_type, ssz_bytes)) } /// Convert to an [`Entry`] @@ -628,7 +629,7 @@ mod tests { use super::*; use crate::test_utils::{create_header, create_test_receipt, create_test_receipts}; use alloy_eips::eip4895::Withdrawals; - use alloy_primitives::{Bytes, U256}; + use alloy_primitives::{b256, Bytes, U256}; use reth_ethereum_primitives::{Receipt, TxType}; #[test] @@ -680,7 +681,7 @@ mod tests { fn test_total_difficulty_ssz_le_encoding() { // Verify that total-difficulty is encoded as SSZ uint256 (little-endian). // See https://github.com/eth-clients/e2store-format-specs/blob/main/formats/ere.md - let value = U256::from(1u64); + let value = U256::ONE; let td = TotalDifficulty::new(value); let entry = td.to_entry(); @@ -751,7 +752,7 @@ mod tests { ); let full = base .with_receipts(CompressedSlimReceipts::new(vec![3])) - .with_total_difficulty(TotalDifficulty::new(U256::from(1u64))) + .with_total_difficulty(TotalDifficulty::new(U256::ONE)) .with_proof(Proof::new(vec![4])); assert_eq!(full.component_count(), 5); assert!(full.receipts.is_some() && full.total_difficulty.is_some() && full.proof.is_some()); @@ -841,12 +842,12 @@ mod tests { fn test_accumulator_from_header_records_known_vectors() { // Known-answer vectors computed from the SSZ spec: // hash_tree_root(List[HeaderRecord, 8192]) - let expected_empty: B256 = - "4a8c3a07c8d23adc5bac61157555c3c784d53d9bc110c1370809bd23cd93777d".parse().unwrap(); - let expected_single_zero: B256 = - "81fd641249670887a731386e756a7a1538dc781b1b0bf016889045d350812817".parse().unwrap(); - let expected_single_nonzero: B256 = - "ada35c48d81117f4fd588554cd4c4752356336e84cb41106dea1ceb4cfac8799".parse().unwrap(); + let expected_empty = + b256!("4a8c3a07c8d23adc5bac61157555c3c784d53d9bc110c1370809bd23cd93777d"); + let expected_single_zero = + b256!("81fd641249670887a731386e756a7a1538dc781b1b0bf016889045d350812817"); + let expected_single_nonzero = + b256!("ada35c48d81117f4fd588554cd4c4752356336e84cb41106dea1ceb4cfac8799"); // Empty list let acc_empty = Accumulator::from_header_records(&[]).unwrap(); @@ -859,7 +860,7 @@ mod tests { // Single record with non-zero values let records2 = vec![HeaderRecord { - block_hash: B256::from([1u8; 32]), + block_hash: B256::repeat_byte(1u8), total_difficulty: U256::from(100u64), }]; let acc2 = Accumulator::from_header_records(&records2).unwrap(); diff --git a/crates/era/src/ere/types/group.rs b/crates/era/src/ere/types/group.rs index 62acdee56ee..064d51d1d43 100644 --- a/crates/era/src/ere/types/group.rs +++ b/crates/era/src/ere/types/group.rs @@ -502,7 +502,7 @@ mod tests { fn test_ere_group_basic_construction() { let blocks = vec![sample_block(10), sample_block(15), sample_block(20)]; - let accumulator = Accumulator::new(B256::from([0xDD; 32])); + let accumulator = Accumulator::new(B256::repeat_byte(0xDD)); let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200, 300, 400, 500, 600]); let group = EreGroup::new(blocks, Some(accumulator.clone()), block_index); @@ -529,7 +529,7 @@ mod tests { #[test] fn test_ere_group_add_entries() { let blocks = vec![sample_block(10)]; - let accumulator = Accumulator::new(B256::from([0xDD; 32])); + let accumulator = Accumulator::new(B256::repeat_byte(0xDD)); let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200]); let mut group = EreGroup::new(blocks, Some(accumulator), block_index); diff --git a/crates/era/src/test_utils.rs b/crates/era/src/test_utils.rs index 0dd3c139866..0985e3f2138 100644 --- a/crates/era/src/test_utils.rs +++ b/crates/era/src/test_utils.rs @@ -54,9 +54,9 @@ pub(crate) fn create_test_receipt( let data_byte = (i + 100) as u8; logs.push(Log { - address: Address::from([address_byte; 20]), + address: Address::repeat_byte(address_byte), data: LogData::new_unchecked( - vec![B256::from([topic_byte; 32]), B256::from([topic_byte + 1; 32])], + vec![B256::repeat_byte(topic_byte), B256::repeat_byte(topic_byte + 1)], alloy_primitives::Bytes::from(vec![data_byte, data_byte + 1, data_byte + 2]), ), }); diff --git a/crates/ethereum/cli/Cargo.toml b/crates/ethereum/cli/Cargo.toml index c0bf2c532a7..a7c6c75a4e6 100644 --- a/crates/ethereum/cli/Cargo.toml +++ b/crates/ethereum/cli/Cargo.toml @@ -36,6 +36,7 @@ tracing.workspace = true tempfile.workspace = true [features] +account-ext = ["reth-node-ethereum/account-ext"] otlp = ["reth-tracing/otlp", "reth-node-core/otlp"] otlp-logs = ["reth-tracing/otlp-logs", "reth-node-core/otlp-logs"] diff --git a/crates/ethereum/cli/src/app.rs b/crates/ethereum/cli/src/app.rs index 44fe4dbde98..226208a0a50 100644 --- a/crates/ethereum/cli/src/app.rs +++ b/crates/ethereum/cli/src/app.rs @@ -108,13 +108,18 @@ where None => { let runtime_config = match &self.cli.command { Commands::Node(command) => { - reth_tasks::RuntimeConfig::default().with_rayon(RayonConfig { + let config = reth_tasks::RuntimeConfig::default().with_rayon(RayonConfig { reserved_cpu_cores: command.engine.reserved_cpu_cores, proof_storage_worker_threads: command.engine.storage_worker_count, proof_account_worker_threads: command.engine.account_worker_count, prewarming_threads: command.engine.prewarming_threads, ..Default::default() - }) + }); + if let Some(cpu_cores) = command.engine.cpu_cores { + config.with_cpu_cores(cpu_cores) + } else { + config + } } _ => reth_tasks::RuntimeConfig::default(), }; diff --git a/crates/ethereum/consensus/src/lib.rs b/crates/ethereum/consensus/src/lib.rs index 66e8118a651..108579a77fa 100644 --- a/crates/ethereum/consensus/src/lib.rs +++ b/crates/ethereum/consensus/src/lib.rs @@ -112,7 +112,7 @@ impl EthBeaconConsensus impl FullConsensus for EthBeaconConsensus where - ChainSpec: Send + Sync + EthChainSpec
+ EthereumHardforks + Debug, + ChainSpec: Send + EthChainSpec
+ EthereumHardforks, N: NodePrimitives, { fn validate_block_post_execution( @@ -144,7 +144,7 @@ where impl Consensus for EthBeaconConsensus where B: Block, - ChainSpec: EthChainSpec
+ EthereumHardforks + Debug + Send + Sync, + ChainSpec: EthChainSpec
+ EthereumHardforks, { fn validate_body_against_header( &self, @@ -170,7 +170,7 @@ where impl HeaderValidator for EthBeaconConsensus where H: BlockHeader, - ChainSpec: EthChainSpec
+ EthereumHardforks + Debug + Send + Sync, + ChainSpec: EthChainSpec
+ EthereumHardforks, { fn validate_header(&self, header: &SealedHeader) -> Result<(), ConsensusError> { let header = header.header(); diff --git a/crates/ethereum/consensus/src/validation.rs b/crates/ethereum/consensus/src/validation.rs index a144e50289f..57362d3ecb7 100644 --- a/crates/ethereum/consensus/src/validation.rs +++ b/crates/ethereum/consensus/src/validation.rs @@ -176,8 +176,9 @@ pub fn compare_receipts_root_and_logs_bloom( #[cfg(test)] mod tests { + use super::*; - use alloy_primitives::{b256, hex}; + use alloy_primitives::b256; use reth_ethereum_primitives::Receipt; #[test] @@ -188,7 +189,7 @@ mod tests { // Compare against expected values assert!(verify_receipts( b256!("0x61353b4fb714dc1fccacbf7eafc4273e62f3d1eed716fe41b2a0cd2e12c63ebc"), - Bloom::from(hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")), + Bloom::ZERO, &receipts ) .is_ok()); diff --git a/crates/ethereum/evm/src/lib.rs b/crates/ethereum/evm/src/lib.rs index 1cb5a7b1670..c901a901049 100644 --- a/crates/ethereum/evm/src/lib.rs +++ b/crates/ethereum/evm/src/lib.rs @@ -18,22 +18,28 @@ extern crate alloc; use alloc::{borrow::Cow, sync::Arc}; -use alloy_consensus::Header; +use alloy_consensus::{Header, Transaction}; +use alloy_eips::merge::SLOT_DURATION_SECS; use alloy_evm::{ - eth::{EthBlockExecutionCtx, EthBlockExecutorFactory}, + eth::{EthBlockExecutionCtx, EthBlockExecutorFactory, EthTxResult}, EthEvmFactory, FromRecoveredTx, FromTxWithEncoded, }; +use alloy_primitives::{Address, B256}; #[cfg(feature = "jit")] use core::any::Any; -use core::{convert::Infallible, fmt::Debug}; +use core::{any::TypeId, convert::Infallible, fmt::Debug}; use reth_chainspec::{ChainSpec, EthChainSpec, MAINNET}; use reth_ethereum_primitives::{Block, EthPrimitives, TransactionSigned}; use reth_evm::{ eth::NextEvmEnvAttributes, precompiles::PrecompilesMap, ConfigureEvm, EvmEnv, EvmFactory, - JitBackend, NextBlockEnvAttributes, SenderRecoveryCache, TransactionEnvMut, + HaltReasonFor, JitBackend, NextBlockEnvAttributes, SenderRecoveryCache, TransactionEnvMut, + TxExecutionResultFor, }; use reth_primitives_traits::{SealedBlock, SealedHeader}; -use revm::{context::BlockEnv, primitives::hardfork::SpecId}; +use revm::{ + context::{result::ResultAndState, BlockEnv}, + primitives::hardfork::SpecId, +}; #[cfg(feature = "std")] use reth_evm::{ConfigureEngineEvm, ExecutableTxIterator}; @@ -266,6 +272,46 @@ where slot_number: attributes.slot_number, }) } + + fn reuses_prewarm_results(&self) -> bool { + // Custom factories may change transaction validation or read state outside the database. + let factory = TypeId::of::(); + factory == TypeId::of::() || + factory == TypeId::of::() + } + + fn prewarm_tx_result( + &self, + tx: &TransactionSigned, + result: ResultAndState>, + ) -> Option> { + Some(EthTxResult { + result, + blob_gas_used: tx.blob_gas_used().unwrap_or_default(), + tx_type: tx.tx_type(), + }) + } + + fn speculative_next_evm_env( + &self, + parent: &SealedHeader, + beneficiary: Address, + ) -> Option { + let Ok(evm_env) = self.next_evm_env( + parent, + &NextBlockEnvAttributes { + timestamp: parent.timestamp + SLOT_DURATION_SECS, + suggested_fee_recipient: beneficiary, + prev_randao: B256::ZERO, + gas_limit: parent.gas_limit, + parent_beacon_block_root: None, + withdrawals: None, + extra_data: Bytes::new(), + slot_number: None, + }, + ); + Some(evm_env) + } } #[cfg(feature = "std")] @@ -375,14 +421,22 @@ where mod tests { use super::*; use alloy_consensus::Header; + use alloy_evm::{ + eth::{EthEvm, EthEvmContext}, + Database, + }; use alloy_genesis::Genesis; use reth_chainspec::{Chain, ChainSpec}; use reth_evm::{execute::ProviderError, EvmEnv}; use revm::{ - context::{BlockEnv, CfgEnv}, + context::{ + result::{EVMError, HaltReason}, + BlockEnv, CfgEnv, TxEnv, + }, database::CacheDB, - database_interface::EmptyDBTyped, + database_interface::{DBErrorMarker, EmptyDBTyped}, inspector::NoOpInspector, + Inspector, }; #[test] @@ -553,6 +607,54 @@ mod tests { assert_eq!(evm.tx, Default::default()); } + #[test] + fn test_reuses_prewarm_results_only_for_known_factories() { + assert!(EthEvmConfig::mainnet().reuses_prewarm_results()); + assert!(EthEvmConfig::new_with_evm_factory( + MAINNET.clone(), + factory::RethEvmFactory::default() + ) + .reuses_prewarm_results()); + + #[derive(Debug, Clone)] + struct CustomFactory(EthEvmFactory); + + impl EvmFactory for CustomFactory { + type Evm>> = + EthEvm; + type Context = EthEvmContext; + type Tx = TxEnv; + type Error = EVMError; + type HaltReason = HaltReason; + type Spec = SpecId; + type BlockEnv = BlockEnv; + type Precompiles = PrecompilesMap; + + fn create_evm( + &self, + db: DB, + input: EvmEnv, + ) -> Self::Evm { + self.0.create_evm(db, input) + } + + fn create_evm_with_inspector>>( + &self, + db: DB, + input: EvmEnv, + inspector: I, + ) -> Self::Evm { + self.0.create_evm_with_inspector(db, input, inspector) + } + } + + assert!(!EthEvmConfig::new_with_evm_factory( + MAINNET.clone(), + CustomFactory(EthEvmFactory::default()) + ) + .reuses_prewarm_results()); + } + #[cfg(feature = "jit")] #[test] fn test_jit_support_downcast_updates_reth_factory() { diff --git a/crates/ethereum/evm/tests/execute.rs b/crates/ethereum/evm/tests/execute.rs index 03b466943a1..3a2a664a639 100644 --- a/crates/ethereum/evm/tests/execute.rs +++ b/crates/ethereum/evm/tests/execute.rs @@ -23,37 +23,29 @@ use reth_primitives_traits::{ }; use reth_testing_utils::generators::{self, sign_tx_with_key_pair}; use revm::{ - database::{CacheDB, EmptyDB, TransitionState}, + database::{InMemoryDB, TransitionState}, primitives::address, state::{AccountInfo, Bytecode, EvmState}, Database, }; use std::sync::{mpsc, Arc}; -fn create_database_with_beacon_root_contract() -> CacheDB { - let mut db = CacheDB::new(Default::default()); +fn create_database_with_beacon_root_contract() -> InMemoryDB { + let mut db = InMemoryDB::default(); - let beacon_root_contract_account = AccountInfo { - code_hash: keccak256(BEACON_ROOTS_CODE.clone()), - nonce: 1, - code: Some(Bytecode::new_raw(BEACON_ROOTS_CODE.clone())), - ..Default::default() - }; + let beacon_root_contract_account = + AccountInfo::from_bytecode(Bytecode::new_raw(BEACON_ROOTS_CODE.clone())); db.insert_account_info(BEACON_ROOTS_ADDRESS, beacon_root_contract_account); db } -fn create_database_with_withdrawal_requests_contract() -> CacheDB { - let mut db = CacheDB::new(Default::default()); +fn create_database_with_withdrawal_requests_contract() -> InMemoryDB { + let mut db = InMemoryDB::default(); - let withdrawal_requests_contract_account = AccountInfo { - nonce: 1, - code_hash: keccak256(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone()), - code: Some(Bytecode::new_raw(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone())), - ..Default::default() - }; + let withdrawal_requests_contract_account = + AccountInfo::from_bytecode(Bytecode::new_raw(WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone())); db.insert_account_info( WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, @@ -147,7 +139,7 @@ fn eip_4788_no_code_cancun() { ..Header::default() }; - let db = CacheDB::new(EmptyDB::default()); + let db = InMemoryDB::default(); // DON'T deploy the contract at genesis let chain_spec = Arc::new( @@ -328,18 +320,14 @@ fn eip_4788_high_base_fee() { } /// Create a state provider with blockhashes and the EIP-2935 system contract. -fn create_database_with_block_hashes(latest_block: u64) -> CacheDB { - let mut db = CacheDB::new(Default::default()); +fn create_database_with_block_hashes(latest_block: u64) -> InMemoryDB { + let mut db = InMemoryDB::default(); for block_number in 0..=latest_block { db.cache.block_hashes.insert(U256::from(block_number), keccak256(block_number.to_string())); } - let blockhashes_contract_account = AccountInfo { - code_hash: keccak256(HISTORY_STORAGE_CODE.clone()), - code: Some(Bytecode::new_raw(HISTORY_STORAGE_CODE.clone())), - nonce: 1, - ..Default::default() - }; + let blockhashes_contract_account = + AccountInfo::from_bytecode(Bytecode::new_raw(HISTORY_STORAGE_CODE.clone())); db.insert_account_info(HISTORY_STORAGE_ADDRESS, blockhashes_contract_account); @@ -571,7 +559,7 @@ fn eip_2935_state_transition_inside_fork() { U256::ZERO ); assert!(executor.with_state_mut(|state| { - state.storage(HISTORY_STORAGE_ADDRESS, U256::from(1)).unwrap().is_zero() + state.storage(HISTORY_STORAGE_ADDRESS, U256::ONE).unwrap().is_zero() })); // attempt to execute block 2, this should not fail @@ -602,8 +590,7 @@ fn eip_2935_state_transition_inside_fork() { U256::ZERO ); assert_ne!( - executor - .with_state_mut(|state| state.storage(HISTORY_STORAGE_ADDRESS, U256::from(1)).unwrap()), + executor.with_state_mut(|state| state.storage(HISTORY_STORAGE_ADDRESS, U256::ONE).unwrap()), U256::ZERO ); assert!(executor.with_state_mut(|state| { @@ -730,7 +717,7 @@ fn block_gas_limit_error() { gas_price: header.base_fee_per_gas.unwrap().into(), gas_limit: 2_500_000, // higher than block gas limit to: TxKind::Call(WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS), - value: U256::from(1), + value: U256::ONE, input, }), ); @@ -771,7 +758,7 @@ fn test_balance_increment_not_duplicated() { let withdrawal_recipient = address!("0x1000000000000000000000000000000000000000"); - let mut db = CacheDB::new(EmptyDB::default()); + let mut db = InMemoryDB::default(); let initial_balance = 100; db.insert_account_info( withdrawal_recipient, diff --git a/crates/ethereum/node/Cargo.toml b/crates/ethereum/node/Cargo.toml index 37de01f4c68..22eda4d570a 100644 --- a/crates/ethereum/node/Cargo.toml +++ b/crates/ethereum/node/Cargo.toml @@ -41,6 +41,11 @@ reth-engine-primitives = { workspace = true, features = ["std"] } reth-payload-primitives.workspace = true reth-tasks.workspace = true reth-trie-common.workspace = true +reth-errors.workspace = true +reth-snap-sync.workspace = true +reth-stages.workspace = true +reth-engine-tree.workspace = true +reth-network-p2p.workspace = true # ethereum alloy-consensus.workspace = true alloy-eips.workspace = true @@ -53,6 +58,7 @@ alloy-rpc-types-engine = { workspace = true, features = ["ssz"] } # async tokio = { workspace = true, features = ["sync"] } +tokio-util.workspace = true # revm with required ethereum features # Note: this must be kept to ensure all features are properly enabled/forwarded @@ -75,6 +81,9 @@ reth-e2e-test-utils.workspace = true reth-tasks.workspace = true reth-testing-utils.workspace = true reth-stages-types.workspace = true +reth-downloaders.workspace = true +reth-eth-wire-types.workspace = true +reth-network-peers.workspace = true tempfile.workspace = true jsonrpsee-core.workspace = true @@ -95,9 +104,24 @@ alloy-rpc-types-trace.workspace = true similar-asserts.workspace = true reqwest.workspace = true reth-rpc-layer.workspace = true +reth-stages-api = { workspace = true, features = ["test-utils"] } +reth-prune.workspace = true +reth-static-file.workspace = true +reth-consensus.workspace = true [features] default = [] +account-ext = [ + "reth-provider/account-ext", + "revm/account-ext", + "alloy-genesis/account-ext", + "alloy-rpc-types-eth/account-ext", + "reth-primitives-traits/account-ext", + "reth-revm/account-ext", + "reth-stages-types/account-ext", + "reth-rpc/account-ext", + "reth-rpc-eth-api/account-ext", +] jit = ["reth-evm-ethereum/jit"] gmp = ["reth-evm-ethereum/gmp"] asm-keccak = [ @@ -130,4 +154,10 @@ test-utils = [ "reth-stages-types/test-utils", "reth-tasks/test-utils", "reth-trie-common/test-utils", + "reth-stages/test-utils", + "reth-downloaders/test-utils", + "reth-network-p2p/test-utils", + "reth-consensus/test-utils", + "reth-engine-tree/test-utils", + "reth-stages-api/test-utils", ] diff --git a/crates/ethereum/node/src/engine_ssz_proxy.rs b/crates/ethereum/node/src/engine_ssz_proxy.rs index 06bda66b1bb..c2c07cb2927 100644 --- a/crates/ethereum/node/src/engine_ssz_proxy.rs +++ b/crates/ethereum/node/src/engine_ssz_proxy.rs @@ -8,7 +8,7 @@ use crate::engine_ssz_witness::{ EngineSszWitness, EngineSszWitnessError, PayloadStatusWithWitness, }; use alloy_consensus::{Transaction, TxEnvelope}; -use alloy_eips::{eip2718::Decodable2718, eip7685::Requests}; +use alloy_eips::eip7685::Requests; use alloy_primitives::{Bytes, B128, B256}; use alloy_rpc_types_engine::{ ssz_engine_types::{ @@ -34,7 +34,7 @@ use jsonrpsee::server::{HttpBody, HttpRequest, HttpResponse}; use reth_chainspec::{EthereumHardfork, EthereumHardforks}; use reth_engine_primitives::EngineApiValidator; use reth_ethereum_engine_primitives::EthEngineTypes; -use reth_provider::{BalProvider, BlockReader, HeaderProvider, StateProviderFactory}; +use reth_provider::{BalProvider, BlockReader, StateProviderFactory}; use reth_rpc::EngineApi; use reth_rpc_engine_api::EngineApiError; use reth_tracing::tracing::debug; @@ -362,7 +362,7 @@ impl EngineSszApi for reth_node_builder::rpc::NoopEngineApi {} impl EngineSszApi for EthEngineApi where - Provider: HeaderProvider + BlockReader + StateProviderFactory + BalProvider + 'static, + Provider: BlockReader + StateProviderFactory + BalProvider + 'static, Pool: TransactionPool + 'static, Validator: EngineApiValidator, ChainSpec: EthereumHardforks + Send + Sync + 'static, @@ -807,7 +807,7 @@ async fn submit_payload( payload: ExecutionData, ) -> Result where - Provider: HeaderProvider + BlockReader + StateProviderFactory + BalProvider + 'static, + Provider: BlockReader + StateProviderFactory + BalProvider + 'static, Pool: TransactionPool + 'static, Validator: EngineApiValidator, ChainSpec: EthereumHardforks + Send + Sync + 'static, @@ -856,7 +856,7 @@ async fn handle_get_payload_bodies( request: PayloadBodiesRequest, ) -> HttpResponse where - Provider: HeaderProvider + BlockReader + StateProviderFactory + BalProvider + 'static, + Provider: BlockReader + StateProviderFactory + BalProvider + 'static, Pool: TransactionPool + 'static, Validator: EngineApiValidator, ChainSpec: EthereumHardforks + Send + Sync + 'static, @@ -1121,7 +1121,7 @@ fn decode_new_payload_request( } } - let versioned_hashes = calculate_versioned_hashes(&inner.transactions)?; + let versioned_hashes = calculate_versioned_hashes(&payload)?; let sidecar = match parent_root { Some(parent_beacon_block_root) => { let cancun = CancunPayloadFields { parent_beacon_block_root, versioned_hashes }; @@ -1137,11 +1137,10 @@ fn decode_new_payload_request( Ok(ExecutionData::new(payload, sidecar)) } -fn calculate_versioned_hashes(transactions: &[Bytes]) -> Result, PayloadDecodeError> { +fn calculate_versioned_hashes(payload: &ExecutionPayload) -> Result, PayloadDecodeError> { let mut versioned_hashes = Vec::new(); - for transaction in transactions { - let transaction = TxEnvelope::decode_2718_exact(transaction.as_ref()) - .map_err(PayloadDecodeError::InvalidTransaction)?; + for transaction in payload.decoded_transactions::() { + let transaction = transaction.map_err(PayloadDecodeError::InvalidTransaction)?; if let Some(hashes) = transaction.blob_versioned_hashes() { versioned_hashes.extend_from_slice(hashes); } @@ -1439,26 +1438,11 @@ mod tests { ); } - #[tokio::test] - async fn post_body_limits_apply_without_content_length() { - for size in [16, 17] { - let request = HttpRequest::builder() - .header(CONTENT_TYPE, OCTET_STREAM) - .body(HttpBody::from(vec![0; size])) - .unwrap(); - let response = read_ssz_body(request, 16).await; - if size == 16 { - assert_eq!(response.unwrap().len(), size); - } else { - assert_eq!(response.unwrap_err().status(), STATUS_PAYLOAD_TOO_LARGE); - } - } - } - #[tokio::test] async fn engine_errors_preserve_validation_semantics() { use alloy_rpc_types_engine::ForkchoiceUpdateError; use reth_engine_primitives::BeaconForkChoiceUpdateError; + use reth_payload_primitives::PayloadBuilderError; for (error, status, kind) in [ (EngineApiError::UnknownPayload, 404, "unknown-payload"), (EngineApiError::BlobRequestTooLarge { len: 129 }, 413, "request-too-large"), @@ -1480,6 +1464,13 @@ mod tests { 422, "invalid-attributes", ), + ( + EngineApiError::ForkChoiceUpdate(BeaconForkChoiceUpdateError::PayloadJob( + PayloadBuilderError::MissingParentHeader(B256::ZERO), + )), + 500, + "internal", + ), ] { let response = engine_error_response(error); assert_eq!(response.status(), status); @@ -1672,11 +1663,7 @@ mod tests { #[test] fn decodes_forkchoice_v4_with_custody_columns() { - let forkchoice_state = ForkchoiceState { - head_block_hash: B256::ZERO, - safe_block_hash: B256::ZERO, - finalized_block_hash: B256::ZERO, - }; + let forkchoice_state = ForkchoiceState::same_hash(B256::ZERO); let encoded = ForkchoiceUpdateAmsterdam { forkchoice_state, payload_attributes: Optional::none(), @@ -1693,11 +1680,7 @@ mod tests { #[test] fn decodes_forkchoice_cancun_payload_attributes() { - let forkchoice_state = ForkchoiceState { - head_block_hash: B256::ZERO, - safe_block_hash: B256::ZERO, - finalized_block_hash: B256::ZERO, - }; + let forkchoice_state = ForkchoiceState::same_hash(B256::ZERO); let attrs = PayloadAttributesCancun { timestamp: 1, prev_randao: B256::with_last_byte(2), diff --git a/crates/ethereum/node/src/engine_ssz_witness.rs b/crates/ethereum/node/src/engine_ssz_witness.rs index c8b331ac40d..214136f664a 100644 --- a/crates/ethereum/node/src/engine_ssz_witness.rs +++ b/crates/ethereum/node/src/engine_ssz_witness.rs @@ -36,7 +36,7 @@ impl EngineSszWitnessGenerator { impl EngineSszWitness for EngineSszWitnessGenerator where - Provider: HeaderProvider + StateProviderFactory + Clone + Send + Sync + 'static, + Provider: HeaderProvider + StateProviderFactory + Clone + 'static, Provider::Header: alloy_rlp::Encodable, Evm: ConfigureEvm + 'static, { diff --git a/crates/ethereum/node/src/lib.rs b/crates/ethereum/node/src/lib.rs index e2e93efc81e..a121c564e99 100644 --- a/crates/ethereum/node/src/lib.rs +++ b/crates/ethereum/node/src/lib.rs @@ -33,3 +33,5 @@ pub use engine::EthereumEngineValidator; pub mod engine_ssz_proxy; pub mod engine_ssz_witness; + +pub mod snap; diff --git a/crates/ethereum/node/src/node.rs b/crates/ethereum/node/src/node.rs index 9c268e05d3a..29c6bbc4df3 100644 --- a/crates/ethereum/node/src/node.rs +++ b/crates/ethereum/node/src/node.rs @@ -693,7 +693,7 @@ where Primitives: NodePrimitives, >, Node: FullNodeTypes, - Evm: ConfigureEvm> + Clone + 'static, + Evm: ConfigureEvm> + 'static, { type Pool = EthTransactionPool; @@ -739,7 +739,9 @@ where .with_additional_tasks(ctx.config().txpool.additional_validation_tasks) .build_with_tasks(ctx.task_executor().clone(), blob_store.clone()); - if validator.validator().eip4844() || self.init_kzg_settings { + if (validator.validator().eip4844() || self.init_kzg_settings) && + !ctx.config().debug.lazy_kzg_settings + { // initializing the KZG settings can be expensive, this should be done upfront so that // it doesn't impact the first block or the first gossiped blob transaction, so we // initialize this in the background @@ -816,7 +818,7 @@ pub struct EthereumEngineValidatorBuilder; impl PayloadValidatorBuilder for EthereumEngineValidatorBuilder where Types: NodeTypes< - ChainSpec: Hardforks + EthereumHardforks + Clone + 'static, + ChainSpec: Hardforks + EthereumHardforks + 'static, Payload: EngineTypes + PayloadTypes, Primitives = EthPrimitives, @@ -829,14 +831,3 @@ where Ok(EthereumEngineValidator::new(ctx.config.chain.clone())) } } - -#[cfg(test)] -mod tests { - use super::EthereumPoolBuilder; - - #[test] - fn configures_kzg_settings_initialization() { - assert!(!EthereumPoolBuilder::new().init_kzg_settings); - assert!(EthereumPoolBuilder::new().with_init_kzg_settings(true).init_kzg_settings); - } -} diff --git a/crates/ethereum/node/src/snap/context.rs b/crates/ethereum/node/src/snap/context.rs new file mode 100644 index 00000000000..2313af0535d --- /dev/null +++ b/crates/ethereum/node/src/snap/context.rs @@ -0,0 +1,210 @@ +//! Reports the node's headers, peers and forkchoice movement to a snap bootstrap run. + +use alloy_primitives::B256; +use reth_network_p2p::download::DownloadClient; +use reth_provider::{ + providers::ProviderNodeTypes, BlockNumReader, DatabaseProviderFactory, ProviderFactory, +}; +use reth_snap_sync::{SnapSyncContext, SnapSyncError}; +use reth_tracing::tracing::debug; +use std::{pin::pin, time::Duration}; +use tokio::{sync::watch, time::Instant}; + +// Sampling well below the block time resumes a stalled step soon after new peers connect. +const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_secs(2); + +// A peer that starts serving the pivot state, or one replacing another, leaves the peer count +// where it was, so a step stalled on such a peer only resumes by retrying. +const DEFAULT_RETRY_INTERVAL: Duration = Duration::from_secs(30); + +// `SnapSyncContext` for one bootstrap run inside the snap backfill. Headers only advance between +// runs, so a new forkchoice target ends a wait and lets the backfill refresh them. +#[derive(Debug)] +pub(crate) struct NodeSnapContext { + factory: ProviderFactory, + // Peer counts come from the client serving the run's requests. + client: C, + // Forkchoice targets forwarded by the engine. + targets: watch::Receiver, + // Finalized blocks forwarded by the engine, the zero hash until one arrives. + finalized: watch::Receiver, + // How often to check for new peers. + interval: Duration, + // How long to wait before retrying with the same peers. + retry: Duration, +} + +impl NodeSnapContext { + pub(crate) const fn new( + factory: ProviderFactory, + client: C, + targets: watch::Receiver, + finalized: watch::Receiver, + ) -> Self { + Self { + factory, + client, + targets, + finalized, + interval: DEFAULT_SAMPLE_INTERVAL, + retry: DEFAULT_RETRY_INTERVAL, + } + } + + #[cfg(test)] + const fn with_intervals(mut self, interval: Duration, retry: Duration) -> Self { + self.interval = interval; + self.retry = retry; + self + } +} + +impl SnapSyncContext for NodeSnapContext +where + N: ProviderNodeTypes, + C: DownloadClient, +{ + fn head(&self) -> Result { + Ok(self.factory.database_provider_ro()?.last_block_number()?) + } + + fn finalized(&self) -> Result, SnapSyncError> { + let hash = *self.finalized.borrow(); + if hash.is_zero() { + return Ok(None) + } + // Only a finalized block whose header is synced can anchor the pivot. + Ok(self.factory.provider()?.block_number(hash)?) + } + + async fn wait_for_progress(&mut self, _head: u64) -> bool { + // Peers connected before the wait already failed to serve the stalled step. + let peers = self.client.num_connected_peers(); + let mut retry = pin!(tokio::time::sleep(self.retry)); + let mut sample = tokio::time::interval_at(Instant::now() + self.interval, self.interval); + loop { + tokio::select! { + // New headers need the pipeline, which only runs once this run stops. A closed + // channel means the backfill is gone. + _ = self.targets.changed() => return false, + // Peers the count cannot distinguish are retried instead of waited out. + () = &mut retry => { + debug!(target: "sync::snap", peers, "Retrying the stalled snap step"); + return true + } + _ = sample.tick() => {} + } + if self.client.num_connected_peers() > peers { + return true + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reth_db::{tables, transaction::DbTxMut}; + use reth_network_peers::PeerId; + use reth_provider::{ + test_utils::{create_test_provider_factory, insert_headers, MockNodeTypesWithDB}, + DBProvider, + }; + use reth_testing_utils::generators::{self, random_header_range}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const TEST_INTERVAL: Duration = Duration::from_millis(5); + const TEST_RETRY: Duration = Duration::from_millis(500); + + fn context( + peers: &TestPeers, + ) -> (watch::Sender, NodeSnapContext) { + let (targets, receiver) = watch::channel(B256::repeat_byte(1)); + let finalized = watch::channel(B256::ZERO).1; + let context = + NodeSnapContext::new(create_test_provider_factory(), peers, receiver, finalized) + .with_intervals(TEST_INTERVAL, TEST_RETRY); + (targets, context) + } + + // A peer count the test moves between samples. + #[derive(Debug, Default)] + struct TestPeers(AtomicUsize); + + impl DownloadClient for TestPeers { + fn report_bad_message(&self, _peer_id: PeerId) {} + + fn num_connected_peers(&self) -> usize { + self.0.load(Ordering::Relaxed) + } + } + + #[test] + fn the_finalized_block_resolves_once_its_header_is_synced() { + let factory = create_test_provider_factory(); + let headers = random_header_range(&mut generators::rng(), 0..4, B256::ZERO); + insert_headers(&factory, &headers); + // The header stage indexes hashes alongside the headers. + let provider = factory.database_provider_rw().unwrap(); + for header in &headers { + provider.tx_ref().put::(header.hash(), header.number).unwrap(); + } + provider.commit().unwrap(); + let peers = TestPeers::default(); + let (finalized, receiver) = watch::channel(B256::ZERO); + let context = NodeSnapContext::new(factory, &peers, watch::channel(B256::ZERO).1, receiver); + + // Nothing is known as finalized until the engine reports a block. + assert_eq!(context.finalized().unwrap(), None); + + finalized.send(headers[2].hash()).unwrap(); + assert_eq!(context.finalized().unwrap(), Some(2)); + + // A finalized block whose header isn't synced yet can't anchor a pivot. + finalized.send(B256::repeat_byte(0xff)).unwrap(); + assert_eq!(context.finalized().unwrap(), None); + } + + #[tokio::test] + async fn a_new_target_ends_the_wait_for_headers() { + let peers = TestPeers::default(); + let (targets, mut context) = context(&peers); + + targets.send(B256::repeat_byte(2)).unwrap(); + + assert!(!context.wait_for_progress(0).await); + } + + #[tokio::test] + async fn a_dropped_backfill_ends_the_wait() { + let peers = TestPeers::default(); + let (targets, mut context) = context(&peers); + + drop(targets); + + assert!(!context.wait_for_progress(0).await); + } + + #[tokio::test] + async fn new_peers_end_the_wait() { + let peers = TestPeers::default(); + let (_targets, mut context) = context(&peers); + + // Peers count as new only once the wait has sampled its baseline. + let (progressed, ()) = tokio::join!(context.wait_for_progress(0), async { + tokio::time::sleep(TEST_INTERVAL).await; + peers.0.fetch_add(1, Ordering::Relaxed); + }); + + assert!(progressed); + } + + #[tokio::test] + async fn an_unchanged_peer_count_retries_once_the_wait_is_out() { + // A peer that starts serving the state mid-wait keeps the count where it was. + let peers = TestPeers(AtomicUsize::new(1)); + let (_targets, mut context) = context(&peers); + + assert!(context.wait_for_progress(0).await); + } +} diff --git a/crates/ethereum/node/src/snap/handoff.rs b/crates/ethereum/node/src/snap/handoff.rs new file mode 100644 index 00000000000..542d967d2a4 --- /dev/null +++ b/crates/ethereum/node/src/snap/handoff.rs @@ -0,0 +1,579 @@ +//! Hands snap-downloaded state over to the staged pipeline. +//! +//! The merkle stage rebuilds the trie from the downloaded state, and only a matching root on a +//! still canonical pivot is published and accepted. + +use alloy_eips::BlockNumHash; +use reth_errors::{ConsensusError, RethError}; +use reth_provider::{ + providers::ProviderNodeTypes, DBProvider, DatabaseProviderFactory, HeaderProvider, + ProviderFactory, ProviderResult, StageCheckpointReader, StageCheckpointWriter, +}; +use reth_snap_sync::{SnapAttemptStore, SnapStateVerifier, SnapWrite}; +use reth_stages::{ + stages::MerkleStage, BlockErrorKind, ExecInput, PipelineError, Stage, StageError, StageId, +}; +use reth_tracing::tracing::{error, info}; +use tokio_util::sync::CancellationToken; + +/// Hands one attempt's downloaded state over to the staged pipeline. +/// +/// Every step commits on its own and repeats safely, so an interrupted handoff runs again. +#[derive(Debug)] +pub struct SnapHandoff { + // Database the state was downloaded into. + factory: ProviderFactory, +} + +impl SnapHandoff { + /// Creates a handoff over the node's database. + pub const fn new(factory: ProviderFactory) -> Self { + Self { factory } + } + + /// Rebuilds the trie at the pivot of `write`'s attempt and checks its root, abandoning the + /// attempt on a mismatch so the next run starts a new one. Progress commits as it goes, and + /// `stop` ends it early. + /// + /// The attempt's state must have been handed to the merkle stage with + /// [`SnapStateVerifier::start_trie_rebuild`]. + pub fn rebuild( + &self, + write: SnapWrite, + stop: &CancellationToken, + ) -> Result { + let pivot = self.pivot(write)?; + match self.rebuild_trie(pivot, stop) { + Err(PipelineError::Stage(StageError::Block { + error: BlockErrorKind::Validation(ConsensusError::BodyStateRootDiff(diff)), + .. + })) => { + // Downloaded state is authenticated against the pivot, so this is a local fault. + error!(target: "sync::snap", ?pivot, %diff, "Snap state root mismatch, abandoning the attempt"); + self.abandon()?; + Ok(RebuildOutcome::RootMismatch) + } + rebuilt => rebuilt, + } + } + + /// Publishes the state downloaded under `write` at its attempt's pivot and accepts it, so the + /// pipeline continues above the pivot. The pivot must still be canonical, and its trie is + /// rebuilt first if [`Self::rebuild`] hasn't finished, until `stop` fires. + /// + /// The attempt's state must have been handed to the merkle stage with + /// [`SnapStateVerifier::start_trie_rebuild`]. + pub fn hand_off( + &self, + write: SnapWrite, + stop: &CancellationToken, + ) -> Result { + let pivot = self.pivot(write)?; + if !self.is_canonical(pivot)? { + self.abandon()?; + return Ok(HandoffOutcome::PivotReorged) + } + + // A trie already rebuilt to the pivot returns at once. + match self.rebuild(write, stop)? { + RebuildOutcome::Rebuilt => {} + RebuildOutcome::RootMismatch => return Ok(HandoffOutcome::RootMismatch), + RebuildOutcome::Stopped => return Ok(HandoffOutcome::Stopped), + } + + self.publish(write)?; + info!(target: "sync::snap", pivot = pivot.number, "Snap state published; history below it is unavailable"); + Ok(HandoffOutcome::Completed) + } + + /// Publishes an attempt whose trie is rebuilt at a still canonical pivot, finishing a publish + /// that stopped before its database commit. An attempt rebuilt but not yet handed off is + /// published as well. Runs before the consistency check, which would otherwise unwind the + /// static files below their anchor. + pub fn resume_interrupted_publish(&self) -> Result<(), PipelineError> { + let Some((write, pivot)) = self.interrupted_publish()? else { return Ok(()) }; + info!(target: "sync::snap", pivot, "Resuming an interrupted snap state publish"); + self.publish(write) + } + + // An unfinished attempt whose trie is rebuilt at a still canonical pivot. Read from the + // database alone, since a publish deletes the static files before that commit. + fn interrupted_publish(&self) -> Result, PipelineError> { + let provider = self.factory.provider()?; + let Some(write) = provider.active_snap_write().map_err(RethError::other)? else { + return Ok(None) + }; + let pivot = provider.authorize_snap_write(write).map_err(RethError::other)?.pivot(); + let rebuilt = provider.is_trie_rebuild_started(write).map_err(RethError::other)? && + provider + .get_stage_checkpoint(StageId::MerkleExecute)? + .is_some_and(|checkpoint| checkpoint.block_number == pivot.number); + Ok((rebuilt && self.is_canonical(pivot)?).then_some((write, pivot.number))) + } + + // Pivot of the attempt `write` belongs to, refusing a write the attempt no longer accepts. + fn pivot(&self, write: SnapWrite) -> Result { + let provider = self.factory.provider()?; + Ok(provider.authorize_snap_write(write).map_err(RethError::other)?.pivot()) + } + + // Forkchoice can reorg the pivot out while its state downloads, and publishing anchors the + // node to it, so a pivot that is no longer canonical is dropped instead of published. + fn is_canonical(&self, pivot: BlockNumHash) -> ProviderResult { + Ok(self + .factory + .provider()? + .sealed_header(pivot.number)? + .is_some_and(|header| header.hash() == pivot.hash)) + } + + // Drops the attempt, so the next run starts a new one. + fn abandon(&self) -> Result<(), PipelineError> { + let provider = self.factory.database_provider_rw()?; + provider.abandon_snap_attempt().map_err(RethError::other)?; + provider.commit()?; + Ok(()) + } + + // Accepts the state, anchors the static files at the verified pivot and moves the checkpoints + // there in one database commit, so a stop before it leaves the attempt to resume. + fn publish(&self, write: SnapWrite) -> Result<(), PipelineError> { + let provider = self.factory.database_provider_rw()?; + // Deleted static files don't roll back with the database, so a rejected state must fail + // before them. + let pivot = provider.verify_state_root(write).map_err(RethError::other)?.target().number; + // History below the pivot was never downloaded, so it counts as pruned and the static + // files start there. + provider.anchor_pruned_static_files(pivot)?; + provider.publish_snap_state(pivot).map_err(RethError::other)?; + provider.commit()?; + Ok(()) + } + + // Rebuilds the trie from the downloaded state up to `pivot`, committing the stage's progress + // in chunks so a restart resumes it. The stage checks the root against the pivot's header. + fn rebuild_trie( + &self, + pivot: BlockNumHash, + stop: &CancellationToken, + ) -> Result { + let mut stage = MerkleStage::default_execution(); + loop { + if stop.is_cancelled() { + return Ok(RebuildOutcome::Stopped) + } + let provider = self.factory.database_provider_rw()?; + let checkpoint = provider.get_stage_checkpoint(StageId::MerkleExecute)?; + let output = + stage.execute(&provider, ExecInput { target: Some(pivot.number), checkpoint })?; + provider.save_stage_checkpoint(StageId::MerkleExecute, output.checkpoint)?; + provider.commit()?; + if output.done { + return Ok(RebuildOutcome::Rebuilt) + } + } + } +} + +/// What a handoff did with the downloaded state. +#[must_use] +#[derive(Debug, Eq, PartialEq)] +pub enum HandoffOutcome { + /// The state is published, its trie rebuilt and its root verified. + Completed, + /// The pivot left the canonical chain, so the attempt was abandoned for a new one. + PivotReorged, + /// The rebuilt root differs from the pivot's header, so the attempt was abandoned for a new + /// one. + RootMismatch, + /// The trie rebuild was stopped before reaching the pivot, so nothing was published. + Stopped, +} + +/// How a trie rebuild ended. +#[must_use] +#[derive(Debug, Eq, PartialEq)] +pub enum RebuildOutcome { + /// The trie is rebuilt at the pivot and its root matches the pivot's header. + Rebuilt, + /// The rebuilt root differs from the pivot's header, so the attempt was abandoned for a new + /// one. + RootMismatch, + /// The rebuild was stopped before reaching the pivot. Its progress so far is committed. + Stopped, +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::snap::tests::hashed_factory; + use alloy_consensus::Header; + use alloy_primitives::B256; + use futures::future::{ready, Ready}; + use reth_db::{tables, transaction::DbTxMut}; + use reth_downloaders::snap::{AccountRangeDownloader, AccountRangeOutcome}; + use reth_eth_wire_types::snap::{ + AccountData, AccountRangeMessage, GetAccountRangeMessage, GetBlockAccessListsMessage, + GetByteCodesMessage, GetStorageRangesMessage, + }; + use reth_network_p2p::{ + download::DownloadClient, + error::PeerRequestResult, + priority::Priority, + snap::client::{SnapClient, SnapResponse}, + }; + use reth_network_peers::{PeerId, WithPeerId}; + use reth_primitives_traits::Account; + use reth_provider::{ + test_utils::MockNodeTypesWithDB, BlockWriter, MetadataProvider, StaticFileProviderFactory, + StaticFileSegment, StaticFileWriter, + }; + use reth_snap_sync::{ + SnapAccountStore, SnapGeneration, DEFAULT_RESPONSE_BYTES, DEFAULT_SCAN_CHUNK, MAX_HASH, + }; + use reth_stages::StageCheckpoint; + use reth_tasks::Runtime; + use reth_trie_common::{root::state_root_unsorted, TrieAccount}; + + pub(crate) const PIVOT: u64 = 1; + + // Serves every account range from one trie, as a peer holding all of it would. A complete + // trie needs no proof. + #[derive(Debug)] + struct WholeTrieClient(Vec<(B256, TrieAccount)>); + + impl DownloadClient for WholeTrieClient { + fn report_bad_message(&self, _peer_id: PeerId) {} + + fn num_connected_peers(&self) -> usize { + 1 + } + } + + impl SnapClient for WholeTrieClient { + type Output = Ready>; + + fn get_account_range_with_priority( + &self, + request: GetAccountRangeMessage, + _priority: Priority, + ) -> Self::Output { + let accounts = self + .0 + .iter() + .map(|(key, account)| AccountData::from_trie_account(*key, account)) + .collect(); + let message = + AccountRangeMessage { request_id: request.request_id, accounts, proof: Vec::new() }; + ready(Ok(WithPeerId::new(PeerId::random(), SnapResponse::AccountRange(message)))) + } + + fn get_storage_ranges(&self, _request: GetStorageRangesMessage) -> Self::Output { + unreachable!("the accounts have no storage") + } + + fn get_storage_ranges_with_priority( + &self, + _request: GetStorageRangesMessage, + _priority: Priority, + ) -> Self::Output { + unreachable!("the accounts have no storage") + } + + fn get_byte_codes(&self, _request: GetByteCodesMessage) -> Self::Output { + unreachable!("the accounts have no code") + } + + fn get_byte_codes_with_priority( + &self, + _request: GetByteCodesMessage, + _priority: Priority, + ) -> Self::Output { + unreachable!("the accounts have no code") + } + + fn get_block_access_lists_with_priority( + &self, + _request: GetBlockAccessListsMessage, + _priority: Priority, + ) -> Self::Output { + unreachable!("the attempt stays at its pivot") + } + } + + // Headers through the pivot on storage v2. + fn with_headers() -> ProviderFactory { + with_headers_committing_to(Header::default().state_root) + } + + // Headers through the pivot on storage v2, each committing to `state_root`. + fn with_headers_committing_to(state_root: B256) -> ProviderFactory { + let factory = hashed_factory(); + let static_files = factory.static_file_provider(); + let mut writer = static_files.latest_writer(StaticFileSegment::Headers).unwrap(); + let mut parent = B256::ZERO; + for number in 0..=PIVOT { + let header = Header { number, parent_hash: parent, state_root, ..Default::default() }; + let hash = header.hash_slow(); + writer.append_header(&header, &hash).unwrap(); + parent = hash; + } + writer.commit().unwrap(); + drop(writer); + factory + } + + // Headers through the pivot, with an unverified attempt downloading state at it. + fn downloading() -> (ProviderFactory, SnapWrite) { + let factory = with_headers(); + let provider = factory.database_provider_rw().unwrap(); + let header = provider.sealed_header(PIVOT).unwrap().unwrap(); + let write = provider + .start_snap_attempt(SnapGeneration::new(header.num_hash(), header.state_root)) + .unwrap(); + provider.commit().unwrap(); + (factory, write) + } + + // Headers committing to two plain accounts, with an attempt at the pivot that downloaded both. + pub(crate) fn downloaded_attempt() -> (ProviderFactory, SnapWrite) { + let accounts: Vec<_> = (1..=2) + .map(|nonce| { + ( + B256::with_last_byte(nonce), + TrieAccount { nonce: nonce.into(), ..Default::default() }, + ) + }) + .collect(); + let root = state_root_unsorted(accounts.clone()); + let factory = with_headers_committing_to(root); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(PIVOT).unwrap().unwrap().num_hash(); + let write = provider.start_snap_attempt(SnapGeneration::new(pivot, root)).unwrap(); + provider.start_account_coverage(write).unwrap(); + let request = GetAccountRangeMessage { + request_id: 1, + root_hash: root, + starting_hash: B256::ZERO, + limit_hash: MAX_HASH, + response_bytes: DEFAULT_RESPONSE_BYTES, + }; + let downloader = + AccountRangeDownloader::new(WholeTrieClient(accounts), request, Runtime::test()) + .unwrap(); + let AccountRangeOutcome::Verified(range) = futures::executor::block_on(downloader).unwrap() + else { + panic!("the client serves the requested root") + }; + provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); + provider.commit().unwrap(); + (factory, write) + } + + // An attempt that downloaded all of its state, handed to the merkle stage. + fn handed_to_merkle() -> (ProviderFactory, SnapWrite) { + let (factory, write) = downloaded_attempt(); + let provider = factory.database_provider_rw().unwrap(); + provider.start_trie_rebuild(write, DEFAULT_SCAN_CHUNK, &CancellationToken::new()).unwrap(); + provider.commit().unwrap(); + (factory, write) + } + + // An attempt that downloaded all of its state, with its trie rebuilt at the pivot. + fn rebuilt() -> (ProviderFactory, SnapWrite) { + let (factory, write) = handed_to_merkle(); + let rebuild = SnapHandoff::new(factory.clone()).rebuild(write, &CancellationToken::new()); + assert_eq!(rebuild.unwrap(), RebuildOutcome::Rebuilt); + (factory, write) + } + + // A publish whose static files committed before the node stopped, ahead of its database + // commit. + fn interrupt_publish(factory: &ProviderFactory) { + let provider = factory.database_provider_rw().unwrap(); + provider.anchor_pruned_static_files(PIVOT).unwrap(); + provider.publish_snap_state(PIVOT).unwrap(); + factory.static_file_provider().finalize().unwrap(); + } + + // Asserts the state is published at the pivot and accepted. + fn assert_published(factory: &ProviderFactory) { + assert_eq!(factory.check_consistency().unwrap(), (None, None)); + let static_files = factory.static_file_provider(); + for segment in StaticFileSegment::iter().filter(|segment| !segment.is_headers()) { + assert_eq!( + static_files.get_highest_static_file_block(segment), + Some(PIVOT), + "{segment}" + ); + } + let provider = factory.database_provider_ro().unwrap(); + assert_eq!( + provider.get_stage_checkpoint(StageId::Execution).unwrap(), + Some(StageCheckpoint::new(PIVOT)) + ); + assert!(provider.snap_attempt().unwrap().unwrap().is_verified()); + } + + // Headers through the pivot, with the state published at it. + fn published() -> ProviderFactory { + let factory = with_headers(); + let provider = factory.database_provider_rw().unwrap(); + provider.anchor_pruned_static_files(PIVOT).unwrap(); + provider.publish_snap_state(PIVOT).unwrap(); + provider.commit().unwrap(); + factory + } + + #[test] + fn resuming_without_an_interrupted_publish_changes_nothing() { + let (factory, ..) = downloading(); + + SnapHandoff::new(factory.clone()).resume_interrupted_publish().unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.get_stage_checkpoint(StageId::Execution).unwrap(), None); + assert_eq!(factory.check_consistency().unwrap(), (None, None)); + } + + #[test] + fn the_block_after_the_pivot_appends_to_the_anchored_files() { + let factory = published(); + let provider = factory.database_provider_rw().unwrap(); + + // Appending fails unless the anchored segments start right after the pivot. + provider.append_block_bodies(vec![(PIVOT + 1, Some(&Default::default()))]).unwrap(); + provider.commit().unwrap(); + } + + #[test] + fn a_pivot_reorged_out_is_abandoned_instead_of_published() { + let factory = with_headers(); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(PIVOT).unwrap().unwrap(); + let write = provider + .start_snap_attempt(SnapGeneration::new(pivot.num_hash(), pivot.state_root)) + .unwrap(); + provider.commit().unwrap(); + + // The canonical header at the pivot's number is a different block now. + let replacement = Header { extra_data: vec![1].into(), ..pivot.header().clone() }; + let static_files = factory.static_file_provider(); + let mut writer = static_files.latest_writer(StaticFileSegment::Headers).unwrap(); + writer.prune_headers(1).unwrap(); + writer.commit().unwrap(); + writer.append_header(&replacement, &replacement.hash_slow()).unwrap(); + writer.commit().unwrap(); + drop(writer); + let handoff = + SnapHandoff::new(factory.clone()).hand_off(write, &CancellationToken::new()).unwrap(); + + assert_eq!(handoff, HandoffOutcome::PivotReorged); + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.active_snap_write().unwrap().is_none()); + } + + #[test] + fn an_abandoned_attempt_is_not_an_interrupted_publish() { + let (factory, ..) = downloading(); + let provider = factory.database_provider_rw().unwrap(); + provider.abandon_snap_attempt().unwrap(); + provider.anchor_pruned_static_files(PIVOT).unwrap(); + provider.commit().unwrap(); + + SnapHandoff::new(factory.clone()).resume_interrupted_publish().unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.get_stage_checkpoint(StageId::Execution).unwrap(), None); + } + + #[test] + fn a_root_mismatch_abandons_the_attempt_before_publishing() { + let (factory, write) = downloading(); + let provider = factory.database_provider_rw().unwrap(); + // State whose root differs from the empty root the pivot header commits to. + provider + .tx_ref() + .put::( + B256::repeat_byte(3), + Account { nonce: 1, ..Default::default() }, + ) + .unwrap(); + provider.commit().unwrap(); + + let handoff = SnapHandoff::new(factory.clone()).hand_off(write, &CancellationToken::new()); + + assert_eq!(handoff.unwrap(), HandoffOutcome::RootMismatch); + + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.active_snap_write().unwrap().is_none()); + assert_eq!(provider.get_stage_checkpoint(StageId::Execution).unwrap(), None); + assert_eq!( + factory.static_file_provider().get_lowest_range_start(StaticFileSegment::Transactions), + None + ); + } + + #[test] + fn a_handoff_accepts_the_state_with_its_publish() { + let (factory, write) = rebuilt(); + + let handoff = + SnapHandoff::new(factory.clone()).hand_off(write, &CancellationToken::new()).unwrap(); + + assert_eq!(handoff, HandoffOutcome::Completed); + assert_published(&factory); + } + + #[test] + fn a_publish_interrupted_before_its_checkpoints_resumes_at_startup() { + let (factory, ..) = rebuilt(); + interrupt_publish(&factory); + + // Startup finishes the publish before checking consistency, which would otherwise unwind + // the anchored files to the old checkpoints. + SnapHandoff::new(factory.clone()).resume_interrupted_publish().unwrap(); + + assert_published(&factory); + } + + #[test] + fn a_rejected_publish_keeps_the_static_files() { + let (factory, write) = rebuilt(); + let provider = factory.database_provider_rw().unwrap(); + provider.abandon_snap_attempt().unwrap(); + provider.commit().unwrap(); + + assert!(SnapHandoff::new(factory.clone()).publish(write).is_err()); + + assert_eq!(factory.static_file_provider().earliest_history_height(), 0); + } + + #[test] + fn a_resume_cut_short_resumes_again() { + let (factory, ..) = rebuilt(); + interrupt_publish(&factory); + + // The resume deletes the anchored files at once, then stops before anything commits. + factory.database_provider_rw().unwrap().anchor_pruned_static_files(PIVOT).unwrap(); + + SnapHandoff::new(factory.clone()).resume_interrupted_publish().unwrap(); + + assert_published(&factory); + } + + #[test] + fn a_stopped_handoff_publishes_nothing() { + let (factory, write) = handed_to_merkle(); + let stop = CancellationToken::new(); + stop.cancel(); + + let handoff = SnapHandoff::new(factory.clone()).hand_off(write, &stop).unwrap(); + + assert_eq!(handoff, HandoffOutcome::Stopped); + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.active_snap_write().unwrap(), Some(write)); + assert_eq!(provider.get_stage_checkpoint(StageId::Execution).unwrap(), None); + assert_eq!(factory.static_file_provider().earliest_history_height(), 0); + } +} diff --git a/crates/ethereum/node/src/snap/mod.rs b/crates/ethereum/node/src/snap/mod.rs new file mode 100644 index 00000000000..2bdc9e88907 --- /dev/null +++ b/crates/ethereum/node/src/snap/mod.rs @@ -0,0 +1,491 @@ +//! Runs a snap/2 bootstrap as the engine's backfill. +//! +//! One task alternates header sync and snap state writes while the engine skips forkchoice, so +//! no other writer touches the database. Each pass: +//! +//! 1. Syncs headers to the target; nothing below the pivot executes. +//! 2. Selects a pivot under the head, or resumes the recorded attempt while its pivot is canonical. +//! 3. Downloads state against the pivot's root, carried to newer pivots by block access lists. +//! 4. Hands the state to [`SnapHandoff`], which rebuilds the trie and publishes it at the pivot, or +//! abandons the attempt on a root mismatch so the next pass starts a new one. +//! +//! A forkchoice update ends the current step at its next boundary, so headers catch up first. +//! The staged pipeline then runs the stages above the pivot. + +mod context; +mod handoff; +mod run; +mod selection; + +pub use handoff::{HandoffOutcome, RebuildOutcome, SnapHandoff}; +pub use selection::{EthereumBackfill, EthereumBackfillSync}; + +use alloy_consensus::BlockHeader; +use alloy_primitives::B256; +use futures::FutureExt; +use reth_chainspec::EthChainSpec; +use reth_engine_tree::backfill::{BackfillAction, BackfillEvent, BackfillSync}; +use reth_errors::RethError; +use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; +use reth_provider::{ + providers::ProviderNodeTypes, ChainSpecProvider, DatabaseProviderFactory, MetadataProvider, + ProviderFactory, ProviderResult, StageCheckpointReader, +}; +use reth_stages::{Pipeline, PipelineError, PipelineTarget, PipelineWithResult, StageId}; +use reth_tasks::Runtime; +use run::SnapRun; +use std::task::{ready, Context, Poll}; +use tokio::sync::{oneshot, watch}; +use tokio_util::sync::{CancellationToken, DropGuard}; + +/// Backfills canonical headers, then snap/2 state at a recent pivot. +#[derive(Debug)] +pub struct SnapBackfillSync { + // Serves the snap requests and reports peer counts. + client: C, + provider_factory: ProviderFactory, + runtime: Runtime, + // Owns the pipeline while idle, the result channel while running. + state: SnapBackfillState, + // Target queued by the engine, started on the next poll. + pending_target: Option, + // Latest finalized block the engine reported, kept across runs. + finalized: watch::Sender, +} + +impl SnapBackfillSync { + /// Creates a backfill that has not started any work. + pub fn new( + pipeline: Pipeline, + client: C, + provider_factory: ProviderFactory, + runtime: Runtime, + ) -> Self { + Self { + client, + provider_factory, + runtime, + state: SnapBackfillState::Idle(Some(Box::new(pipeline))), + pending_target: None, + finalized: watch::channel(B256::ZERO).0, + } + } +} + +impl SnapBackfillSync +where + N: ProviderNodeTypes, + C: SnapClient + HeadersClient + Clone + Unpin + 'static, +{ + // Spawns a run if a target is queued and the pipeline is free. + fn try_spawn(&mut self) -> Option { + if !matches!(self.state, SnapBackfillState::Idle(_)) { + return None + } + let target = self.pending_target.take()?; + // Once snap state is verified, or the node was not synced by snap, the staged pipeline + // backfills alone, including unwinds. + let event = match (self.needs_snap(), target) { + // The engine waits for every start to finish, so an unusable target fails the run + // instead of being dropped. + (_, PipelineTarget::Sync(hash)) if hash.is_zero() => { + BackfillEvent::Finished(Err(PipelineError::Internal(RethError::msg( + "snap backfill cannot sync to the zero hash", + )))) + } + (Err(error), _) => { + BackfillEvent::Finished(Err(PipelineError::Internal(RethError::other(error)))) + } + (Ok(false), target) => self.spawn_staged(target), + // Nothing executes on top of snap state before the handoff, so there is nothing a + // snap backfill could unwind. + (Ok(true), PipelineTarget::Unwind(block)) => { + BackfillEvent::Finished(Err(PipelineError::Internal(RethError::msg(format!( + "snap backfill cannot unwind to block {block}" + ))))) + } + (Ok(true), PipelineTarget::Sync(target)) => self.spawn_snap(target), + }; + Some(event) + } + + fn spawn_staged(&mut self, target: PipelineTarget) -> BackfillEvent { + let pipeline = self.take_pipeline(); + let (result_tx, result) = oneshot::channel(); + self.runtime.spawn_critical_blocking_task("pipeline task", async move { + let _ = result_tx.send(pipeline.run_as_fut(Some(target)).await); + }); + self.state = SnapBackfillState::Staged(result); + BackfillEvent::Started(target) + } + + fn spawn_snap(&mut self, target: B256) -> BackfillEvent { + let pipeline = self.take_pipeline(); + let (result_tx, result) = oneshot::channel(); + let (targets, target_rx) = watch::channel(target); + let stop = CancellationToken::new(); + let run = SnapRun::new( + self.client.clone(), + self.provider_factory.clone(), + self.runtime.clone(), + stop.clone(), + self.finalized.subscribe(), + ); + // Node shutdown drops this task as it does the pipeline's; every bootstrap step has either + // committed or left nothing behind. + self.runtime.spawn_critical_blocking_task("snap backfill task", async move { + let _ = result_tx.send(run.run(*pipeline, target_rx).await); + }); + self.state = SnapBackfillState::Running { _stop: stop.drop_guard(), targets, result }; + BackfillEvent::Started(PipelineTarget::Sync(target)) + } + + fn take_pipeline(&mut self) -> Box> { + let SnapBackfillState::Idle(pipeline) = &mut self.state else { + unreachable!("only an idle backfill spawns a run") + }; + pipeline.take().expect("idle backfill owns its pipeline") + } + + // Snap bootstraps a node with nothing executed, and finishes any attempt it has not verified + // yet, including one interrupted after its state was published. + fn needs_snap(&self) -> ProviderResult { + let provider = self.provider_factory.database_provider_ro()?; + if let Some(attempt) = provider.snap_attempt()? { + return Ok(!attempt.is_verified()) + } + // Genesis sets every checkpoint to its own block, which isn't always block 0. + let genesis = self.provider_factory.chain_spec().genesis_header().number(); + Ok(provider + .get_stage_checkpoint(StageId::Execution)? + .is_none_or(|checkpoint| checkpoint.block_number == genesis)) + } +} + +impl BackfillSync for SnapBackfillSync +where + N: ProviderNodeTypes, + C: SnapClient + HeadersClient + Clone + Unpin + 'static, +{ + fn on_action(&mut self, action: BackfillAction) { + match action { + // The zero hash never moves a target or anchors a pivot. + BackfillAction::UpdateTarget(hash) | BackfillAction::UpdateFinalized(hash) + if hash.is_zero() => {} + BackfillAction::Start(target) => self.pending_target = Some(target), + BackfillAction::UpdateTarget(hash) => { + if let SnapBackfillState::Running { targets, .. } = &self.state { + targets.send_if_modified(|current| std::mem::replace(current, hash) != hash); + } + } + // Kept while idle too, so the next run anchors its pivot to known finality. + BackfillAction::UpdateFinalized(hash) => { + self.finalized.send_if_modified(|current| std::mem::replace(current, hash) != hash); + } + } + } + + fn poll(&mut self, cx: &mut Context<'_>) -> Poll { + if let Some(event) = self.try_spawn() { + return Poll::Ready(event) + } + let (SnapBackfillState::Running { result, .. } | SnapBackfillState::Staged(result)) = + &mut self.state + else { + return Poll::Pending + }; + let event = match ready!(result.poll_unpin(cx)) { + Ok((pipeline, result)) => { + self.state = SnapBackfillState::Idle(Some(Box::new(pipeline))); + BackfillEvent::Finished(result) + } + Err(error) => BackfillEvent::TaskDropped(error.to_string()), + }; + Poll::Ready(event) + } +} + +// Owns the idle pipeline, or the handles of the run holding it. +#[derive(Debug)] +enum SnapBackfillState { + Idle(Option>>), + Running { + // Stops the run if the backfill is dropped. Declared first so the run sees the stop + // before the closed target channel. + _stop: DropGuard, + // Latest forkchoice target; older ones are overwritten. + targets: watch::Sender, + // Returns the pipeline with the run's result. + result: oneshot::Receiver>, + }, + // The staged pipeline runs alone, over state that is verified or was never snap synced. + Staged(oneshot::Receiver>), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snap::run::tests::ServesHeaders; + use alloy_consensus::Header; + use futures::{future::poll_fn, Stream, StreamExt}; + use reth_provider::{ + test_utils::{ + create_test_provider_factory, create_test_provider_factory_with_genesis_block_number, + MockNodeTypesWithDB, + }, + DBProvider, DatabaseProviderFactory, MetadataWriter, StageCheckpointReader, + StageCheckpointWriter, StorageSettings, StorageSettingsCache, + }; + use reth_prune::PruneModes; + use reth_stages::{ + ControlFlow, ExecOutput, PipelineEvent, Stage, StageCheckpoint, StageError, StageId, + StageSetBuilder, + }; + use reth_stages_api::test_utils::TestStage; + use reth_static_file::StaticFileProducer; + use std::task::Waker; + + type TestBackfill = SnapBackfillSync; + + type TestProvider = + as DatabaseProviderFactory>::ProviderRW; + + // A backfill whose pipeline holds only a scripted header stage, with that pipeline's events. + fn backfill(headers: TestStage) -> (TestBackfill, impl Stream + Unpin) { + let (pipeline, factory) = pipeline(headers); + let events = pipeline.events(); + let backfill = + SnapBackfillSync::new(pipeline, ServesHeaders::default(), factory, Runtime::test()); + (backfill, events) + } + + // A pipeline holding only a scripted header stage, over a database in the hashed state + // layout snap writes into. + pub(super) fn pipeline( + headers: TestStage, + ) -> (Pipeline, ProviderFactory) { + pipeline_with(headers, watch::channel(B256::ZERO).0) + } + + // Like `pipeline`, with any header stage and a tip channel the test can observe. + pub(super) fn pipeline_with + 'static>( + headers: S, + tip: watch::Sender, + ) -> (Pipeline, ProviderFactory) { + let factory = hashed_factory(); + (pipeline_on(&factory, StageSetBuilder::default().add_stage(headers), tip), factory) + } + + // A pipeline over `factory` holding `stages`. + pub(super) fn pipeline_on( + factory: &ProviderFactory, + stages: StageSetBuilder, + tip: watch::Sender, + ) -> Pipeline { + Pipeline::::builder() + .add_stages(stages) + .with_tip_sender(tip) + .build(factory.clone(), StaticFileProducer::new(factory.clone(), PruneModes::default())) + } + + // An empty database in the hashed state layout snap writes into. + pub(super) fn hashed_factory() -> ProviderFactory { + let factory = create_test_provider_factory(); + let provider = factory.database_provider_rw().unwrap(); + provider.write_storage_settings(StorageSettings::v2()).unwrap(); + provider.commit().unwrap(); + factory.set_storage_settings_cache(StorageSettings::v2()); + factory + } + + pub(super) fn headers_done(block: u64) -> Result { + Ok(ExecOutput { checkpoint: StageCheckpoint::new(block), done: true }) + } + + fn poll_once(backfill: &mut TestBackfill) -> Poll { + backfill.poll(&mut Context::from_waker(Waker::noop())) + } + + fn headers_checkpoint(factory: &ProviderFactory) -> Option { + let provider = factory.database_provider_ro().unwrap(); + provider.get_stage_checkpoint(StageId::Headers).unwrap().map(|it| it.block_number) + } + + // Waits for the running bootstrap's header stage to commit `block`. + pub(super) async fn headers_reach( + events: &mut (impl Stream + Unpin), + block: u64, + ) { + while let Some(event) = events.next().await { + if let PipelineEvent::Ran { stage_id: StageId::Headers, result, .. } = event && + result.checkpoint.block_number == block + { + return + } + } + panic!("the pipeline stopped before headers reached {block}") + } + + pub(super) fn target() -> B256 { + Header { number: 100, ..Default::default() }.hash_slow() + } + + pub(super) fn next_target() -> B256 { + Header { number: 101, ..Default::default() }.hash_slow() + } + + #[tokio::test] + async fn executed_state_backfills_with_the_staged_pipeline() { + let headers = TestStage::new(StageId::Headers).add_exec(headers_done(5)); + let (pipeline, factory) = pipeline(headers); + let provider = factory.database_provider_rw().unwrap(); + provider.save_stage_checkpoint(StageId::Execution, StageCheckpoint::new(42)).unwrap(); + provider.commit().unwrap(); + let mut backfill = SnapBackfillSync::new( + pipeline, + ServesHeaders::default(), + factory.clone(), + Runtime::test(), + ); + + // Executed state has nothing for snap to download, so the pipeline runs alone. + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(target()))); + assert!(matches!(poll_once(&mut backfill), Poll::Ready(BackfillEvent::Started(_)))); + assert!(matches!(backfill.state, SnapBackfillState::Staged(_))); + assert!(matches!(poll_fn(|cx| backfill.poll(cx)).await, BackfillEvent::Finished(Ok(_)))); + + assert_eq!(headers_checkpoint(&factory), Some(5)); + assert!(matches!(backfill.state, SnapBackfillState::Idle(Some(_)))); + } + + #[test] + fn a_genesis_above_block_zero_still_snap_syncs() { + let factory = create_test_provider_factory_with_genesis_block_number(5); + let provider = factory.database_provider_rw().unwrap(); + provider.save_stage_checkpoint(StageId::Execution, StageCheckpoint::new(5)).unwrap(); + provider.commit().unwrap(); + // Only the provider factory decides eligibility, so the pipeline's own database is unused. + let (pipeline, _) = pipeline(TestStage::new(StageId::Headers)); + let backfill = + TestBackfill::new(pipeline, ServesHeaders::default(), factory, Runtime::test()); + + assert!(backfill.needs_snap().unwrap()); + } + + #[test] + fn a_zero_hash_target_fails_without_starting_a_run() { + let (mut backfill, _events) = backfill(TestStage::new(StageId::Headers)); + + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(B256::ZERO))); + + assert!(matches!(poll_once(&mut backfill), Poll::Ready(BackfillEvent::Finished(Err(_))))); + assert!(matches!(backfill.state, SnapBackfillState::Idle(Some(_)))); + } + + #[test] + fn the_finalized_block_is_kept_while_idle() { + let (mut backfill, _events) = backfill(TestStage::new(StageId::Headers)); + let finalized = backfill.finalized.subscribe(); + + backfill.on_action(BackfillAction::UpdateFinalized(target())); + backfill.on_action(BackfillAction::UpdateFinalized(B256::ZERO)); + + assert_eq!(*finalized.borrow(), target()); + assert!(poll_once(&mut backfill).is_pending()); + } + + #[test] + fn a_target_update_while_idle_starts_nothing() { + let (mut backfill, _events) = backfill(TestStage::new(StageId::Headers)); + + backfill.on_action(BackfillAction::UpdateTarget(target())); + + assert!(poll_once(&mut backfill).is_pending()); + assert!(matches!(backfill.state, SnapBackfillState::Idle(Some(_)))); + } + + #[tokio::test] + async fn an_active_bootstrap_holds_the_pipeline_and_coalesces_targets() { + let (mut backfill, mut events) = + backfill(TestStage::new(StageId::Headers).add_exec(headers_done(0))); + + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(target()))); + assert!(matches!( + poll_once(&mut backfill), + Poll::Ready(BackfillEvent::Started(PipelineTarget::Sync(hash))) if hash == target() + )); + headers_reach(&mut events, 0).await; + + let SnapBackfillState::Running { targets, .. } = &backfill.state else { + panic!("the run owns the pipeline") + }; + let mut observer = targets.subscribe(); + backfill.on_action(BackfillAction::UpdateTarget(B256::ZERO)); + assert!(!observer.has_changed().unwrap()); + backfill.on_action(BackfillAction::UpdateTarget(next_target())); + backfill.on_action(BackfillAction::UpdateTarget(next_target())); + + assert!(observer.has_changed().unwrap()); + assert_eq!(*observer.borrow_and_update(), next_target()); + assert!(!observer.has_changed().unwrap()); + // A second start cannot run beside the active one. + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(next_target()))); + assert!(poll_once(&mut backfill).is_pending()); + } + + #[tokio::test] + async fn a_dropped_backfill_stops_its_run_and_releases_the_pipeline() { + let (mut backfill, mut events) = + backfill(TestStage::new(StageId::Headers).add_exec(headers_done(0))); + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(target()))); + assert!(poll_once(&mut backfill).is_ready()); + headers_reach(&mut events, 0).await; + + let SnapBackfillState::Running { _stop, result, .. } = + std::mem::replace(&mut backfill.state, SnapBackfillState::Idle(None)) + else { + panic!("the run owns the pipeline") + }; + drop(_stop); + + let (_pipeline, result) = result.await.unwrap(); + assert!(matches!(result, Ok(ControlFlow::NoProgress { block_number: None }))); + } + + #[tokio::test] + async fn node_shutdown_keeps_committed_progress() { + let (mut backfill, mut events) = + backfill(TestStage::new(StageId::Headers).add_exec(headers_done(0))); + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(target()))); + assert!(poll_once(&mut backfill).is_ready()); + headers_reach(&mut events, 0).await; + + backfill.runtime.graceful_shutdown(); + + // The runtime drops the task; the engine treats that as fatal and the node exits. + assert!(matches!(poll_fn(|cx| backfill.poll(cx)).await, BackfillEvent::TaskDropped(_))); + assert_eq!(headers_checkpoint(&backfill.provider_factory), Some(0)); + } + + #[tokio::test] + async fn a_failed_run_returns_the_pipeline() { + let (mut backfill, _events) = + backfill(TestStage::new(StageId::Headers).add_exec(Err(StageError::ChannelClosed))); + backfill.on_action(BackfillAction::Start(PipelineTarget::Sync(target()))); + assert!(poll_once(&mut backfill).is_ready()); + + assert!(matches!(poll_fn(|cx| backfill.poll(cx)).await, BackfillEvent::Finished(Err(_)))); + // Control and the pipeline are back with the engine, ready for the next target. + assert!(matches!(backfill.state, SnapBackfillState::Idle(Some(_)))); + } + + #[tokio::test] + async fn an_unwind_target_fails_without_starting_a_run() { + let (mut backfill, _events) = backfill(TestStage::new(StageId::Headers)); + + backfill.on_action(BackfillAction::Start(PipelineTarget::Unwind(1))); + + assert!(matches!(poll_once(&mut backfill), Poll::Ready(BackfillEvent::Finished(Err(_))))); + assert!(matches!(backfill.state, SnapBackfillState::Idle(Some(_)))); + } +} diff --git a/crates/ethereum/node/src/snap/run.rs b/crates/ethereum/node/src/snap/run.rs new file mode 100644 index 00000000000..e75491478da --- /dev/null +++ b/crates/ethereum/node/src/snap/run.rs @@ -0,0 +1,987 @@ +//! Alternates header catch-up and snap bootstrap until the state is downloaded. + +use super::{ + context::NodeSnapContext, + handoff::{HandoffOutcome, RebuildOutcome, SnapHandoff}, +}; +use alloy_consensus::{BlockHeader, Sealable}; +use alloy_eips::BlockNumHash; +use alloy_primitives::B256; +use reth_errors::RethError; +use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; +use reth_provider::{ + providers::ProviderNodeTypes, BlockHashReader, BlockNumReader, ProviderError, ProviderFactory, +}; +use reth_snap_sync::{SnapBootstrap, SnapBootstrapOutcome, SnapWrite}; +use reth_stages::{ + ControlFlow, Pipeline, PipelineError, PipelineTarget, PipelineWithResult, StageId, +}; +use reth_tasks::Runtime; +use reth_tracing::tracing::{debug, info, warn}; +use std::{pin::pin, time::Duration}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +// Minimum time between header refreshes while forkchoice moves. +// +// Ten slots keeps the head well inside the ~128 recent blocks peers serve state for, without +// stopping the download every slot. +const HEADER_REFRESH: Duration = Duration::from_secs(120); + +// Retry unresolved targets without flooding peers while the head remains unchanged. +const HEADER_LOOKUP_RETRY: Duration = Duration::from_secs(1); + +// Returning without progress hands control back to the engine without a fatal error. +const STOPPED: ControlFlow = ControlFlow::NoProgress { block_number: None }; + +// Root mismatches retried before the run fails. Every range, list and bytecode is authenticated +// against the pivot, so a mismatch is a local fault a fresh attempt rarely avoids. +const ROOT_MISMATCH_RETRIES: u32 = 1; + +// Everything one spawned run needs, moved into its task. +pub(super) struct SnapRun { + client: C, + factory: ProviderFactory, + runtime: Runtime, + header_refresh: Duration, + // Root mismatches retried before the run fails. + root_mismatch_retries: u32, + // Cancelled when the backfill is dropped. + stop: CancellationToken, + // Latest finalized block the engine reported. + finalized: watch::Receiver, +} + +impl SnapRun { + pub(super) const fn new( + client: C, + factory: ProviderFactory, + runtime: Runtime, + stop: CancellationToken, + finalized: watch::Receiver, + ) -> Self { + Self { + client, + factory, + runtime, + header_refresh: HEADER_REFRESH, + root_mismatch_retries: ROOT_MISMATCH_RETRIES, + stop, + finalized, + } + } + + #[cfg(test)] + const fn with_header_refresh(mut self, header_refresh: Duration) -> Self { + self.header_refresh = header_refresh; + self + } + + #[cfg(test)] + const fn with_root_mismatch_retries(mut self, root_mismatch_retries: u32) -> Self { + self.root_mismatch_retries = root_mismatch_retries; + self + } +} + +impl SnapRun +where + N: ProviderNodeTypes, + C: SnapClient + HeadersClient + Clone + Unpin + 'static, +{ + // Hands the pipeline back with the result, so the backfill can run it again. + pub(super) async fn run( + self, + mut pipeline: Pipeline, + targets: watch::Receiver, + ) -> PipelineWithResult { + let result = self.bootstrap(&mut pipeline, targets).await; + (pipeline, result) + } + + // Alternates header catch-up and snap bootstrap until the state is downloaded or the run + // stops. Every bootstrap step commits, so each loop resumes where the last one stopped. + async fn bootstrap( + &self, + pipeline: &mut Pipeline, + mut targets: watch::Receiver, + ) -> Result { + let mut mismatches = 0; + let verified = loop { + match self.catch_up_headers(pipeline, &mut targets).await? { + Pass::Stopped => return Ok(STOPPED), + Pass::Again | Pass::RootMismatch => continue, + Pass::Done => {} + } + match self.download(&mut targets).await? { + // A closed targets channel means the backfill is gone, so no head will follow. + SnapBootstrapOutcome::Stopped + if self.stop.is_cancelled() || targets.has_changed().is_err() => + { + return Ok(STOPPED) + } + SnapBootstrapOutcome::Stopped => { + debug!(target: "sync::snap", "Refreshing headers before resuming snap sync"); + } + SnapBootstrapOutcome::TrieRebuild { write, pivot } => { + match self.rebuild_and_hand_off(pipeline, &mut targets, write, pivot).await? { + Pass::Stopped => return Ok(STOPPED), + Pass::Again => {} + Pass::RootMismatch => { + mismatches += 1; + if mismatches > self.root_mismatch_retries { + return Err(PipelineError::Internal(RethError::msg(format!( + "snap state root mismatched {mismatches} times, last at block {}", + pivot.number + )))) + } + } + Pass::Done => break Some(pivot), + } + } + SnapBootstrapOutcome::Verified { pivot } => break Some(pivot), + SnapBootstrapOutcome::BeforeBlockAccessLists => break None, + } + }; + match verified { + Some(pivot) => { + info!(target: "sync::snap", ?pivot, "Snap state verified, resuming the pipeline"); + } + None => { + info!(target: "sync::snap", "Chain predates block access lists, syncing with the staged pipeline"); + } + } + self.finish(pipeline, &mut targets).await + } + + // Runs one bootstrap until the state is downloaded, the backfill stops or headers are due. + async fn download( + &self, + targets: &mut watch::Receiver, + ) -> Result { + let run_stop = self.stop.child_token(); + let context = NodeSnapContext::new( + self.factory.clone(), + self.client.clone(), + targets.clone(), + self.finalized.clone(), + ); + let mut session = SnapBootstrap::new( + self.client.clone(), + self.factory.clone(), + self.runtime.clone(), + context, + ) + .with_cancellation(run_stop.clone()) + .with_shutdown(self.stop.clone()); + let mut run = pin!(session.run()); + let outcome = tokio::select! { + biased; + outcome = &mut run => outcome, + () = self.refresh_due(targets) => { + // Stops at the next step boundary, keeping committed progress. + run_stop.cancel(); + run.await + } + }; + match outcome { + Ok(outcome) => Ok(outcome), + Err(error) if error.is_fatal() => Err(PipelineError::Internal(RethError::other(error))), + // A failed sync waits for the next head, and committed progress lets the next bootstrap + // resume. + Err(error) => { + warn!(target: "sync::snap", %error, "Snap sync failed, retrying once forkchoice moves"); + if let Some(Err(_)) = self.stop.run_until_cancelled(targets.changed()).await { + debug!(target: "sync::snap", "Targets channel closed, stopping snap sync"); + } + Ok(SnapBootstrapOutcome::Stopped) + } + } + } + + // Rebuilds the trie at the pivot of `write`'s attempt, then hands the state off. The rebuild + // can take hours, so it stops with the backfill. + async fn rebuild_and_hand_off( + &self, + pipeline: &mut Pipeline, + targets: &mut watch::Receiver, + write: SnapWrite, + pivot: BlockNumHash, + ) -> Result { + match self.with_handoff(move |handoff, stop| handoff.rebuild(write, stop)).await? { + RebuildOutcome::Rebuilt => self.hand_off(pipeline, targets, write, pivot).await, + RebuildOutcome::RootMismatch => Ok(Pass::RootMismatch), + RebuildOutcome::Stopped => Ok(Pass::Stopped), + } + } + + // Catches headers up, since forkchoice may have moved during the rebuild, then publishes the + // state if its pivot is still canonical. + async fn hand_off( + &self, + pipeline: &mut Pipeline, + targets: &mut watch::Receiver, + write: SnapWrite, + pivot: BlockNumHash, + ) -> Result { + let headers = self.catch_up_headers(pipeline, targets).await?; + if headers != Pass::Done { + return Ok(headers) + } + Ok(match self.with_handoff(move |handoff, stop| handoff.hand_off(write, stop)).await? { + HandoffOutcome::Completed => Pass::Done, + HandoffOutcome::PivotReorged => { + info!(target: "sync::snap", ?pivot, "Snap pivot was reorged before the handoff, restarting"); + Pass::Again + } + HandoffOutcome::RootMismatch => Pass::RootMismatch, + HandoffOutcome::Stopped => Pass::Stopped, + }) + } + + // Runs `f` on the blocking pool, since rebuilding and publishing read every account. + async fn with_handoff( + &self, + f: impl FnOnce(SnapHandoff, &CancellationToken) -> Result + Send + 'static, + ) -> Result { + let handoff = SnapHandoff::new(self.factory.clone()); + let stop = self.stop.clone(); + self.runtime + .spawn_blocking(move || f(handoff, &stop)) + .await + .map_err(|error| PipelineError::Internal(RethError::other(error)))? + } + + // Runs every stage to the latest target, above the pivot once snap state is published. + async fn finish( + &self, + pipeline: &mut Pipeline, + targets: &mut watch::Receiver, + ) -> Result { + let Some((target, ahead)) = self.resolve_target(targets).await? else { return Ok(STOPPED) }; + // A stale target never completes the header stage, so the stages run to the local + // head and the engine follows forkchoice from there. + let target = if ahead { target } else { self.local_head()? }; + let stages = pipeline.run_until(StageId::Finish, Some(PipelineTarget::Sync(target))); + self.stop.run_until_cancelled(stages).await.unwrap_or(Ok(STOPPED)) + } + + // Syncs headers to the latest target. A detached head unwinds headers short of the target, + // and a target that moved during the pass is not synced yet, so both need another pass. + async fn catch_up_headers( + &self, + pipeline: &mut Pipeline, + targets: &mut watch::Receiver, + ) -> Result { + let Some((target, ahead)) = self.resolve_target(targets).await? else { + return Ok(Pass::Stopped) + }; + if !ahead { + debug!(target: "sync::snap", %target, "Forkchoice target is not above the local headers, skipping the header pass"); + return Ok(Pass::Done) + } + // Snap needs canonical headers and their BAL hashes, but nothing below the pivot may + // execute, so only the header stage runs. Finish the pass before retargeting: dropping it + // retains partially downloaded headers in the stage's ETL collectors. + let headers = pipeline.run_until(StageId::Headers, Some(PipelineTarget::Sync(target))); + let Some(headers) = self.stop.run_until_cancelled(headers).await else { + return Ok(Pass::Stopped) + }; + if headers?.is_unwind() || targets.has_changed().unwrap_or(false) { + return Ok(Pass::Again) + } + Ok(Pass::Done) + } + + // Whether `target` is above the local headers, or `None` when no peer serves its header. The + // header stage only finishes once it downloads the block after the local head, so a pass to a + // sibling or an older block never completes. + async fn is_ahead(&self, target: B256) -> Result, PipelineError> { + let head = { + let provider = self.factory.provider()?; + if provider.block_number(target)?.is_some() { + return Ok(Some(false)) + } + provider.last_block_number()? + }; + let header = self.client.get_header(target.into()).await; + // A peer may answer with another block's header. + Ok(header + .ok() + .and_then(|header| header.into_data()) + .filter(|header| header.hash_slow() == target) + .map(|header| header.number() > head)) + } + + // Returns the hash of the highest local header. + fn local_head(&self) -> Result { + let provider = self.factory.provider()?; + let number = provider.last_block_number()?; + Ok(provider.block_hash(number)?.ok_or(ProviderError::HeaderNotFound(number.into()))?) + } + + // Resolves once the refresh interval has passed and forkchoice has moved, or once the + // backfill is gone. + async fn refresh_due(&self, targets: &mut watch::Receiver) { + tokio::time::sleep(self.header_refresh).await; + let _ = targets.changed().await; + } + + // Resolve the target before starting a pipeline pass, which cannot safely be retargeted with + // headers buffered. Only the peer lookup and retry delay are interrupted by forkchoice. + async fn resolve_target( + &self, + targets: &mut watch::Receiver, + ) -> Result, PipelineError> { + loop { + let target = *targets.borrow_and_update(); + let ahead = tokio::select! { + biased; + () = self.stop.cancelled() => return Ok(None), + Ok(()) = targets.changed() => continue, + ahead = self.is_ahead(target) => ahead?, + }; + if let Some(ahead) = ahead { + return Ok(Some((target, ahead))) + } + tokio::select! { + biased; + () = self.stop.cancelled() => return Ok(None), + Ok(()) = targets.changed() => {}, + () = tokio::time::sleep(HEADER_LOOKUP_RETRY) => {}, + } + } + } +} + +// What a header or handoff pass leaves the run to do. +#[derive(Debug, PartialEq, Eq)] +enum Pass { + // The backfill stopped, so the run returns without progress. + Stopped, + // Headers moved or the pivot was reorged out, so the run starts over from headers. + Again, + // The rebuilt root differs from the pivot's header, so the attempt was abandoned and the run + // starts over from headers. + RootMismatch, + // The pass finished, so the run moves on to its next step. + Done, +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::snap::{ + handoff::tests::{downloaded_attempt, PIVOT}, + tests::{ + hashed_factory, headers_done, headers_reach, next_target, pipeline, pipeline_on, + pipeline_with, target, + }, + }; + use alloy_consensus::Header; + use alloy_eips::eip1898::BlockWithParent; + use futures::future::{pending, ready, Either, Pending, Ready}; + use reth_consensus::ConsensusError; + use reth_db::{tables, transaction::DbTxMut}; + use reth_eth_wire_types::snap::{ + GetAccountRangeMessage, GetBlockAccessListsMessage, GetByteCodesMessage, + GetStorageRangesMessage, + }; + use reth_network_p2p::{ + download::DownloadClient, error::PeerRequestResult, headers::client::HeadersRequest, + priority::Priority, NoopFullBlockClient, + }; + use reth_network_peers::{PeerId, WithPeerId}; + use reth_primitives_traits::{Account, SealedHeader}; + use reth_provider::{ + test_utils::{insert_headers, MockNodeTypesWithDB}, + DBProvider, DatabaseProviderFactory, HeaderProvider, MetadataProvider, + }; + use reth_snap_sync::{SnapAttemptStore, SnapStateVerifier, DEFAULT_SCAN_CHUNK}; + use reth_stages::{ + ExecInput, ExecOutput, Stage, StageError, StageSetBuilder, UnwindInput, UnwindOutput, + }; + use reth_stages_api::test_utils::TestStage; + use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + task::{Context, Poll}, + }; + + // Answers a header request with the requested one of `headers`, or with all of them when none + // matches, as a faulty peer would. Snap requests fail like the noop client's. + #[derive(Clone, Debug)] + pub(crate) struct ServesHeaders { + headers: Vec
, + noop: NoopFullBlockClient, + empty_response: Arc, + pending_target: Option, + } + + impl Default for ServesHeaders { + fn default() -> Self { + Self { + headers: (100..=101) + .map(|number| Header { number, ..Default::default() }) + .collect(), + noop: NoopFullBlockClient::default(), + empty_response: Arc::default(), + pending_target: None, + } + } + } + + impl DownloadClient for ServesHeaders { + fn report_bad_message(&self, _peer_id: PeerId) {} + + fn num_connected_peers(&self) -> usize { + 1 + } + } + + impl HeadersClient for ServesHeaders { + type Header = Header; + type Output = + Either>>, Pending>>>; + + fn get_headers_with_priority( + &self, + request: HeadersRequest, + _priority: Priority, + ) -> Self::Output { + if self.pending_target.is_some_and(|hash| request.start == hash.into()) { + return Either::Right(pending()) + } + if self.empty_response.swap(false, Ordering::Relaxed) { + return Either::Left(ready(Ok(WithPeerId::new(PeerId::random(), vec![])))) + } + let requested = self + .headers + .iter() + .find(|header| request.start == header.hash_slow().into()) + .map_or_else(|| self.headers.clone(), |header| vec![header.clone()]); + Either::Left(ready(Ok(WithPeerId::new(PeerId::random(), requested)))) + } + } + + impl SnapClient for ServesHeaders { + type Output = ::Output; + + fn get_account_range_with_priority( + &self, + request: GetAccountRangeMessage, + priority: Priority, + ) -> Self::Output { + self.noop.get_account_range_with_priority(request, priority) + } + + fn get_storage_ranges(&self, request: GetStorageRangesMessage) -> Self::Output { + self.noop.get_storage_ranges(request) + } + + fn get_storage_ranges_with_priority( + &self, + request: GetStorageRangesMessage, + priority: Priority, + ) -> Self::Output { + self.noop.get_storage_ranges_with_priority(request, priority) + } + + fn get_byte_codes(&self, request: GetByteCodesMessage) -> Self::Output { + self.noop.get_byte_codes(request) + } + + fn get_byte_codes_with_priority( + &self, + request: GetByteCodesMessage, + priority: Priority, + ) -> Self::Output { + self.noop.get_byte_codes_with_priority(request, priority) + } + + fn get_block_access_lists_with_priority( + &self, + request: GetBlockAccessListsMessage, + priority: Priority, + ) -> Self::Output { + self.noop.get_block_access_lists_with_priority(request, priority) + } + } + + // A header stage that waits for `gate`, then records whether a snap attempt exists at each + // pass. + #[derive(Debug)] + struct GatedHeaders { + gate: watch::Receiver, + attempts: Arc>>, + stage: TestStage, + } + + impl Stage for GatedHeaders { + fn id(&self) -> StageId { + StageId::Headers + } + + fn poll_execute_ready( + &mut self, + cx: &mut Context<'_>, + _input: ExecInput, + ) -> Poll> { + if *self.gate.borrow() { + return Poll::Ready(Ok(())) + } + cx.waker().wake_by_ref(); + Poll::Pending + } + + fn execute( + &mut self, + provider: &Provider, + input: ExecInput, + ) -> Result { + self.attempts.lock().unwrap().push(provider.snap_attempt()?.is_some()); + self.stage.execute(provider, input) + } + + fn unwind( + &mut self, + _provider: &Provider, + _input: UnwindInput, + ) -> Result { + unreachable!("nothing unwinds in this test") + } + } + + // A run over `factory` that refreshes headers as soon as forkchoice moves. + fn snap_run( + factory: &ProviderFactory, + ) -> (SnapRun, CancellationToken) { + snap_run_with(factory, ServesHeaders::default()) + } + + // A run over `factory` requesting from `client` that refreshes headers as soon as forkchoice + // moves. + fn snap_run_with( + factory: &ProviderFactory, + client: C, + ) -> (SnapRun, CancellationToken) { + let stop = CancellationToken::new(); + let run = SnapRun::new( + client, + factory.clone(), + Runtime::test(), + stop.clone(), + watch::channel(B256::ZERO).1, + ) + .with_header_refresh(Duration::ZERO); + (run, stop) + } + + // A downloaded attempt with its trie rebuild started, and a pipeline over the same database + // running `headers`. + fn handoff_ready( + headers: TestStage, + ) -> ( + Pipeline, + ProviderFactory, + SnapWrite, + BlockNumHash, + ) { + let (factory, write) = downloaded_attempt(); + let pivot = factory.sealed_header(PIVOT).unwrap().unwrap().num_hash(); + let provider = factory.database_provider_rw().unwrap(); + provider.start_trie_rebuild(write, DEFAULT_SCAN_CHUNK, &CancellationToken::new()).unwrap(); + provider.commit().unwrap(); + let pipeline = pipeline_on( + &factory, + StageSetBuilder::default().add_stage(headers), + watch::channel(B256::ZERO).0, + ); + (pipeline, factory, write, pivot) + } + + // A header stage error that unwinds the pipeline; only the unwind matters here. + fn detached_head() -> StageError { + StageError::DetachedHead { + local_head: Box::new(BlockWithParent { + parent: B256::ZERO, + block: BlockNumHash::default(), + }), + header: Box::new(BlockWithParent { + parent: B256::ZERO, + block: BlockNumHash::default(), + }), + error: Box::new(ConsensusError::BaseFeeMissing), + } + } + + #[tokio::test] + async fn a_new_target_refreshes_headers_then_resumes_until_stopped() { + let (pipeline, factory) = pipeline( + TestStage::new(StageId::Headers).add_exec(headers_done(0)).add_exec(headers_done(1)), + ); + let (targets, receiver) = watch::channel(target()); + let (run, stop) = snap_run(&factory); + let mut events = pipeline.events(); + let run = tokio::spawn(run.run(pipeline, receiver)); + headers_reach(&mut events, 0).await; + + targets.send(next_target()).unwrap(); + + // The bootstrap stopped, headers caught up to the new target and a new bootstrap resumed. + headers_reach(&mut events, 1).await; + assert!(!run.is_finished()); + + stop.cancel(); + let (_pipeline, result) = run.await.unwrap(); + assert!(matches!(result, Ok(ControlFlow::NoProgress { block_number: None }))); + } + + #[tokio::test] + async fn a_target_moved_during_headers_is_synced_before_the_bootstrap() { + let (open, gate) = watch::channel(false); + let attempts = Arc::new(Mutex::new(Vec::new())); + let headers = GatedHeaders { + gate, + attempts: attempts.clone(), + stage: TestStage::new(StageId::Headers) + .add_exec(headers_done(1)) + .add_exec(headers_done(2)), + }; + let (tip, _) = watch::channel(B256::ZERO); + let mut pipeline_tip = tip.subscribe(); + let (pipeline, factory) = pipeline_with(headers, tip); + // Headers with block access list hashes, so a bootstrap can anchor a pivot. + let mut parent = B256::ZERO; + let stored: Vec<_> = (0..=64) + .map(|number| { + let header = SealedHeader::seal_slow(Header { + number, + parent_hash: parent, + block_access_list_hash: Some(B256::ZERO), + ..Default::default() + }); + parent = header.hash(); + header + }) + .collect(); + insert_headers(&factory, &stored); + // Targets above the stored headers that peers serve, so each pass runs to completion. + let target = Header { number: 65, parent_hash: parent, ..Default::default() }; + let next = Header { number: 66, parent_hash: target.hash_slow(), ..Default::default() }; + let (target_hash, next_hash) = (target.hash_slow(), next.hash_slow()); + let client = ServesHeaders { headers: vec![target, next], ..Default::default() }; + let (targets, receiver) = watch::channel(target_hash); + let (run, stop) = snap_run_with(&factory, client); + let mut events = pipeline.events(); + let run = tokio::spawn(run.run(pipeline, receiver)); + + // Forkchoice moves while the first header pass is still downloading. + pipeline_tip.wait_for(|tip| *tip == target_hash).await.unwrap(); + targets.send(next_hash).unwrap(); + open.send(true).unwrap(); + + tokio::time::timeout(Duration::from_secs(5), headers_reach(&mut events, 2)) + .await + .expect("headers are synced to the moved target"); + stop.cancel(); + let (_pipeline, result) = run.await.unwrap(); + assert!(matches!(result, Ok(ControlFlow::NoProgress { block_number: None }))); + // No bootstrap recorded an attempt on the outdated headers between the two passes. + assert_eq!(*attempts.lock().unwrap(), [false, false]); + } + + #[tokio::test] + async fn a_detached_head_syncs_headers_again_before_the_bootstrap() { + let (pipeline, factory) = pipeline( + TestStage::new(StageId::Headers) + .add_exec(Err(detached_head())) + .add_exec(headers_done(2)), + ); + let (_targets, receiver) = watch::channel(target()); + let (run, stop) = snap_run(&factory); + let mut events = pipeline.events(); + let run = tokio::spawn(run.run(pipeline, receiver)); + + // Forkchoice never moves, so only the unwind itself can trigger the second header run. + tokio::time::timeout(Duration::from_secs(5), headers_reach(&mut events, 2)) + .await + .expect("headers are synced again after the unwind"); + stop.cancel(); + let (_pipeline, result) = run.await.unwrap(); + assert!(matches!(result, Ok(ControlFlow::NoProgress { block_number: None }))); + } + + #[tokio::test] + async fn the_pipeline_finishes_to_the_latest_target() { + let (tip, _) = watch::channel(B256::ZERO); + let pipeline_tip = tip.subscribe(); + let (mut pipeline, factory) = + pipeline_with(TestStage::new(StageId::Finish).add_exec(headers_done(1)), tip); + let (targets, mut receiver) = watch::channel(target()); + let (run, _stop) = snap_run(&factory); + + // Forkchoice moved while the state was downloading. + targets.send(next_target()).unwrap(); + run.finish(&mut pipeline, &mut receiver).await.unwrap(); + + assert_eq!(*pipeline_tip.borrow(), next_target()); + } + + #[tokio::test] + async fn a_moved_target_syncs_headers_before_the_handoff() { + let (mut pipeline, factory, write, pivot) = + handoff_ready(TestStage::new(StageId::Headers).add_exec(headers_done(PIVOT))); + let (targets, mut receiver) = watch::channel(target()); + let (run, _stop) = snap_run(&factory); + + // Forkchoice moved while the trie was rebuilding. + targets.send(next_target()).unwrap(); + let pass = + run.rebuild_and_hand_off(&mut pipeline, &mut receiver, write, pivot).await.unwrap(); + + assert_eq!(pass, Pass::Done); + assert!(factory.provider().unwrap().snap_attempt().unwrap().unwrap().is_verified()); + } + + #[tokio::test] + async fn a_reorg_during_the_rebuild_syncs_headers_again_without_a_handoff() { + let (mut pipeline, factory, write, pivot) = + handoff_ready(TestStage::new(StageId::Headers).add_exec(Err(detached_head()))); + let (_targets, mut receiver) = watch::channel(target()); + let (run, _stop) = snap_run(&factory); + + let pass = + run.rebuild_and_hand_off(&mut pipeline, &mut receiver, write, pivot).await.unwrap(); + + assert_eq!(pass, Pass::Again); + assert!(!factory.provider().unwrap().snap_attempt().unwrap().unwrap().is_verified()); + } + + #[tokio::test] + async fn a_root_mismatch_starts_a_new_attempt_instead_of_failing() { + let (mut pipeline, factory, write, pivot) = handoff_ready(TestStage::new(StageId::Headers)); + // State whose root differs from the one the pivot header commits to. + let provider = factory.database_provider_rw().unwrap(); + provider + .tx_ref() + .put::( + B256::repeat_byte(3), + Account { nonce: 1, ..Default::default() }, + ) + .unwrap(); + provider.commit().unwrap(); + let (_targets, mut receiver) = watch::channel(target()); + let (run, _stop) = snap_run(&factory); + + let pass = + run.rebuild_and_hand_off(&mut pipeline, &mut receiver, write, pivot).await.unwrap(); + + assert_eq!(pass, Pass::RootMismatch); + assert!(factory.provider().unwrap().active_snap_write().unwrap().is_none()); + } + + #[tokio::test] + async fn repeated_root_mismatches_fail_the_run() { + let (mut pipeline, factory, _, _) = + handoff_ready(TestStage::new(StageId::Headers).add_exec(headers_done(PIVOT))); + // State whose root differs from the one the pivot header commits to. + let provider = factory.database_provider_rw().unwrap(); + provider + .tx_ref() + .put::( + B256::repeat_byte(3), + Account { nonce: 1, ..Default::default() }, + ) + .unwrap(); + provider.commit().unwrap(); + let (_targets, receiver) = watch::channel(target()); + let (run, _stop) = snap_run(&factory); + + let result = run.with_root_mismatch_retries(0).bootstrap(&mut pipeline, receiver).await; + + assert!(matches!(result, Err(PipelineError::Internal(_))), "{result:?}"); + assert!(factory.provider().unwrap().active_snap_write().unwrap().is_none()); + } + + #[tokio::test] + async fn a_chain_before_block_access_lists_syncs_with_the_staged_pipeline() { + let factory = hashed_factory(); + let stages = StageSetBuilder::default() + .add_stage( + TestStage::new(StageId::Headers) + .add_exec(headers_done(0)) + .add_exec(headers_done(0)), + ) + .add_stage(TestStage::new(StageId::Finish).add_exec(headers_done(0))); + let mut pipeline = pipeline_on(&factory, stages, watch::channel(B256::ZERO).0); + insert_headers(&factory, &[SealedHeader::seal_slow(Header::default())]); + let (_targets, receiver) = watch::channel(target()); + let (run, _stop) = snap_run(&factory); + + // The second header run is the staged pipeline finishing to the target. + let result = run.bootstrap(&mut pipeline, receiver).await.unwrap(); + + assert_ne!(result, STOPPED); + assert!(factory.provider().unwrap().snap_attempt().unwrap().is_none()); + } + + #[tokio::test] + async fn a_sibling_of_the_local_head_skips_the_header_pass() { + // A header stage with no outputs, so running it fails the test. + let (mut pipeline, factory) = + pipeline_with(TestStage::new(StageId::Headers), watch::channel(B256::ZERO).0); + let (run, _, mut receiver) = sibling_of_head(&factory); + + let pass = run.catch_up_headers(&mut pipeline, &mut receiver).await.unwrap(); + + assert_eq!(pass, Pass::Done); + } + + #[tokio::test] + async fn a_sibling_of_the_local_head_finishes_to_the_local_head() { + let factory = hashed_factory(); + let stages = StageSetBuilder::default() + .add_stage(TestStage::new(StageId::Headers).add_exec(headers_done(1))) + .add_stage(TestStage::new(StageId::Finish).add_exec(headers_done(1))); + let (tip, pipeline_tip) = watch::channel(B256::ZERO); + let mut pipeline = pipeline_on(&factory, stages, tip); + let (run, head, mut receiver) = sibling_of_head(&factory); + + let result = run.finish(&mut pipeline, &mut receiver).await.unwrap(); + + assert_ne!(result, STOPPED); + assert_eq!(*pipeline_tip.borrow(), head); + } + + #[tokio::test] + async fn a_header_other_than_the_target_leaves_it_unresolved() { + let factory = hashed_factory(); + let genesis = Header::default(); + insert_headers(&factory, &[SealedHeader::seal_slow(genesis.clone())]); + // The peer answers with the genesis header, which isn't the requested target. + let client = ServesHeaders { headers: vec![genesis], ..Default::default() }; + let (run, _stop) = snap_run_with(&factory, client); + + assert_eq!(run.is_ahead(target()).await.unwrap(), None); + } + + #[tokio::test] + async fn a_new_target_replaces_an_unresolved_one_before_headers_start() { + let (tip, pipeline_tip) = watch::channel(B256::ZERO); + let (mut pipeline, factory) = + pipeline_with(TestStage::new(StageId::Headers).add_exec(headers_done(1)), tip); + let (targets, mut receiver) = watch::channel(B256::repeat_byte(0xff)); + let (run, _stop) = snap_run(&factory); + let mut pass = pin!(run.catch_up_headers(&mut pipeline, &mut receiver)); + + assert!(futures::poll!(&mut pass).is_pending()); + assert_eq!(*pipeline_tip.borrow(), B256::ZERO); + targets.send(next_target()).unwrap(); + + let pass = tokio::time::timeout(Duration::from_secs(5), pass) + .await + .expect("the new target resolves without starting the old header pass"); + assert_eq!(pass.unwrap(), Pass::Done); + assert_eq!(*pipeline_tip.borrow(), next_target()); + } + + #[tokio::test] + async fn a_new_target_replaces_an_unresolved_one_before_finishing() { + let factory = hashed_factory(); + let stages = StageSetBuilder::default() + .add_stage(TestStage::new(StageId::Headers).add_exec(headers_done(1))) + .add_stage(TestStage::new(StageId::Finish).add_exec(headers_done(1))); + let (tip, pipeline_tip) = watch::channel(B256::ZERO); + let mut pipeline = pipeline_on(&factory, stages, tip); + let (targets, mut receiver) = watch::channel(B256::repeat_byte(0xff)); + let (run, _stop) = snap_run(&factory); + let mut finished = pin!(run.finish(&mut pipeline, &mut receiver)); + + assert!(futures::poll!(&mut finished).is_pending()); + assert_eq!(*pipeline_tip.borrow(), B256::ZERO); + targets.send(next_target()).unwrap(); + + let result = tokio::time::timeout(Duration::from_secs(5), finished) + .await + .expect("the new target resolves before the pipeline starts"); + assert_ne!(result.unwrap(), STOPPED); + assert_eq!(*pipeline_tip.borrow(), next_target()); + } + + #[tokio::test] + async fn a_new_target_interrupts_a_pending_header_lookup() { + let (tip, pipeline_tip) = watch::channel(B256::ZERO); + let (mut pipeline, factory) = + pipeline_with(TestStage::new(StageId::Headers).add_exec(headers_done(1)), tip); + let (targets, mut receiver) = watch::channel(target()); + let client = ServesHeaders { pending_target: Some(target()), ..Default::default() }; + let (run, _stop) = snap_run_with(&factory, client); + let mut pass = pin!(run.catch_up_headers(&mut pipeline, &mut receiver)); + + assert!(futures::poll!(&mut pass).is_pending()); + assert_eq!(*pipeline_tip.borrow(), B256::ZERO); + targets.send(next_target()).unwrap(); + + let pass = tokio::time::timeout(Duration::from_secs(5), pass) + .await + .expect("forkchoice interrupts the pending peer request"); + assert_eq!(pass.unwrap(), Pass::Done); + assert_eq!(*pipeline_tip.borrow(), next_target()); + } + + #[tokio::test] + async fn an_empty_response_retries_a_stale_target_without_starting_headers() { + let (tip, pipeline_tip) = watch::channel(B256::ZERO); + let (mut pipeline, factory) = pipeline_with(TestStage::new(StageId::Headers), tip); + let (run, _, mut receiver) = sibling_of_head(&factory); + run.client.empty_response.store(true, Ordering::Relaxed); + let mut pass = pin!(run.catch_up_headers(&mut pipeline, &mut receiver)); + + assert!(futures::poll!(&mut pass).is_pending()); + assert_eq!(*pipeline_tip.borrow(), B256::ZERO); + let pass = tokio::time::timeout(Duration::from_secs(5), pass) + .await + .expect("the same target is retried without another forkchoice update"); + + assert_eq!(pass.unwrap(), Pass::Done); + assert_eq!(*pipeline_tip.borrow(), B256::ZERO); + } + + #[tokio::test] + async fn stopping_an_unresolved_target_leaves_headers_untouched() { + let (tip, pipeline_tip) = watch::channel(B256::ZERO); + let (mut pipeline, factory) = pipeline_with(TestStage::new(StageId::Headers), tip); + let (_targets, mut receiver) = watch::channel(target()); + let client = ServesHeaders { pending_target: Some(target()), ..Default::default() }; + let (run, stop) = snap_run_with(&factory, client); + let mut pass = pin!(run.catch_up_headers(&mut pipeline, &mut receiver)); + + assert!(futures::poll!(&mut pass).is_pending()); + stop.cancel(); + + assert_eq!(pass.await.unwrap(), Pass::Stopped); + assert_eq!(*pipeline_tip.borrow(), B256::ZERO); + } + + // Headers through block 1 in `factory`, and a run whose forkchoice target is a sibling of + // block 1 that peers serve. Returns the run, the local head's hash and the target receiver. + fn sibling_of_head( + factory: &ProviderFactory, + ) -> (SnapRun, B256, watch::Receiver) { + let genesis = SealedHeader::seal_slow(Header::default()); + let head = SealedHeader::seal_slow(Header { + number: 1, + parent_hash: genesis.hash(), + ..Default::default() + }); + insert_headers(factory, &[genesis.clone(), head.clone()]); + let sibling = + Header { number: 1, parent_hash: genesis.hash(), gas_limit: 1, ..Default::default() }; + let receiver = watch::channel(sibling.hash_slow()).1; + let client = ServesHeaders { headers: vec![sibling], ..Default::default() }; + (snap_run_with(factory, client).0, head.hash(), receiver) + } +} diff --git a/crates/ethereum/node/src/snap/selection.rs b/crates/ethereum/node/src/snap/selection.rs new file mode 100644 index 00000000000..24e6014d7dd --- /dev/null +++ b/crates/ethereum/node/src/snap/selection.rs @@ -0,0 +1,217 @@ +//! Picks the engine's backfill at launch: the staged pipeline, or snap sync with `--snap.v2`. + +use super::{SnapBackfillSync, SnapHandoff}; +use reth_engine_tree::backfill::{BackfillAction, BackfillEvent, BackfillSync, PipelineSync}; +use reth_network_p2p::{headers::client::HeadersClient, snap::client::SnapClient}; +use reth_node_builder::{ + sync::{BackfillContext, BackfillSyncBuilder, PipelineBackfill}, + NodeConfig, +}; +use reth_provider::{providers::ProviderNodeTypes, DatabaseProviderFactory, ProviderFactory}; +use std::task::{Context, Poll}; + +/// Builds the Ethereum node's backfill: the staged pipeline, or snap sync with `--snap.v2`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EthereumBackfill { + /// The staged pipeline, which every node runs unless snap sync is opted into. + Pipeline, + /// Snap sync, on chains with block access lists. + Snap, +} + +impl EthereumBackfill { + /// Picks the backfill for `config`'s `--snap.v2` setting. + pub const fn new(config: &NodeConfig) -> Self { + if config.network.snap_v2 { + Self::Snap + } else { + Self::Pipeline + } + } +} + +impl BackfillSyncBuilder for EthereumBackfill +where + N: ProviderNodeTypes, + C: SnapClient + HeadersClient + Clone + Unpin + 'static, +{ + type Backfill = EthereumBackfillSync; + + fn build(self, ctx: BackfillContext) -> eyre::Result { + // Genesis has stored the storage settings by now, so the layout check covers a fresh + // database too. + ctx.provider_factory().database_provider_ro()?.ensure_sync_mode(self == Self::Snap)?; + match self { + Self::Pipeline => PipelineBackfill.build(ctx).map(EthereumBackfillSync::Pipeline), + Self::Snap => { + let client = ctx.client().clone(); + let provider_factory = ctx.provider_factory().clone(); + let runtime = ctx.runtime().clone(); + Ok(EthereumBackfillSync::Snap(Box::new(SnapBackfillSync::new( + ctx.into_pipeline(), + client, + provider_factory, + runtime, + )))) + } + } + } + + fn recover(&mut self, provider_factory: &ProviderFactory) -> eyre::Result<()> { + SnapHandoff::new(provider_factory.clone()).resume_interrupted_publish()?; + // The snap layout is checked in `build`, after genesis initializes it. + if *self == Self::Pipeline { + provider_factory.database_provider_ro()?.ensure_sync_mode(false)?; + } + Ok(()) + } +} + +/// The Ethereum node's backfill: the staged pipeline, or snap sync. +#[derive(Debug)] +pub enum EthereumBackfillSync { + /// The staged pipeline, which every node runs unless snap sync is opted into. + Pipeline(PipelineSync), + /// Snap sync, which downloads state at a pivot before the pipeline continues above it. + Snap(Box>), +} + +impl BackfillSync for EthereumBackfillSync +where + N: ProviderNodeTypes, + C: SnapClient + HeadersClient + Clone + Unpin + 'static, +{ + fn on_action(&mut self, action: BackfillAction) { + match self { + Self::Pipeline(sync) => sync.on_action(action), + Self::Snap(sync) => sync.on_action(action), + } + } + + fn poll(&mut self, cx: &mut Context<'_>) -> Poll { + match self { + Self::Pipeline(sync) => sync.poll(cx), + Self::Snap(sync) => sync.poll(cx), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::snap::tests::pipeline_on; + use alloy_primitives::B256; + use reth_chainspec::{ChainSpec, ChainSpecBuilder}; + use reth_network_p2p::NoopFullBlockClient; + use reth_node_core::args::NetworkArgs; + use reth_primitives_traits::{Header, SealedHeader}; + use reth_provider::{ + test_utils::{ + create_test_provider_factory_with_chain_spec, insert_headers, MockNodeTypesWithDB, + }, + ChainSpecProvider, DBProvider, MetadataWriter, ProviderError, StorageSettings, + StorageSettingsCache, + }; + use reth_snap_sync::{SnapAttemptStore, SnapGeneration}; + use reth_stages::StageSetBuilder; + use reth_tasks::Runtime; + use std::sync::Arc; + + type TestFactory = ProviderFactory; + + fn factory(chain_spec: ChainSpec) -> TestFactory { + create_test_provider_factory_with_chain_spec(Arc::new(chain_spec)) + } + + fn amsterdam() -> ChainSpec { + ChainSpecBuilder::mainnet().amsterdam_activated().build() + } + + // The `--snap.v2` setting on `factory`'s chain. + fn backfill(snap_v2: bool, factory: &TestFactory) -> EthereumBackfill { + let config = NodeConfig::new(factory.chain_spec()) + .with_network(NetworkArgs { snap_v2, ..Default::default() }); + EthereumBackfill::new(&config) + } + + // The backfill `EthereumBackfill` builds over `factory`, or why it refused to. + fn build( + snap_v2: bool, + factory: TestFactory, + ) -> eyre::Result> { + let pipeline = pipeline_on( + &factory, + StageSetBuilder::default(), + tokio::sync::watch::channel(B256::ZERO).0, + ); + backfill(snap_v2, &factory).build(BackfillContext::new( + pipeline, + NoopFullBlockClient::default(), + factory, + Runtime::test(), + )) + } + + // Runs `EthereumBackfill`'s startup recovery over `factory`. + fn recover(snap_v2: bool, factory: &TestFactory) -> eyre::Result<()> { + BackfillSyncBuilder::::recover( + &mut backfill(snap_v2, factory), + factory, + ) + } + + #[test] + fn the_staged_pipeline_runs_without_the_flag() { + assert!(matches!( + build(false, factory(amsterdam())).unwrap(), + EthereumBackfillSync::Pipeline(_) + )); + } + + #[test] + fn the_flag_selects_snap_sync() { + let factory = factory(amsterdam()); + recover(true, &factory).unwrap(); + + // Genesis supplies the layout before the backfill is built. + factory.set_storage_settings_cache(StorageSettings::v2()); + assert!(matches!(build(true, factory).unwrap(), EthereumBackfillSync::Snap(_))); + } + + #[test] + fn building_refuses_snap_into_a_legacy_layout() { + let factory = factory(amsterdam()); + // Genesis caches the stored layout before the backfill is built. + factory.set_storage_settings_cache(StorageSettings::v1()); + + // Recovery runs before genesis stores the settings, so only the build can refuse it. + let error = build(true, factory).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ProviderError::SnapStorageLayoutUnsupported) + )); + } + + #[test] + fn recovery_refuses_unverified_snap_state_without_the_flag() { + let factory = factory(amsterdam()); + factory.set_storage_settings_cache(StorageSettings::v2()); + let provider = factory.database_provider_rw().unwrap(); + provider.write_storage_settings(StorageSettings::v2()).unwrap(); + provider.commit().unwrap(); + let genesis = SealedHeader::seal_slow(Header::default()); + insert_headers(&factory, std::slice::from_ref(&genesis)); + let provider = factory.database_provider_rw().unwrap(); + provider + .start_snap_attempt(SnapGeneration::new(genesis.num_hash(), genesis.state_root)) + .unwrap(); + provider.commit().unwrap(); + + let error = recover(false, &factory).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(ProviderError::SnapStateRequiresSnapSync { .. }) + )); + recover(true, &factory).unwrap(); + } +} diff --git a/crates/ethereum/node/tests/e2e/blobs.rs b/crates/ethereum/node/tests/e2e/blobs.rs index feef4cc18a9..c04ca28f52f 100644 --- a/crates/ethereum/node/tests/e2e/blobs.rs +++ b/crates/ethereum/node/tests/e2e/blobs.rs @@ -1,16 +1,14 @@ -use alloy_eips::Decodable2718; +use alloy_eips::{merge::SLOT_DURATION_SECS, Decodable2718}; use reth_chainspec::EthereumHardfork; use reth_e2e_test_utils::{ - test_chain_spec, test_chain_spec_builder, transaction::TransactionTestContext, E2ETestSetupExt, + node::Finality, test_chain_spec, test_chain_spec_builder, transaction::TransactionTestContext, + E2ETestSetupExt, }; use reth_ethereum_engine_primitives::BlobSidecars; use reth_ethereum_primitives::PooledTransactionVariant; use reth_node_ethereum::EthereumNode; use reth_transaction_pool::TransactionPool; -use std::{ - sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; +use std::sync::Arc; #[tokio::test] async fn can_handle_blobs() -> eyre::Result<()> { @@ -19,15 +17,8 @@ async fn can_handle_blobs() -> eyre::Result<()> { let chain_spec = test_chain_spec(EthereumHardfork::Cancun); let genesis_hash = chain_spec.genesis_hash(); let (mut node, wallet) = EthereumNode::test_setup(1, chain_spec).build_single().await?; - - // inject normal tx - let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.signer(1)).await; - let tx_hash = node.rpc.inject_tx(raw_tx).await?; - // build payload with normal tx - let payload = node.new_payload().await?; - - // clean the pool - node.inner.pool.remove_transactions(vec![tx_hash]); + // keep genesis finalized, so the blob block can be reorged + node.set_finality(Finality::Keep); // build blob tx let blob_tx = TransactionTestContext::tx_with_blobs_bytes(1, wallet.signer(0)).await?; @@ -39,19 +30,16 @@ async fn can_handle_blobs() -> eyre::Result<()> { // validate sidecar TransactionTestContext::validate_sidecar(envelope); - // build a payload - let blob_payload = node.new_payload().await?; - - // submit the blob payload - let blob_block_hash = node.submit_payload(blob_payload).await?; - - node.update_forkchoice(genesis_hash, blob_block_hash).await?; + // mine the blob tx and wait for the pool to remove it + let blob_payload = node.advance_block_synced().await?; + assert!(blob_payload.block().body().transactions().any(|tx| *tx.hash() == blob_tx_hash)); - // submit normal payload (reorg) - let block_hash = node.submit_payload(payload).await?; - node.update_forkchoice(genesis_hash, block_hash).await?; + // reorg the blob block out with a block on genesis, which can't include the blob tx anymore + let block_hash = node.advance_block_on(genesis_hash).await?.block().hash(); - tokio::time::sleep(std::time::Duration::from_secs(3)).await; + // wait for the pool to process the reorg, and then re-inject the blob tx + node.wait_for_pool_head(block_hash).await?; + node.wait_for_pooled([blob_tx_hash]).await?; // expects the blob tx to be back in the pool let envelope = node.rpc.envelope_by_hash(blob_tx_hash).await?; @@ -66,7 +54,6 @@ async fn can_send_legacy_sidecar_post_activation() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let chain_spec = test_chain_spec(EthereumHardfork::Osaka); - let genesis_hash = chain_spec.genesis_hash(); let (mut node, wallet) = EthereumNode::test_setup(1, chain_spec) .with_rpc_modifier(|rpc| rpc.with_force_blob_sidecar_upcasting()) .build_single() @@ -90,10 +77,8 @@ async fn can_send_legacy_sidecar_post_activation() -> eyre::Result<()> { // build a payload let blob_payload = node.new_payload().await?; - // submit the blob payload - let blob_block_hash = node.submit_payload(blob_payload).await?; - - node.update_forkchoice(genesis_hash, blob_block_hash).await?; + // import the blob payload + node.import_payload(blob_payload).await?; Ok(()) } @@ -102,25 +87,19 @@ async fn can_send_legacy_sidecar_post_activation() -> eyre::Result<()> { async fn blob_conversion_at_osaka() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let current_timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); - // Osaka activates in 2 slots - let osaka_timestamp = current_timestamp + 24; + // Keep genesis outside the conversion window. Importing the Prague payload below starts + // conversion two slots before Osaka, after all legacy sidecar assertions have completed. + let prague_timestamp = 2 * SLOT_DURATION_SECS; + let osaka_timestamp = prague_timestamp + 2 * SLOT_DURATION_SECS; let chain_spec = Arc::new( test_chain_spec_builder().prague_activated().with_osaka_at(osaka_timestamp).build(), ); - let genesis_hash = chain_spec.genesis_hash(); let (mut node, wallet) = EthereumNode::test_setup(1, chain_spec) .with_rpc_modifier(|rpc| rpc.with_force_blob_sidecar_upcasting()) .build_single() .await?; - // build a dummy payload at `current_timestamp` - let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.signer(0)).await; - node.rpc.inject_tx(raw_tx).await?; - node.payload.timestamp = current_timestamp - 1; - node.advance_block().await?; - // build blob txs let first_blob = TransactionTestContext::tx_with_blobs_bytes(1, wallet.signer(1)).await?; let second_blob = TransactionTestContext::tx_with_blobs_bytes(1, wallet.signer(2)).await?; @@ -151,8 +130,10 @@ async fn blob_conversion_at_osaka() -> eyre::Result<()> { TransactionTestContext::validate_sidecar(envelope); // build last Prague payload - node.payload.timestamp = current_timestamp + 1; + node.set_next_payload_timestamp(prague_timestamp)?; let prague_payload = node.new_payload().await?; + assert_eq!(prague_payload.block().timestamp, prague_timestamp); + assert!(prague_payload.block().body().transactions().any(|tx| *tx.hash() == blob_tx_hash)); assert!(matches!(prague_payload.sidecars(), BlobSidecars::Eip4844(_))); // inject second blob tx to the pool @@ -164,7 +145,14 @@ async fn blob_conversion_at_osaka() -> eyre::Result<()> { // validate sidecar TransactionTestContext::validate_sidecar(envelope); - tokio::time::sleep(Duration::from_secs(6)).await; + // Import the Prague payload to trigger conversion only after checking both legacy sidecars. + node.import_payload(prague_payload).await?; + + // wait for the pool to convert the sidecar ahead of the Osaka activation + node.wait_for_pool(|pool| { + pool.get_blob(blob_tx_hash).ok().flatten().is_some_and(|sidecar| sidecar.is_eip7594()) + }) + .await?; // fetch second blob tx from rpc again let envelope = node.rpc.envelope_by_hash(blob_tx_hash).await?; @@ -173,18 +161,16 @@ async fn blob_conversion_at_osaka() -> eyre::Result<()> { // validate sidecar TransactionTestContext::validate_sidecar(envelope); - // submit the Prague payload - node.update_forkchoice(genesis_hash, node.submit_payload(prague_payload).await?).await?; - // Build first Osaka payload - node.payload.timestamp = osaka_timestamp - 1; + node.set_next_payload_timestamp(osaka_timestamp)?; let osaka_payload = node.new_payload().await?; + assert_eq!(osaka_payload.block().timestamp, osaka_timestamp); // Assert that it includes the second blob tx with eip7594 sidecar assert!(osaka_payload.block().body().transactions().any(|tx| *tx.hash() == blob_tx_hash)); assert!(matches!(osaka_payload.sidecars(), BlobSidecars::Eip7594(_))); - node.update_forkchoice(genesis_hash, node.submit_payload(osaka_payload).await?).await?; + node.import_payload(osaka_payload).await?; Ok(()) } diff --git a/crates/ethereum/node/tests/e2e/custom_genesis.rs b/crates/ethereum/node/tests/e2e/custom_genesis.rs index 0b11d6a1e14..12127e9143f 100644 --- a/crates/ethereum/node/tests/e2e/custom_genesis.rs +++ b/crates/ethereum/node/tests/e2e/custom_genesis.rs @@ -92,3 +92,32 @@ async fn can_advance_on_genesis_newer_than_payload_timestamp() -> eyre::Result<( Ok(()) } + +/// Tests that the next payload is built at the timestamp set on the test context, that later +/// payloads continue from it, and that timestamps not after the latest block are rejected. +#[tokio::test] +async fn can_set_next_payload_timestamp() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let genesis_timestamp = 2_000_000_000; + let mut genesis = test_genesis(); + genesis.timestamp = genesis_timestamp; + let chain_spec = + Arc::new(test_chain_spec_builder().genesis(genesis).cancun_activated().build()); + + let (mut node, _) = EthereumNode::test_setup(1, chain_spec).build_single().await?; + + assert_eq!( + node.set_next_payload_timestamp(genesis_timestamp).unwrap_err().to_string(), + "next payload timestamp 2000000000 must be greater than the latest block timestamp 2000000000" + ); + + let timestamp = genesis_timestamp + 100; + node.set_next_payload_timestamp(timestamp)?; + assert_eq!(node.advance_block().await?.block().timestamp, timestamp); + assert_eq!(node.advance_block().await?.block().timestamp, timestamp + 1); + + assert!(node.set_next_payload_timestamp(timestamp + 1).is_err()); + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/dev.rs b/crates/ethereum/node/tests/e2e/dev.rs index ebe920d88cc..f2fdfdf3955 100644 --- a/crates/ethereum/node/tests/e2e/dev.rs +++ b/crates/ethereum/node/tests/e2e/dev.rs @@ -25,30 +25,16 @@ async fn can_run_dev_node() -> eyre::Result<()> { .await?; let node = &node.inner; - let canon_state = node.provider.canonical_in_memory_state(); - let mut safe_block = canon_state.subscribe_safe_block(); - let mut finalized_block = canon_state.subscribe_finalized_block(); - assert_chain_advances(node).await; let chain_info = node.provider.chain_info()?; - // Startup can leave an unread genesis notification, and the canonical head notification - // precedes the safe/finalized updates. Wait for the mined block itself on both channels. - tokio::time::timeout(Duration::from_secs(10), async { - tokio::try_join!( - safe_block.wait_for(|header| { - header.as_ref().is_some_and(|header| header.num_hash() == chain_info.into()) - }), - finalized_block.wait_for(|header| { - header.as_ref().is_some_and(|header| header.num_hash() == chain_info.into()) - }), - ) - .map(|_| ()) + // The canonical head notification precedes the safe/finalized updates. + poll_until("safe and finalized block to be the mined block", || async { + Ok((node.provider.safe_block_num_hash()? == Some(chain_info.into()) && + node.provider.finalized_block_num_hash()? == Some(chain_info.into())) + .then_some(())) }) - .await??; - - assert_eq!(node.provider.safe_block_num_hash()?, Some(chain_info.into())); - assert_eq!(node.provider.finalized_block_num_hash()?, Some(chain_info.into())); + .await?; Ok(()) } diff --git a/crates/ethereum/node/tests/e2e/eip6780.rs b/crates/ethereum/node/tests/e2e/eip6780.rs index a40de697b8b..40ede726340 100644 --- a/crates/ethereum/node/tests/e2e/eip6780.rs +++ b/crates/ethereum/node/tests/e2e/eip6780.rs @@ -1,6 +1,6 @@ //! Post-Cancun SELFDESTRUCT (EIP-6780) scenario suite driven through the engine API. //! -//! The same scenario battery runs against multiple hardfork targets (Cancun, Osaka, Amsterdam) +//! The same scenario battery runs against multiple hardfork targets (Osaka, Amsterdam) //! on a single node per fork: every block goes through payload building, `newPayload` and //! forkchoice via the consensus engine, and assertions cover receipts, RPC state and the //! committed execution outcome. At the end of a suite the persisted database is checked with @@ -29,28 +29,17 @@ use alloy_network::TransactionBuilder; use alloy_primitives::{Address, Bytes, TxKind, B256, U256}; use alloy_provider::Provider; use alloy_rpc_types_eth::{TransactionReceipt, TransactionRequest}; -use futures::StreamExt; use reth_chainspec::EthereumHardfork; use reth_e2e_test_utils::{ - receipt::await_successful_receipts, - trie::{assert_trie_consistency, wait_for_persisted_block}, - E2ETestSetupExt, NodeHelperType, + trie::assert_trie_consistency, wallet::TestAccount, E2ETestSetupExt, NodeHelperType, }; use reth_node_ethereum::EthereumNode; use reth_provider::Chain; use reth_revm::db::BundleAccount; -use std::{sync::Arc, time::Duration}; - -const MAX_FEE_PER_GAS: u128 = 20_000_000_000; -const MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000; +use std::sync::Arc; const ETH: u128 = 1_000_000_000_000_000_000; -#[tokio::test] -async fn test_eip6780_selfdestruct_cancun() -> eyre::Result<()> { - run_selfdestruct_suite(EthereumHardfork::Cancun).await -} - #[tokio::test] async fn test_eip6780_selfdestruct_osaka() -> eyre::Result<()> { run_selfdestruct_suite(EthereumHardfork::Osaka).await @@ -77,15 +66,13 @@ async fn run_selfdestruct_suite(fork: EthereumHardfork) -> eyre::Result<()> { }) .build_single() .await?; - let signer = wallet.inner.clone(); - let provider = node.rpc_provider_with_wallet(signer.clone()); + let provider = node.rpc_provider(); let mut ctx = SuiteCtx { node, provider, fork, - signer: signer.address(), - nonce: 0, + account: wallet.account(0).with_gas_limit(TX_GAS_LIMIT), factory: Address::ZERO, last_committed: None, }; @@ -107,8 +94,7 @@ async fn run_selfdestruct_suite(fork: EthereumHardfork) -> eyre::Result<()> { for _ in 0..4 { ctx.mine_block(vec![]).await?; } - wait_for_persisted_block(&ctx.node.inner.provider, scenario_tip, Duration::from_secs(30)) - .await?; + ctx.node.wait_for_persisted_block(scenario_tip).await?; assert_trie_consistency(&ctx.node.inner.provider)?; Ok(()) @@ -119,8 +105,8 @@ struct SuiteCtx

{ node: NodeHelperType, provider: P, fork: EthereumHardfork, - signer: Address, - nonce: u64, + /// The account sending all transactions. + account: TestAccount, /// The mode-switched `CREATE2` factory all scenarios deploy through. factory: Address, /// The chain segment committed by the most recently mined block. @@ -128,39 +114,21 @@ struct SuiteCtx

{ } impl SuiteCtx

{ - /// Sends the given transactions from the signer and mines a block containing exactly them. + /// Sends the given transactions from the signer and mines a block containing exactly them, + /// failing if any of them reverted. /// - /// Nonces and fees are filled in automatically in transaction order. + /// Nonces, gas limits and fees are filled in by the account in transaction order. async fn mine_block( &mut self, txs: Vec, ) -> eyre::Result> { - let expected = txs.len(); - let mut pending = Vec::with_capacity(expected); + let mut raw_txs = Vec::with_capacity(txs.len()); for tx in txs { - let nonce = self.nonce; - self.nonce += 1; - let tx = tx - .with_from(self.signer) - .with_nonce(nonce) - .with_max_fee_per_gas(MAX_FEE_PER_GAS) - .with_max_priority_fee_per_gas(MAX_PRIORITY_FEE_PER_GAS); - pending.push(self.provider.send_transaction(tx).await?); + raw_txs.push(self.account.sign_tx_bytes(tx).await); } - - let payload = self.node.advance_block().await?; - let included = payload.block().body().transactions().count(); - assert_eq!(included, expected, "block should contain exactly the sent transactions"); - - let notification = self - .node - .canonical_stream - .next() - .await - .ok_or_else(|| eyre::eyre!("canonical stream ended"))?; - self.last_committed = Some(notification.committed()); - - await_successful_receipts(pending).await + let mined = self.node.mine(raw_txs).await?.ensure_success()?; + self.last_committed = Some(mined.chain); + Ok(mined.receipts) } /// Returns the account entry of the most recently committed block's bundle state. @@ -231,12 +199,12 @@ struct Fixtures { /// Deploys the `CREATE2` factory and the fixture contracts for the pre-existing and revert /// scenarios. async fn deploy_fixtures(ctx: &mut SuiteCtx

) -> eyre::Result { - let base_nonce = ctx.nonce; - ctx.factory = ctx.signer.create(base_nonce); - let self_destructing_to_self = ctx.signer.create(base_nonce + 1); - let revert_child = ctx.signer.create(base_nonce + 2); - let reverter = ctx.signer.create(base_nonce + 3); - let revert_wrapper = ctx.signer.create(base_nonce + 4); + let (signer, base_nonce) = (ctx.account.address(), ctx.account.nonce()); + ctx.factory = signer.create(base_nonce); + let self_destructing_to_self = signer.create(base_nonce + 1); + let revert_child = signer.create(base_nonce + 2); + let reverter = signer.create(base_nonce + 3); + let revert_wrapper = signer.create(base_nonce + 4); let receipts = ctx .mine_block(vec![ @@ -516,26 +484,23 @@ async fn destroy_and_recreate_in_same_block( // Transaction builders // -// All transactions use a generous fixed gas limit: Amsterdam repricing raises costs well above -// the historic values (a plain transfer no longer fits into 21k gas), and gas estimation cannot -// be used because some transactions depend on contracts created earlier in the same block. +// All transactions use the generous fixed gas limit of the account: Amsterdam repricing raises +// costs well above the historic values (a plain transfer no longer fits into 21k gas), and gas +// estimation cannot be used because some transactions depend on contracts created earlier in the +// same block. const TX_GAS_LIMIT: u64 = 1_000_000; fn create_tx(initcode: Bytes, value: U256) -> TransactionRequest { - TransactionRequest::default() - .with_kind(TxKind::Create) - .with_input(initcode) - .with_value(value) - .with_gas_limit(TX_GAS_LIMIT) + TransactionRequest::default().with_kind(TxKind::Create).with_input(initcode).with_value(value) } fn call_tx(to: Address) -> TransactionRequest { - TransactionRequest::default().with_to(to).with_gas_limit(TX_GAS_LIMIT) + TransactionRequest::default().with_to(to) } fn transfer_tx(to: Address, value: U256) -> TransactionRequest { - TransactionRequest::default().with_to(to).with_value(value).with_gas_limit(TX_GAS_LIMIT) + TransactionRequest::default().with_to(to).with_value(value) } /// Calls the factory to `CREATE2`-deploy the given initcode, optionally calling the deployed @@ -548,14 +513,10 @@ fn factory_create_tx( value: U256, ) -> TransactionRequest { let mut input = Vec::with_capacity(64 + initcode.len()); - input.extend_from_slice(&U256::from(call_after_create as u8).to_be_bytes::<32>()); + input.extend_from_slice(B256::with_last_byte(call_after_create as u8).as_slice()); input.extend_from_slice(salt.as_slice()); input.extend_from_slice(initcode); - TransactionRequest::default() - .with_to(factory) - .with_input(input) - .with_value(value) - .with_gas_limit(TX_GAS_LIMIT) + TransactionRequest::default().with_to(factory).with_input(input).with_value(value) } // Scenario parameters and small conversion helpers diff --git a/crates/ethereum/node/tests/e2e/eth.rs b/crates/ethereum/node/tests/e2e/eth.rs index b7f2e263816..9afda2b0161 100644 --- a/crates/ethereum/node/tests/e2e/eth.rs +++ b/crates/ethereum/node/tests/e2e/eth.rs @@ -25,7 +25,7 @@ use reth_node_ethereum::{ engine_ssz_witness::PayloadStatusWithWitness, EthereumAddOns, EthereumNode, }; use reth_provider::{BlockNumReader, StateProviderFactory}; -use reth_rpc_api::TestingBuildBlockRequestV1; +use reth_rpc_api::{TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection}; use reth_tasks::Runtime; use ssz::Encode; @@ -37,20 +37,6 @@ const ENGINE_V1_BLOBS_ROUTE: &str = "/engine/v1/blobs/v1"; const ENGINE_CAPABILITIES_ROUTE: &str = "/engine/v1/capabilities"; const ENGINE_IDENTITY_ROUTE: &str = "/engine/v1/identity"; -#[tokio::test] -async fn can_run_eth_node() -> eyre::Result<()> { - reth_tracing::init_test_tracing(); - - let (mut node, wallet) = - EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; - let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.inner).await; - - // make the node advance and assert the block has been committed to the blockchain - node.inject_and_advance(raw_tx).await?; - - Ok(()) -} - #[tokio::test] #[cfg(unix)] async fn can_run_eth_node_with_auth_engine_api_over_ipc() -> eyre::Result<()> { @@ -88,8 +74,10 @@ async fn test_failed_run_eth_node_with_no_auth_engine_api_over_ipc_opts() -> eyr async fn test_engine_graceful_shutdown() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let (mut node, wallet) = - EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let (mut node, wallet) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) + .with_restartable_nodes() + .build_single() + .await?; let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.inner).await; node.inject_and_advance(raw_tx).await?; @@ -118,6 +106,10 @@ async fn test_engine_graceful_shutdown() -> eyre::Result<()> { let db_block = node.inner.provider.last_block_number()?; assert_eq!(db_block, 1, "Database should have persisted block 1"); + // Stop the node so its database is closed before the test process exits. Otherwise node + // threads can still use RocksDB while the process tears down, which aborts. + node.stop().await?; + Ok(()) } @@ -146,7 +138,10 @@ async fn test_testing_build_block_v1_osaka() -> eyre::Result<()> { extra_data: None, }; - let envelope = node.testing_build_block_v1(request).await?; + let TestingBuildBlockResponseV1::V5(envelope) = node.testing_build_block_v1(request).await? + else { + panic!("expected a V5 envelope") + }; let engine_client = node.auth_server_handle().http_client(); let payload = envelope.execution_payload.clone(); @@ -189,7 +184,7 @@ async fn test_engine_ssz_proxy_can_mine_block() -> eyre::Result<()> { let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.signer(0)).await; - let envelope = node + let TestingBuildBlockResponseV1::V5(envelope) = node .testing_build_block_v1(TestingBuildBlockRequestV1 { parent_block_hash: genesis_hash, payload_attributes: eth_payload_attributes( @@ -199,7 +194,10 @@ async fn test_engine_ssz_proxy_can_mine_block() -> eyre::Result<()> { transactions: vec![raw_tx], extra_data: None, }) - .await?; + .await? + else { + panic!("expected a V5 envelope") + }; let payload = envelope.execution_payload; let block_hash = payload.payload_inner.payload_inner.block_hash; @@ -549,11 +547,7 @@ async fn test_engine_ssz_request_validation() -> eyre::Result<()> { let auth = node.auth_server_handle(); let url = auth.http_url(); let client = reqwest::Client::new(); - let state = ForkchoiceState { - head_block_hash: chain.genesis_hash(), - safe_block_hash: chain.genesis_hash(), - finalized_block_hash: chain.genesis_hash(), - }; + let state = ForkchoiceState::same_hash(chain.genesis_hash()); for (fork, withdrawals, expected_error) in [ ("cancun", 0, Some("unsupported-fork")), ("osaka", 0, Some("unsupported-fork")), diff --git a/crates/ethereum/node/tests/e2e/finality.rs b/crates/ethereum/node/tests/e2e/finality.rs new file mode 100644 index 00000000000..32054879d11 --- /dev/null +++ b/crates/ethereum/node/tests/e2e/finality.rs @@ -0,0 +1,47 @@ +//! Tests for the finality policy of blocks imported by the test context. + +use alloy_primitives::B256; +use alloy_rpc_types_engine::ForkchoiceState; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{node::Finality, E2ETestSetupExt}; +use reth_node_ethereum::EthereumNode; + +/// Tests that imported blocks become the head, while the safe and finalized blocks follow the +/// finality policy of the test context. +#[tokio::test] +async fn imported_blocks_follow_finality_policy() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, _) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let genesis = node.block_hash(0); + let state = |head: B256, finalized: B256| ForkchoiceState { + head_block_hash: head, + safe_block_hash: finalized, + finalized_block_hash: finalized, + }; + + node.set_finality(Finality::Keep); + for _ in 0..2 { + let head = node.advance_block().await?.block().hash(); + assert_eq!(node.current_forkchoice_state()?, state(head, genesis)); + } + + // Block 3 lags to genesis, which is finalized already, block 4 lags to block 1. + node.set_finality(Finality::Lag(3)); + let head = node.advance_block().await?.block().hash(); + assert_eq!(node.current_forkchoice_state()?, state(head, genesis)); + let head = node.advance_block().await?.block().hash(); + assert_eq!(node.current_forkchoice_state()?, state(head, node.block_hash(1))); + + node.set_finality(Finality::Head); + let finalized = node.advance_block().await?.block().hash(); + assert_eq!(node.current_forkchoice_state()?, state(finalized, finalized)); + + // Block 6 lags to block 4, which is below the finalized block 5. + node.set_finality(Finality::Lag(2)); + let head = node.advance_block().await?.block().hash(); + assert_eq!(node.current_forkchoice_state()?, state(head, finalized)); + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/forkchoice.rs b/crates/ethereum/node/tests/e2e/forkchoice.rs index 3dc23e654b8..a38a119c405 100644 --- a/crates/ethereum/node/tests/e2e/forkchoice.rs +++ b/crates/ethereum/node/tests/e2e/forkchoice.rs @@ -3,15 +3,14 @@ use alloy_eips::BlockNumberOrTag; use alloy_primitives::B256; use alloy_provider::Provider; -use alloy_rpc_types_engine::{ForkchoiceState, PayloadStatusEnum}; -use jsonrpsee_core::client::Error; +use alloy_rpc_types_engine::{ForkchoiceState, ForkchoiceUpdateError, PayloadStatusEnum}; +use futures::FutureExt; use reth_chainspec::EthereumHardfork; -use reth_e2e_test_utils::{ - eth_payload_attributes, test_chain_spec, trie::wait_for_persisted_block, E2ETestSetupExt, -}; +use reth_e2e_test_utils::{eth_payload_attributes, test_chain_spec, E2ETestSetupExt}; +use reth_node_api::BeaconForkChoiceUpdateError; use reth_node_ethereum::{EthEngineTypes, EthereumNode}; use reth_provider::{DatabaseProviderFactory, HeaderProvider}; -use reth_rpc_api::{EngineApiClient, TestingBuildBlockRequestV1}; +use reth_rpc_api::{EngineApiClient, TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection}; use std::time::Duration; @@ -41,7 +40,7 @@ async fn invalid_forkchoice_preserves_canonical_state() -> eyre::Result<()> { let mut chains = [vec![genesis], vec![genesis]]; for (branch, length) in [3, 2].into_iter().enumerate() { for number in 1..=length { - let envelope = producer + let TestingBuildBlockResponseV1::V5(envelope) = producer .testing_build_block_v1(TestingBuildBlockRequestV1 { parent_block_hash: *chains[branch].last().unwrap(), payload_attributes: eth_payload_attributes( @@ -51,7 +50,10 @@ async fn invalid_forkchoice_preserves_canonical_state() -> eyre::Result<()> { transactions: vec![], extra_data: None, }) - .await?; + .await? + else { + panic!("expected a V5 envelope") + }; let payload = envelope.execution_payload; let hash = payload.payload_inner.payload_inner.block_hash; for client in [&producer_engine, &engine] { @@ -68,16 +70,14 @@ async fn invalid_forkchoice_preserves_canonical_state() -> eyre::Result<()> { chains[branch].push(hash); } if branch == 0 { - let status = EngineApiClient::::fork_choice_updated_v3( - &engine, - ForkchoiceState { + let status = node + .engine + .forkchoice_updated(ForkchoiceState { head_block_hash: chains[0][3], safe_block_hash: genesis, finalized_block_hash: genesis, - }, - None, - ) - .await?; + }) + .await?; assert_eq!(status.payload_status.status, PayloadStatusEnum::Valid); } } @@ -96,11 +96,16 @@ async fn invalid_forkchoice_preserves_canonical_state() -> eyre::Result<()> { safe_block_hash: safe, finalized_block_hash: finalized, }; - let err = EngineApiClient::::fork_choice_updated_v3(&engine, state, None) - .await - .unwrap_err(); - let Error::Call(err) = err else { panic!("Expected an RPC error, got {err:?}") }; - assert_eq!(err.code(), -38002, "{state:?}"); + let err = node.engine.forkchoice_updated(state).await.unwrap_err(); + assert!( + matches!( + err, + BeaconForkChoiceUpdateError::ForkchoiceUpdateError( + ForkchoiceUpdateError::InvalidState + ) + ), + "{state:?}: {err}" + ); for (tag, hash) in [ (BlockNumberOrTag::Latest, a[3]), @@ -119,16 +124,14 @@ async fn invalid_forkchoice_preserves_canonical_state() -> eyre::Result<()> { } // Safe/finalized hashes on the proposed branch must be accepted before it is canonical. - let status = EngineApiClient::::fork_choice_updated_v3( - &engine, - ForkchoiceState { + let status = node + .engine + .forkchoice_updated(ForkchoiceState { head_block_hash: b[2], safe_block_hash: b[2], finalized_block_hash: b[1], - }, - None, - ) - .await?; + }) + .await?; assert_eq!(status.payload_status.status, PayloadStatusEnum::Valid); for (tag, hash) in [ (BlockNumberOrTag::Latest, b[2]), @@ -142,15 +145,23 @@ async fn invalid_forkchoice_preserves_canonical_state() -> eyre::Result<()> { assert!(rpc.get_block_by_number(BlockNumberOrTag::Number(3)).await?.is_none()); // Invalid payload attributes must not roll back an otherwise valid forkchoice update. - let err = EngineApiClient::::fork_choice_updated_v3( - &engine, - ForkchoiceState::same_hash(b[2]), - Some(eth_payload_attributes(&chain_spec, 1)), - ) - .await - .unwrap_err(); - let Error::Call(err) = err else { panic!("Expected an RPC error, got {err:?}") }; - assert_eq!(err.code(), -38003); + let err = node + .engine + .forkchoice_updated_with_attributes( + ForkchoiceState::same_hash(b[2]), + eth_payload_attributes(&chain_spec, 1), + ) + .await + .unwrap_err(); + assert!( + matches!( + err, + BeaconForkChoiceUpdateError::ForkchoiceUpdateError( + ForkchoiceUpdateError::UpdatedInvalidPayloadAttributes + ) + ), + "{err}" + ); assert_eq!( rpc.get_block_by_number(BlockNumberOrTag::Finalized).await?.unwrap().header.hash, b[2] @@ -203,7 +214,7 @@ async fn assert_fcu_restores_reorged_out_persisted_head(sibling_len: u64) -> eyr let mut chains = [vec![genesis], vec![genesis]]; for (branch, length) in [3, sibling_len].into_iter().enumerate() { for number in 1..=length { - let envelope = producer + let TestingBuildBlockResponseV1::V5(envelope) = producer .testing_build_block_v1(TestingBuildBlockRequestV1 { parent_block_hash: *chains[branch].last().unwrap(), payload_attributes: eth_payload_attributes( @@ -213,7 +224,10 @@ async fn assert_fcu_restores_reorged_out_persisted_head(sibling_len: u64) -> eyr transactions: vec![], extra_data: None, }) - .await?; + .await? + else { + panic!("expected a V5 envelope") + }; let payload = envelope.execution_payload; let hash = payload.payload_inner.payload_inner.block_hash; for client in [&producer_engine, &engine] { @@ -231,7 +245,7 @@ async fn assert_fcu_restores_reorged_out_persisted_head(sibling_len: u64) -> eyr } if branch == 0 { node.update_forkchoice(genesis, chains[0][3]).await?; - wait_for_persisted_block(&node.inner.provider, 3, Duration::from_secs(30)).await?; + node.wait_for_persisted_block(3).await?; } } let [a, b] = chains; @@ -244,15 +258,11 @@ async fn assert_fcu_restores_reorged_out_persisted_head(sibling_len: u64) -> eyr for head in [*b.last().unwrap(), a[3]] { let status = tokio::time::timeout( Duration::from_secs(10), - EngineApiClient::::fork_choice_updated_v3( - &engine, - ForkchoiceState { - head_block_hash: head, - safe_block_hash: B256::ZERO, - finalized_block_hash: B256::ZERO, - }, - None, - ), + node.engine.forkchoice_updated(ForkchoiceState { + head_block_hash: head, + safe_block_hash: B256::ZERO, + finalized_block_hash: B256::ZERO, + }), ) .await??; assert_eq!(status.payload_status.status, PayloadStatusEnum::Valid); @@ -281,3 +291,102 @@ async fn assert_fcu_restores_reorged_out_persisted_head(sibling_len: u64) -> eyr Ok(()) } + +/// A forkchoice update to genesis must unwind the canonical chain when unwinding to a canonical +/// ancestor is enabled. Genesis is always loaded from the database and has no parent state. +#[tokio::test] +async fn fcu_unwinds_canonical_chain_to_genesis() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = test_chain_spec(EthereumHardfork::Cancun); + let (mut nodes, _) = EthereumNode::test_setup(1, chain_spec.clone()) + .with_tree_config_modifier(|config| { + config + .with_always_process_payload_attributes_on_canonical_head(true) + .with_unwind_canonical_header(true) + }) + .with_rpc_modifier(|rpc| { + rpc.with_http_api(RpcModuleSelection::from([ + RethRpcModule::Eth, + RethRpcModule::Testing, + ])) + }) + .build() + .await?; + let node = nodes.pop().unwrap(); + let genesis = node.block_hash(0); + let engine = node.auth_server_handle().http_client(); + let rpc = node.rpc_provider(); + + let mut head = genesis; + for number in 1..=3 { + let TestingBuildBlockResponseV1::V5(envelope) = node + .testing_build_block_v1(TestingBuildBlockRequestV1 { + parent_block_hash: head, + payload_attributes: eth_payload_attributes(&chain_spec, number), + transactions: vec![], + extra_data: None, + }) + .await? + else { + panic!("expected a V5 envelope") + }; + let payload = envelope.execution_payload; + let hash = payload.payload_inner.payload_inner.block_hash; + let status = + EngineApiClient::::new_payload_v3(&engine, payload, vec![], B256::ZERO) + .await?; + assert_eq!(status.status, PayloadStatusEnum::Valid); + node.update_forkchoice(genesis, hash).await?; + head = hash; + } + assert_eq!(rpc.get_block_number().await?, 3); + + let status = node + .engine + .forkchoice_updated(ForkchoiceState { + head_block_hash: genesis, + safe_block_hash: genesis, + finalized_block_hash: genesis, + }) + .await?; + assert_eq!(status.payload_status.status, PayloadStatusEnum::Valid); + assert_eq!( + rpc.get_block_by_number(BlockNumberOrTag::Latest).await?.unwrap().header.hash, + genesis, + "unwinding to genesis must make it the canonical head" + ); + + Ok(()) +} + +/// Payload jobs the harness did not start or did not resolve must not affect the next block it +/// builds, e.g. a job started by a forkchoice update with payload attributes sent by the test, or +/// by a cancelled `advance_block`. +#[tokio::test] +async fn advance_block_ignores_foreign_payload_jobs() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let chain_spec = test_chain_spec(EthereumHardfork::Cancun); + let (mut node, _) = EthereumNode::test_setup(1, chain_spec.clone()).build_single().await?; + let genesis = node.block_hash(0); + let engine = node.auth_server_handle().http_client(); + + let attributes = eth_payload_attributes(&chain_spec, node.payload.timestamp + 10); + let updated = EngineApiClient::::fork_choice_updated_v3( + &engine, + ForkchoiceState::same_hash(genesis), + Some(attributes), + ) + .await?; + assert!(updated.payload_id.is_some()); + + // The first poll sends the forkchoice update that starts the payload job. + assert!(node.advance_block().now_or_never().is_none()); + + let payload = node.advance_block().await?; + assert_eq!(payload.block().parent_hash, genesis); + assert_eq!(node.block_hash(1), payload.block().hash()); + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/invalid_payload.rs b/crates/ethereum/node/tests/e2e/invalid_payload.rs index bb52ef19e0b..47688ccdcdf 100644 --- a/crates/ethereum/node/tests/e2e/invalid_payload.rs +++ b/crates/ethereum/node/tests/e2e/invalid_payload.rs @@ -10,9 +10,11 @@ use alloy_rpc_types_engine::{ExecutionPayloadV1, ExecutionPayloadV3, PayloadStat use rand::{rngs::StdRng, Rng, SeedableRng}; use reth_chainspec::EthereumHardfork; use reth_e2e_test_utils::{transaction::TransactionTestContext, E2ETestSetupExt}; -use reth_ethereum_primitives::TransactionSigned; +use reth_ethereum_engine_primitives::EthBuiltPayload; +use reth_ethereum_primitives::{EthPrimitives, TransactionSigned}; use reth_node_ethereum::EthereumNode; -use reth_primitives_traits::SignedTransaction; +use reth_primitives_traits::{RecoveredBlock, SignedTransaction}; +use std::sync::Arc; use reth_rpc_api::EngineApiClient; @@ -406,7 +408,7 @@ async fn undecodable_bal_is_invalid_payload() -> eyre::Result<()> { RequestsOrHash::Requests(envelope.execution_requests.clone()), ) .await?; - assert!(matches!(invalid_status.status, PayloadStatusEnum::Invalid { .. })); + assert!(invalid_status.is_invalid()); assert_eq!(invalid_status.latest_valid_hash, None); // The same block with well-formed block access list bytes is processed normally. @@ -418,7 +420,45 @@ async fn undecodable_bal_is_invalid_payload() -> eyre::Result<()> { RequestsOrHash::Requests(envelope.execution_requests), ) .await?; - assert!(matches!(status.status, PayloadStatusEnum::Valid)); + assert!(status.is_valid()); + + Ok(()) +} + +/// Tests that `submit_payload` returns an error with the validation error and the latest valid +/// hash if the engine reports the payload invalid, while `submit_payload_with_status` returns the +/// `INVALID` status. +#[tokio::test] +async fn submit_payload_errors_on_invalid_payload() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, _) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let payload = node.new_payload().await?; + + // Reseal the block with a wrong state root, so it only fails validation after execution. + let mut block = payload.block().clone_block(); + block.header.state_root = B256::ZERO; + let block = RecoveredBlock::new_unhashed(block, payload.recovered_block().senders().to_vec()); + let invalid = + EthBuiltPayload::::new(Arc::new(block), payload.fees(), None, None); + let invalid_hash = invalid.block().hash(); + let parent_hash = invalid.block().parent_hash; + + assert_eq!( + node.submit_payload(invalid.clone()).await.unwrap_err().to_string(), + format!( + "payload {invalid_hash} is invalid (latest valid hash: {parent_hash}): mismatched \ + block state root: got {}, expected {}", + payload.block().state_root, + B256::ZERO + ) + ); + + // The engine now rejects the payload as a known invalid block. + let status = node.submit_payload_with_status(invalid).await?; + assert!(status.is_invalid(), "{status}"); + assert_eq!(status.latest_valid_hash, Some(parent_hash)); Ok(()) } diff --git a/crates/ethereum/node/tests/e2e/main.rs b/crates/ethereum/node/tests/e2e/main.rs index 7272b4fade8..fcf33b6cdeb 100644 --- a/crates/ethereum/node/tests/e2e/main.rs +++ b/crates/ethereum/node/tests/e2e/main.rs @@ -5,14 +5,18 @@ mod custom_genesis; mod dev; mod eip6780; mod eth; +mod finality; mod forkchoice; mod invalid_payload; mod p2p; mod pool; mod prestate; +mod reorg; +mod restart; mod rpc; mod selfdestruct; mod simulate; +mod snap; mod utils; const fn main() {} diff --git a/crates/ethereum/node/tests/e2e/p2p.rs b/crates/ethereum/node/tests/e2e/p2p.rs index cb076baf906..6bbd2d433bc 100644 --- a/crates/ethereum/node/tests/e2e/p2p.rs +++ b/crates/ethereum/node/tests/e2e/p2p.rs @@ -1,16 +1,12 @@ use crate::utils::advance_with_random_transactions; -use alloy_consensus::{SignableTransaction, TxEip1559, TxEnvelope}; -use alloy_eips::{BlockNumberOrTag, Encodable2718}; -use alloy_network::TxSignerSync; -use alloy_primitives::B256; +use alloy_eips::BlockNumberOrTag; +use alloy_primitives::{Address, B256}; use alloy_provider::Provider; use alloy_rpc_types_engine::{ForkchoiceState, PayloadStatusEnum}; use futures::StreamExt; use rand::{rngs::StdRng, seq::IndexedRandom, Rng, SeedableRng}; use reth_chainspec::EthereumHardfork; -use reth_e2e_test_utils::{ - test_chain_spec, transaction::TransactionTestContext, wallet::Wallet, E2ETestSetupExt, -}; +use reth_e2e_test_utils::{test_chain_spec, transaction::TransactionTestContext, E2ETestSetupExt}; use reth_engine_primitives::ConsensusEngineEvent; use reth_ethereum_primitives::EthPrimitives; use reth_network::{test_utils::Testnet, NetworkInfo, Peers, PeersInfo}; @@ -20,7 +16,6 @@ use reth_node_ethereum::EthereumNode; use reth_primitives_traits::SealedBlock; use reth_provider::test_utils::MockEthProvider; use reth_tasks::Runtime; -use reth_transaction_pool::TransactionPool; use std::{net::UdpSocket, sync::Arc, time::Duration}; #[tokio::test] @@ -145,15 +140,11 @@ async fn rejects_downloaded_block_with_invalid_bal_hash() -> eyre::Result<()> { ); peer_provider.add_block(invalid_hash, invalid_block.into_block()); - let mut testnet = Testnet::create_with(1, peer_provider).await; - testnet.for_each_mut(|peer| peer.install_request_handler()); - let peer = testnet.peers()[0].handle(); - let peer_id = *peer.peer_id(); - let peer_addr = peer.local_addr(); - let _testnet = testnet.spawn(); + let testnet = Testnet::create_with(1, peer_provider).await.with_request_handlers().spawn(); + let [peer] = testnet.peers_array(); - node.inner.network.add_peer(peer_id, peer_addr); - assert_eq!(node.network.next_session_established().await, Some(peer_id)); + node.inner.network.add_peer(*peer.peer_id(), peer.local_addr()); + assert_eq!(node.network.next_session_established().await, Some(*peer.peer_id())); let mut engine_events = node.inner.add_ons_handle.consensus_engine_events().new_listener(); let state = ForkchoiceState { @@ -242,7 +233,7 @@ async fn test_long_reorg() -> eyre::Result<()> { second_node.sync_to(head.header.hash).await?; // Produce a fork chain with blocks 21..60 - second_node.payload.timestamp = head.header.timestamp; + second_node.set_next_payload_timestamp(head.header.timestamp + 1)?; advance_with_random_transactions(&mut second_node, 40, &mut rng, true).await?; // Reorg first node from 100th block to new 60th block. @@ -288,15 +279,7 @@ async fn test_pipeline_sync_target_head_becomes_finalized() -> eyre::Result<()> // Send exactly one FCU with an unknown head == safe == finalized. The node must promote this // FCU when pipeline sync completes, without relying on another FCU. second_node.update_forkchoice(target.header.hash, target.header.hash).await?; - - tokio::time::timeout(Duration::from_secs(40), async { - while second_provider.get_block_number().await? != TARGET_BLOCK { - tokio::time::sleep(Duration::from_millis(100)).await; - } - eyre::Ok(()) - }) - .await - .map_err(|_| eyre::eyre!("timed out waiting for pipeline sync to block {TARGET_BLOCK}"))??; + second_node.wait_for_head(target.header.hash).await?; let finalized = second_provider .get_block_by_number(BlockNumberOrTag::Finalized) @@ -332,7 +315,7 @@ async fn test_reorg_through_backfill() -> eyre::Result<()> { second_node.sync_to(head.header.hash).await?; // Produce an unfinalized fork chain with 30 blocks - second_node.payload.timestamp = head.header.timestamp; + second_node.set_next_payload_timestamp(head.header.timestamp + 1)?; advance_with_random_transactions(&mut second_node, 30, &mut rng, false).await?; // Now reorg second node to the finalized canonical head @@ -348,27 +331,13 @@ async fn test_tx_propagation() -> eyre::Result<()> { let chain_spec = test_chain_spec(EthereumHardfork::Prague); - // Setup wallet - let chain_id = chain_spec.chain().into(); - let wallet = Wallet::new(1).inner; - let mut nonce = 0; - let mut build_tx = || { - let mut tx = TxEip1559 { - chain_id, - max_priority_fee_per_gas: 1_000_000_000, - max_fee_per_gas: 1_000_000_000, - gas_limit: 100_000, - nonce, - ..Default::default() - }; - nonce += 1; - let signature = wallet.sign_transaction_sync(&mut tx).unwrap(); - TxEnvelope::Eip1559(tx.into_signed(signature)) - }; - // Setup 10 nodes - let (mut nodes, _) = - EthereumNode::test_setup(10, chain_spec).with_connect_nodes(false).build().await?; + let (mut nodes, wallet) = EthereumNode::test_setup(10, chain_spec) + .with_connect_nodes(false) + .with_restartable_nodes() + .build() + .await?; + let mut account = wallet.account(0); // Connect all nodes to the first one let (first, rest) = nodes.split_at_mut(1); @@ -377,23 +346,25 @@ async fn test_tx_propagation() -> eyre::Result<()> { } // Build and send transaction to first node - let tx = build_tx(); - let tx_hash = *tx.tx_hash(); - let _ = nodes[0].rpc.inject_tx(tx.encoded_2718().into()).await?; + let tx_hash = nodes[0].rpc.inject_tx(account.tx().to(Address::ZERO).await).await?; // Wait until all nodes have the transaction for node in &nodes { - node.wait_for_pool(|pool| pool.contains(&tx_hash)).await?; + node.wait_for_pooled([tx_hash]).await?; } // Build and send one more transaction to a random node - let tx = build_tx(); - let tx_hash = *tx.tx_hash(); - let _ = nodes.choose(&mut rand::rng()).unwrap().rpc.inject_tx(tx.encoded_2718().into()).await?; + let raw_tx = account.tx().to(Address::ZERO).await; + let tx_hash = nodes.choose(&mut rand::rng()).unwrap().rpc.inject_tx(raw_tx).await?; // Wait until all nodes have the transaction for node in &nodes { - node.wait_for_pool(|pool| pool.contains(&tx_hash)).await?; + node.wait_for_pooled([tx_hash]).await?; + } + + // Close each database before process exit can run libmdbx's global destructor. + for node in nodes { + node.stop().await?; } Ok(()) diff --git a/crates/ethereum/node/tests/e2e/pool.rs b/crates/ethereum/node/tests/e2e/pool.rs index 9b68ff9ff80..a0559dbc65e 100644 --- a/crates/ethereum/node/tests/e2e/pool.rs +++ b/crates/ethereum/node/tests/e2e/pool.rs @@ -1,28 +1,29 @@ use alloy_consensus::{EthereumTxEnvelope, TxEip4844}; use alloy_eips::{eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M, Encodable2718}; -use alloy_primitives::{Address, TxKind, B256, U256}; +use alloy_primitives::{Address, B256, U256}; use alloy_provider::Provider; -use alloy_rpc_types_eth::TransactionRequest; use reth_chainspec::EthereumHardfork; use reth_e2e_test_utils::{ - test_chain_spec, transaction::TransactionTestContext, wait::poll_until, E2ETestSetupExt, + node::Finality, test_chain_spec, test_chain_spec_builder, test_genesis, + transaction::TransactionTestContext, wait::poll_until, E2ETestSetupExt, }; use reth_node_core::args::TxPoolArgs; use reth_node_ethereum::EthereumNode; use reth_primitives_traits::Recovered; -use reth_provider::CanonStateSubscriptions; +use reth_provider::{BlockNumReader, CanonStateSubscriptions}; use reth_transaction_pool::{ blobstore::InMemoryBlobStore, test_utils::OkValidator, BlockInfo, CoinbaseTipOrdering, EthPooledTransaction, Pool, PoolTransaction, TransactionOrigin, TransactionPool, TransactionPoolExt, }; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; #[tokio::test] async fn rpc_enforces_minimum_priority_fee() -> eyre::Result<()> { reth_tracing::init_test_tracing(); const MINIMUM_PRIORITY_FEE: u128 = 1; + const MAX_FEE: u128 = 20_000_000_000; let (node, wallet) = EthereumNode::test_setup_for(EthereumHardfork::Cancun) .with_node_config_modifier(|config| { @@ -34,20 +35,9 @@ async fn rpc_enforces_minimum_priority_fee() -> eyre::Result<()> { .build_single() .await?; let provider = node.rpc_provider(); + let mut account = wallet.account(0); - let transaction = |max_priority_fee_per_gas| TransactionRequest { - nonce: Some(0), - value: Some(U256::from(100)), - to: Some(TxKind::Call(Address::ZERO)), - gas: Some(21_000), - max_fee_per_gas: Some(20_000_000_000), - max_priority_fee_per_gas: Some(max_priority_fee_per_gas), - chain_id: Some(1), - ..Default::default() - }; - - let below_minimum = - TransactionTestContext::sign_tx_bytes(wallet.inner.clone(), transaction(0)).await; + let below_minimum = account.transfer(Address::ZERO, U256::from(100)).fees(MAX_FEE, 0).await; let err = provider.send_raw_transaction(&below_minimum).await.unwrap_err(); assert!( err.to_string().contains("transaction priority fee below minimum required priority fee 1"), @@ -55,9 +45,12 @@ async fn rpc_enforces_minimum_priority_fee() -> eyre::Result<()> { ); assert!(node.inner.pool.is_empty()); - let at_minimum = - TransactionTestContext::sign_tx_bytes(wallet.inner, transaction(MINIMUM_PRIORITY_FEE)) - .await; + // The rejected transaction did not use up its nonce. + let at_minimum = account + .transfer(Address::ZERO, U256::from(100)) + .fees(MAX_FEE, MINIMUM_PRIORITY_FEE) + .nonce(0) + .await; let pending = provider.send_raw_transaction(&at_minimum).await?; assert_eq!(node.inner.pool.len(), 1); assert!(node.inner.pool.contains(pending.tx_hash())); @@ -111,10 +104,8 @@ async fn maintain_txpool_stale_eviction() -> eyre::Result<()> { txpool.add_transaction(TransactionOrigin::External, pooled_tx).await.unwrap(); assert_eq!(txpool.len(), 1); - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - - // stale tx should be evicted - assert_eq!(txpool.len(), 0); + poll_until("stale transaction to be evicted", || async { Ok(txpool.is_empty().then_some(())) }) + .await?; Ok(()) } @@ -136,6 +127,8 @@ async fn maintain_txpool_reorg() -> eyre::Result<()> { let chain_spec = test_chain_spec(EthereumHardfork::Cancun); let genesis_hash = chain_spec.genesis_hash(); let (mut node, wallet) = EthereumNode::test_setup(1, chain_spec).build_single().await?; + // keep genesis finalized, so the block with tx1 can be reorged + node.set_finality(Finality::Keep); let runtime = node.inner.task_executor.clone(); let (w1, w2) = (wallet.signer(0), wallet.signer(1)); @@ -184,26 +177,10 @@ async fn maintain_txpool_reorg() -> eyre::Result<()> { txpool.add_transaction(TransactionOrigin::External, pooled_tx2).await.unwrap(); // inject tx1, make the node advance and eventually generate `CanonStateNotification::Commit` - // event to propagate to the pool + // event to propagate to the pool. Wait for the node's own pool to drop tx1, so the block of the + // reorg below can't include it. let _ = node.rpc.inject_tx(envelop1.encoded_2718().into()).await.unwrap(); - - // build a payload based on tx1 - let payload1 = node.new_payload().await?; - - // clean up the internal pool of the provider node - node.inner.pool.remove_transactions(vec![tx_hash1]); - - // inject tx2, make the node reorg and eventually generate `CanonStateNotification::Reorg` event - // to propagate to the pool - let _ = node.rpc.inject_tx(envelop2.encoded_2718().into()).await.unwrap(); - - // build a payload based on tx2 - let payload2 = node.new_payload().await?; - - // submit payload1 - let block_hash1 = node.submit_payload(payload1).await?; - - node.update_forkchoice(genesis_hash, block_hash1).await?; + node.advance_block_synced().await?; // wait for pool to process `CanonStateNotification::Commit` event correctly, and finally tx1 // will be removed and tx2 is still in the pool. @@ -212,10 +189,10 @@ async fn maintain_txpool_reorg() -> eyre::Result<()> { }) .await?; - // submit payload2 - let block_hash2 = node.submit_payload(payload2).await?; - - node.update_forkchoice(genesis_hash, block_hash2).await?; + // inject tx2 and mine it in a block on genesis, make the node reorg and eventually generate + // `CanonStateNotification::Reorg` event to propagate to the pool + let _ = node.rpc.inject_tx(envelop2.encoded_2718().into()).await.unwrap(); + node.advance_block_on(genesis_hash).await?; // wait for pool to process `CanonStateNotification::Reorg` event properly, and finally tx1 // will be added back to the pool and tx2 will be removed. @@ -227,65 +204,100 @@ async fn maintain_txpool_reorg() -> eyre::Result<()> { Ok(()) } -// Test that the pool's maintenance task can correctly handle `CanonStateNotification::Commit` -// events +// Test that the pool processed the new block once `advance_block_synced` returns. #[tokio::test] -async fn maintain_txpool_commit() -> eyre::Result<()> { +async fn advance_block_synced_waits_for_pool() -> eyre::Result<()> { reth_tracing::init_test_tracing(); - let txpool = Pool::new( - OkValidator::default(), - CoinbaseTipOrdering::default(), - InMemoryBlobStore::default(), - Default::default(), - ); - - // Directly generate a node to simulate various traits such as `StateProviderFactory` required - // by the pool maintenance task let (mut node, wallet) = EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; - let runtime = node.inner.task_executor.clone(); - runtime.spawn_critical_task( - "txpool maintenance task", - reth_transaction_pool::maintain::maintain_transaction_pool_future( - node.inner.provider.clone(), - txpool.clone(), - node.inner.provider.clone().canonical_state_stream(), - runtime.clone(), - reth_transaction_pool::maintain::MaintainPoolConfig::default(), - ), - ); + let mut account = wallet.account(0); + for _ in 0..3 { + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; + let tx_hash = node.rpc.inject_tx(raw_tx).await?; - let envelop = TransactionTestContext::transfer_tx(1, wallet.inner).await; - let tx = Recovered::new_unchecked( - EthereumTxEnvelope::::from(envelop.clone()), - Default::default(), - ); - let pooled_tx = EthPooledTransaction::new(tx.clone(), 200); + let payload = node.advance_block_synced().await?; + let block = payload.block(); + assert!(block.body().transactions().any(|tx| *tx.hash() == tx_hash)); - let block_info = BlockInfo { - block_gas_limit: ETHEREUM_BLOCK_GAS_LIMIT_30M, - last_seen_block_hash: B256::ZERO, - last_seen_block_number: 0, - pending_basefee: 10, - pending_blob_fee: Some(10), - }; + // the mined transaction is removed from the pool without waiting any further + let info = node.inner.pool.block_info(); + assert_eq!(info.last_seen_block_hash, block.hash()); + assert_eq!(info.last_seen_block_number, block.header().number); + assert!(node.inner.pool.is_empty()); + } - txpool.set_block_info(block_info); + Ok(()) +} - txpool.add_transaction(TransactionOrigin::External, pooled_tx).await.unwrap(); - assert_eq!(txpool.len(), 1); +// Test that the pool transaction waits return once the transactions entered and left the pool. +#[tokio::test] +async fn wait_for_pooled_and_pool_removal() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); - // make the node advance and eventually generate `CanonStateNotification::Commit` event to - // propagate to the pool - let _ = node.rpc.inject_tx(envelop.encoded_2718().into()).await.unwrap(); - let _ = node.advance_block().await.unwrap(); + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; - // wait for pool to process `CanonStateNotification::Commit` event correctly, and finally the - // pool will be cleared. - poll_until("pool to process the commit", || async { Ok(txpool.is_empty().then_some(())) }) - .await?; + let mut account = wallet.account(0); + let mut hashes = Vec::new(); + for _ in 0..2 { + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; + hashes.push(node.rpc.inject_tx(raw_tx).await?); + } + node.wait_for_pooled(hashes.clone()).await?; + + // the pool removes the mined transactions in the background + let payload = node.advance_block().await?; + assert_eq!(payload.block().body().transactions().count(), hashes.len()); + node.wait_for_pool_removal(hashes).await?; + assert!(node.inner.pool.is_empty()); + + Ok(()) +} + +// Test that `advance_until_pool_drained` mines the pending transactions over as many blocks as +// they need, without a trailing empty block, and does not wait for a transaction behind a nonce +// gap. +#[tokio::test] +async fn advance_until_pool_drained_mines_pending_transactions() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + // A block of 100k gas fits four transfers. + let mut genesis = test_genesis(); + genesis.gas_limit = 100_000; + let chain_spec = test_chain_spec_builder().genesis(genesis).cancun_activated().build(); + let (mut node, wallet) = + EthereumNode::test_setup(1, Arc::new(chain_spec)).build_single().await?; + + let mut account = wallet.account(0).with_gas_limit(21_000); + let mut pending = Vec::new(); + for _ in 0..10 { + let raw_tx = account.transfer(Address::random(), U256::from(100)).await; + pending.push(node.rpc.inject_tx(raw_tx).await?); + } + let gap_nonce = account.nonce() + 1; + let queued = account.transfer(Address::random(), U256::from(100)).nonce(gap_nonce).await; + let queued = node.rpc.inject_tx(queued).await?; + + let payloads = node.advance_until_pool_drained().await?; + assert_eq!(payloads.len(), 3); + assert!(payloads.iter().all(|payload| !payload.block().body().transactions.is_empty())); + let mined = payloads + .iter() + .flat_map(|payload| payload.block().body().transactions().map(|tx| *tx.hash())) + .collect::>(); + assert_eq!(mined, pending); + let head = payloads.last().unwrap().block().number; + assert_eq!(node.inner.provider.best_block_number()?, head); + + let size = node.inner.pool.pool_size(); + assert_eq!((size.pending, size.queued), (0, 1)); + assert!(node.inner.pool.contains(&queued)); + + // Nothing is pending, so the chain does not advance. + assert!(node.advance_until_pool_drained().await?.is_empty()); + assert_eq!(node.inner.provider.best_block_number()?, head); Ok(()) } diff --git a/crates/ethereum/node/tests/e2e/reorg.rs b/crates/ethereum/node/tests/e2e/reorg.rs new file mode 100644 index 00000000000..936db184de4 --- /dev/null +++ b/crates/ethereum/node/tests/e2e/reorg.rs @@ -0,0 +1,75 @@ +//! Tests for building forks and reorging with the test context. + +use alloy_primitives::B256; +use alloy_rpc_types_engine::ForkchoiceState; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{node::Finality, E2ETestSetupExt}; +use reth_node_ethereum::EthereumNode; + +/// Tests that a fork built with `advance_fork` becomes the head, that `reorg_to` switches back to +/// the previous chain, and that both explain why they can't reorg below the finalized block. +#[tokio::test] +async fn can_build_forks_and_reorg() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, _) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let genesis = node.block_hash(0); + node.set_finality(Finality::Keep); + + // a1 <- a2 and b1 <- b2 <- b3, both on genesis. + let a = node.advance_blocks(2).await?; + let (a1, a2) = (a[0].block().hash(), a[1].block().hash()); + let b = node.advance_fork(genesis, 3).await?; + let (b1, b3) = (b[0].block().hash(), b[2].block().hash()); + assert_eq!(b[0].block().parent_hash, genesis); + assert_eq!( + node.current_forkchoice_state()?, + ForkchoiceState { + head_block_hash: b3, + safe_block_hash: genesis, + finalized_block_hash: genesis + } + ); + + // Building on a canonical ancestor leaves the head. + assert_eq!(node.build_payload_on(b1).await?.block().parent_hash, b1); + assert_eq!(node.current_forkchoice_state()?.head_block_hash, b3); + + node.reorg_to(a2).await?; + assert_eq!(node.current_forkchoice_state()?.head_block_hash, a2); + + assert_eq!( + node.reorg_to(a1).await.unwrap_err().to_string(), + format!( + "block {a1} is a canonical ancestor of the head {a2}, which the engine does not move \ + the head back to, build a block on it with `advance_block_on` instead" + ) + ); + + // Once a3 is finalized, neither chain B nor the blocks below a3 can become the head. + node.set_finality(Finality::Head); + let a3 = node.advance_block().await?.block().hash(); + let below_finalized = |head: B256| { + format!( + "the node can't reorg to block {head}: it does not descend from the safe and \ + finalized blocks of the node (finalized: 3 ({a3})). Only blocks above the finalized \ + block can be reorged, import them under `Finality::Keep` or `Finality::Lag`, see \ + `NodeTestContext::set_finality`" + ) + }; + assert_eq!(node.reorg_to(b3).await.unwrap_err().to_string(), below_finalized(b3)); + assert_eq!(node.build_payload_on(a2).await.unwrap_err().to_string(), below_finalized(a2)); + + let unknown = B256::repeat_byte(1); + assert_eq!( + node.reorg_to(unknown).await.unwrap_err().to_string(), + format!( + "the node does not know block {unknown} or one of its ancestors, or is syncing, \ + submit the blocks first, e.g. with `submit_payload`" + ) + ); + assert_eq!(node.current_forkchoice_state()?, ForkchoiceState::same_hash(a3)); + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/restart.rs b/crates/ethereum/node/tests/e2e/restart.rs new file mode 100644 index 00000000000..47a5b2d894f --- /dev/null +++ b/crates/ethereum/node/tests/e2e/restart.rs @@ -0,0 +1,264 @@ +//! Tests for stopping a node and starting it again on its datadir. + +use alloy_consensus::BlockHeader; +use alloy_primitives::{Address, U256}; +use alloy_provider::Provider; +use futures::TryStreamExt; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{ + wait::{poll_until, WAIT_TIMEOUT}, + E2ETestSetupExt, +}; +use reth_exex::ExExEvent; +use reth_network::PeersInfo; +use reth_node_ethereum::EthereumNode; +use reth_provider::{BlockIdReader, BlockNumReader, DatabaseProviderFactory, HeaderProvider}; +use reth_transaction_pool::TransactionPool; +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::mpsc; + +/// A restarted node reopens the chain it persisted, reinserts its pending local transactions and +/// builds on its head, with both storage layouts. +#[tokio::test] +async fn restart_keeps_persisted_chain() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + for storage_v2 in [false, true] { + let (mut node, wallet) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_restartable_nodes() + .with_storage_v2(storage_v2) + .with_tree_config_modifier(|config| { + config.with_persistence_threshold(0).with_memory_block_buffer_target(0) + }) + .build_single() + .await?; + + let recipient = Address::random(); + let mut account = wallet.account(0); + let raw_tx = account.transfer(recipient, U256::from(100)).await; + let (tx_hash, _) = node.inject_and_advance(raw_tx).await?; + let head = node.advance_block().await?.block().hash(); + node.wait_for_persisted_block(2).await?; + let pending_tx_hash = + node.rpc.inject_tx(account.transfer(recipient, U256::ONE).await).await?; + let exit_future = + node.take_exit_future().ok_or_else(|| eyre::eyre!("the node has no exit future"))?; + + let mut node = node.restart().await?; + + // The consensus engine of the stopped node exited cleanly. + exit_future.await?; + assert_eq!(node.inner.provider.best_block_number()?, 2); + assert_eq!(node.block_hash(2), head); + assert_eq!(node.inner.provider.safe_block_hash()?, Some(head)); + assert_eq!(node.inner.provider.finalized_block_hash()?, Some(head)); + let receipt = node + .rpc + .transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("receipt of {tx_hash} not found after the restart"))?; + assert_eq!(receipt.block_number, Some(1)); + assert!(receipt.status()); + assert_eq!(node.rpc_provider().get_balance(recipient).await?, U256::from(100)); + + node.wait_for_pool(|pool| pool.contains(&pending_tx_hash)).await?; + let payload = node.advance_block().await?; + let block = payload.block(); + assert_eq!(block.number(), 3); + assert_eq!(block.parent_hash(), head); + assert!(block.body().transactions().any(|tx| *tx.hash() == pending_tx_hash)); + } + + Ok(()) +} + +/// Stopping a node persists its canonical blocks that were still in memory, and drops the blocks +/// that are not canonical. +#[tokio::test] +async fn stop_persists_canonical_chain_only() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_restartable_nodes() + .build_single() + .await?; + + node.advance_blocks(3).await?; + let head = node.block_hash(3); + // A valid block on top of the head that never becomes canonical. + let side_block = node.new_payload().await?; + node.submit_payload(side_block.clone()).await?; + // The engine keeps recent blocks in memory with the default persistence threshold. + assert_eq!(node.inner.provider.database_provider_ro()?.best_block_number()?, 0); + + let node = node.restart().await?; + + assert_eq!(node.inner.provider.database_provider_ro()?.best_block_number()?, 3); + assert_eq!(node.inner.provider.best_block_number()?, 3); + assert_eq!(node.block_hash(3), head); + assert!(node.inner.provider.header(side_block.block().hash())?.is_none()); + + node.import_payload(side_block).await?; + assert_eq!(node.inner.provider.best_block_number()?, 4); + + Ok(()) +} + +/// Nodes are not restartable unless the setup opts in. +#[tokio::test] +async fn stop_requires_restartable_node() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (node, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague).build_single().await?; + + let err = node.stop().await.unwrap_err(); + assert_eq!( + err.to_string(), + "the node is not restartable, launch it with \ + `E2ETestSetupBuilder::with_restartable_nodes` to stop it" + ); + + Ok(()) +} + +/// A provider of the node that is held across `stop` keeps its database open, which `stop` +/// reports instead of closing the database under it. +#[tokio::test] +async fn stop_fails_while_provider_is_held() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (node, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_restartable_nodes() + .build_single() + .await?; + + let provider = node.inner.provider.clone(); + // The held provider never releases the database, so a short timeout fails the same way. + let release_timeout = Duration::from_millis(100); + let err = node.stop_with_release_timeout(release_timeout).await.unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "the database of the stopped node is still in use {release_timeout:?} after the node \ + shut down: drop all handles that hold a provider of the node, e.g. a clone of \ + `inner.provider`, before stopping it" + ) + ); + assert_eq!(provider.best_block_number()?, 0); + + Ok(()) +} + +/// A database transaction that is held across `stop` keeps the database open without a handle of +/// it, which `stop` reports instead of leaving it to the next launch. +#[tokio::test] +async fn stop_fails_while_transaction_is_open() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (node, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_restartable_nodes() + .build_single() + .await?; + + let provider = node.inner.provider.database_provider_ro()?; + let err = node.stop().await.unwrap_err(); + assert_eq!( + err.to_string(), + "the database of the stopped node is still open: drop all database transactions of the \ + node, e.g. providers of `database_provider_ro`, before stopping it" + ); + assert_eq!(provider.best_block_number()?, 0); + + Ok(()) +} + +/// A restarted node reconnects to its peer and syncs the blocks it missed while it was stopped. +#[tokio::test] +async fn restarted_node_syncs_from_peer() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut nodes, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_num_nodes(2) + .with_restartable_nodes() + .build() + .await?; + let node = nodes.pop().expect("two nodes"); + let mut peer = nodes.pop().expect("two nodes"); + + let payload = peer.advance_block().await?; + node.import_payload(payload).await?; + let stopped = node.stop().await?; + + peer.advance_blocks(3).await?; + let head = peer.block_hash(4); + let network = &peer.inner.network; + poll_until("the peer to notice the disconnect of the stopped node", || async { + Ok((network.num_connected_peers() == 0).then_some(())) + }) + .await?; + + let mut node = stopped.start().await?; + assert_eq!(node.inner.provider.best_block_number()?, 1); + node.connect(&mut peer).await; + node.sync_to(head).await?; + assert_eq!(node.block_hash(4), head); + + Ok(()) +} + +/// A restarted node is launched with the node factory and node builder modifiers of the setup +/// again, here an `ExEx` that sees the blocks of the restarted node. +#[tokio::test] +async fn restart_applies_node_factory_and_builder_modifiers() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let launches = Arc::new(AtomicUsize::new(0)); + let (committed_tx, mut committed_rx) = mpsc::unbounded_channel(); + let (node, _) = EthereumNode::test_setup_for(EthereumHardfork::Prague) + .with_restartable_nodes() + .with_node({ + let launches = launches.clone(); + move |_| { + launches.fetch_add(1, Ordering::Relaxed); + EthereumNode::default() + } + }) + .with_node_builder_modifier(move |builder| { + let committed_tx = committed_tx.clone(); + builder.install_exex("committed-chains", |mut ctx| async move { + Ok(async move { + while let Some(notification) = ctx.notifications.try_next().await? { + if let Some(chain) = notification.committed_chain() { + ctx.events.send(ExExEvent::FinishedHeight(chain.tip().num_hash()))?; + let _ = committed_tx.send(chain); + } + } + Ok(()) + }) + }) + }) + .build_single() + .await?; + + let mut node = node.restart().await?; + assert_eq!(launches.load(Ordering::Relaxed), 2); + + let hash = node.advance_block().await?.block().hash(); + tokio::time::timeout(WAIT_TIMEOUT, async { + while let Some(chain) = committed_rx.recv().await { + if chain.tip().hash() == hash { + return Ok(()) + } + } + Err(eyre::eyre!("ExEx stopped before receiving block {hash}")) + }) + .await??; + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/rpc.rs b/crates/ethereum/node/tests/e2e/rpc.rs index 976d6c6e331..a0608b0f56e 100644 --- a/crates/ethereum/node/tests/e2e/rpc.rs +++ b/crates/ethereum/node/tests/e2e/rpc.rs @@ -1,7 +1,7 @@ use alloy_eips::{ eip2718::Encodable2718, eip7910::EthConfig, eip7928::BlockAccessList, BlockNumberOrTag, }; -use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; +use alloy_primitives::{bytes, keccak256, Address, Bytes, B256, U256}; use alloy_provider::{ ext::DebugApi, network::TransactionBuilder, Provider, ProviderBuilder, SendableTx, }; @@ -17,17 +17,18 @@ use alloy_rpc_types_engine::{ use alloy_rpc_types_eth::{ error::EthRpcErrorCode, state::{AccountOverride, StateOverride}, - TransactionRequest, + AccessListResult, TransactionRequest, }; use alloy_rpc_types_trace::geth::{ CallConfig, ChainBlockTraceResult, GethDebugTracingOptions, GethTrace, }; use jsonrpsee::core::client::{ClientT, Subscription, SubscriptionClientT}; use rand::{rngs::StdRng, Rng, SeedableRng}; -use reth_chainspec::{ChainSpecBuilder, EthChainSpec, EthereumHardfork}; +use reth_chainspec::{EthChainSpec, EthereumHardfork}; use reth_e2e_test_utils::{ receipt::PendingTransactionExt, test_chain_spec, test_chain_spec_builder, - transaction::TransactionTestContext, wallet::Wallet, E2ETestSetupExt, NodeHelperType, + transaction::TransactionTestContext, wait::poll_until, wallet::Wallet, E2ETestSetupExt, + NodeHelperType, }; use reth_network::{types::NatResolver, PeersInfo}; use reth_node_builder::{NodeBuilder, NodeHandle}; @@ -66,7 +67,7 @@ async fn test_rpc_shares_sender_recovery_cache_with_execution() -> eyre::Result< use reth_primitives_traits::SignedTransaction; use reth_transaction_pool::test_utils::TransactionGenerator; - let chain_spec = Arc::new(ChainSpecBuilder::mainnet().cancun_activated().build()); + let chain_spec = test_chain_spec(EthereumHardfork::Cancun); let (node, _) = EthereumNode::test_setup(1, chain_spec).build_single().await?; let cache = node.inner.evm_config.sender_recovery_cache.as_ref().unwrap(); let client = node.rpc_client().unwrap(); @@ -133,7 +134,7 @@ async fn test_bal_prewarming_for_transaction_replay() -> eyre::Result<()> { .await?; let provider = node.rpc_provider_with_wallet(wallet.signer(0)); let first = provider - .send_transaction(TransactionRequest::default().to(Address::ZERO).value(U256::from(1))) + .send_transaction(TransactionRequest::default().to(Address::ZERO).value(U256::ONE)) .await?; let second = provider .send_transaction(TransactionRequest::default().to(Address::ZERO).value(U256::from(2))) @@ -149,17 +150,11 @@ async fn test_bal_prewarming_for_transaction_replay() -> eyre::Result<()> { let hashes = [*first.tx_hash(), receipt.transaction_hash]; let mut expected = Vec::new(); if prewarm { - tokio::time::timeout(std::time::Duration::from_secs(10), async { - loop { - let (_, bal) = - cache.get_recovered_block_and_maybe_bal(block_hash).await?.unwrap(); - if bal.is_some() { - return Ok::<_, eyre::Report>(()); - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } + poll_until(format!("prewarmed block access list of block {block_hash}"), || async { + let (_, bal) = cache.get_recovered_block_and_maybe_bal(block_hash).await?.unwrap(); + Ok(bal.is_some().then_some(())) }) - .await??; + .await?; } else { assert!(cache .get_recovered_block_and_maybe_bal(block_hash) @@ -279,8 +274,7 @@ async fn test_debug_trace_chain_subscription() -> eyre::Result<()> { node.advance_block().await?; let _ = GasWaster::deploy_builder(&provider, U256::from(5)).send().await?; let _ = GasWaster::deploy_builder(&provider, U256::from(7)).send().await?; - node.advance_block().await?; - node.advance_block().await?; + node.advance_blocks(2).await?; let client = node.inner.rpc_server_handle().ws_client().await.unwrap(); let invalid: Result, _> = client @@ -337,7 +331,7 @@ async fn test_debug_trace_eip8037_gas() -> eyre::Result<()> { let (mut node, wallet) = EthereumNode::test_setup(1, chain_spec).build_single().await?; let provider = node.rpc_provider_with_wallet(wallet.signer(0)); - let pre_amsterdam = GasWaster::deploy_builder(&provider, U256::from(1)).send().await?; + let pre_amsterdam = GasWaster::deploy_builder(&provider, U256::ONE).send().await?; let pre_amsterdam_hash = *pre_amsterdam.tx_hash(); node.advance_block().await?; pre_amsterdam.successful_receipt().await?; @@ -369,7 +363,7 @@ async fn test_debug_trace_eip8037_gas() -> eyre::Result<()> { assert_eq!(frame.state_gas_used, None); assert_eq!(frame.gas_refund, None); - let amsterdam = GasWaster::deploy_builder(&provider, U256::from(1)).send().await?; + let amsterdam = GasWaster::deploy_builder(&provider, U256::ONE).send().await?; let amsterdam_hash = *amsterdam.tx_hash(); node.advance_block().await?; amsterdam.successful_receipt().await?; @@ -478,56 +472,6 @@ async fn test_flashbots_validate_v3() -> eyre::Result<()> { Ok(()) } -#[tokio::test] -async fn test_flashbots_validate_uses_shared_sender_recovery_cache() -> eyre::Result<()> { - reth_tracing::init_test_tracing(); - - let (mut node, wallet) = - EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; - let cache = node.inner.evm_config.sender_recovery_cache.clone().expect("cache is enabled"); - - let signer = wallet.signer(0); - let sender = signer.address(); - let provider = node.rpc_provider_with_wallet(signer); - - let tx_hash = *provider - .send_transaction(TransactionRequest::default().to(Address::ZERO)) - .await? - .tx_hash(); - // Transaction ingress has already recovered the sender through the shared cache. - let payload = node.new_payload().await?; - assert!(payload.block().body().transactions.iter().any(|tx| *tx.tx_hash() == tx_hash)); - assert_eq!(cache.get(&tx_hash), Some(sender)); - - let request = BuilderBlockValidationRequestV3 { - request: SignedBidSubmissionV3 { - message: BidTrace { - parent_hash: payload.block().parent_hash, - block_hash: payload.block().hash(), - gas_used: payload.block().gas_used, - gas_limit: payload.block().gas_limit, - ..Default::default() - }, - execution_payload: ExecutionPayloadV3::from_block_unchecked( - payload.block().hash(), - &payload.block().clone().into_block(), - ), - blobs_bundle: BlobsBundleV1::new([]), - signature: Default::default(), - }, - parent_beacon_block_root: payload.block().parent_beacon_block_root.unwrap(), - registered_gas_limit: payload.block().gas_limit, - }; - - provider - .raw_request::<_, ()>("flashbots_validateBuilderSubmissionV3".into(), (&request,)) - .await?; - // Validation accepts the submission while reusing the sender cached by transaction ingress. - assert_eq!(cache.get(&tx_hash), Some(sender)); - - Ok(()) -} - #[tokio::test] async fn test_flashbots_validate_v4() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -780,7 +724,7 @@ async fn test_estimate_gas_basic_transfers_post_amsterdam() -> eyre::Result<()> let self_send_gas = provider .estimate_gas( - TransactionRequest::default().with_from(from).with_to(from).with_value(U256::from(1)), + TransactionRequest::default().with_from(from).with_to(from).with_value(U256::ONE), ) .await?; assert_eq!(self_send_gas, 12_000); @@ -795,7 +739,7 @@ async fn test_estimate_gas_basic_transfers_post_amsterdam() -> eyre::Result<()> TransactionRequest::default() .with_from(from) .with_to(existing_recipient) - .with_value(U256::from(1)), + .with_value(U256::ONE), ) .await?; assert_eq!(value_existing_gas, 21_000); @@ -813,7 +757,7 @@ async fn test_estimate_gas_basic_transfers_post_amsterdam() -> eyre::Result<()> TransactionRequest::default() .with_from(from) .with_to(fresh_recipient) - .with_value(U256::from(1)), + .with_value(U256::ONE), ) .await?; assert!(value_fresh_gas > 21_000); @@ -824,10 +768,7 @@ async fn test_estimate_gas_basic_transfers_post_amsterdam() -> eyre::Result<()> let mut overrides = StateOverride::default(); overrides.insert( gas_gate, - AccountOverride { - code: Some("0x5a610fa010600957fe5b00".parse::()?), - ..Default::default() - }, + AccountOverride { code: Some(bytes!("0x5a610fa010600957fe5b00")), ..Default::default() }, ); let gated_tx = TransactionRequest::default().with_from(from).with_to(gas_gate); let gated_gas = provider.estimate_gas(gated_tx.clone()).overrides(overrides.clone()).await?; @@ -892,7 +833,6 @@ async fn test_eth_config() -> eyre::Result<()> { #[tokio::test] async fn test_sepolia_amsterdam_eth_config() -> eyre::Result<()> { use alloy_consensus::Header; - use alloy_primitives::hex; use reth_chainspec::{ sepolia::{SEPOLIA_AMSTERDAM_TIMESTAMP, SEPOLIA_BPO2_TIMESTAMP}, SEPOLIA, @@ -924,14 +864,14 @@ async fn test_sepolia_amsterdam_eth_config() -> eyre::Result<()> { let config = provider.client().request_noparams::("eth_config").await?; if timestamp < SEPOLIA_AMSTERDAM_TIMESTAMP { assert_eq!(config.current.activation_time, SEPOLIA_BPO2_TIMESTAMP); - assert_eq!(config.current.fork_id, Bytes::from_static(&hex!("268956b6"))); + assert_eq!(config.current.fork_id, bytes!("268956b6")); let next = config.next.unwrap(); assert_eq!(next.activation_time, SEPOLIA_AMSTERDAM_TIMESTAMP); - assert_eq!(next.fork_id, Bytes::from_static(&hex!("6c1d9423"))); + assert_eq!(next.fork_id, bytes!("6c1d9423")); assert_eq!(config.last.unwrap(), next); } else { assert_eq!(config.current.activation_time, SEPOLIA_AMSTERDAM_TIMESTAMP); - assert_eq!(config.current.fork_id, Bytes::from_static(&hex!("6c1d9423"))); + assert_eq!(config.current.fork_id, bytes!("6c1d9423")); assert!(config.next.is_none()); assert!(config.last.is_none()); } @@ -974,6 +914,33 @@ async fn test_admin_external_ip() -> eyre::Result<()> { Ok(()) } +// +#[tokio::test] +async fn test_disable_auth_server_skips_jwt_secret() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let missing_dir = tempfile::tempdir()?; + let missing = missing_dir.path().join("missing-jwt.hex"); + for auth_jwtsecret in [None, Some(missing.clone())] { + let mut rpc = RpcServerArgs::default().with_unused_ports(); + rpc.disable_auth_server = true; + rpc.auth_jwtsecret = auth_jwtsecret; + let node_config = NodeConfig::test() + .with_chain(Arc::new(test_chain_spec_builder().build())) + .with_unused_ports() + .with_rpc(rpc); + let NodeHandle { node, node_exit_future: _ } = NodeBuilder::new(node_config) + .testing_node(Runtime::test()) + .node(EthereumNode::default()) + .launch() + .await?; + assert!(!node.data_dir.jwt().exists()); + } + assert!(!missing.exists()); + + Ok(()) +} + #[tokio::test] async fn test_admin_node_info_uses_discv5_port_when_discv4_is_disabled() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -1059,3 +1026,267 @@ async fn test_admin_node_info_discv5_enr_uses_nat_extip_when_discv4_is_disabled( Ok(()) } + +#[tokio::test] +async fn test_advance_until_receipt() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let payloads = node.advance_blocks(2).await?; + assert_eq!(payloads.iter().map(|payload| payload.block().number).collect::>(), [1, 2]); + + let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.signer(0)).await; + let hash = node.rpc.inject_tx(raw_tx).await?; + let receipt = node.advance_until_receipt(hash).await?; + assert!(receipt.status()); + assert_eq!(receipt.block_number, Some(3)); + + // The transaction is already included, so the chain does not advance. + assert_eq!(node.advance_until_receipt(hash).await?, receipt); + assert_eq!(node.rpc_provider().get_block_number().await?, 3); + + Ok(()) +} + +#[tokio::test] +async fn test_advance_while_send_raw_transaction_sync() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let provider = node.rpc_provider(); + let raw_tx = TransactionTestContext::transfer_tx_bytes(1, wallet.signer(0)).await; + + // The request only returns once the transaction is mined. + let receipt = node.advance_while(provider.send_raw_transaction_sync(&raw_tx)).await??; + assert_eq!(receipt.transaction_hash, keccak256(&raw_tx)); + assert!(receipt.status()); + assert_eq!(node.rpc.transaction_receipt(receipt.transaction_hash).await?, Some(receipt)); + + Ok(()) +} + +/// Before Amsterdam `eth_call` ignores the EIP-7825 transaction gas cap. With Amsterdam's EIP-8037 +/// it caps execution gas, so a call that runs out of execution gas fails like the transaction +/// would. +#[tokio::test] +async fn eth_call_caps_execution_gas_under_amsterdam() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + // Loops `CALLDATALOAD(0)` times, about 26 gas each, then returns 1. + let burner = Address::with_last_byte(0x42); + let overrides = StateOverride::from_iter([( + burner, + AccountOverride::default() + .with_code(bytes!("6000355b6001900380600357600160005260206000f3")), + )]); + let call = |iterations: u64| { + TransactionRequest::default() + .to(burner) + .gas_limit(29_000_000) + .input(Bytes::from(U256::from(iterations).to_be_bytes::<32>()).into()) + }; + + for fork in [EthereumHardfork::Osaka, EthereumHardfork::Amsterdam] { + let (node, _) = EthereumNode::test_setup_for(fork).build_single().await?; + let provider = node.rpc_provider(); + + let ok: Bytes = + provider.raw_request("eth_call".into(), (call(10_000), "latest", &overrides)).await?; + assert_eq!(U256::from_be_slice(&ok), U256::ONE, "{fork}"); + + // About 26M gas of execution, above the 16,777,216 cap. + let call_result = provider + .raw_request::<_, Bytes>("eth_call".into(), (call(1_000_000), "latest", &overrides)) + .await; + let access_list: AccessListResult = provider + .raw_request("eth_createAccessList".into(), (call(1_000_000), "latest", &overrides)) + .await?; + if fork == EthereumHardfork::Amsterdam { + let err = call_result.unwrap_err().to_string(); + assert!(err.contains("out of gas"), "{err}"); + assert_eq!(access_list.gas_used, U256::from(16_777_216)); + assert!(access_list.error.is_some()); + } else { + assert_eq!(U256::from_be_slice(&call_result?), U256::ONE); + assert!(access_list.gas_used > U256::from(26_000_000)); + assert!(access_list.error.is_none()); + } + } + + Ok(()) +} + +#[tokio::test] +async fn test_test_account_tx_builder() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let provider = node.rpc_provider(); + let mut account = wallet.account(0).with_gas_limit(100_000); + + // Init code returning the runtime `PUSH0 CALLDATALOAD PUSH0 SSTORE STOP`, which stores the + // first calldata word at slot 0. + let runtime = bytes!("5f355f5500"); + let init_code = bytes!("645f355f55005f526005601bf3"); + + let contract = account.next_contract_address(); + let deploy = node.rpc.inject_tx(account.deploy(init_code).await).await?; + let receipt = node.advance_until_receipt(deploy).await?; + assert!(receipt.status()); + assert_eq!(receipt.contract_address, Some(contract)); + assert_eq!(provider.get_code_at(contract).await?, runtime); + + let value = U256::from(0x42); + let recipient = Address::with_last_byte(0x77); + let call = node.rpc.inject_tx(account.call(contract, B256::from(value)).await).await?; + let transfer = node.rpc.inject_tx(account.transfer(recipient, U256::from(7)).await).await?; + let receipt = node.advance_until_receipt(transfer).await?; + assert!(receipt.status()); + let call_receipt = node.rpc.transaction_receipt(call).await?.expect("call is included"); + assert!(call_receipt.status()); + assert_eq!(call_receipt.block_number, receipt.block_number); + + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await?, value); + assert_eq!(provider.get_balance(recipient).await?, U256::from(7)); + assert_eq!(provider.get_transaction_count(account.address()).await?, account.nonce()); + assert_eq!(account.nonce(), 3); + + Ok(()) +} + +#[tokio::test] +async fn test_mine() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let mut account = wallet.account(0); + let recipient = Address::with_last_byte(0x77); + + // Leaves canonical notifications in the stream that `mine` has to skip. + node.advance_blocks(2).await?; + + // The receipts are in the order of the given transactions, not in block order. + let first = account.transfer(recipient, U256::ONE).await; + let second = account.transfer(recipient, U256::from(2)).await; + let mined = node.mine([second.clone(), first.clone()]).await?.ensure_success()?; + assert_eq!(mined.block().number, 3); + assert_eq!( + mined.receipts.iter().map(|receipt| receipt.transaction_hash).collect::>(), + [keccak256(&second), keccak256(&first)] + ); + assert_eq!(mined.chain.range(), 3..=3); + assert_eq!( + mined.chain.execution_outcome().account(&recipient).flatten().map(|acc| acc.balance), + Some(U256::from(3)) + ); + + // Other pool transactions must not ride along, and the block is not imported if they do. + let stray = node.rpc.inject_tx(account.transfer(recipient, U256::from(3)).await).await?; + assert_eq!( + node.mine([]).await.unwrap_err().to_string(), + format!( + "block 4 does not include the expected transactions: missing [], unexpected pool \ + transactions [{stray:?}]" + ) + ); + let mined = node.mine_including([]).await?; + assert_eq!(mined.block().number, 4); + assert_eq!(mined.chain.transaction_hashes().copied().collect::>(), [stray]); + + // A transaction the block can not include, here because of a nonce gap, is missing. + let nonce = account.nonce() + 1; + let gapped = account.transfer(recipient, U256::from(4)).nonce(nonce).await; + assert_eq!( + node.mine([gapped.clone()]).await.unwrap_err().to_string(), + format!( + "block 5 does not include the expected transactions: missing [{:?}], unexpected pool \ + transactions []", + keccak256(&gapped) + ) + ); + + // Transactions that reached the pool by other means are mined by hash, with the same checks. + assert_eq!( + node.mine_pooled([B256::ZERO]).await.unwrap_err().to_string(), + format!("transaction {} is not in the pool", B256::ZERO) + ); + let provider = node.rpc_provider_with_wallet(wallet.signer(1)); + let first = *provider + .send_transaction(TransactionRequest::default().to(recipient).value(U256::from(5))) + .await? + .tx_hash(); + let second = *provider + .send_transaction(TransactionRequest::default().to(recipient).value(U256::from(6))) + .await? + .tx_hash(); + assert_eq!( + node.mine_pooled([second]).await.unwrap_err().to_string(), + format!( + "block 5 does not include the expected transactions: missing [], unexpected pool \ + transactions [{first:?}]" + ) + ); + let mined = node.mine_pooled([second, first]).await?.ensure_success()?; + assert_eq!(mined.block().number, 5); + assert_eq!( + mined.receipts.iter().map(|receipt| receipt.transaction_hash).collect::>(), + [second, first] + ); + + Ok(()) +} + +#[tokio::test] +async fn test_mine_signed() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (mut node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; + let signer = wallet.signer(0); + let chain_id = node.inner.chain_spec().chain().id(); + let request = |nonce| { + TransactionRequest::default() + .with_chain_id(chain_id) + .with_nonce(nonce) + .with_gas_limit(100_000) + .with_max_fee_per_gas(20e9 as u128) + .with_max_priority_fee_per_gas(1e9 as u128) + }; + let recipient = Address::with_last_byte(0x77); + + // Signed envelopes are mined without encoding them first, receipts in the given order. + let first = TransactionTestContext::sign_tx( + signer.clone(), + request(0).with_to(recipient).with_value(U256::ONE), + ) + .await; + let second = TransactionTestContext::sign_tx( + signer.clone(), + request(1).with_to(recipient).with_value(U256::from(2)), + ) + .await; + let mined = node.mine_signed([second.clone(), first.clone()]).await?; + assert_eq!(mined.block().number, 1); + assert_eq!( + mined.receipts.iter().map(|receipt| receipt.transaction_hash).collect::>(), + [*second.tx_hash(), *first.tx_hash()] + ); + + // A reverted transaction is mined, but reported as an error. + // PUSH1 0 PUSH1 0 REVERT + let reverting = + TransactionTestContext::sign_tx(signer, request(2).with_deploy_code(bytes!("60006000fd"))) + .await; + let err = node.mine_signed([reverting.clone()]).await.unwrap_err().to_string(); + assert!( + err.starts_with(&format!("transaction {} reverted after using", reverting.tx_hash())), + "{err}" + ); + assert_eq!(node.rpc_provider().get_block_number().await?, 2); + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/selfdestruct.rs b/crates/ethereum/node/tests/e2e/selfdestruct.rs index 8efcbaa9c22..955a468ec5e 100644 --- a/crates/ethereum/node/tests/e2e/selfdestruct.rs +++ b/crates/ethereum/node/tests/e2e/selfdestruct.rs @@ -7,21 +7,13 @@ //! We disable prewarming to ensure deterministic cache behavior and verify the execution //! output state contains the expected account status after SELFDESTRUCT. -use alloy_network::TransactionBuilder; -use alloy_primitives::{bytes, Address, Bytes, TxKind, U256}; +use alloy_primitives::{bytes, Bytes, U256}; use alloy_provider::Provider; -use alloy_rpc_types_eth::TransactionRequest; -use futures::StreamExt; use reth_chainspec::EthereumHardfork; -use reth_e2e_test_utils::{ - receipt::PendingTransactionExt, wallet::Wallet, E2ETestSetupExt, NodeHelperType, -}; +use reth_e2e_test_utils::{wallet::Wallet, E2ETestSetupExt, NodeHelperType}; use reth_node_ethereum::EthereumNode; use reth_revm::db::BundleAccount; -const MAX_FEE_PER_GAS: u128 = 20_000_000_000; -const MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000; - /// Launches a node for the given hardfork with prewarming disabled, so the execution output state /// is deterministic. async fn setup_node( @@ -35,38 +27,6 @@ async fn setup_node( .await } -fn deploy_tx(from: Address, nonce: u64, init_code: Bytes) -> TransactionRequest { - TransactionRequest::default() - .with_from(from) - .with_nonce(nonce) - .with_gas_limit(500_000) - .with_max_fee_per_gas(MAX_FEE_PER_GAS) - .with_max_priority_fee_per_gas(MAX_PRIORITY_FEE_PER_GAS) - .with_input(init_code) - .with_kind(TxKind::Create) -} - -fn call_tx(from: Address, to: Address, nonce: u64) -> TransactionRequest { - TransactionRequest::default() - .with_from(from) - .with_to(to) - .with_nonce(nonce) - .with_gas_limit(100_000) - .with_max_fee_per_gas(MAX_FEE_PER_GAS) - .with_max_priority_fee_per_gas(MAX_PRIORITY_FEE_PER_GAS) -} - -fn transfer_tx(from: Address, to: Address, nonce: u64, value: U256) -> TransactionRequest { - TransactionRequest::default() - .with_from(from) - .with_to(to) - .with_nonce(nonce) - .with_value(value) - .with_gas_limit(21_000) - .with_max_fee_per_gas(MAX_FEE_PER_GAS) - .with_max_priority_fee_per_gas(MAX_PRIORITY_FEE_PER_GAS) -} - /// Creates init code for a contract that selfdestructs during deployment (same tx). /// This tests the EIP-6780 exception where SELFDESTRUCT in same tx as creation still works. /// @@ -134,30 +94,18 @@ async fn test_selfdestruct_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut node, wallet) = setup_node(EthereumHardfork::Cancun).await?; - let signer = wallet.inner.clone(); - let provider = node.rpc_provider_with_wallet(signer.clone()); + let mut account = wallet.account(0); + let provider = node.rpc_provider(); // Deploy contract that stores 0x42 at slot 0 and selfdestructs on any call - let pending = provider - .send_transaction(deploy_tx(signer.address(), 0, selfdestruct_contract_init_code())) - .await?; - node.advance_block().await?; - let receipt = pending.successful_receipt().await?; - - let contract_address = receipt.contract_address.expect("Should have contract address"); - - // Consume the canonical notification for deployment block - let _ = node.canonical_stream.next().await; + let deploy = account.deploy(selfdestruct_contract_init_code()).await; + let mined = node.mine([deploy]).await?.ensure_success()?; + let contract_address = + mined.receipts[0].contract_address.expect("Should have contract address"); // Trigger SELFDESTRUCT by calling the contract - let pending = provider.send_transaction(call_tx(signer.address(), contract_address, 1)).await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Get the canonical notification for the selfdestruct block - let notification = node.canonical_stream.next().await.unwrap(); - let chain = notification.committed(); - let execution_outcome = chain.execution_outcome(); + let mined = node.mine([account.tx().to(contract_address).await]).await?.ensure_success()?; + let execution_outcome = mined.chain.execution_outcome(); // Verify the output state: post-Dencun, account should NOT be destroyed let account_state: Option<&BundleAccount> = execution_outcome.bundle.account(&contract_address); @@ -177,14 +125,8 @@ async fn test_selfdestruct_post_dencun() -> eyre::Result<()> { // This tests cache behavior - if cache has stale data, execution would be incorrect. // Post-Dencun: calling the contract should trigger SELFDESTRUCT again (but only transfer // balance) - let pending = provider.send_transaction(call_tx(signer.address(), contract_address, 2)).await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Consume the canonical notification - let notification = node.canonical_stream.next().await.unwrap(); - let chain = notification.committed(); - let execution_outcome = chain.execution_outcome(); + let mined = node.mine([account.tx().to(contract_address).await]).await?.ensure_success()?; + let execution_outcome = mined.chain.execution_outcome(); // Verify the output state still shows account NOT destroyed let account_state: Option<&BundleAccount> = execution_outcome.bundle.account(&contract_address); @@ -219,23 +161,14 @@ async fn test_selfdestruct_same_tx_post_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut node, wallet) = setup_node(EthereumHardfork::Cancun).await?; - let signer = wallet.inner.clone(); - let provider = node.rpc_provider_with_wallet(signer.clone()); + let mut account = wallet.account(0); + let provider = node.rpc_provider(); // Deploy contract that selfdestructs during its constructor - let pending = provider - .send_transaction(deploy_tx(signer.address(), 0, selfdestruct_in_constructor_init_code())) - .await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Calculate the contract address (CREATE uses sender + nonce) - let contract_address = signer.address().create(0); - - // Get the canonical notification for the deployment block - let notification = node.canonical_stream.next().await.unwrap(); - let chain = notification.committed(); - let execution_outcome = chain.execution_outcome(); + let contract_address = account.next_contract_address(); + let deploy = account.deploy(selfdestruct_in_constructor_init_code()).await; + let mined = node.mine([deploy]).await?.ensure_success()?; + let execution_outcome = mined.chain.execution_outcome(); // Verify the output state: same-tx SELFDESTRUCT should destroy the account let account_state: Option<&BundleAccount> = execution_outcome.bundle.account(&contract_address); @@ -252,14 +185,8 @@ async fn test_selfdestruct_same_tx_post_dencun() -> eyre::Result<()> { assert_eq!(slot0, U256::ZERO, "Post-Dencun same-tx: Storage should be cleared"); // Send ETH to the destroyed address in a new block to test cache behavior - let pending = provider - .send_transaction(transfer_tx(signer.address(), contract_address, 1, U256::from(1000))) - .await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Consume the canonical notification - let _ = node.canonical_stream.next().await; + let transfer = account.transfer(contract_address, U256::from(1000)).await; + node.mine([transfer]).await?.ensure_success()?; // Verify code is still empty and account received ETH let code_final = provider.get_code_at(contract_address).await?; @@ -286,30 +213,18 @@ async fn test_selfdestruct_pre_dencun() -> eyre::Result<()> { reth_tracing::init_test_tracing(); let (mut node, wallet) = setup_node(EthereumHardfork::Shanghai).await?; - let signer = wallet.inner.clone(); - let provider = node.rpc_provider_with_wallet(signer.clone()); + let mut account = wallet.account(0); + let provider = node.rpc_provider(); // Deploy contract that stores 0x42 at slot 0 and selfdestructs on any call - let pending = provider - .send_transaction(deploy_tx(signer.address(), 0, selfdestruct_contract_init_code())) - .await?; - node.advance_block().await?; - let receipt = pending.successful_receipt().await?; - - let contract_address = receipt.contract_address.expect("Should have contract address"); - - // Consume the canonical notification for deployment block - let _ = node.canonical_stream.next().await; + let deploy = account.deploy(selfdestruct_contract_init_code()).await; + let mined = node.mine([deploy]).await?.ensure_success()?; + let contract_address = + mined.receipts[0].contract_address.expect("Should have contract address"); // Trigger SELFDESTRUCT by calling the contract - let pending = provider.send_transaction(call_tx(signer.address(), contract_address, 1)).await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Get the canonical notification for the selfdestruct block - let notification = node.canonical_stream.next().await.unwrap(); - let chain = notification.committed(); - let execution_outcome = chain.execution_outcome(); + let mined = node.mine([account.tx().to(contract_address).await]).await?.ensure_success()?; + let execution_outcome = mined.chain.execution_outcome(); // Verify the output state: pre-Dencun, account MUST be destroyed let account_state: Option<&BundleAccount> = execution_outcome.bundle.account(&contract_address); @@ -328,16 +243,9 @@ async fn test_selfdestruct_pre_dencun() -> eyre::Result<()> { // Send ETH to the destroyed contract address in a new block. // This tests cache behavior - the cache should correctly reflect the account was destroyed. // Pre-Dencun: the contract no longer exists, so this is just a plain ETH transfer. - let pending = provider - .send_transaction(transfer_tx(signer.address(), contract_address, 2, U256::from(1000))) - .await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Consume the canonical notification - let notification = node.canonical_stream.next().await.unwrap(); - let chain = notification.committed(); - let execution_outcome = chain.execution_outcome(); + let transfer = account.transfer(contract_address, U256::from(1000)).await; + let mined = node.mine([transfer]).await?.ensure_success()?; + let execution_outcome = mined.chain.execution_outcome(); // Verify the output state shows the account exists (received ETH) but has no code let account_state: Option<&BundleAccount> = execution_outcome.bundle.account(&contract_address); @@ -382,50 +290,32 @@ async fn test_selfdestruct_same_tx_preexisting_account_post_dencun() -> eyre::Re reth_tracing::init_test_tracing(); let (mut node, wallet) = setup_node(EthereumHardfork::Cancun).await?; - let signer = wallet.inner.clone(); - let provider = node.rpc_provider_with_wallet(signer.clone()); + let mut account = wallet.account(0); + let provider = node.rpc_provider(); // Calculate where the contract will be deployed (CREATE uses sender + nonce) - // We'll use nonce 1 for deployment, so first send ETH with nonce 0 - let future_contract_address = signer.address().create(1); + // We'll use the next nonce for deployment, so first send ETH with the current one. + let future_contract_address = account.address().create(account.nonce() + 1); // Send ETH to the future contract address first (makes it a pre-existing account) - let pending = provider - .send_transaction(transfer_tx( - signer.address(), - future_contract_address, - 0, - U256::from(1000), - )) - .await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Consume the canonical notification - let _ = node.canonical_stream.next().await; + let transfer = account.transfer(future_contract_address, U256::from(1000)).await; + node.mine([transfer]).await?.ensure_success()?; // Verify the account exists and has balance let balance_before = provider.get_balance(future_contract_address).await?; assert_eq!(balance_before, U256::from(1000), "Account should have ETH before deployment"); // Now deploy contract that selfdestructs during its constructor to the same address - let pending = provider - .send_transaction(deploy_tx(signer.address(), 1, selfdestruct_in_constructor_init_code())) - .await?; - node.advance_block().await?; - let receipt = pending.successful_receipt().await?; + let deploy = account.deploy(selfdestruct_in_constructor_init_code()).await; + let mined = node.mine([deploy]).await?.ensure_success()?; // Verify deployment went to the expected address assert_eq!( - receipt.contract_address, + mined.receipts[0].contract_address, Some(future_contract_address), "Contract should be deployed to pre-computed address" ); - - // Get the canonical notification for the deployment block - let notification = node.canonical_stream.next().await.unwrap(); - let chain = notification.committed(); - let execution_outcome = chain.execution_outcome(); + let execution_outcome = mined.chain.execution_outcome(); // Verify the output state: same-tx exception DOES apply because contract was created this tx // The account should be marked as destroyed. Since it had prior state (ETH balance), @@ -453,19 +343,8 @@ async fn test_selfdestruct_same_tx_preexisting_account_post_dencun() -> eyre::Re ); // Send ETH to the destroyed address to verify cache behavior - let pending = provider - .send_transaction(transfer_tx( - signer.address(), - future_contract_address, - 2, - U256::from(2000), - )) - .await?; - node.advance_block().await?; - pending.successful_receipt().await?; - - // Consume notification - let _ = node.canonical_stream.next().await; + let transfer = account.transfer(future_contract_address, U256::from(2000)).await; + node.mine([transfer]).await?.ensure_success()?; // Verify the account received ETH and has no code (it's now just an EOA) let balance_final = provider.get_balance(future_contract_address).await?; diff --git a/crates/ethereum/node/tests/e2e/simulate.rs b/crates/ethereum/node/tests/e2e/simulate.rs index 3beed879114..feace46e807 100644 --- a/crates/ethereum/node/tests/e2e/simulate.rs +++ b/crates/ethereum/node/tests/e2e/simulate.rs @@ -1,4 +1,4 @@ -use alloy_primitives::{bytes, Address, U256}; +use alloy_primitives::{address, bytes, Address, U256}; use alloy_provider::Provider; use alloy_rpc_types_eth::{ simulate::{SimBlock, SimulatePayload, SimulatedBlock}, @@ -8,6 +8,7 @@ use alloy_rpc_types_eth::{ use reth_chainspec::EthereumHardfork; use reth_e2e_test_utils::{test_chain_spec_builder, E2ETestSetupExt}; use reth_node_ethereum::EthereumNode; +use revm::primitives::eip7825::TX_GAS_LIMIT_CAP; use std::sync::Arc; #[tokio::test] @@ -24,8 +25,8 @@ async fn test_simulate_v1_transfer_logs_across_amsterdam() -> eyre::Result<()> { (from, AccountOverride::default().with_balance(U256::from(100)).with_nonce(0)), (reverting, AccountOverride::default().with_code(bytes!("5f5ffd"))), ])) - .call(TransactionRequest::default().from(from).to(to).value(U256::from(1))) - .call(TransactionRequest::default().from(from).to(reverting).value(U256::from(1))); + .call(TransactionRequest::default().from(from).to(to).value(U256::ONE)) + .call(TransactionRequest::default().from(from).to(reverting).value(U256::ONE)); let mut untraced: Option> = None; for trace_transfers in [false, true] { let mut payload = SimulatePayload::default() @@ -119,28 +120,6 @@ async fn test_simulate_v1_block_access_list_hash_across_amsterdam() -> eyre::Res Ok(()) } -#[tokio::test] -async fn test_simulate_v1_explicit_gas_uses_remaining_block_gas() -> eyre::Result<()> { - reth_tracing::init_test_tracing(); - - let (node, wallet) = - EthereumNode::test_setup_for(EthereumHardfork::Cancun).build_single().await?; - let provider = node.rpc_provider_with_wallet(wallet.signer(0)); - - let tx = TransactionRequest::default().to(Address::ZERO).gas_limit(3_000_000); - let sim_block = SimBlock::default().call(tx.clone()).call(tx); - let payload = SimulatePayload::default().extend(sim_block); - - let result: Vec = - provider.raw_request("eth_simulateV1".into(), (&payload, "latest")).await?; - - assert_eq!(result.len(), 1); - assert_eq!(result[0].calls.len(), 2); - assert!(result[0].calls.iter().all(|call| call.status)); - - Ok(()) -} - #[tokio::test] async fn test_simulate_v1_no_fields_call_defaults_to_remaining_block_gas() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -179,7 +158,7 @@ async fn assert_validation_uses_remaining_gas(fork: EthereumHardfork) -> eyre::R let (node, wallet) = EthereumNode::test_setup_for(fork).build_single().await?; let provider = node.rpc_provider_with_wallet(wallet.signer(0)); - let from: Address = "0xc000000000000000000000000000000000000000".parse()?; + let from = address!("0xc000000000000000000000000000000000000000"); let state_overrides = StateOverridesBuilder::default().with_balance(from, U256::from(1_000_000_000u64)).build(); let tx = TransactionRequest::default() @@ -209,7 +188,7 @@ async fn assert_validation_uses_remaining_gas(fork: EthereumHardfork) -> eyre::R let txs = result[0].inner.transactions.as_transactions().expect("expected full transactions"); assert_eq!(txs.len(), 1); assert_eq!(txs[0].gas_limit(), 30_000_000); - assert!(txs[0].gas_limit() > (1 << 24)); + assert!(txs[0].gas_limit() > TX_GAS_LIMIT_CAP); Ok(()) } @@ -225,6 +204,86 @@ async fn test_simulate_v1_validation_omitted_gas_uses_remaining_gas_amsterdam() assert_validation_uses_remaining_gas(EthereumHardfork::Amsterdam).await } +#[tokio::test] +async fn test_simulate_v1_validation_omitted_gas_uses_remaining_state_gas_amsterdam( +) -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Amsterdam).build_single().await?; + let provider = node.rpc_provider_with_wallet(wallet.signer(0)); + + let contract = Address::with_last_byte(0x42); + let call = + TransactionRequest::default().to(contract).max_fee_per_gas(0).max_priority_fee_per_gas(0); + let sim_block = SimBlock::default() + .with_block_overrides(BlockOverrides { + gas_limit: Some(30_000_000), + base_fee: Some(U256::ZERO), + ..Default::default() + }) + .with_state_overrides(StateOverride::from_iter([( + contract, + AccountOverride::default().with_code(bytes!("0x00")), + )])) + .call(call.clone().gas_limit(100_000)) + .call(call); + let payload = + SimulatePayload::default().with_validation().with_full_transactions().extend(sim_block); + + let result: Vec = + provider.raw_request("eth_simulateV1".into(), (&payload, "latest")).await?; + + assert!(result[0].calls.iter().all(|call| call.status)); + assert!(result[0].calls[0].gas_used > 0); + let txs = result[0].inner.transactions.as_transactions().expect("expected full transactions"); + assert_eq!(txs[1].gas_limit(), 30_000_000); + + Ok(()) +} + +#[tokio::test] +async fn test_simulate_v1_execution_gas_cap_amsterdam() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (node, wallet) = + EthereumNode::test_setup_for(EthereumHardfork::Amsterdam).build_single().await?; + let provider = node.rpc_provider_with_wallet(wallet.signer(0)); + + let contract = Address::with_last_byte(0x42); + let sim_block = SimBlock::default() + .with_block_overrides(BlockOverrides { + gas_limit: Some(30_000_000), + base_fee: Some(U256::ZERO), + ..Default::default() + }) + .with_state_overrides(StateOverride::from_iter([( + contract, + // Return the remaining gas using GAS. + AccountOverride::default().with_code(bytes!("0x5a5f5260205ff3")), + )])) + .call( + TransactionRequest::default() + .to(contract) + .max_fee_per_gas(0) + .max_priority_fee_per_gas(0), + ); + + for validation in [false, true] { + let mut payload = SimulatePayload::default().extend(sim_block.clone()); + payload.validation = validation; + + let result: Vec = + provider.raw_request("eth_simulateV1".into(), (&payload, "latest")).await?; + + assert!(result[0].calls[0].status); + let gas = U256::from_be_slice(&result[0].calls[0].return_data); + assert_eq!(gas > U256::from(TX_GAS_LIMIT_CAP), !validation); + } + + Ok(()) +} + #[tokio::test] async fn test_simulate_v1_blockhash_reads_prior_simulated_block() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -305,8 +364,8 @@ async fn test_simulate_v1_with_max_fee_per_blob_gas_only() -> eyre::Result<()> { let _ = provider.send_transaction(TransactionRequest::default().to(Address::ZERO)).await?; node.advance_block().await?; - let from: Address = "0xc000000000000000000000000000000000000000".parse()?; - let to: Address = "0xc100000000000000000000000000000000000000".parse()?; + let from = address!("0xc000000000000000000000000000000000000000"); + let to = address!("0xc100000000000000000000000000000000000000"); let tx = TransactionRequest::default() .from(from) @@ -364,3 +423,38 @@ async fn test_simulate_v1_too_many_blocks_error() -> eyre::Result<()> { Ok(()) } + +#[tokio::test] +async fn test_simulate_v1_sender_at_max_nonce_errors() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + + let (node, _) = + EthereumNode::test_setup_for(EthereumHardfork::Amsterdam).build_single().await?; + let provider = node.rpc_provider(); + + let from = address!("0xc000000000000000000000000000000000000000"); + let to = address!("0xc100000000000000000000000000000000000000"); + let sim_block = SimBlock::default() + .with_state_overrides(StateOverride::from_iter([( + from, + AccountOverride::default().with_nonce(u64::MAX), + )])) + .call(TransactionRequest::default().from(from).to(to)); + + for validation in [false, true] { + let mut payload = SimulatePayload::default().extend(sim_block.clone()); + payload.validation = validation; + + let err = provider + .raw_request::<_, Vec>("eth_simulateV1".into(), (&payload, "latest")) + .await + .unwrap_err(); + let err = err.as_error_resp().expect("expected JSON-RPC error response"); + + assert_eq!(err.code, -32603); + assert_eq!(err.message, "nonce has max value"); + assert!(err.data.is_none()); + } + + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/snap.rs b/crates/ethereum/node/tests/e2e/snap.rs new file mode 100644 index 00000000000..24442df188f --- /dev/null +++ b/crates/ethereum/node/tests/e2e/snap.rs @@ -0,0 +1,696 @@ +//! End-to-end sync scenarios with `--snap.v2`. +//! +//! - A fresh node snap-syncs the state at the finalized block of a 40 block Amsterdam chain from a +//! serving peer, then executes the blocks above it, with persisted state and trie nodes matching +//! the head state root. +//! - A fresh node syncs 40 finalized Prague blocks through the staged pipeline without starting a +//! snap attempt. +//! - A snap-synced node downloads paginated contract storage and missing bytecode, stays unverified +//! until the bytecode arrives, then reads the downloaded state and executes a new call that +//! updates that storage. +//! - A completed snap sync survives a restart, preserves its verified attempt and state root, and +//! imports another block without downloading snap state again. +//! - An interrupted storage download resumes after restart from its persisted cursor within the +//! same snap attempt, then completes with consistent persisted state and trie nodes. +//! - An expired pivot advances through BALs without restarting the attempt or redownloading covered +//! account ranges, then completes with the expected balances, nonce and state root. +//! - A download pauses when its only serving peer disconnects, keeps incomplete state unpublished, +//! and completes the same attempt when the peer returns. +//! - A restarted download recovers from a reorged pivot using orphaned and canonical BALs, repairs +//! orphan-only storage changes, and completes with the replacement chain's state root. +//! - A fresh node downloads state below an optimistic sync head, then executes the remaining blocks +//! through the staged pipeline. + +use crate::utils::advance_with_random_transactions; +use alloy_primitives::{bytes, keccak256, Bytes, B256, U256}; +use alloy_provider::Provider; +use alloy_rpc_types_engine::{ForkchoiceState, PayloadStatusEnum}; +use alloy_sol_types::SolValue; +use rand::{rngs::StdRng, SeedableRng}; +use reth_chainspec::EthereumHardfork; +use reth_e2e_test_utils::{ + node::Finality, + snap::SnapControl, + trie::assert_trie_consistency, + wait::{assert_holds_for, poll_until}, + E2ETestSetupBuilder, E2ETestSetupExt, NodeHelperType, +}; +use reth_eth_wire_types::snap::SnapProtocolMessage; +use reth_network::PeersInfo; +use reth_node_ethereum::{snap::EthereumBackfill, EthereumNode}; +use reth_provider::{ + DatabaseProviderFactory, HeaderProvider, MetadataProvider, StageCheckpointReader, +}; +use reth_snap_sync::{SnapAccountStore, SnapAttemptStore, SnapStorageStore, DEFAULT_HEAD_DISTANCE}; +use reth_stages_types::StageId; +use reth_tasks::Runtime; +use std::time::Duration; + +// Blocks the serving node builds: more than the backfill threshold of 32 blocks, and few enough +// that it still serves the state of every block, see `SNAPSHOT_STATE_RETENTION`. +const CHAIN_LENGTH: u64 = 40; + +// Finalized block a syncing node anchors its snap pivot to. It is below the head, so the blocks +// above it execute on the downloaded state. +const FINALIZED: u64 = 32; + +// Head of the reorged pivot scenario before the reorg. The optimistic pivot must lie after the fork +// at block 32. +const INITIAL_HEAD: u64 = 100; + +// Runtime that increments slot 0: PUSH0 SLOAD PUSH1 1 ADD PUSH0 SSTORE STOP. +const INCREMENTER: Bytes = bytes!("5f546001015f5500"); + +// Init code that returns `INCREMENTER`: PUSH8 runtime PUSH0 MSTORE PUSH1 8 PUSH1 24 RETURN. +const INCREMENTER_INIT_CODE: Bytes = bytes!("675f546001015f55005f5260086018f3"); + +// A node on `fork` that serves snap/2 from the storage layout snap writes, and picks its backfill +// from `--snap.v2` like `reth node` does. +fn snap_setup(fork: EthereumHardfork, runtime: Runtime) -> E2ETestSetupBuilder { + snap_node(fork).with_runtime(runtime) +} + +// A setup without a shared runtime can also launch restartable nodes. +fn snap_node(fork: EthereumHardfork) -> E2ETestSetupBuilder { + EthereumNode::test_setup_for(fork) + .with_storage_v2(true) + .with_node_config_modifier(|mut config| { + config.network.snap_v2 = true; + config + }) + .with_backfill(EthereumBackfill::new) +} + +// A serving node with `CHAIN_LENGTH` finalized blocks of random transactions, and a fresh node +// connected to it. +async fn serving_and_syncing( + fork: EthereumHardfork, +) -> eyre::Result<(NodeHelperType, NodeHelperType)> { + let runtime = Runtime::test(); + let (mut server, _) = snap_setup(fork, runtime.clone()).build_single().await?; + let mut rng = StdRng::seed_from_u64(1); + advance_with_random_transactions(&mut server, CHAIN_LENGTH as usize, &mut rng, true).await?; + + let client = syncing_client(&mut server, snap_setup(fork, runtime)).await?; + Ok((server, client)) +} + +// A fresh node launched from `setup` and connected to `server`, which persists every block. +// +// The snap backfill starts at the finalized block and only runs the pipeline to the head if a +// forkchoice update reaches it before it finishes. Otherwise the engine executes the blocks above +// the pivot in memory, and with the default persistence threshold they never reach the database +// the tests read. +async fn syncing_client( + server: &mut NodeHelperType, + setup: E2ETestSetupBuilder, +) -> eyre::Result> { + let (mut client, _) = setup + .with_tree_config_modifier(|config| { + config.with_persistence_threshold(0).with_memory_block_buffer_target(0) + }) + .build_single() + .await?; + client.connect(server).await; + Ok(client) +} + +// Seeds enough storage to require pagination, then returns the slot-zero incrementer runtime. +fn incrementer_init_code() -> Bytes { + let mut init_code = Vec::new(); + for slot in 1..=16 { + init_code.extend_from_slice(&[0x60, slot, 0x60, slot, 0x55]); + } + init_code.extend_from_slice(&INCREMENTER_INIT_CODE); + init_code.into() +} + +// Calldata for the storage writer used by the reorg scenario. +fn storage_input(slot: u64, value: u64) -> Bytes { + (slot, value).abi_encode_params().into() +} + +#[tokio::test] +async fn a_fresh_node_snap_syncs_to_the_head() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (server, client) = serving_and_syncing(EthereumHardfork::Amsterdam).await?; + let finalized = server.block_hash(FINALIZED); + + client + .sync_to_forkchoice(ForkchoiceState { + head_block_hash: server.block_hash(CHAIN_LENGTH), + safe_block_hash: finalized, + finalized_block_hash: finalized, + }) + .await?; + + // The state at the pivot came from snap, not from executing the chain. + let attempt = client.snap_attempt()?; + let pivot = server.inner.provider.sealed_header(FINALIZED)?.unwrap(); + assert!(attempt.is_verified()); + assert_eq!(attempt.pivot(), pivot.num_hash()); + assert_eq!(attempt.state_root(), pivot.state_root); + // The blocks above the pivot executed on that state. + client.wait_for_persisted_block(CHAIN_LENGTH).await?; + assert_trie_consistency(&client.inner.provider)?; + Ok(()) +} + +#[tokio::test] +async fn a_chain_before_amsterdam_syncs_with_the_staged_pipeline() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (server, client) = serving_and_syncing(EthereumHardfork::Prague).await?; + + client.sync_to(server.block_hash(CHAIN_LENGTH)).await?; + + client.wait_for_persisted_block(CHAIN_LENGTH).await?; + assert_trie_consistency(&client.inner.provider)?; + assert!(client.inner.provider.database_provider_ro()?.snap_attempt()?.is_none()); + // The pipeline executed the chain. + let execution = client.inner.provider.get_stage_checkpoint(StageId::Execution)?.unwrap(); + assert_eq!(execution.block_number, CHAIN_LENGTH); + Ok(()) +} + +#[tokio::test] +async fn a_snap_synced_node_executes_downloaded_contract_code() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let runtime = Runtime::test(); + let (mut server, wallet) = + snap_setup(EthereumHardfork::Amsterdam, runtime.clone()).build_single().await?; + let mut account = wallet.account(0).with_fees(2_000_000_000, 1_000_000_000); + + let contract = account.next_contract_address(); + server + .mine([account.deploy(incrementer_init_code()).gas_limit(8_000_000).await]) + .await? + .ensure_success()?; + server.mine([account.call(contract, Bytes::new()).await]).await?.ensure_success()?; + server.advance_blocks(CHAIN_LENGTH - 2).await?; + + let control = SnapControl::with_response_bytes(256); + let code_hash = keccak256(&INCREMENTER); + let bytecode = control.pause_on(move |request| { + matches!(request, SnapProtocolMessage::GetByteCodes(request) if request.hashes.contains(&code_hash)) + }); + let setup = snap_setup(EthereumHardfork::Amsterdam, runtime).with_snap_control(&control); + let client = syncing_client(&mut server, setup).await?; + let status = client + .engine + .forkchoice_updated(ForkchoiceState::same_hash(server.block_hash(CHAIN_LENGTH))) + .await?; + assert_eq!(status.payload_status.status, PayloadStatusEnum::Syncing); + bytecode.reached().await?; + let pending = client.snap_attempt()?; + assert!(!pending.is_verified()); + assert_eq!( + client.inner.provider.get_stage_checkpoint(StageId::Finish)?.unwrap().block_number, + 0 + ); + let contract_hash = keccak256(contract); + assert!(control.take_requests().iter().any(|request| matches!(request, + SnapProtocolMessage::GetStorageRanges(request) + if request.account_hashes == [contract_hash] && request.starting_hash.unwrap_or(B256::ZERO) != B256::ZERO + )), "contract storage must require a continuation request"); + bytecode.release(); + client.sync_to(server.block_hash(CHAIN_LENGTH)).await?; + client.wait_for_persisted_block(CHAIN_LENGTH).await?; + + let attempt = client.snap_attempt()?; + assert!(attempt.is_verified()); + assert_eq!(attempt.id(), pending.id()); + assert_eq!(attempt.pivot().number, CHAIN_LENGTH); + let provider = client.rpc_provider(); + assert_eq!(provider.get_code_at(contract).await?, INCREMENTER); + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await?, U256::ONE); + for slot in 1..=16 { + assert_eq!(provider.get_storage_at(contract, U256::from(slot)).await?, U256::from(slot)); + } + + let next = server.mine([account.call(contract, Bytes::new()).await]).await?.ensure_success()?; + client.import_payload(next.payload).await?; + + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await?, U256::from(2)); + assert_eq!(provider.get_transaction_count(account.address()).await?, account.nonce()); + client.wait_for_persisted_block(CHAIN_LENGTH + 1).await?; + assert_trie_consistency(&client.inner.provider)?; + assert_eq!(client.inner.provider.database_provider_ro()?.snap_attempt()?, Some(attempt)); + Ok(()) +} + +#[tokio::test] +async fn a_completed_snap_sync_survives_restart() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let runtime = Runtime::test(); + let (mut server, _) = snap_setup(EthereumHardfork::Amsterdam, runtime).build_single().await?; + server.advance_blocks(CHAIN_LENGTH).await?; + let control = SnapControl::default(); + let setup = + snap_node(EthereumHardfork::Amsterdam).with_restartable_nodes().with_snap_control(&control); + let client = syncing_client(&mut server, setup).await?; + client.sync_to(server.block_hash(CHAIN_LENGTH)).await?; + client.wait_for_persisted_block(CHAIN_LENGTH).await?; + let attempt = client.snap_attempt()?; + assert!(attempt.is_verified()); + control.take_requests(); + + let client = client.restart().await?; + assert_eq!(client.block_hash(CHAIN_LENGTH), server.block_hash(CHAIN_LENGTH)); + assert_eq!(client.inner.provider.database_provider_ro()?.snap_attempt()?, Some(attempt)); + assert_trie_consistency(&client.inner.provider)?; + let next = server.mine([]).await?; + client.import_payload(next.payload).await?; + client.wait_for_persisted_block(CHAIN_LENGTH + 1).await?; + assert_trie_consistency(&client.inner.provider)?; + assert!(control.take_requests().is_empty(), "verified state must not be downloaded again"); + Ok(()) +} + +#[tokio::test] +async fn partial_storage_resumes_after_restart() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut server, wallet) = snap_node(EthereumHardfork::Amsterdam).build_single().await?; + let mut account = wallet.account(0).with_fees(2_000_000_000, 1_000_000_000); + server + .mine([account.deploy(incrementer_init_code()).gas_limit(8_000_000).await]) + .await? + .ensure_success()?; + server.advance_blocks(CHAIN_LENGTH - 1).await?; + let control = SnapControl::with_response_bytes(256); + // Stop after one page has committed, before fetching the continuation. + let continuation = control.pause_on(|request| { + matches!(request, + SnapProtocolMessage::GetStorageRanges(request) + if request.starting_hash.unwrap_or(B256::ZERO) != B256::ZERO + ) + }); + let setup = + snap_node(EthereumHardfork::Amsterdam).with_restartable_nodes().with_snap_control(&control); + let client = syncing_client(&mut server, setup).await?; + client + .engine + .forkchoice_updated(ForkchoiceState::same_hash(server.block_hash(CHAIN_LENGTH))) + .await?; + let SnapProtocolMessage::GetStorageRanges(request) = continuation.reached().await? else { + panic!("paused storage request") + }; + let (write, origin, account, resume_at) = { + let provider = client.inner.provider.database_provider_ro()?; + let write = provider.active_snap_write()?.unwrap(); + let origin = provider.account_coverage(write)?.unwrap().next().unwrap(); + let account = request.account_hashes[0]; + let resume_at = request.starting_hash.unwrap_or(B256::ZERO); + assert_eq!(provider.storage_progress(write, origin)?.resume_at(account), Some(resume_at)); + (write, origin, account, resume_at) + }; + let stopped = client.stop().await?; + let resumed = control.pause_on(move |request| { + matches!(request, + SnapProtocolMessage::GetStorageRanges(request) if request.account_hashes == [account] + ) + }); + let mut client = stopped.start().await?; + { + let provider = client.inner.provider.database_provider_ro()?; + assert_eq!(provider.active_snap_write()?, Some(write)); + assert_eq!(provider.storage_progress(write, origin)?.resume_at(account), Some(resume_at)); + } + client.connect(&mut server).await; + client + .engine + .forkchoice_updated(ForkchoiceState::same_hash(server.block_hash(CHAIN_LENGTH))) + .await?; + let SnapProtocolMessage::GetStorageRanges(resumed_request) = resumed.reached().await? else { + panic!("resumed storage request") + }; + assert_eq!(resumed_request.starting_hash.unwrap_or(B256::ZERO), resume_at); + resumed.release(); + client.sync_to(server.block_hash(CHAIN_LENGTH)).await?; + client.wait_for_persisted_block(CHAIN_LENGTH).await?; + let attempt = client.snap_attempt()?; + assert_eq!(attempt.id(), write.attempt()); + assert!(attempt.is_verified()); + assert_trie_consistency(&client.inner.provider)?; + Ok(()) +} + +#[tokio::test] +async fn an_expired_pivot_advances_with_bals_without_restarting() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut server, wallet) = snap_node(EthereumHardfork::Amsterdam) + .with_tree_config_modifier(|config| { + config.with_persistence_threshold(0).with_memory_block_buffer_target(0) + }) + .build_single() + .await?; + // An early account is already persisted when the next range is paused. + let mut account = (0..20) + .map(|index| wallet.account(index)) + .min_by_key(|account| keccak256(account.address())) + .unwrap(); + let recipient = (0..20) + .map(|index| wallet.account(index)) + .max_by_key(|account| keccak256(account.address())) + .unwrap() + .address(); + server.advance_blocks(FINALIZED).await?; + // Change the root immediately after the pivot: empty blocks would keep its root servable. + server.mine([account.transfer(recipient, U256::ONE).await]).await?.ensure_success()?; + server.advance_blocks(CHAIN_LENGTH - FINALIZED - 1).await?; + let account_hash = keccak256(account.address()); + let control = SnapControl::with_response_bytes(256); + let downloaded = control.pause_on(move |request| { + matches!(request, + SnapProtocolMessage::GetAccountRange(request) if request.starting_hash > account_hash + ) + }); + let setup = snap_node(EthereumHardfork::Amsterdam).with_snap_control(&control); + let client = syncing_client(&mut server, setup).await?; + let finalized = server.block_hash(FINALIZED); + client + .engine + .forkchoice_updated(ForkchoiceState { + head_block_hash: server.block_hash(CHAIN_LENGTH), + safe_block_hash: finalized, + finalized_block_hash: finalized, + }) + .await?; + downloaded.reached().await?; + let (attempt, origin) = { + let provider = client.inner.provider.database_provider_ro()?; + let attempt = provider.snap_attempt()?.unwrap(); + assert_eq!(attempt.pivot().number, FINALIZED); + let write = provider.active_snap_write()?.unwrap(); + let origin = provider.account_coverage(write)?.unwrap().next().unwrap(); + assert!(origin > account_hash); + assert!(origin <= keccak256(recipient)); + (attempt, origin) + }; + + // The peer drops the old pivot state from its retention window while BALs remain served. + server.mine([account.transfer(recipient, U256::ONE).await]).await?.ensure_success()?; + let target = 210; + server.advance_blocks(target - CHAIN_LENGTH - 1).await?; + server.wait_for_persisted_block(target).await?; + control.take_requests(); + let bals = + control.pause_on(|request| matches!(request, SnapProtocolMessage::GetBlockAccessLists(_))); + client.engine.forkchoice_updated(ForkchoiceState::same_hash(server.block_hash(target))).await?; + downloaded.release(); + bals.reached().await?; + let advanced = client.snap_attempt()?; + assert_eq!(advanced.id(), attempt.id()); + assert_eq!(advanced.pivot().number, target); + assert!(!advanced.is_verified()); + bals.release(); + client.sync_to(server.block_hash(target)).await?; + client.wait_for_persisted_block(target).await?; + + let requests = control.take_requests(); + let requested_blocks: Vec<_> = requests + .iter() + .filter_map(|request| match request { + SnapProtocolMessage::GetBlockAccessLists(request) => { + Some(request.block_hashes.iter().copied()) + } + _ => None, + }) + .flatten() + .collect(); + assert_eq!( + requested_blocks, + ((FINALIZED + 1)..=target).map(|number| server.block_hash(number)).collect::>() + ); + assert!( + requests + .iter() + .filter_map(|request| match request { + SnapProtocolMessage::GetAccountRange(request) => Some(request.starting_hash), + _ => None, + }) + .all(|starting_hash| starting_hash >= origin), + "downloaded account ranges must be retained" + ); + let completed = client.snap_attempt()?; + assert_eq!(completed.id(), attempt.id()); + assert_eq!(completed.pivot(), advanced.pivot()); + assert!(completed.is_verified()); + assert_eq!( + client.rpc_provider().get_transaction_count(account.address()).await?, + account.nonce() + ); + assert_eq!( + client.rpc_provider().get_balance(account.address()).await?, + server.rpc_provider().get_balance(account.address()).await? + ); + assert_eq!( + client.rpc_provider().get_balance(recipient).await?, + server.rpc_provider().get_balance(recipient).await? + ); + assert_trie_consistency(&client.inner.provider)?; + Ok(()) +} + +#[tokio::test] +async fn a_download_resumes_when_its_only_peer_returns() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let (mut server, _) = + snap_node(EthereumHardfork::Amsterdam).with_restartable_nodes().build_single().await?; + server.advance_blocks(CHAIN_LENGTH).await?; + let head = server.block_hash(CHAIN_LENGTH); + let control = SnapControl::with_response_bytes(256); + let continuation = control.pause_on(|request| { + matches!(request, + SnapProtocolMessage::GetAccountRange(request) if request.starting_hash != B256::ZERO + ) + }); + let setup = snap_node(EthereumHardfork::Amsterdam).with_snap_control(&control); + let mut client = syncing_client(&mut server, setup).await?; + client.engine.forkchoice_updated(ForkchoiceState::same_hash(head)).await?; + continuation.reached().await?; + let attempt = client.snap_attempt()?; + let stopped = server.stop().await?; + poll_until("the only serving peer to disconnect", || async { + Ok((client.inner.network.num_connected_peers() == 0).then_some(())) + }) + .await?; + continuation.release(); + assert_holds_for( + Duration::from_secs(1), + "the incomplete attempt to remain unpublished without peers", + || async { + let current = client.snap_attempt()?; + Ok(current.id() == attempt.id() && + !current.is_verified() && + client + .inner + .provider + .get_stage_checkpoint(StageId::Finish)? + .unwrap() + .block_number == + 0) + }, + ) + .await?; + let mut server = stopped.start().await?; + server.connect(&mut client).await; + client.sync_to(head).await?; + client.wait_for_persisted_block(CHAIN_LENGTH).await?; + let completed = client.snap_attempt()?; + assert_eq!(completed.id(), attempt.id()); + assert!(completed.is_verified()); + assert_trie_consistency(&client.inner.provider)?; + Ok(()) +} + +#[tokio::test] +async fn a_reorged_pivot_repairs_orphaned_storage() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let runtime = Runtime::test(); + let (mut server, wallet) = + snap_setup(EthereumHardfork::Amsterdam, runtime.clone()).build_single().await?; + let (mut fork, _) = snap_setup(EthereumHardfork::Amsterdam, runtime).build_single().await?; + server.set_finality(Finality::Keep); + fork.set_finality(Finality::Keep); + // Choose a contract early in the trie so some account ranges remain after it is downloaded. + let mut account = (0..20) + .map(|index| wallet.account(index)) + .min_by_key(|account| keccak256(account.next_contract_address())) + .unwrap(); + let contract = account.next_contract_address(); + let contract_hash = keccak256(contract); + // Store calldata word 1 at the key in word 0: PUSH1 32 CALLDATALOAD PUSH0 CALLDATALOAD SSTORE + // STOP. + let deployed = server + .mine([account.deploy(bytes!("666020355f3555005f5260076019f3")).await]) + .await? + .ensure_success()?; + fork.import_payload(deployed.payload).await?; + for payload in server.advance_blocks(31).await? { + fork.import_payload(payload).await?; + } + fork.wait_for_pool_head(server.block_hash(32)).await?; + fork.set_next_payload_timestamp( + server.inner.provider.sealed_header(32)?.unwrap().timestamp + 1, + )?; + let mut fork_account = account.clone(); + let orphaned = server + .mine([ + account.call(contract, storage_input(0, 11)).await, + account.call(contract, storage_input(1, 7)).await, + ]) + .await? + .ensure_success()?; + server.advance_blocks(INITIAL_HEAD - 33).await?; + // No finalized pivot is supplied, so the initial pivot is the default distance behind the head. + let pivot = INITIAL_HEAD - DEFAULT_HEAD_DISTANCE; + let orphaned_hashes: Vec<_> = (33..=pivot).map(|number| server.block_hash(number)).collect(); + let control = SnapControl::with_response_bytes(256); + let downloaded = control.pause_on(move |request| { + matches!(request, + SnapProtocolMessage::GetAccountRange(request) if request.starting_hash > contract_hash + ) + }); + let setup = + snap_node(EthereumHardfork::Amsterdam).with_restartable_nodes().with_snap_control(&control); + let client = syncing_client(&mut server, setup).await?; + client.update_optimistic_forkchoice(server.block_hash(INITIAL_HEAD)).await?; + downloaded.reached().await?; + let attempt = client.snap_attempt()?; + assert_eq!(attempt.pivot().number, pivot); + let stopped = client.stop().await?; + + // Both branches change slot 0, but only the orphan changes slot 1 and only the new fork + // changes slot 2. Replaying the new branch alone cannot remove the orphan-only value. + let replacement = fork + .mine([ + fork_account.call(contract, storage_input(0, 22)).await, + fork_account.call(contract, storage_input(2, 9)).await, + ]) + .await? + .ensure_success()?; + assert_ne!(replacement.payload.block().hash(), orphaned.payload.block().hash()); + server.import_payload(replacement.payload).await?; + for payload in fork.advance_blocks(101 - 33).await? { + server.import_payload(payload).await?; + } + control.take_requests(); + let recovery = + control.pause_on(|request| matches!(request, SnapProtocolMessage::GetBlockAccessLists(_))); + let mut client = stopped.start().await?; + client.connect(&mut server).await; + client.update_optimistic_forkchoice(server.block_hash(101)).await?; + poll_until("canonical headers to reach the replacement head", || async { + Ok(client.inner.provider.sealed_header(101)?) + }) + .await?; + let head = client.inner.provider.sealed_header(101)?.unwrap(); + let parent = client.inner.provider.sealed_header(100)?.unwrap(); + assert_eq!( + head.parent_hash, + parent.hash(), + "header catch-up must replace the old branch before recovering snap state" + ); + recovery.reached().await?; + assert_eq!(client.snap_attempt()?.id(), attempt.id()); + recovery.release(); + client + .sync_to_forkchoice(ForkchoiceState { + head_block_hash: server.block_hash(101), + safe_block_hash: B256::ZERO, + finalized_block_hash: B256::ZERO, + }) + .await?; + client.wait_for_persisted_block(101).await?; + let requests = control.take_requests(); + let requested_blocks: Vec<_> = requests + .iter() + .filter_map(|request| match request { + SnapProtocolMessage::GetBlockAccessLists(request) => { + Some(request.block_hashes.iter().copied()) + } + _ => None, + }) + .flatten() + .collect(); + let expected: Vec<_> = orphaned_hashes + .into_iter() + .chain((33..=pivot + 1).map(|number| server.block_hash(number))) + .collect(); + assert_eq!(requested_blocks, expected); + assert!(requests.iter().any(|request| matches!(request, + SnapProtocolMessage::GetAccountRange(request) if request.starting_hash == contract_hash && request.limit_hash == contract_hash + )), "the downloaded contract must be repaired at the new pivot"); + let completed = client.snap_attempt()?; + assert_eq!(completed.id(), attempt.id()); + assert_eq!(completed.pivot().number, pivot + 1); + assert!(completed.is_verified()); + for (slot, value) in [(0, 22), (1, 0), (2, 9)] { + assert_eq!( + client.rpc_provider().get_storage_at(contract, U256::from(slot)).await?, + U256::from(value) + ); + } + assert_trie_consistency(&client.inner.provider)?; + Ok(()) +} + +#[tokio::test] +async fn a_fresh_node_executes_blocks_after_an_optimistic_snap_pivot() -> eyre::Result<()> { + reth_tracing::init_test_tracing(); + let runtime = Runtime::test(); + let (mut server, wallet) = + snap_setup(EthereumHardfork::Amsterdam, runtime.clone()).build_single().await?; + let mut account = wallet.account(0); + + let contract = account.next_contract_address(); + server.mine([account.deploy(INCREMENTER_INIT_CODE).await]).await?.ensure_success()?; + server.mine([account.call(contract, Bytes::new()).await]).await?.ensure_success()?; + server.advance_blocks(CHAIN_LENGTH - 2).await?; + let pivot = server.inner.provider.sealed_header(CHAIN_LENGTH)?.unwrap(); + server.wait_for_pool_head(pivot.hash()).await?; + + // Keep the pivot finalized while every later block changes the downloaded storage. + server.set_finality(Finality::Keep); + for _ in 0..DEFAULT_HEAD_DISTANCE { + let mined = + server.mine([account.call(contract, Bytes::new()).await]).await?.ensure_success()?; + server.wait_for_pool_head(mined.block().hash()).await?; + } + let head = server.inner.provider.sealed_header(CHAIN_LENGTH + DEFAULT_HEAD_DISTANCE)?.unwrap(); + assert_eq!(server.current_forkchoice_state()?.finalized_block_hash, pivot.hash()); + assert_ne!(pivot.state_root, head.state_root); + + let client = + syncing_client(&mut server, snap_setup(EthereumHardfork::Amsterdam, runtime)).await?; + + // sync_to would finalize the head too, removing the blocks the pipeline must execute. + let state = ForkchoiceState { + head_block_hash: head.hash(), + safe_block_hash: B256::ZERO, + finalized_block_hash: B256::ZERO, + }; + let updated = client.engine.forkchoice_updated(state).await?; + assert_eq!(updated.payload_status.status, PayloadStatusEnum::Syncing); + client.wait_for_head(head.hash()).await?; + client.wait_for_persisted_block(head.number).await?; + + let persisted = client.inner.provider.database_provider_ro()?; + let attempt = persisted.snap_attempt()?.unwrap(); + assert!(attempt.is_verified()); + assert_eq!(attempt.pivot(), pivot.num_hash()); + assert_eq!(attempt.state_root(), pivot.state_root); + assert_eq!(persisted.sealed_header(head.number)?.unwrap(), head); + for stage in [StageId::Execution, StageId::Finish] { + assert_eq!(persisted.get_stage_checkpoint(stage)?.unwrap().block_number, head.number); + } + let provider = client.rpc_provider(); + assert_eq!(provider.get_code_at(contract).await?, INCREMENTER); + assert_eq!( + provider.get_storage_at(contract, U256::ZERO).await?, + U256::from(DEFAULT_HEAD_DISTANCE + 1) + ); + assert_eq!(provider.get_transaction_count(account.address()).await?, account.nonce()); + assert_trie_consistency(&client.inner.provider)?; + Ok(()) +} diff --git a/crates/ethereum/node/tests/e2e/utils.rs b/crates/ethereum/node/tests/e2e/utils.rs index e7bf3e9c543..3727b01b1fa 100644 --- a/crates/ethereum/node/tests/e2e/utils.rs +++ b/crates/ethereum/node/tests/e2e/utils.rs @@ -1,4 +1,4 @@ -use alloy_eips::{eip2930::AccessListItem, eip7702::Authorization, BlockId, BlockNumberOrTag}; +use alloy_eips::{eip2930::AccessListItem, eip7702::Authorization, BlockId}; use alloy_primitives::{bytes, U256}; use alloy_provider::{ network::{ @@ -11,7 +11,7 @@ use alloy_signer::SignerSync; use eyre::{ensure, eyre}; use rand::{seq::IndexedRandom, Rng}; use reqwest::{header, RequestBuilder, Response, StatusCode}; -use reth_e2e_test_utils::{wallet::Wallet, NodeHelperType}; +use reth_e2e_test_utils::{node::Finality, wallet::Wallet, NodeHelperType}; use reth_ethereum_primitives::TxType; use reth_node_ethereum::EthereumNode; use reth_rpc_builder::auth::AuthServerHandle; @@ -19,12 +19,16 @@ use reth_rpc_layer::secret_to_bearer_header; use ssz::{Decode, Encode}; /// Advances node by producing blocks with random transactions. +/// +/// The blocks become the safe and finalized block if `finalize` is set, otherwise the safe and +/// finalized blocks stay. The node keeps the corresponding [`Finality`] policy afterwards. pub(crate) async fn advance_with_random_transactions( node: &mut NodeHelperType, num_blocks: usize, rng: &mut impl Rng, finalize: bool, ) -> eyre::Result<()> { + node.set_finality(if finalize { Finality::Head } else { Finality::Keep }); let provider = node.rpc_provider(); let signers = Wallet::new(1).with_chain_id(provider.get_chain_id().await?).wallet_gen(); @@ -44,14 +48,14 @@ pub(crate) async fn advance_with_random_transactions( let nonce = provider .get_transaction_count(signer.address()) - .block_id(BlockId::Number(BlockNumberOrTag::Pending)) + .block_id(BlockId::pending()) .await?; let mut tx = TransactionRequest::default().with_from(signer.address()).with_nonce(nonce); let should_create = - rng.random::() && tx_type != TxType::Eip4844 && tx_type != TxType::Eip7702; + rng.random::() && !tx_type.is_eip4844() && !tx_type.is_eip7702(); if should_create { tx = tx.into_create().with_input(dummy_bytecode.clone()); } else { @@ -64,7 +68,7 @@ pub(crate) async fn advance_with_random_transactions( tx = tx.with_gas_price(provider.get_gas_price().await?); } - if rng.random::() || tx_type == TxType::Eip2930 { + if rng.random::() || tx_type.is_eip2930() { tx = tx.with_access_list( vec![AccessListItem { address: *call_destinations.choose(rng).unwrap(), @@ -74,14 +78,14 @@ pub(crate) async fn advance_with_random_transactions( ); } - if tx_type == TxType::Eip7702 { + if tx_type.is_eip7702() { let signer = signers.choose(rng).unwrap(); let auth = Authorization { chain_id: U256::from(provider.get_chain_id().await?), address: *call_destinations.choose(rng).unwrap(), nonce: provider .get_transaction_count(signer.address()) - .block_id(BlockId::Number(BlockNumberOrTag::Pending)) + .block_id(BlockId::pending()) .await?, }; let sig = signer.sign_hash_sync(&auth.signature_hash())?; @@ -90,7 +94,7 @@ pub(crate) async fn advance_with_random_transactions( let gas = provider .estimate_gas(tx.clone()) - .block(BlockId::Number(BlockNumberOrTag::Pending)) + .block(BlockId::pending()) .await .unwrap_or(1_000_000); @@ -106,14 +110,7 @@ pub(crate) async fn advance_with_random_transactions( } } - let payload = node.build_and_submit_payload().await?; - if finalize { - node.update_forkchoice(payload.block().hash(), payload.block().hash()).await?; - } else { - let last_safe = - provider.get_block_by_number(BlockNumberOrTag::Safe).await?.unwrap().header.hash; - node.update_forkchoice(last_safe, payload.block().hash()).await?; - } + node.advance_block().await?; for pending in pending { let receipt = pending.get_receipt().await?; diff --git a/crates/ethereum/node/tests/it/builder.rs b/crates/ethereum/node/tests/it/builder.rs index 5961f422e55..d5ff3308472 100644 --- a/crates/ethereum/node/tests/it/builder.rs +++ b/crates/ethereum/node/tests/it/builder.rs @@ -1,17 +1,66 @@ //! Node builder setup tests. -use std::sync::Arc; +use std::{ + str::FromStr, + sync::{Arc, Mutex}, +}; -use reth_db::{ - test_utils::{create_test_rw_db, TempDatabase}, - DatabaseEnv, +use reth_db::test_utils::create_test_rw_db; +use reth_node_builder::{ + sync::{BackfillContext, BackfillSyncBuilder, PipelineBackfill}, + FullNodeComponents, NodeBuilder, NodeConfig, +}; +use reth_node_core::{ + args::DatadirArgs, + dirs::{DataDirPath, MaybePlatformPath}, }; -use reth_node_api::NodeTypesWithDBAdapter; -use reth_node_builder::{EngineNodeLauncher, FullNodeComponents, NodeBuilder, NodeConfig}; use reth_node_ethereum::node::{EthereumAddOns, EthereumNode}; -use reth_provider::providers::BlockchainProvider; +use reth_provider::{providers::ProviderNodeTypes, ProviderFactory, StageCheckpointReader}; use reth_rpc_builder::Identity; +use reth_stages_types::StageId; use reth_tasks::Runtime; +use tempfile::{tempdir, TempDir}; + +// Records the order its hooks run in, building the staged pipeline backfill. +#[derive(Default)] +struct RecordingBackfill { + // Hooks called so far. + calls: Arc>>, + // Whether `recover` refuses the database. + fail_recovery: bool, +} + +impl BackfillSyncBuilder for RecordingBackfill { + type Backfill = >::Backfill; + + fn build(self, ctx: BackfillContext) -> eyre::Result { + self.calls.lock().unwrap().push("build"); + PipelineBackfill.build(ctx) + } + + fn recover(&mut self, provider_factory: &ProviderFactory) -> eyre::Result<()> { + // The database is open and readable by now. + provider_factory.provider()?.get_stage_checkpoint(StageId::Finish)?; + self.calls.lock().unwrap().push("recover"); + if self.fail_recovery { + eyre::bail!("recovery refused the database") + } + Ok(()) + } +} + +// A test node config with its own data directory, kept alive by the returned guard. +fn launch_config() -> (NodeConfig, TempDir) { + let datadir = tempdir().expect("temp datadir"); + let datadir_args = DatadirArgs { + datadir: MaybePlatformPath::::from_str(datadir.path().to_str().unwrap()) + .expect("valid datadir"), + static_files_path: Some(datadir.path().join("static")), + rocksdb_path: Some(datadir.path().join("rocksdb")), + pprof_dumps_path: Some(datadir.path().join("pprof")), + }; + (NodeConfig::test().with_datadir_args(datadir_args), datadir) +} #[test] fn test_basic_setup() { @@ -44,76 +93,48 @@ fn test_basic_setup() { .check_launch(); } -#[tokio::test] -async fn test_eth_launcher() { - let runtime = Runtime::test(); +#[test] +fn test_node_setup() { let config = NodeConfig::test(); let db = create_test_rw_db(); let _builder = - NodeBuilder::new(config) - .with_database(db) - .with_launch_context(runtime.clone()) - .with_types_and_provider::>>, - >>() - .with_components(EthereumNode::components()) - .with_add_ons(EthereumAddOns::default()) - .apply(|builder| { - let _ = builder.db(); - builder - }) - .launch_with_fn(|builder| { - let launcher = EngineNodeLauncher::new( - runtime.clone(), - builder.config().datadir(), - Default::default(), - ); - builder.launch_with(launcher) - }); + NodeBuilder::new(config).with_database(db).node(EthereumNode::default()).check_launch(); } -#[test] -fn test_eth_launcher_with_tokio_runtime() { - // #[tokio::test] can not be used here because we need to create a custom tokio runtime - // and it would be dropped before the test is finished, resulting in a panic. - let main_rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - - let custom_rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - - main_rt.block_on(async { - let runtime = Runtime::test(); - let config = NodeConfig::test(); - let db = create_test_rw_db(); - let _builder = - NodeBuilder::new(config) - .with_database(db) - .with_launch_context(runtime.clone()) - .with_types_and_provider::>>, - >>() - .with_components(EthereumNode::components()) - .with_add_ons( - EthereumAddOns::default().with_tokio_runtime(Some(custom_rt.handle().clone())), - ) - .apply(|builder| { - let _ = builder.db(); - builder - }) - .launch_with_fn(|builder| { - let launcher = EngineNodeLauncher::new( - runtime.clone(), - builder.config().datadir(), - Default::default(), - ); - builder.launch_with(launcher) - }); - }); +#[tokio::test(flavor = "multi_thread")] +async fn custom_backfill_recovers_then_builds() -> eyre::Result<()> { + let (config, _datadir) = launch_config(); + let backfill = RecordingBackfill::default(); + let calls = Arc::clone(&backfill.calls); + + let _node = NodeBuilder::new(config) + .with_database(create_test_rw_db()) + .with_launch_context(Runtime::test()) + .with_types::() + .with_components(EthereumNode::components()) + .with_add_ons(EthereumAddOns::default()) + .launch_with_debug_capabilities_and_backfill(backfill) + .await?; + + assert_eq!(*calls.lock().unwrap(), ["recover", "build"]); + Ok(()) } -#[test] -fn test_node_setup() { - let config = NodeConfig::test(); - let db = create_test_rw_db(); - let _builder = - NodeBuilder::new(config).with_database(db).node(EthereumNode::default()).check_launch(); +#[tokio::test] +async fn failed_backfill_recovery_stops_the_launch() { + let (config, _datadir) = launch_config(); + let backfill = RecordingBackfill { fail_recovery: true, ..Default::default() }; + let calls = Arc::clone(&backfill.calls); + + let launched = NodeBuilder::new(config) + .with_database(create_test_rw_db()) + .with_launch_context(Runtime::test()) + .with_types::() + .with_components(EthereumNode::components()) + .with_add_ons(EthereumAddOns::default()) + .launch_with_debug_capabilities_and_backfill(backfill) + .await; + + assert!(launched.is_err()); + assert_eq!(*calls.lock().unwrap(), ["recover"]); } diff --git a/crates/ethereum/node/tests/it/testing.rs b/crates/ethereum/node/tests/it/testing.rs index d736afcdd35..49aa9873ce5 100644 --- a/crates/ethereum/node/tests/it/testing.rs +++ b/crates/ethereum/node/tests/it/testing.rs @@ -1,202 +1,122 @@ //! E2E tests for the testing RPC namespace. use alloy_primitives::{Bytes, B256}; -use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV4; use alloy_rpc_types_eth::BlockNumberOrTag; use jsonrpsee_core::client::ClientT; use reth_chainspec::EthereumHardfork; -use reth_db::test_utils::create_test_rw_db; -use reth_e2e_test_utils::{eth_payload_attributes, test_chain_spec}; +use reth_e2e_test_utils::{eth_payload_attributes, test_chain_spec, E2ETestSetupExt}; use reth_ethereum_engine_primitives::EthPayloadAttributes; -use reth_node_builder::{NodeBuilder, NodeConfig}; -use reth_node_core::{ - args::DatadirArgs, - dirs::{DataDirPath, MaybePlatformPath}, -}; -use reth_node_ethereum::{node::EthereumAddOns, EthereumNode}; -use reth_rpc_api::TestingBuildBlockRequestV1; +use reth_node_ethereum::EthereumNode; +use reth_rpc_api::{TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection}; -use reth_tasks::Runtime; use serde_json::Value; -use std::str::FromStr; -use tempfile::tempdir; -use tokio::sync::oneshot; #[tokio::test(flavor = "multi_thread")] -async fn testing_rpc_build_block_works() -> eyre::Result<()> { - let runtime = Runtime::test(); - let mut rpc_args = - reth_node_core::args::RpcServerArgs::default().with_http().with_unused_ports(); - rpc_args.http_api = Some(RpcModuleSelection::from_iter([RethRpcModule::Testing])); - let tempdir = tempdir().expect("temp datadir"); - let datadir_args = DatadirArgs { - datadir: MaybePlatformPath::::from_str(tempdir.path().to_str().unwrap()) - .expect("valid datadir"), - static_files_path: Some(tempdir.path().join("static")), - rocksdb_path: Some(tempdir.path().join("rocksdb")), - pprof_dumps_path: Some(tempdir.path().join("pprof")), - }; - let config = NodeConfig::test().with_datadir_args(datadir_args).with_rpc(rpc_args); - let db = create_test_rw_db(); - - let (tx, rx): ( - oneshot::Sender>, - oneshot::Receiver>, - ) = oneshot::channel(); - - let builder = NodeBuilder::new(config) - .with_database(db) - .with_launch_context(runtime) - .with_types::() - .with_components(EthereumNode::components()) - .with_add_ons(EthereumAddOns::default()) - .on_rpc_started(move |ctx, handles| { - let Some(client) = handles.rpc.http_client() else { return Ok(()) }; - - let chain = ctx.config().chain.clone(); - let parent_block_hash = chain.genesis_hash(); - let payload_attributes = eth_payload_attributes(&chain, chain.genesis().timestamp + 1); - - let request = TestingBuildBlockRequestV1 { - parent_block_hash, - payload_attributes, - transactions: vec![], - extra_data: None, - }; - - tokio::spawn(async move { - let res: eyre::Result = client - .request("testing_buildBlockV1", request.into_params()) - .await - .map_err(Into::into); - let _ = tx.send(res); - }); - - Ok(()) - }); - - // Launch the node with the default engine launcher. - let launcher = builder.engine_api_launcher(); - let _node = builder.launch_with(launcher).await?; - - // Wait for the testing RPC call to return. - let res = rx.await.expect("testing_buildBlockV1 response"); - assert!(res.is_ok(), "testing_buildBlockV1 failed: {:?}", res.err()); +async fn testing_rpc_build_block_returns_amsterdam_payload_fields() -> eyre::Result<()> { + for (fork, is_amsterdam) in + [(EthereumHardfork::Cancun, false), (EthereumHardfork::Amsterdam, true)] + { + let chain_spec = test_chain_spec(fork); + let (node, _) = EthereumNode::test_setup(1, chain_spec.clone()) + .with_rpc_modifier(|rpc| { + rpc.with_http_api(RpcModuleSelection::from_iter([RethRpcModule::Testing])) + }) + .build_single() + .await?; + let client = node.rpc_client().expect("HTTP RPC server is enabled"); + + let timestamp = chain_spec.genesis().timestamp + 1; + let slot_number = timestamp + 42; + let mut payload_attributes = eth_payload_attributes(&chain_spec, timestamp); + payload_attributes.slot_number = is_amsterdam.then_some(slot_number); + let parent_beacon_block_root = payload_attributes.parent_beacon_block_root; + let request = TestingBuildBlockRequestV1 { + parent_block_hash: chain_spec.genesis_hash(), + payload_attributes, + transactions: vec![], + extra_data: None, + }; + let response: Value = client.request("testing_buildBlockV1", request.into_params()).await?; + let payload = &response["executionPayload"]; + + assert_eq!(payload.get("blockAccessList").is_some(), is_amsterdam); + assert_eq!(payload.get("slotNumber").is_some(), is_amsterdam); + match serde_json::from_value::(response)? { + TestingBuildBlockResponseV1::V5(_) => assert!(!is_amsterdam), + TestingBuildBlockResponseV1::V6(envelope) => { + assert!(is_amsterdam); + let payload = envelope.execution_payload; + assert_eq!(payload.slot_number, slot_number); + let block_hash = payload.payload_inner.payload_inner.payload_inner.block_hash; + let mut block = payload.into_block_raw()?; + block.header.parent_beacon_block_root = parent_beacon_block_root; + block.header.requests_hash = Some(envelope.execution_requests.requests_hash()); + assert_eq!(block.header.hash_slow(), block_hash); + } + } + } Ok(()) } #[tokio::test(flavor = "multi_thread")] async fn testing_rpc_commit_block_works() -> eyre::Result<()> { - let runtime = Runtime::test(); - let mut rpc_args = - reth_node_core::args::RpcServerArgs::default().with_http().with_unused_ports(); - rpc_args.http_api = - Some(RpcModuleSelection::from_iter([RethRpcModule::Eth, RethRpcModule::Testing])); - let tempdir = tempdir().expect("temp datadir"); - let datadir_args = DatadirArgs { - datadir: MaybePlatformPath::::from_str(tempdir.path().to_str().unwrap()) - .expect("valid datadir"), - static_files_path: Some(tempdir.path().join("static")), - rocksdb_path: Some(tempdir.path().join("rocksdb")), - pprof_dumps_path: Some(tempdir.path().join("pprof")), - }; let chain_spec = test_chain_spec(EthereumHardfork::Amsterdam); - let config = NodeConfig::test() - .with_chain(chain_spec) - .with_datadir_args(datadir_args) - .with_rpc(rpc_args); - let db = create_test_rw_db(); - - let (tx, rx): (oneshot::Sender>, oneshot::Receiver>) = - oneshot::channel(); - - let builder = NodeBuilder::new(config) - .with_database(db) - .with_launch_context(runtime) - .with_types::() - .with_components(EthereumNode::components()) - .with_add_ons(EthereumAddOns::default()) - .on_rpc_started(move |ctx, handles| { - let Some(client) = handles.rpc.http_client() else { return Ok(()) }; - - let chain = ctx.config().chain.clone(); - let timestamp = chain.genesis().timestamp + 1; - let target_gas_limit = chain.genesis().gas_limit - 100; - let payload_attributes = EthPayloadAttributes { - target_gas_limit: Some(target_gas_limit), - ..eth_payload_attributes(&chain, timestamp) - }; - - tokio::spawn(async move { - let res: eyre::Result<()> = async { - let extra_data = Bytes::from_static(b"reth"); - let block_hash: B256 = client - .request( - "testing_commitBlockV1", - ( - payload_attributes.clone(), - Vec::::new(), - Some(extra_data.clone()), - ), - ) - .await?; - let latest: Value = client - .request("eth_getBlockByNumber", (BlockNumberOrTag::Latest, false)) - .await?; - let latest_hash = - latest.get("hash").and_then(Value::as_str).expect("latest block hash"); - assert_eq!(latest_hash, block_hash.to_string()); - assert_eq!(latest.get("extraData"), Some(&serde_json::to_value(extra_data)?)); - assert_eq!( - latest.get("gasLimit").and_then(Value::as_str), - Some(format!("{target_gas_limit:#x}").as_str()) - ); - assert!(latest - .get("transactions") - .and_then(Value::as_array) - .expect("block transactions") - .is_empty()); - - let mut next_payload_attributes = payload_attributes.clone(); - next_payload_attributes.timestamp += 12; - next_payload_attributes.slot_number = Some(next_payload_attributes.timestamp); - let next_block_hash: B256 = client - .request( - "testing_commitBlockV1", - ( - next_payload_attributes, - Option::>::None, - Option::::None, - ), - ) - .await?; - let next_latest: Value = client - .request("eth_getBlockByNumber", (BlockNumberOrTag::Latest, false)) - .await?; - let next_block_hash = next_block_hash.to_string(); - let block_hash = block_hash.to_string(); - assert_eq!( - next_latest.get("hash").and_then(Value::as_str), - Some(next_block_hash.as_str()) - ); - assert_eq!( - next_latest.get("parentHash").and_then(Value::as_str), - Some(block_hash.as_str()) - ); - Ok(()) - } - .await; - let _ = tx.send(res); - }); - - Ok(()) - }); - - let launcher = builder.engine_api_launcher(); - let _node = builder.launch_with(launcher).await?; + let (node, _) = EthereumNode::test_setup(1, chain_spec.clone()) + .with_rpc_modifier(|rpc| { + rpc.with_http_api(RpcModuleSelection::from_iter([ + RethRpcModule::Eth, + RethRpcModule::Testing, + ])) + }) + .build_single() + .await?; + let client = node.rpc_client().expect("HTTP RPC server is enabled"); + + let timestamp = chain_spec.genesis().timestamp + 1; + let target_gas_limit = chain_spec.genesis().gas_limit - 100; + let payload_attributes = EthPayloadAttributes { + target_gas_limit: Some(target_gas_limit), + ..eth_payload_attributes(&chain_spec, timestamp) + }; - rx.await.expect("testing_commitBlockV1 response")?; + let extra_data = Bytes::from_static(b"reth"); + let block_hash: B256 = client + .request( + "testing_commitBlockV1", + (payload_attributes.clone(), Vec::::new(), Some(extra_data.clone())), + ) + .await?; + let latest: Value = + client.request("eth_getBlockByNumber", (BlockNumberOrTag::Latest, false)).await?; + let latest_hash = latest.get("hash").and_then(Value::as_str).expect("latest block hash"); + assert_eq!(latest_hash, block_hash.to_string()); + assert_eq!(latest.get("extraData"), Some(&serde_json::to_value(extra_data)?)); + assert_eq!( + latest.get("gasLimit").and_then(Value::as_str), + Some(format!("{target_gas_limit:#x}").as_str()) + ); + assert!(latest + .get("transactions") + .and_then(Value::as_array) + .expect("block transactions") + .is_empty()); + + let mut next_payload_attributes = payload_attributes; + next_payload_attributes.timestamp += 12; + next_payload_attributes.slot_number = Some(next_payload_attributes.timestamp); + let next_block_hash: B256 = client + .request( + "testing_commitBlockV1", + (next_payload_attributes, Option::>::None, Option::::None), + ) + .await?; + let next_latest: Value = + client.request("eth_getBlockByNumber", (BlockNumberOrTag::Latest, false)).await?; + let next_block_hash = next_block_hash.to_string(); + let block_hash = block_hash.to_string(); + assert_eq!(next_latest.get("hash").and_then(Value::as_str), Some(next_block_hash.as_str())); + assert_eq!(next_latest.get("parentHash").and_then(Value::as_str), Some(block_hash.as_str())); Ok(()) } diff --git a/crates/ethereum/primitives/src/receipt.rs b/crates/ethereum/primitives/src/receipt.rs index cb7adb88562..cc27d283821 100644 --- a/crates/ethereum/primitives/src/receipt.rs +++ b/crates/ethereum/primitives/src/receipt.rs @@ -10,12 +10,13 @@ pub type RpcReceipt = EthereumReceipt; #[cfg(test)] mod tests { + use super::*; use crate::TransactionSigned; use alloy_consensus::{ReceiptWithBloom, TxReceipt, TxType}; use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{ - address, b256, bloom, bytes, hex_literal::hex, Address, Bloom, Bytes, Log, LogData, + address, b256, bytes, hex_literal::hex, Address, Bloom, Bytes, Log, LogData, }; use alloy_rlp::{Decodable, Encodable}; use reth_codecs::Compact; @@ -49,7 +50,7 @@ mod tests { tx_type: TxType::Legacy, cumulative_gas_used: 0x1u64, logs: vec![Log::new_unchecked( - address!("0x0000000000000000000000000000000000000011"), + Address::with_last_byte(0x11), vec![ b256!("0x000000000000000000000000000000000000000000000000000000000000dead"), b256!("0x000000000000000000000000000000000000000000000000000000000000beef"), @@ -58,7 +59,7 @@ mod tests { )], success: false, }, - logs_bloom: [0; 256].into(), + logs_bloom: Bloom::ZERO, }; receipt.encode(&mut data); @@ -81,7 +82,7 @@ mod tests { tx_type: TxType::Legacy, cumulative_gas_used: 0x1u64, logs: vec![Log::new_unchecked( - address!("0x0000000000000000000000000000000000000011"), + Address::with_last_byte(0x11), vec![ b256!("0x000000000000000000000000000000000000000000000000000000000000dead"), b256!("0x000000000000000000000000000000000000000000000000000000000000beef"), @@ -90,7 +91,7 @@ mod tests { )], success: false, }, - logs_bloom: [0; 256].into(), + logs_bloom: Bloom::ZERO, }; let receipt = ReceiptWithBloom::decode(&mut &data[..]).unwrap(); @@ -211,9 +212,7 @@ mod tests { address: Address::ZERO, data: LogData::new_unchecked(vec![], Default::default()), }]; - let bloom = bloom!( - "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" - ); + let bloom = Bloom::with_last_byte(1); let receipt = ReceiptWithBloom { receipt: Receipt { tx_type: TxType::Eip2930, diff --git a/crates/ethereum/reth/Cargo.toml b/crates/ethereum/reth/Cargo.toml index 05b0744ab26..6d36b4ef2ea 100644 --- a/crates/ethereum/reth/Cargo.toml +++ b/crates/ethereum/reth/Cargo.toml @@ -53,6 +53,18 @@ alloy-rpc-types-engine = { workspace = true, optional = true } [features] default = ["std"] +account-ext = [ + "reth-ethereum-cli?/account-ext", + "reth-revm?/account-ext", + "alloy-rpc-types-eth?/account-ext", + "reth-codecs?/account-ext", + "reth-primitives-traits/account-ext", + "reth-node-ethereum?/account-ext", + "reth-provider?/account-ext", + "reth-trie?/account-ext", + "reth-rpc?/account-ext", + "reth-trie-db?/account-ext", +] std = [ "reth-chainspec/std", "reth-ethereum-primitives/std", diff --git a/crates/evm/evm/src/aliases.rs b/crates/evm/evm/src/aliases.rs index 6ebcb71c27e..4fa95fada85 100644 --- a/crates/evm/evm/src/aliases.rs +++ b/crates/evm/evm/src/aliases.rs @@ -32,6 +32,10 @@ pub type HaltReasonFor = as EvmFactory>::HaltReason; /// Helper to access [`EvmFactory::Tx`] for a given [`ConfigureEvm`]. pub type TxEnvFor = as EvmFactory>::Tx; +/// Helper to access [`BlockExecutorFactory::TxExecutionResult`] for a given [`ConfigureEvm`]. +pub type TxExecutionResultFor = + <::BlockExecutorFactory as BlockExecutorFactory>::TxExecutionResult; + /// Helper to access [`BlockExecutorFactory::ExecutionCtx`] for a given [`ConfigureEvm`]. pub type ExecutionCtxFor<'a, Evm> = <::BlockExecutorFactory as BlockExecutorFactory>::ExecutionCtx<'a>; diff --git a/crates/evm/evm/src/execute.rs b/crates/evm/evm/src/execute.rs index 3f7c00734d8..fadc2490ab5 100644 --- a/crates/evm/evm/src/execute.rs +++ b/crates/evm/evm/src/execute.rs @@ -671,7 +671,7 @@ where } fn size_hint(&self) -> usize { - self.db.bundle_state.size_hint() + self.db.bundle_size_hint() } fn take_bal(&mut self) -> Option { @@ -741,10 +741,11 @@ impl, Tx> ExecutableTxParts for WithTxEnv::default(); + let db = InMemoryDB::default(); let executor = provider.executor(db); let _ = executor.execute(&Default::default()); } diff --git a/crates/evm/evm/src/lib.rs b/crates/evm/evm/src/lib.rs index 4dfb0b6a841..d6ed4cc954b 100644 --- a/crates/evm/evm/src/lib.rs +++ b/crates/evm/evm/src/lib.rs @@ -31,7 +31,7 @@ use reth_execution_errors::BlockExecutionError; use reth_primitives_traits::{ BlockTy, HeaderTy, NodePrimitives, ReceiptTy, SealedBlock, SealedHeader, TxTy, }; -use revm::{database::State, primitives::hardfork::SpecId}; +use revm::{context::result::ResultAndState, database::State, primitives::hardfork::SpecId}; pub mod either; /// EVM environment configuration. @@ -398,9 +398,30 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin { where DB: Database, I: InspectorFor> + 'a, + { + self.create_block_builder_with(evm, parent, ctx, |executor| executor) + } + + /// Creates a [`BlockBuilder`] like [`ConfigureEvm::create_block_builder`], with a customized + /// executor. + fn create_block_builder_with<'a, DB, I, E>( + &'a self, + evm: EvmFor, I>, + parent: &'a SealedHeader>, + ctx: ::ExecutionCtx<'a>, + f: impl FnOnce(BlockExecutorForEvm<'a, Self, DB, I>) -> E, + ) -> impl BlockBuilder + where + DB: Database, + I: InspectorFor> + 'a, + E: alloy_evm::block::BlockExecutor< + Evm = EvmFor, I>, + Transaction = TxTy, + Receipt = ReceiptTy, + >, { BasicBlockBuilder { - executor: self.create_executor(evm, ctx.clone()), + executor: f(self.create_executor(evm, ctx.clone())), ctx, assembler: self.block_assembler(), parent, @@ -488,6 +509,57 @@ pub trait ConfigureEvm: Clone + Debug + Send + Sync + Unpin { ) -> impl Executor { BasicBlockExecutor::new(self, db) } + + /// Returns whether engine validation may commit transaction results from speculative prewarm + /// execution instead of executing the transactions again. + /// + /// Return `true` only if all of these hold, see also [`Self::prewarm_tx_result`]: + /// - the block executor executes a transaction by checking that its gas limit, capped by the + /// transaction gas limit cap, fits into the block gas limit minus the last receipt's + /// cumulative gas used, and then running [`Evm::transact`] with the transaction environment; + /// - [`Evm::transact`] performs the standard caller nonce and balance validation, and rejects + /// transactions with the largest nonce even if nonce checks are disabled; + /// - EVMs created by this configuration with JIT support disabled call inspector hooks for + /// every executed instruction; + /// - execution depends only on recorded database reads and the transaction and block + /// environments reproduced during prewarming, with no unrecorded external state. + /// + /// [`Evm::transact`]: alloy_evm::Evm::transact + fn reuses_prewarm_results(&self) -> bool { + false + } + + /// Wraps a speculative prewarm execution result of `tx` into the result consumed by + /// [`BlockExecutor::commit_transaction`](alloy_evm::block::BlockExecutor::commit_transaction). + /// + /// Only called if [`Self::reuses_prewarm_results`] returns `true`. + /// Returning `None` rejects and consumes this speculative result; the engine executes the + /// transaction normally instead. + fn prewarm_tx_result( + &self, + tx: &TxTy, + result: ResultAndState>, + ) -> Option> { + let _ = (tx, result); + None + } + + /// Returns the predicted environment of the block after `parent` with `beneficiary`, in which + /// engine validation speculatively executes pool transactions before the block arrives. + /// + /// Validation reuses such results only for blocks whose environment matches the predicted + /// one, apart from the beneficiary, timestamp, prevrandao and gas limit, which the + /// transactions must not have observed. Only called if [`Self::reuses_prewarm_results`] + /// returns `true`. Returns `None` if the environment cannot be predicted, which disables + /// recording pool transaction results. + fn speculative_next_evm_env( + &self, + parent: &SealedHeader>, + beneficiary: Address, + ) -> Option> { + let _ = (parent, beneficiary); + None + } } /// JIT backend controls exposed by an EVM configuration. diff --git a/crates/evm/evm/src/sender_recovery.rs b/crates/evm/evm/src/sender_recovery.rs index b3665bc4ed2..5a506b05b0d 100644 --- a/crates/evm/evm/src/sender_recovery.rs +++ b/crates/evm/evm/src/sender_recovery.rs @@ -134,18 +134,6 @@ mod tests { assert_eq!(shared_cache.recover(&transaction).unwrap(), sender); } - #[test] - fn failed_recovery_is_not_cached() { - let transaction = TransactionSigned::new_unhashed( - Transaction::Legacy(TxLegacy::default()), - Signature::new(U256::ZERO, U256::ZERO, false), - ); - let cache = SenderRecoveryCache::new(4); - - assert!(cache.recover(&transaction).is_err()); - assert_eq!(cache.get(transaction.tx_hash()), None); - } - #[test] fn custom_recovery_runs_only_on_cache_miss() { let transaction = TransactionSigned::new_unhashed( diff --git a/crates/evm/execution-errors/src/trie.rs b/crates/evm/execution-errors/src/trie.rs index ac650a976fe..0a591061824 100644 --- a/crates/evm/execution-errors/src/trie.rs +++ b/crates/evm/execution-errors/src/trie.rs @@ -70,7 +70,7 @@ impl From for ProviderError { match value { StateProofError::Database(error) => Self::Database(error), StateProofError::Rlp(error) => Self::Rlp(error), - StateProofError::TrieInconsistency(msg) => Self::Database(DatabaseError::Other(msg)), + error @ StateProofError::TrieInconsistency(_) => Self::other(error), } } } diff --git a/crates/evm/execution-types/src/chain.rs b/crates/evm/execution-types/src/chain.rs index 79d078938b9..0744dca73a1 100644 --- a/crates/evm/execution-types/src/chain.rs +++ b/crates/evm/execution-types/src/chain.rs @@ -559,7 +559,7 @@ pub(super) mod serde_bincode_compat { use core::marker::PhantomData; use reth_ethereum_primitives::EthPrimitives; use reth_primitives_traits::{NodePrimitives, SealedBlock}; - use reth_trie_common::ComputedTrieData; + use reth_trie_common::SortedTrieData; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_with::{DeserializeAs, SerializeAs}; @@ -626,12 +626,12 @@ pub(super) mod serde_bincode_compat { trie_updates: value .trie_data .iter() - .map(|(k, v)| (*k, v.get().sorted.trie_updates.as_ref().into())) + .map(|(k, v)| (*k, v.get().trie_updates.as_ref().into())) .collect(), hashed_state: value .trie_data .iter() - .map(|(k, v)| (*k, v.get().sorted.hashed_state.as_ref().into())) + .map(|(k, v)| (*k, v.get().hashed_state.as_ref().into())) .collect(), } } @@ -653,13 +653,7 @@ pub(super) mod serde_bincode_compat { .into_iter() .map(|(k, v)| { let hashed_state = hashed_state_map.get(&k).cloned().unwrap_or_default(); - ( - k, - LazyTrieData::ready(ComputedTrieData::new( - hashed_state, - Arc::new(v.into()), - )), - ) + (k, LazyTrieData::ready(SortedTrieData::new(hashed_state, Arc::new(v.into())))) }) .collect(); @@ -758,10 +752,10 @@ mod tests { #[test] fn chain_append() { let block: RecoveredBlock = Default::default(); - let block1_hash = B256::new([0x01; 32]); - let block2_hash = B256::new([0x02; 32]); - let block3_hash = B256::new([0x03; 32]); - let block4_hash = B256::new([0x04; 32]); + let block1_hash = B256::repeat_byte(0x01); + let block2_hash = B256::repeat_byte(0x02); + let block3_hash = B256::repeat_byte(0x03); + let block4_hash = B256::repeat_byte(0x04); let mut block1 = block.clone(); let mut block2 = block.clone(); @@ -796,12 +790,12 @@ mod tests { let execution_outcome1: ExecutionOutcome = ExecutionOutcome::new( BundleState::new( vec![( - Address::new([2; 20]), + Address::repeat_byte(2), None, Some(AccountInfo::default()), HashMap::default(), )], - vec![vec![(Address::new([2; 20]), None, vec![])]], + vec![vec![(Address::repeat_byte(2), None, vec![])]], vec![], ), vec![vec![]], @@ -812,12 +806,12 @@ mod tests { let execution_outcome2 = ExecutionOutcome::new( BundleState::new( vec![( - Address::new([3; 20]), + Address::repeat_byte(3), None, Some(AccountInfo::default()), HashMap::default(), )], - vec![vec![(Address::new([3; 20]), None, vec![])]], + vec![vec![(Address::repeat_byte(3), None, vec![])]], vec![], ), vec![vec![]], @@ -826,16 +820,16 @@ mod tests { ); let mut block1: RecoveredBlock = Default::default(); - let block1_hash = B256::new([15; 32]); + let block1_hash = B256::repeat_byte(15); block1.set_block_number(1); block1.set_hash(block1_hash); - block1.push_sender(Address::new([4; 20])); + block1.push_sender(Address::repeat_byte(4)); let mut block2: RecoveredBlock = Default::default(); - let block2_hash = B256::new([16; 32]); + let block2_hash = B256::repeat_byte(16); block2.set_block_number(2); block2.set_hash(block2_hash); - block2.push_sender(Address::new([4; 20])); + block2.push_sender(Address::repeat_byte(4)); let mut block_state_extended = execution_outcome1; block_state_extended.extend(execution_outcome2); @@ -858,8 +852,8 @@ mod tests { let block: RecoveredBlock = Default::default(); // Define block hashes for block1 and block2 - let block1_hash = B256::new([0x01; 32]); - let block2_hash = B256::new([0x02; 32]); + let block1_hash = B256::repeat_byte(0x01); + let block2_hash = B256::repeat_byte(0x02); // Clone the default block into block1 and block2 let mut block1 = block.clone(); diff --git a/crates/evm/execution-types/src/execute.rs b/crates/evm/execution-types/src/execute.rs index 572349cb50a..bc78755cc52 100644 --- a/crates/evm/execution-types/src/execute.rs +++ b/crates/evm/execution-types/src/execute.rs @@ -47,7 +47,7 @@ impl BlockExecutionOutput { /// /// This means that depending on status we can potentially return `U256::ZERO`. pub fn storage(&self, address: &Address, storage_key: U256) -> Option { - self.state.account(address).and_then(|a| a.storage_slot(storage_key)) + self.state.storage(address, storage_key) } } diff --git a/crates/evm/execution-types/src/execution_outcome.rs b/crates/evm/execution-types/src/execution_outcome.rs index dbc47168465..de046d72118 100644 --- a/crates/evm/execution-types/src/execution_outcome.rs +++ b/crates/evm/execution-types/src/execution_outcome.rs @@ -199,7 +199,7 @@ impl ExecutionOutcome { /// /// This means that depending on status we can potentially return `U256::ZERO`. pub fn storage(&self, address: &Address, storage_key: U256) -> Option { - self.bundle.account(address).and_then(|a| a.storage_slot(storage_key)) + self.bundle.storage(address, storage_key) } /// Return bytecode if known. @@ -573,8 +573,8 @@ mod tests { fn test_initialization() { // Create a new BundleState object with initial data let bundle = BundleState::new( - vec![(Address::new([2; 20]), None, Some(AccountInfo::default()), HashMap::default())], - vec![vec![(Address::new([2; 20]), None, vec![])]], + vec![(Address::repeat_byte(2), None, Some(AccountInfo::default()), HashMap::default())], + vec![vec![(Address::repeat_byte(2), None, vec![])]], vec![], ); @@ -610,11 +610,11 @@ mod tests { // Create a BundleStateInit object and insert initial data let mut state_init: BundleStateInit = AddressMap::default(); state_init - .insert(Address::new([2; 20]), (None, Some(Account::default()), B256Map::default())); + .insert(Address::repeat_byte(2), (None, Some(Account::default()), B256Map::default())); // Create an AddressMap for account reverts and insert initial data let mut revert_inner: AddressMap = AddressMap::default(); - revert_inner.insert(Address::new([2; 20]), (None, vec![])); + revert_inner.insert(Address::repeat_byte(2), (None, vec![])); // Create a RevertsInit object and insert the revert_inner data let mut revert_init: RevertsInit = HashMap::default(); diff --git a/crates/exex/exex/src/backfill/job.rs b/crates/exex/exex/src/backfill/job.rs index a88c51e143e..dff16e797e2 100644 --- a/crates/exex/exex/src/backfill/job.rs +++ b/crates/exex/exex/src/backfill/job.rs @@ -13,8 +13,8 @@ use reth_evm::execute::{BlockExecutionError, BlockExecutionOutput, Executor}; use reth_node_api::{Block as _, BlockBody as _, NodePrimitives}; use reth_primitives_traits::{format_gas_throughput, RecoveredBlock, SignedTransaction}; use reth_provider::{ - BlockReader, Chain, ExecutionOutcome, HeaderProvider, ProviderError, StateProvider, - StateProviderFactory, TransactionVariant, + BlockReader, Chain, ExecutionOutcome, ProviderError, StateProvider, StateProviderFactory, + TransactionVariant, }; use reth_prune_types::PruneModes; use reth_revm::database::StateProviderDatabase; @@ -41,7 +41,7 @@ pub struct BackfillJob { impl Iterator for BackfillJob where E: ConfigureEvm> + 'static, - P: HeaderProvider + BlockReader + StateProviderFactory, + P: BlockReader + StateProviderFactory, { type Item = BackfillJobResult>; @@ -57,7 +57,7 @@ where impl BackfillJob where E: ConfigureEvm> + 'static, - P: BlockReader + HeaderProvider + StateProviderFactory, + P: BlockReader + StateProviderFactory, { /// Converts the backfill job into a single block backfill job. pub fn into_single_blocks(self) -> SingleBlockBackfillJob { @@ -170,7 +170,7 @@ pub struct SingleBlockBackfillJob { impl Iterator for SingleBlockBackfillJob where E: ConfigureEvm> + 'static, - P: HeaderProvider + BlockReader + StateProviderFactory, + P: BlockReader + StateProviderFactory, { type Item = BackfillJobResult<( RecoveredBlock, @@ -185,7 +185,7 @@ where impl SingleBlockBackfillJob where E: ConfigureEvm> + 'static, - P: HeaderProvider + BlockReader + StateProviderFactory, + P: BlockReader + StateProviderFactory, { /// Converts the single block backfill job into a stream. pub fn into_stream( diff --git a/crates/exex/exex/src/context.rs b/crates/exex/exex/src/context.rs index 4da560f58d0..4b67a08035a 100644 --- a/crates/exex/exex/src/context.rs +++ b/crates/exex/exex/src/context.rs @@ -1,10 +1,9 @@ use crate::{ExExContextDyn, ExExEvent, ExExNotifications, ExExNotificationsStream}; use alloy_eips::BlockNumHash; use reth_exex_types::ExExHead; -use reth_node_api::{FullNodeComponents, NodePrimitives, NodeTypes, PrimitivesTy}; +use reth_node_api::{FullNodeComponents, NodeTypes, PrimitivesTy}; use reth_node_core::node_config::NodeConfig; use reth_payload_builder::PayloadBuilderHandle; -use reth_provider::BlockReader; use reth_tasks::TaskExecutor; use std::fmt::Debug; use tokio::sync::mpsc::{error::SendError, UnboundedSender}; @@ -42,7 +41,6 @@ pub struct ExExContext { impl Debug for ExExContext where Node: FullNodeComponents, - Node::Provider: Debug, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExExContext") @@ -59,8 +57,6 @@ where impl ExExContext where Node: FullNodeComponents, - Node::Provider: Debug + BlockReader, - Node::Types: NodeTypes, { /// Returns dynamic version of the context pub fn into_dyn(self) -> ExExContextDyn> { @@ -71,7 +67,6 @@ where impl ExExContext where Node: FullNodeComponents, - Node::Types: NodeTypes, { /// Returns the transaction pool of the node. pub fn pool(&self) -> &Node::Pool { @@ -142,7 +137,6 @@ mod tests { use crate::ExExContext; use reth_exex_types::ExExHead; use reth_node_api::FullNodeComponents; - use reth_provider::BlockReader; /// #[test] @@ -152,10 +146,7 @@ mod tests { ctx: ExExContext, } - impl ExEx - where - Node::Provider: BlockReader, - { + impl ExEx { async fn _test_bounds(mut self) -> eyre::Result<()> { self.ctx.pool(); self.ctx.evm_config(); diff --git a/crates/exex/exex/src/dyn_context.rs b/crates/exex/exex/src/dyn_context.rs index 820676f5153..8648b2487c3 100644 --- a/crates/exex/exex/src/dyn_context.rs +++ b/crates/exex/exex/src/dyn_context.rs @@ -4,9 +4,8 @@ use alloy_eips::BlockNumHash; use reth_chainspec::EthChainSpec; use reth_ethereum_primitives::EthPrimitives; -use reth_node_api::{FullNodeComponents, HeaderTy, NodePrimitives, NodeTypes, PrimitivesTy}; +use reth_node_api::{FullNodeComponents, HeaderTy, NodePrimitives, PrimitivesTy}; use reth_node_core::node_config::NodeConfig; -use reth_provider::BlockReader; use std::fmt::Debug; use tokio::sync::mpsc; @@ -52,8 +51,7 @@ impl Debug for ExExContextDyn { impl From> for ExExContextDyn> where - Node: FullNodeComponents>, - Node::Provider: Debug + BlockReader, + Node: FullNodeComponents, { fn from(ctx: ExExContext) -> Self { let config = ctx.config.map_chainspec(|chainspec| { diff --git a/crates/exex/exex/src/manager.rs b/crates/exex/exex/src/manager.rs index e8355a85234..50c4da01e4e 100644 --- a/crates/exex/exex/src/manager.rs +++ b/crates/exex/exex/src/manager.rs @@ -788,7 +788,7 @@ mod tests { // Define the notification for testing let mut block1: RecoveredBlock = Default::default(); - block1.set_hash(B256::new([0x01; 32])); + block1.set_hash(B256::repeat_byte(0x01)); block1.set_block_number(10); let notification1 = ExExNotification::ChainCommitted { @@ -806,7 +806,7 @@ mod tests { // Push another notification let mut block2: RecoveredBlock = Default::default(); - block2.set_hash(B256::new([0x02; 32])); + block2.set_hash(B256::repeat_byte(0x02)); block2.set_block_number(20); let notification2 = ExExNotification::ChainCommitted { @@ -849,7 +849,7 @@ mod tests { // Push some notifications to fill part of the buffer let mut block1: RecoveredBlock = Default::default(); - block1.set_hash(B256::new([0x01; 32])); + block1.set_hash(B256::repeat_byte(0x01)); block1.set_block_number(10); let notification1 = ExExNotification::ChainCommitted { @@ -1138,11 +1138,11 @@ mod tests { // Setup two blocks for the chain commit notification let mut block1: RecoveredBlock = Default::default(); - block1.set_hash(B256::new([0x01; 32])); + block1.set_hash(B256::repeat_byte(0x01)); block1.set_block_number(10); let mut block2: RecoveredBlock = Default::default(); - block2.set_hash(B256::new([0x02; 32])); + block2.set_hash(B256::repeat_byte(0x02)); block2.set_block_number(11); // Setup a notification @@ -1191,7 +1191,7 @@ mod tests { exex_handle.finished_height = Some(BlockNumHash::new(15, B256::random())); let mut block1: RecoveredBlock = Default::default(); - block1.set_hash(B256::new([0x01; 32])); + block1.set_hash(B256::repeat_byte(0x01)); block1.set_block_number(10); let notification = ExExNotification::ChainCommitted { diff --git a/crates/exex/exex/src/notifications.rs b/crates/exex/exex/src/notifications.rs index c8efb6773f9..158a0761c41 100644 --- a/crates/exex/exex/src/notifications.rs +++ b/crates/exex/exex/src/notifications.rs @@ -6,7 +6,7 @@ use reth_ethereum_primitives::EthPrimitives; use reth_evm::ConfigureEvm; use reth_exex_types::ExExHead; use reth_node_api::NodePrimitives; -use reth_provider::{BlockNumReader, BlockReader, Chain, HeaderProvider, StateProviderFactory}; +use reth_provider::{BlockNumReader, BlockReader, Chain, StateProviderFactory}; use reth_stages_api::ExecutionStageThresholds; use reth_tracing::tracing::debug; use std::{ @@ -164,8 +164,8 @@ where impl ExExNotificationsStream for ExExNotifications where - P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static, - E: ConfigureEvm> + Clone + Unpin + 'static, + P: BlockReader + StateProviderFactory + Clone + Unpin + 'static, + E: ConfigureEvm> + 'static, { fn set_without_head(&mut self) { let current = std::mem::replace(&mut self.inner, ExExNotificationsInner::Invalid); @@ -221,7 +221,7 @@ where impl Stream for ExExNotifications where - P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static, + P: BlockReader + StateProviderFactory + Clone + Unpin + 'static, E: ConfigureEvm> + 'static, { type Item = eyre::Result>; @@ -254,7 +254,7 @@ where impl Debug for ExExNotificationsWithoutHead where - E: ConfigureEvm + Debug, + E: ConfigureEvm, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExExNotifications") @@ -385,8 +385,8 @@ where impl ExExNotificationsWithHead where - P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static, - E: ConfigureEvm> + Clone + Unpin + 'static, + P: BlockReader + StateProviderFactory + Clone + Unpin + 'static, + E: ConfigureEvm> + 'static, { /// Checks if the ExEx head is on the canonical chain. /// @@ -475,8 +475,8 @@ where impl Stream for ExExNotificationsWithHead where - P: BlockReader + HeaderProvider + StateProviderFactory + Clone + Unpin + 'static, - E: ConfigureEvm> + Clone + Unpin + 'static, + P: BlockReader + StateProviderFactory + Clone + Unpin + 'static, + E: ConfigureEvm> + 'static, { type Item = eyre::Result>; diff --git a/crates/exex/exex/src/wal/storage.rs b/crates/exex/exex/src/wal/storage.rs index a69509c05bc..12d6f12d5ad 100644 --- a/crates/exex/exex/src/wal/storage.rs +++ b/crates/exex/exex/src/wal/storage.rs @@ -191,8 +191,8 @@ mod tests { use reth_trie_common::{ serde_bincode_compat, updates::{StorageTrieUpdates, StorageTrieUpdatesSorted, TrieUpdates}, - BranchNodeCompact, ComputedTrieData, HashedPostState, HashedStorage, HashedStorageSorted, - LazyTrieData, Nibbles, + BranchNodeCompact, HashedPostState, HashedStorage, HashedStorageSorted, LazyTrieData, + Nibbles, SortedTrieData, }; use std::{collections::BTreeMap, fs::File, sync::Arc}; @@ -233,7 +233,7 @@ mod tests { let decoded: StorageTrieUpdatesSorted = decoded.into(); assert_eq!(decoded.storage_nodes, storage_nodes); - let storage_slots = vec![(B256::from([1; 32]), U256::from(1))]; + let storage_slots = vec![(B256::repeat_byte(1), U256::ONE)]; let encoded = rmp_serde::encode::to_vec(&(&storage_slots, false))?; let decoded: serde_bincode_compat::hashed_state::HashedStorageSorted<'_> = rmp_serde::decode::from_slice(&encoded)?; @@ -286,8 +286,8 @@ mod tests { let block = Block::default().seal_slow().try_recover()?; let block_number = block.header().number(); - let hashed_address = B256::from([1; 32]); - let storage_key = B256::from([2; 32]); + let hashed_address = B256::repeat_byte(1); + let storage_key = B256::repeat_byte(2); let trie_updates = TrieUpdates { account_nodes: HashMap::from_iter([ @@ -318,7 +318,7 @@ mod tests { )]), }; - let trie_data = LazyTrieData::ready(ComputedTrieData::new( + let trie_data = LazyTrieData::ready(SortedTrieData::new( Arc::new(hashed_state.into_sorted()), Arc::new(trie_updates.into_sorted()), )); diff --git a/crates/metrics/src/thread.rs b/crates/metrics/src/thread.rs index 80d7b4a06b1..835b4e4cac0 100644 --- a/crates/metrics/src/thread.rs +++ b/crates/metrics/src/thread.rs @@ -125,11 +125,6 @@ mod platform { use super::*; use crate::thread::ThreadResourceUsage; - #[test] - fn samples_current_thread() { - assert!(ThreadResourceUsage::now().elapsed().is_some()); - } - #[test] fn measures_forced_minor_page_fault() { let mapping_len = 4096; @@ -178,16 +173,6 @@ mod platform { pub(super) fn current_thread_resource_usage() -> Option { None } - - #[cfg(test)] - mod tests { - use crate::thread::ThreadResourceUsage; - - #[test] - fn unsupported_platform_returns_none() { - assert!(ThreadResourceUsage::now().elapsed().is_none()); - } - } } #[cfg(test)] diff --git a/crates/net/banlist/src/lib.rs b/crates/net/banlist/src/lib.rs index 2de02e13c30..1577cdacf9a 100644 --- a/crates/net/banlist/src/lib.rs +++ b/crates/net/banlist/src/lib.rs @@ -125,14 +125,14 @@ impl BanList { /// Bans the IP until the timestamp. /// /// This does not ban non-global IPs. - /// If the IP is already banned, the timeout will be updated to the new value. + /// An existing ban is only extended; indefinite bans remain indefinite. pub fn ban_ip_until(&mut self, ip: IpAddr, until: Instant) { self.ban_ip_with(ip, Some(until)); } /// Bans the peer until the timestamp. /// - /// If the peer is already banned, the timeout will be updated to the new value. + /// An existing ban is only extended; indefinite bans remain indefinite. pub fn ban_peer_until(&mut self, node_id: PeerId, until: Instant) { self.ban_peer_with(node_id, Some(until)); } @@ -151,29 +151,40 @@ impl BanList { /// Bans the peer indefinitely or until the given timeout. /// - /// If the peer is already banned, the timeout will be updated to the new value. + /// An existing ban is only extended; indefinite bans remain indefinite. pub fn ban_peer_with(&mut self, node_id: PeerId, until: Option) { - self.banned_peers.insert(node_id, until); + self.banned_peers + .entry(node_id) + .and_modify(|current| extend_ban(current, until)) + .or_insert(until); } /// Bans the ip indefinitely or until the given timeout. /// /// This does not ban non-global IPs. - /// If the IP is already banned, the timeout will be updated to the new value. + /// An existing ban is only extended; indefinite bans remain indefinite. pub fn ban_ip_with(&mut self, ip: IpAddr, until: Option) { if is_global(&ip) { - self.banned_ips.insert(ip, until); + self.banned_ips + .entry(ip) + .and_modify(|current| extend_ban(current, until)) + .or_insert(until); } } } +/// Extends a ban, treating no deadline as an indefinite ban. +fn extend_ban(current: &mut Option, until: Option) { + *current = current.zip(until).map(|(current, until)| current.max(until)); +} + #[cfg(test)] mod tests { use super::*; #[test] fn can_ban_unban_peer() { - let peer = PeerId::new([1; 64]); + let peer = PeerId::repeat_byte(1); let mut banlist = BanList::default(); banlist.ban_peer(peer); assert!(banlist.is_banned_peer(&peer)); @@ -214,6 +225,37 @@ mod tests { banlist.ban_ip(ip); assert!(!banlist.is_banned_ip(&ip)); } + + #[test] + fn bans_only_extend_until_explicitly_removed() { + let now = Instant::now(); + let later = now + std::time::Duration::from_secs(60); + let peer = PeerId::repeat_byte(1); + let ip = IpAddr::from([1, 1, 1, 1]); + for (initial, update, expected) in [ + (None, Some(now), None), + (Some(later), Some(now), Some(later)), + (Some(now), Some(later), Some(later)), + (Some(now), None, None), + ] { + let mut bans = BanList::default(); + bans.ban_peer_with(peer, initial); + bans.ban_ip_with(ip, initial); + bans.ban_peer_with(peer, update); + bans.ban_ip_with(ip, update); + assert_eq!(bans.banned_peers[&peer], expected); + assert_eq!(bans.banned_ips[&ip], expected); + bans.evict(now + std::time::Duration::from_secs(1)); + assert!(bans.is_banned(&peer, &ip)); + bans.unban_peer(&peer); + bans.unban_ip(&ip); + assert!(!bans.is_banned(&peer, &ip)); + bans.ban_peer_until(peer, now); + bans.ban_ip_until(ip, now); + bans.evict(later); + assert!(!bans.is_banned(&peer, &ip)); + } + } } /// IP filter for restricting network communication to specific IP ranges using CIDR notation. diff --git a/crates/net/discv4/src/config.rs b/crates/net/discv4/src/config.rs index c2b30d3daf1..007d85ea794 100644 --- a/crates/net/discv4/src/config.rs +++ b/crates/net/discv4/src/config.rs @@ -10,6 +10,7 @@ use reth_net_nat::{NatResolver, ResolveNatInterval}; use reth_network_peers::NodeRecord; use std::{ collections::{HashMap, HashSet}, + net::IpAddr, time::Duration, }; @@ -62,6 +63,10 @@ pub struct Discv4Config { pub additional_eip868_rlp_pairs: HashMap, Bytes>, /// If configured, try to resolve public ip pub external_ip_resolver: Option, + /// External IP of the opposite family to the local [`NodeRecord`] address, advertised in the + /// local EIP-868 ENR next to it (`ip` + `ip6`). Serving that family requires a secondary + /// socket of that family (shared-port mode). + pub secondary_advertised_ip: Option, /// If configured and a `external_ip_resolver` is configured, try to resolve the external ip /// using this interval. pub resolve_external_ip_interval: Option, @@ -134,6 +139,7 @@ impl Default for Discv4Config { enforce_expiration_timestamps: true, additional_eip868_rlp_pairs: Default::default(), external_ip_resolver: Some(Default::default()), + secondary_advertised_ip: None, // By default retry public IP using a 5min interval resolve_external_ip_interval: Some(Duration::from_secs(60 * 5)), } @@ -298,6 +304,12 @@ impl Discv4ConfigBuilder { self } + /// Sets the secondary-family external IP advertised in the local EIP-868 ENR. + pub const fn secondary_advertised_ip(&mut self, ip: IpAddr) -> &mut Self { + self.config.secondary_advertised_ip = Some(ip); + self + } + /// Sets the interval at which the external IP is to be resolved. pub const fn resolve_external_ip_interval( &mut self, diff --git a/crates/net/discv4/src/lib.rs b/crates/net/discv4/src/lib.rs index d54ac82d4e0..b4a6eb116da 100644 --- a/crates/net/discv4/src/lib.rs +++ b/crates/net/discv4/src/lib.rs @@ -26,7 +26,7 @@ use crate::{ error::{DecodePacketError, Discv4Error}, - proto::{FindNode, Message, Neighbours, Packet, Ping, Pong}, + proto::{FindNode, Message, Neighbours, NodeEndpoint, Packet, Ping, Pong}, }; use alloy_primitives::{bytes::Bytes, hex, B256}; use discv5::{ @@ -249,22 +249,35 @@ impl Discv4 { trace!(target: "discv4", local_addr=?socket.local_addr(), "opened UDP socket"); let (tx, rx) = mpsc::channel(config.udp_ingress_message_buffer); - Self::bind_with_socket(socket, Some(tx), rx, local_node_record, secret_key, config) + Self::bind_with_socket(socket, None, Some(tx), rx, local_node_record, secret_key, config) } /// Creates a new `Discv4` instance using a pre-bound shared socket. No receive loop is /// spawned; instead returns an [`IngressHandler`] that should be used to forward raw packets /// received by the socket owner (e.g. discv5 unrecognized frames). + /// + /// An optional secondary socket of the opposite IP family (e.g. discv5's dual-stack sibling + /// socket) makes the service answer peers of that family too: outgoing packets are routed to + /// the socket matching the destination family, while ingress for both arrives via the + /// returned [`IngressHandler`]. pub fn bind_shared( socket: Arc, + secondary_socket: Option>, local_node_record: NodeRecord, secret_key: SecretKey, config: Discv4Config, ) -> io::Result<(Self, Discv4Service, IngressHandler)> { let (tx, rx) = mpsc::channel(config.udp_ingress_message_buffer); let local_id = local_node_record.id; - let (discv4, service) = - Self::bind_with_socket(socket, None, rx, local_node_record, secret_key, config)?; + let (discv4, service) = Self::bind_with_socket( + socket, + secondary_socket, + None, + rx, + local_node_record, + secret_key, + config, + )?; let handler = IngressHandler::new(tx, local_id); @@ -273,6 +286,7 @@ impl Discv4 { fn bind_with_socket( socket: Arc, + secondary_socket: Option>, ingress_tx: Option, ingress_rx: IngressReceiver, mut local_node_record: NodeRecord, @@ -284,6 +298,7 @@ impl Discv4 { let mut service = Discv4Service::new( socket, + secondary_socket, ingress_tx, ingress_rx, local_addr, @@ -560,15 +575,24 @@ impl Discv4Service { /// /// If `ingress_tx` is `Some`, the receive loop is spawned to read from the socket. If `None`, /// the caller feeds packets into `ingress_rx` externally (shared socket mode). + #[expect(clippy::too_many_arguments)] pub(crate) fn new( socket: Arc, + secondary_socket: Option>, ingress_tx: Option, ingress_rx: IngressReceiver, local_address: SocketAddr, local_node_record: NodeRecord, secret_key: SecretKey, - config: Discv4Config, + mut config: Discv4Config, ) -> Self { + // normalize once: only an opposite-family secondary socket can serve the advertisement + config.secondary_advertised_ip = config.secondary_advertised_ip.filter(|ip| { + secondary_socket.is_some() && + !ip.is_unspecified() && + ip.is_ipv4() != local_node_record.address.is_ipv4() + }); + let (egress_tx, egress_rx) = mpsc::channel(config.udp_egress_message_buffer); let mut tasks = JoinSet::<()>::new(); @@ -578,7 +602,7 @@ impl Discv4Service { } let udp = Arc::clone(&socket); - tasks.spawn(send_loop(udp, egress_rx)); + tasks.spawn(send_loop(udp, secondary_socket, local_address.is_ipv4(), egress_rx)); let kbuckets = KBucketsTable::new( NodeKey::from(&local_node_record).into(), @@ -611,13 +635,20 @@ impl Discv4Service { // for EIP-868 construct an ENR let local_eip_868_enr = { let mut builder = Enr::builder(); - builder.ip(local_node_record.address); - if local_node_record.address.is_ipv4() { - builder.udp4(local_node_record.udp_port); - builder.tcp4(local_node_record.tcp_port); - } else { - builder.udp6(local_node_record.udp_port); - builder.tcp6(local_node_record.tcp_port); + for ip in + std::iter::once(local_node_record.address).chain(config.secondary_advertised_ip) + { + // Keep port keys for NAT resolution, but omit unspecified IP addresses. + if !ip.is_unspecified() { + builder.ip(ip); + } + if ip.is_ipv4() { + builder.udp4(local_node_record.udp_port); + builder.tcp4(local_node_record.tcp_port); + } else { + builder.udp6(local_node_record.udp_port); + builder.tcp6(local_node_record.tcp_port); + } } for (key, val) in &config.additional_eip868_rlp_pairs { @@ -697,6 +728,14 @@ impl Discv4Service { /// Sets the given ip address as the node's external IP in the node record announced in /// discovery pub fn set_external_ip_addr(&mut self, external_ip: IpAddr) { + // an opposite-family resolution would overwrite the secondary ENR key and flip the + // record's family, so a secondary advertisement pins the record's family + if self.config.secondary_advertised_ip.is_some() && + external_ip.is_ipv4() != self.local_node_record.address.is_ipv4() + { + debug!(target: "discv4", ?external_ip, "Ignoring opposite-family external ip"); + return + } if self.local_node_record.address != external_ip { debug!(target: "discv4", ?external_ip, "Updating external ip"); self.local_node_record.address = external_ip; @@ -880,7 +919,7 @@ impl Discv4Service { let _ = self.egress.try_send((payload, to)).map_err(|err| { debug!(target: "discv4", %err, "dropped outgoing packet"); }); - self.pending_find_nodes.insert(node.id, FindNodeRequest::new(ctx)); + self.pending_find_nodes.insert(node.id, FindNodeRequest::new(ctx, to.ip())); } /// Sends a new `FindNode` packet to the node with `target` as the lookup target but checks @@ -1177,10 +1216,8 @@ impl Discv4Service { // > If no communication with the sender of this ping has occurred within the last 12h, a // > ping should be sent in addition to pong in order to receive an endpoint proof. // - // Note: we only mark if the node is absent because the `last 12h` condition is handled by - // the ping interval let mut is_new_insert = false; - let mut needs_bond = false; + let mut needs_bond = !self.has_bond(record.id, record.address); let mut is_proven = false; let old_enr = match self.kbuckets.entry(&key) { @@ -1256,11 +1293,11 @@ impl Discv4Service { self.try_ping(record, PingReason::InitialInsert); } else if needs_bond { self.try_ping(record, PingReason::EstablishBond); - } else if is_proven { + } else { // if node has been proven, this means we've received a pong and verified its endpoint // proof. We've also sent a pong above to verify our endpoint proof, so we can now // send our find_nodes request if PingReason::Lookup - if let Some((_, ctx)) = self.pending_lookup.remove(&record.id) { + if is_proven && let Some((_, ctx)) = self.pending_lookup.remove(&record.id) { if self.pending_find_nodes.contains_key(&record.id) { // there's already another pending request, unmark it so the next round can // try to send it @@ -1271,7 +1308,7 @@ impl Discv4Service { self.find_node(&record, ctx); } } - } else { + // Request ENR if included in the ping match (ping.enr_sq, old_enr) { (Some(new), Some(old)) if new > old => { @@ -1292,13 +1329,22 @@ impl Discv4Service { return } - if self.pending_pings.contains_key(&node.id) || - self.pending_find_nodes.contains_key(&node.id) - { + if self.pending_pings.get(&node.id).is_some_and(|ping| ping.node.address == node.address) { return } - if self.queued_pings.iter().any(|(n, _)| n.id == node.id) { + if self.queued_pings.iter().any(|(n, _)| n.id == node.id && n.address == node.address) { + return + } + + if self.pending_pings.contains_key(&node.id) || + self.pending_find_nodes.contains_key(&node.id) + { + if matches!(reason, PingReason::EstablishBond) && + self.queued_pings.len() < MAX_QUEUED_PINGS + { + self.queued_pings.push_back((node, reason)); + } return } @@ -1315,12 +1361,15 @@ impl Discv4Service { pub(crate) fn send_ping(&mut self, node: NodeRecord, reason: PingReason) -> B256 { let remote_addr = node.udp_addr(); let id = node.id; - let ping = Ping { - from: self.local_node_record.into(), - to: node.into(), - expire: self.ping_expiration(), - enr_sq: self.enr_seq(), - }; + let mut from: NodeEndpoint = self.local_node_record.into(); + from.tcp_port = if node.address.is_ipv4() { + self.local_eip_868_enr.tcp4() + } else { + self.local_eip_868_enr.tcp6() + } + .unwrap_or_default(); + let ping = + Ping { from, to: node.into(), expire: self.ping_expiration(), enr_sq: self.enr_seq() }; trace!(target: "discv4", ?ping, "sending ping"); let echo_hash = self.send_packet(Message::Ping(ping), remote_addr); @@ -1360,6 +1409,13 @@ impl Discv4Service { trace!(target: "discv4", from=?remote_addr, expected=?request.echo_hash, echo_hash=?pong.echo,"Got unexpected Pong"); return } + // The echo only proves reachability at the IP the ping was sent to. + if request.node.udp_addr().ip().to_canonical() != + remote_addr.ip().to_canonical() + { + trace!(target: "discv4", from=?remote_addr, expected=?request.node.udp_addr(), "Ignoring Pong from unexpected IP"); + return + } } entry.remove() } @@ -1416,35 +1472,34 @@ impl Discv4Service { /// Handler for incoming `EnrResponse` message fn on_enr_response(&mut self, msg: EnrResponse, remote_addr: SocketAddr, id: PeerId) { trace!(target: "discv4", ?remote_addr, ?msg, "received ENR response"); - if let Some(resp) = self.pending_enr_requests.remove(&id) { - // ensure the ENR's public key matches the expected node id - let enr_id = pk2id(&msg.enr.public_key()); - if id != enr_id { - return - } + let Entry::Occupied(request) = self.pending_enr_requests.entry(id) else { return }; - if resp.echo_hash == msg.request_hash { - let key = kad_key(id); - let fork_id = msg.eth_fork_id(); - let (record, old_fork_id) = match self.kbuckets.entry(&key) { - kbucket::Entry::Present(mut entry, _) => { - let id = entry.value_mut().update_with_fork_id(fork_id); - (entry.value().record, id) - } - kbucket::Entry::Pending(mut entry, _) => { - let id = entry.value_mut().update_with_fork_id(fork_id); - (entry.value().record, id) - } - _ => return, - }; - match (fork_id, old_fork_id) { - (Some(new), Some(old)) if new != old => { - self.notify(DiscoveryUpdate::EnrForkId(record, new)) - } - (Some(new), None) => self.notify(DiscoveryUpdate::EnrForkId(record, new)), - _ => {} - } + // A delayed response to an earlier request must not consume its replacement. + // Also ensure the ENR's public key matches the expected node id. + if request.get().echo_hash != msg.request_hash || id != pk2id(&msg.enr.public_key()) { + return + } + request.remove(); + + let key = kad_key(id); + let fork_id = msg.eth_fork_id(); + let (record, old_fork_id) = match self.kbuckets.entry(&key) { + kbucket::Entry::Present(mut entry, _) => { + let id = entry.value_mut().update_with_fork_id(fork_id); + (entry.value().record, id) + } + kbucket::Entry::Pending(mut entry, _) => { + let id = entry.value_mut().update_with_fork_id(fork_id); + (entry.value().record, id) + } + _ => return, + }; + match (fork_id, old_fork_id) { + (Some(new), Some(old)) if new != old => { + self.notify(DiscoveryUpdate::EnrForkId(record, new)) } + (Some(new), None) => self.notify(DiscoveryUpdate::EnrForkId(record, new)), + _ => {} } } @@ -1483,6 +1538,13 @@ impl Discv4Service { Entry::Occupied(mut entry) => { { let request = entry.get_mut(); + // `Neighbours` packets carry no request binding, so a validly signed packet + // from the queried node could be replayed from another address. Ignore it and + // keep the request armed for the genuine reply. + if !request.is_from_destination(remote_addr.ip()) { + trace!(target: "discv4", from=?remote_addr, expected=?request.destination, "Ignoring Neighbours from unexpected IP"); + return + } // Mark the request as answered request.answered = true; let total = request.response_count + msg.nodes.len(); @@ -1630,10 +1692,24 @@ impl Discv4Service { } fn evict_expired_requests(&mut self, now: Instant) { - self.pending_enr_requests.retain(|_node_id, enr_request| { - now.duration_since(enr_request.sent_at) < self.config.enr_expiration + let mut failed_enr_requests = Vec::new(); + self.pending_enr_requests.retain(|node_id, enr_request| { + if now.duration_since(enr_request.sent_at) < self.config.enr_expiration { + return true + } + failed_enr_requests.push(*node_id); + false }); + // forget the announced enr seq, so the next ping or pong requests the ENR again. + for node_id in failed_enr_requests { + match self.kbuckets.entry(&kad_key(node_id)) { + kbucket::Entry::Present(mut entry, _) => entry.value_mut().last_enr_seq = None, + kbucket::Entry::Pending(mut entry, _) => entry.value_mut().last_enr_seq = None, + _ => {} + } + } + let mut failed_pings = Vec::new(); self.pending_pings.retain(|node_id, ping_request| { if now.duration_since(ping_request.sent_at) > self.config.ping_expiration { @@ -1761,7 +1837,10 @@ impl Discv4Service { /// Pops buffered ping requests and sends them. fn ping_buffered(&mut self) { - while self.pending_pings.len() < MAX_NODES_PING { + for _ in 0..self.queued_pings.len() { + if self.pending_pings.len() >= MAX_NODES_PING { + break + } match self.queued_pings.pop_front() { Some((next, reason)) => self.try_ping(next, reason), None => break, @@ -1864,6 +1943,13 @@ impl Discv4Service { } else { let _ = self.local_eip_868_enr.set_tcp6(port, &self.secret_key); } + if let Some(ip) = self.config.secondary_advertised_ip { + if ip.is_ipv4() { + let _ = self.local_eip_868_enr.set_tcp4(port, &self.secret_key); + } else { + let _ = self.local_eip_868_enr.set_tcp6(port, &self.secret_key); + } + } } Discv4Command::Terminated => { @@ -2001,10 +2087,22 @@ pub enum Discv4Event { } /// Continuously reads new messages from the channel and writes them to the socket -pub(crate) async fn send_loop(udp: Arc, rx: EgressReceiver) { +pub(crate) async fn send_loop( + udp: Arc, + secondary_udp: Option>, + primary_is_ipv4: bool, + rx: EgressReceiver, +) { let mut stream = ReceiverStream::new(rx); while let Some((payload, to)) = stream.next().await { - match udp.send_to(&payload, to).await { + // a destination with no matching-family socket falls through to the primary, failing + // in `send_to` like any other unreachable destination + let socket = if to.is_ipv4() == primary_is_ipv4 { + &udp + } else { + secondary_udp.as_ref().unwrap_or(&udp) + }; + match socket.send_to(&payload, to).await { Ok(size) => { trace!(target: "discv4", ?to, ?size,"sent payload"); } @@ -2088,11 +2186,12 @@ impl IngressHandler { // A packet starts with the hash of everything that follows it, and `Message::decode` // rejects any packet whose contents do not hash to it. A repeat of a packet we already - // accepted can therefore be recognised from those 32 bytes alone, without decoding. + // accepted from the same IP can therefore be recognised from those 32 bytes alone, + // without decoding. // Decoding runs an ECDSA recovery, so checking here keeps a replayed packet from costing // a signature verification. if data.len() >= MIN_PACKET_SIZE && - self.cache.contains_packet(B256::from_slice(&data[..32])) + self.cache.contains_packet(B256::from_slice(&data[..32]), src.ip()) { trace!(target: "discv4", ?src, "Received duplicate packet."); return @@ -2107,7 +2206,7 @@ impl IngressHandler { // Only packets that decoded are remembered, so a peer cannot suppress a packet we // have not seen yet by guessing its hash. - self.cache.insert_packet(packet.hash); + self.cache.insert_packet(packet.hash, src.ip()); IngressEvent::Packet(src, packet) } @@ -2132,8 +2231,10 @@ struct ReceiveCache { /// This is used to count the number of messages received from a given IP address within an /// interval. ip_messages: HashMap, - // keeps track of unique packet hashes - unique_packets: schnellru::LruMap, + /// Valid packet hashes paired with their canonical source IP. + /// + /// A replay from another IP must not suppress the genuine packet from the expected source. + unique_packets: schnellru::LruMap<(B256, IpAddr), ()>, } impl ReceiveCache { @@ -2158,16 +2259,16 @@ impl ReceiveCache { *ctn } - /// Returns true if we previously received the packet. + /// Returns true if we previously received the packet from this IP. /// /// A hit refreshes the entry so that a packet being replayed repeatedly stays cached. - fn contains_packet(&mut self, hash: B256) -> bool { - self.unique_packets.get(&hash).is_some() + fn contains_packet(&mut self, hash: B256, ip: IpAddr) -> bool { + self.unique_packets.get(&(hash, ip.to_canonical())).is_some() } /// Remembers a packet we accepted. - fn insert_packet(&mut self, hash: B256) { - self.unique_packets.insert(hash, ()); + fn insert_packet(&mut self, hash: B256, ip: IpAddr) { + self.unique_packets.insert((hash, ip.to_canonical()), ()); } } @@ -2415,13 +2516,29 @@ struct FindNodeRequest { answered: bool, /// Response buffer lookup_context: LookupContext, + /// IP address the request was sent to, in canonical form. + destination: IpAddr, } // === impl FindNodeRequest === impl FindNodeRequest { - fn new(resp: LookupContext) -> Self { - Self { sent_at: Instant::now(), response_count: 0, answered: false, lookup_context: resp } + fn new(resp: LookupContext, destination: IpAddr) -> Self { + Self { + sent_at: Instant::now(), + response_count: 0, + answered: false, + lookup_context: resp, + destination: destination.to_canonical(), + } + } + + /// Returns `true` if `ip` is the address this request was sent to. + /// + /// Only the IP is compared, not the port, and IPv4-mapped IPv6 addresses are treated as their + /// IPv4 counterparts because a dual-stack socket reports IPv4 peers as mapped addresses. + fn is_from_destination(&self, ip: IpAddr) -> bool { + self.destination == ip.to_canonical() } } @@ -2590,7 +2707,7 @@ mod tests { use reth_ethereum_forks::{EnrForkIdEntry, ForkHash}; use reth_network_peers::mainnet_nodes; use secp256k1::SECP256K1; - use std::future::poll_fn; + use std::{future::poll_fn, net::Ipv6Addr}; #[tokio::test] async fn test_duplicate_packet_rejected_without_decoding() { @@ -2607,15 +2724,17 @@ mod tests { enr_sq: None, }); let (packet, hash) = msg.encode(&remote_key); - let src = "10.0.0.1:30303".parse().unwrap(); + let src = SocketAddr::from((Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped(), 30303)); handler.handle_packet(&packet, src).await; assert!(matches!(rx.try_recv(), Ok(IngressEvent::Packet(_, _)))); - // the replay is dropped on the hash prefix alone - handler.handle_packet(&packet, src).await; - assert!(rx.try_recv().is_err()); - assert!(handler.cache.contains_packet(hash)); + // Replays from the same IP are dropped regardless of address representation or port. + for duplicate_src in [src, SocketAddr::from(([10, 0, 0, 1], 40404))] { + handler.handle_packet(&packet, duplicate_src).await; + assert_matches::assert_matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)); + } + assert!(handler.cache.contains_packet(hash, src.ip())); } #[tokio::test] @@ -2642,7 +2761,7 @@ mod tests { forged[last] ^= 0xff; handler.handle_packet(&forged, src).await; assert!(matches!(rx.try_recv(), Ok(IngressEvent::BadPacket(..)))); - assert!(!handler.cache.contains_packet(hash)); + assert!(!handler.cache.contains_packet(hash, src.ip())); // so the genuine packet still gets through handler.handle_packet(&packet, src).await; @@ -2667,6 +2786,120 @@ mod tests { assert_eq!(expected, decoded); } + #[tokio::test] + async fn test_secondary_advertised_ip_in_enr() { + let (Ok(secondary), Ok(secondary2)) = + (UdpSocket::bind("[::1]:0").await, UdpSocket::bind("[::1]:0").await) + else { + // no IPv6 loopback on this host + return; + }; + let mut rng = rand_08::thread_rng(); + let (secret_key, pk) = secp256k1::SECP256K1.generate_keypair(&mut rng); + let local_enr = NodeRecord { + address: Ipv4Addr::LOCALHOST.into(), + tcp_port: 30303, + udp_port: 30303, + id: pk2id(&pk), + }; + + let ip6: Ipv6Addr = "2001:db8::1".parse().unwrap(); + let config = Discv4Config::builder().secondary_advertised_ip(ip6.into()).build(); + let socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); + let (_discv4, service, _ingress) = + Discv4::bind_shared(socket, Some(Arc::new(secondary)), local_enr, secret_key, config) + .unwrap(); + + let enr = &service.local_eip_868_enr; + assert_eq!(enr.ip6(), Some(ip6)); + assert_eq!(enr.udp6(), Some(service.local_node_record.udp_port)); + assert_eq!(enr.tcp6(), Some(service.local_node_record.tcp_port)); + + // an unspecified secondary is normalized away even with a secondary socket + let config = + Discv4Config::builder().secondary_advertised_ip(Ipv6Addr::UNSPECIFIED.into()).build(); + let socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); + let (_discv4, service, _ingress) = + Discv4::bind_shared(socket, Some(Arc::new(secondary2)), local_enr, secret_key, config) + .unwrap(); + + let enr = &service.local_eip_868_enr; + assert_eq!(enr.ip6(), None); + assert_eq!(enr.udp6(), None); + assert_eq!(enr.tcp6(), None); + } + + #[tokio::test] + async fn test_secondary_advertised_ip_requires_secondary_socket() { + let ip6: Ipv6Addr = "2001:db8::1".parse().unwrap(); + let config = Discv4Config::builder().secondary_advertised_ip(ip6.into()).build(); + let (_discv4, service) = create_discv4_with_config(config).await; + + let enr = &service.local_eip_868_enr; + assert_eq!(enr.ip6(), None); + assert_eq!(enr.udp6(), None); + assert_eq!(enr.tcp6(), None); + } + + #[tokio::test] + async fn test_external_ip_completes_unspecified_enr_endpoint() { + let mut rng = rand_08::thread_rng(); + let socket: SocketAddr = "127.0.0.1:0".parse().unwrap(); + let (secret_key, pk) = secp256k1::SECP256K1.generate_keypair(&mut rng); + let local_enr = NodeRecord { + address: Ipv4Addr::UNSPECIFIED.into(), + tcp_port: 30303, + udp_port: 30303, + id: pk2id(&pk), + }; + let (_discv4, mut service) = + Discv4::bind(socket, local_enr, secret_key, Default::default()).await.unwrap(); + + let udp_port = service.local_node_record.udp_port; + let enr = &service.local_eip_868_enr; + assert_eq!(enr.ip4(), None); + assert_eq!(enr.udp4(), Some(udp_port)); + assert_eq!(enr.tcp4(), Some(30303)); + + let external_ip: Ipv4Addr = "1.2.3.4".parse().unwrap(); + service.set_external_ip_addr(external_ip.into()); + + let enr = &service.local_eip_868_enr; + assert_eq!(enr.ip4(), Some(external_ip)); + assert_eq!(enr.udp4(), Some(udp_port)); + assert_eq!(enr.tcp4(), Some(30303)); + } + + #[tokio::test] + async fn test_send_loop_routes_by_destination_family() { + let (Ok(secondary), Ok(sink6)) = + (UdpSocket::bind("[::1]:0").await, UdpSocket::bind("[::1]:0").await) + else { + // no IPv6 loopback on this host + return; + }; + let primary = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap()); + let sink4 = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + + let (tx, rx) = mpsc::channel(8); + tokio::spawn(send_loop(primary, Some(Arc::new(secondary)), true, rx)); + + tx.send((Bytes::from_static(b"v4"), sink4.local_addr().unwrap())).await.unwrap(); + tx.send((Bytes::from_static(b"v6"), sink6.local_addr().unwrap())).await.unwrap(); + + let mut buf = [0u8; 2]; + let (read, _) = tokio::time::timeout(Duration::from_secs(5), sink4.recv_from(&mut buf)) + .await + .unwrap() + .unwrap(); + assert_eq!(&buf[..read], b"v4"); + let (read, _) = tokio::time::timeout(Duration::from_secs(5), sink6.recv_from(&mut buf)) + .await + .unwrap() + .unwrap(); + assert_eq!(&buf[..read], b"v6"); + } + #[test] fn test_enr_forkid_entry_decode() { let raw: [u8; 8] = [0xc7, 0xc6, 0x84, 0xdc, 0xe9, 0x6c, 0x2d, 0x80]; @@ -2688,6 +2921,43 @@ mod tests { assert_eq!(&expected[..], encoded.as_slice()); } + #[tokio::test] + async fn test_request_enr_of_proven_node() { + reth_tracing::init_test_tracing(); + let mut rng = rand_08::thread_rng(); + let (_discv4, mut service) = create_discv4().await; + + let id = PeerId::random(); + let addr: SocketAddr = (Ipv4Addr::new(10, 0, 0, 1), DEFAULT_DISCOVERY_PORT).into(); + let mut entry = NodeEntry::new_proven(NodeRecord::new(addr, id)); + entry.last_enr_seq = Some(1); + let _ = service.kbuckets.insert_or_update( + &kad_key(id), + entry, + NodeStatus { + direction: ConnectionDirection::Outgoing, + state: ConnectionState::Connected, + }, + ); + service.received_pongs.on_pong(id, addr.ip()); + let ping = |service: &Discv4Service, rng: &mut rand_08::rngs::ThreadRng| Ping { + from: rng_endpoint(rng), + to: rng_endpoint(rng), + expire: service.ping_expiration(), + enr_sq: Some(2), + }; + + // the node announces a newer record. + service.on_ping(ping(&service, &mut rng), addr, id, B256::random()); + assert!(service.pending_enr_requests.contains_key(&id)); + + // the request goes unanswered, so the next ping has to ask again. + service.evict_expired_requests(Instant::now() + service.config.enr_expiration * 2); + assert!(!service.pending_enr_requests.contains_key(&id)); + service.on_ping(ping(&service, &mut rng), addr, id, B256::random()); + assert!(service.pending_enr_requests.contains_key(&id)); + } + #[test] fn test_local_rotator() { let id = PeerId::random(); @@ -2707,6 +2977,100 @@ mod tests { assert_eq!(rotator.next(&id), id); } + #[tokio::test] + async fn test_ping_establishes_bond_per_ip() { + for (primary, secondary) in + [("127.0.0.1:30303", "[::1]:30303"), ("[::1]:30303", "127.0.0.1:30303")] + { + for initial_ping_pending in [false, true] { + let (_, mut service) = create_discv4().await; + let (egress, mut packets) = mpsc::channel(16); + service.egress = egress; + let primary = NodeRecord::new(primary.parse().unwrap(), PeerId::random()); + let secondary = NodeRecord::new(secondary.parse().unwrap(), primary.id); + service.add_node(primary); + let pong = Pong { + to: service.local_node_record.into(), + echo: service.pending_pings[&primary.id].echo_hash, + expire: service.ping_expiration(), + enr_sq: None, + }; + if !initial_ping_pending { + service.on_pong(pong.clone(), primary.udp_addr(), primary.id); + } + + let ping = Ping { + from: secondary.into(), + to: service.local_node_record.into(), + expire: service.ping_expiration(), + enr_sq: None, + }; + service.on_ping(ping.clone(), secondary.udp_addr(), secondary.id, B256::random()); + if initial_ping_pending { + assert_eq!(service.queued_pings.len(), 1); + service.ping_buffered(); + assert_eq!(service.queued_pings.len(), 1); + service.on_pong(pong, primary.udp_addr(), primary.id); + service.ping_buffered(); + } + + assert!(service.has_bond(primary.id, primary.address)); + assert!(!service.has_bond(secondary.id, secondary.address)); + let request = &service.pending_pings[&secondary.id]; + assert_eq!(request.node.address, secondary.address); + assert!(matches!(request.reason, PingReason::EstablishBond)); + let pong = Pong { + to: service.local_node_record.into(), + echo: request.echo_hash, + expire: service.ping_expiration(), + enr_sq: None, + }; + while packets.try_recv().is_ok() {} + let find_node = + FindNode { id: PeerId::random(), expire: service.ping_expiration() }; + service.on_find_node(find_node, secondary.udp_addr(), secondary.id); + assert!(packets.try_recv().is_err()); + + service.on_pong(pong, secondary.udp_addr(), secondary.id); + assert!(service.has_bond(primary.id, primary.address)); + assert!(service.has_bond(secondary.id, secondary.address)); + service.on_find_node(find_node, secondary.udp_addr(), secondary.id); + let (packet, to) = packets.try_recv().unwrap(); + assert_eq!(to, secondary.udp_addr()); + assert!(matches!(Message::decode(&packet).unwrap().msg, Message::Neighbours(_))); + + service.on_ping(ping, secondary.udp_addr(), secondary.id, B256::random()); + assert!(service.pending_pings.is_empty()); + assert!(service.queued_pings.is_empty()); + } + } + } + + #[tokio::test] + async fn test_ping_advertises_tcp_port_per_family() { + let (_, mut service) = create_discv4().await; + let (egress, mut packets) = mpsc::channel(16); + service.egress = egress; + service.local_node_record.tcp_port = 30303; + service.local_eip_868_enr.set_tcp4(30303, &service.secret_key).unwrap(); + + for (tcp6, expected_tcp6) in [(None, 0), (Some(30304), 30304), (Some(0), 0)] { + if let Some(port) = tcp6 { + service.local_eip_868_enr.set_tcp6(port, &service.secret_key).unwrap(); + } + for (addr, expected) in [("127.0.0.1:30303", 30303), ("[::1]:30303", expected_tcp6)] { + let record = NodeRecord::new(addr.parse().unwrap(), PeerId::random()); + service.send_ping(record, PingReason::InitialInsert); + let (packet, to) = packets.try_recv().unwrap(); + assert_eq!(to, record.udp_addr()); + let Message::Ping(ping) = Message::decode(&packet).unwrap().msg else { + panic!("expected ping"); + }; + assert_eq!(ping.from.tcp_port, expected); + } + } + } + #[tokio::test] async fn test_pending_ping() { let (_, mut service) = create_discv4().await; @@ -3253,7 +3617,7 @@ mod tests { } #[tokio::test] - async fn test_bootnode_not_in_update_stream() { + async fn test_bootnode_in_update_stream() { reth_tracing::init_test_tracing(); let (_, service_1) = create_discv4().await; let peerid_1 = *service_1.local_peer_id(); @@ -3285,11 +3649,11 @@ mod tests { } } - // Assert bootnode did not appear in update stream assert!(bootnode_appeared, "Bootnode should appear in update stream"); } fn insert_proven_node(service: &mut Discv4Service, record: NodeRecord) { + service.received_pongs.on_pong(record.id, record.address); let key = kad_key(record.id); let _ = service.kbuckets.insert_or_update( &key, @@ -3324,6 +3688,25 @@ mod tests { } } + #[tokio::test] + async fn test_pong_from_unpinged_ip_does_not_establish_bond() { + let (_discv4, mut service) = create_discv4().await; + let record = NodeRecord::new("127.0.0.1:30303".parse().unwrap(), PeerId::random()); + let echo_hash = insert_initial_ping(&mut service, record); + let unexpected_addr = "127.0.0.2:30303".parse().unwrap(); + + service.on_pong(make_pong(&service, echo_hash), unexpected_addr, record.id); + + assert!(service.pending_pings.contains_key(&record.id)); + assert!(!service.has_bond(record.id, unexpected_addr.ip())); + + let expected_addr = SocketAddr::from((Ipv4Addr::LOCALHOST.to_ipv6_mapped(), 30304)); + service.on_pong(make_pong(&service, echo_hash), expected_addr, record.id); + + assert!(!service.pending_pings.contains_key(&record.id)); + assert!(service.has_bond(record.id, expected_addr.ip())); + } + #[tokio::test] async fn test_lookup_reset_on_first_bootnode_pong() { let record = NodeRecord::new("0.0.0.0:0".parse().unwrap(), PeerId::random()); @@ -3393,4 +3776,201 @@ mod tests { // flag should be false when lookups are disabled assert!(!service.pending_lookup_reset); } + + #[tokio::test] + async fn test_enr_response_preserves_pending_request() { + let fork_id = ForkId { hash: ForkHash([1, 2, 3, 4]), next: 0 }; + let mut config = Discv4Config::default(); + config.add_eip868_pair("eth", EnrForkIdEntry::from(fork_id)); + let (_remote, mut remote) = create_discv4_with_config(config).await; + let (_discv4, mut service) = create_discv4().await; + let record = remote.local_node_record; + let id = record.id; + let addr = record.udp_addr(); + insert_proven_node(&mut service, record); + service.update_on_reping(record, remote.enr_seq()); + let old_enr = remote.local_eip_868_enr.clone(); + + // Model a request sent in the previous second without waiting for the wall clock. + let (_, old_hash) = + Message::EnrRequest(EnrRequest { expire: service.enr_request_expiration() - 1 }) + .encode(&service.secret_key); + service.pending_enr_requests.get_mut(&id).unwrap().echo_hash = old_hash; + + // A newer sequence replaces the pending request before its response arrives. + remote.local_eip_868_enr.set_tcp4(30304, &remote.secret_key).unwrap(); + service.on_ping( + Ping { + from: record.into(), + to: service.local_node_record.into(), + expire: service.ping_expiration(), + enr_sq: remote.enr_seq(), + }, + addr, + id, + B256::random(), + ); + let request_hash = service.pending_enr_requests[&id].echo_hash; + assert_ne!(request_hash, old_hash); + + service.on_enr_response(EnrResponse { request_hash: old_hash, enr: old_enr }, addr, id); + assert_eq!(service.pending_enr_requests[&id].echo_hash, request_hash); + assert_eq!(service.on_entry(id, |entry| entry.fork_id), Some(None)); + + // A response with the right hash but a different node identity is also ignored. + service.on_enr_response( + EnrResponse { request_hash, enr: service.local_eip_868_enr.clone() }, + addr, + id, + ); + assert_eq!(service.pending_enr_requests[&id].echo_hash, request_hash); + assert_eq!(service.on_entry(id, |entry| entry.fork_id), Some(None)); + + let mut updates = service.update_stream().into_inner(); + service.on_enr_response( + EnrResponse { request_hash, enr: remote.local_eip_868_enr.clone() }, + addr, + id, + ); + assert!(!service.pending_enr_requests.contains_key(&id)); + assert_eq!(service.on_entry(id, |entry| entry.fork_id), Some(Some(fork_id))); + assert_matches::assert_matches!( + updates.try_recv().unwrap(), + DiscoveryUpdate::EnrForkId(found_record, found_fork_id) => { + assert_eq!(found_record, record); + assert_eq!(found_fork_id, fork_id); + } + ); + } + + /// Sets up a service with a pending `FindNode` request to a proven node at `node_addr`. + async fn pending_find_node_service(node_addr: SocketAddr) -> (Discv4Service, NodeRecord) { + let (_discv4, mut service) = create_discv4().await; + + let id = PeerId::random(); + let record = NodeRecord::new(node_addr, id); + let _ = service.kbuckets.insert_or_update( + &kad_key(id), + NodeEntry::new_proven(record), + NodeStatus { + direction: ConnectionDirection::Incoming, + state: ConnectionState::Connected, + }, + ); + + service.lookup(PeerId::random()); + assert_eq!(service.pending_find_nodes.len(), 1); + (service, record) + } + + #[tokio::test] + async fn test_neighbours_from_unexpected_ip_ignored() { + reth_tracing::init_test_tracing(); + + let node_addr = SocketAddr::from(([10, 0, 0, 1], 30303)); + let (mut service, record) = pending_find_node_service(node_addr).await; + let ctx = service.pending_find_nodes[&record.id].lookup_context.clone(); + let lookup_nodes = ctx.inner.closest_nodes.borrow().len(); + + let expire = service.find_node_expiration() + 1000; + let discovered = + NodeRecord::new(SocketAddr::from(([10, 0, 0, 9], 30303)), PeerId::random()); + let msg = Neighbours { nodes: vec![discovered], expire }; + + // A validly signed packet from the queried node's id but from another IP is ignored, even + // if only the port or the address family differs. + for spoofed in [ + SocketAddr::from(([10, 0, 0, 2], 30303)), + SocketAddr::from(([10, 0, 0, 2], 1)), + SocketAddr::from((Ipv4Addr::new(10, 0, 0, 2).to_ipv6_mapped(), 30303)), + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 30303)), + ] { + service.on_neighbours(msg.clone(), spoofed, record.id); + + let request = &service.pending_find_nodes[&record.id]; + assert!(!request.answered); + assert_eq!(request.response_count, 0); + assert_eq!(ctx.inner.closest_nodes.borrow().len(), lookup_nodes); + assert!(service.pending_pings.is_empty()); + } + + // The genuine reply from the queried IP is still accepted, regardless of the port. + service.on_neighbours(msg, SocketAddr::from(([10, 0, 0, 1], 40404)), record.id); + let request = &service.pending_find_nodes[&record.id]; + assert!(request.answered); + assert_eq!(request.response_count, 1); + assert_eq!(ctx.inner.closest_nodes.borrow().len(), lookup_nodes + 1); + } + + #[tokio::test] + async fn test_neighbours_ipv4_mapped_ip_accepted() { + reth_tracing::init_test_tracing(); + + // A dual-stack socket reports IPv4 peers as IPv4-mapped IPv6 addresses. + let node_addr = SocketAddr::from(([10, 0, 0, 1], 30303)); + let (mut service, record) = pending_find_node_service(node_addr).await; + + let expire = service.find_node_expiration() + 1000; + let msg = Neighbours { nodes: Vec::new(), expire }; + let mapped = SocketAddr::from((Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped(), 30303)); + service.on_neighbours(msg, mapped, record.id); + + let request = &service.pending_find_nodes[&record.id]; + assert!(request.answered); + } + + #[tokio::test] + async fn test_neighbours_wrong_ip_replay_does_not_poison_cache() { + let (_discv4, mut service) = create_discv4().await; + let remote_key = SecretKey::new(&mut rand_08::thread_rng()); + let node_addr = SocketAddr::from(([10, 0, 0, 1], 30303)); + let record = NodeRecord::from_secret_key(node_addr, &remote_key); + insert_proven_node(&mut service, record); + service.lookup(PeerId::random()); + assert_eq!(service.pending_find_nodes.len(), 1); + + let ctx = service.pending_find_nodes[&record.id].lookup_context.clone(); + let lookup_nodes = ctx.inner.closest_nodes.borrow().len(); + let discovered = + NodeRecord::new(SocketAddr::from(([10, 0, 0, 9], 30303)), PeerId::random()); + let msg = Message::Neighbours(Neighbours { + nodes: vec![discovered], + expire: service.find_node_expiration(), + }); + let (packet, _) = msg.encode(&remote_key); + let (tx, mut rx) = mpsc::channel(16); + let mut handler = IngressHandler::new(tx, *service.local_peer_id()); + + // A valid replay from another IP must not consume the request or suppress its reply. + handler.handle_packet(&packet, SocketAddr::from(([10, 0, 0, 2], 30303))).await; + let IngressEvent::Packet(src, Packet { msg: Message::Neighbours(msg), node_id, .. }) = + rx.try_recv().unwrap() + else { + panic!("expected a decoded Neighbours packet"); + }; + service.on_neighbours(msg, src, node_id); + let request = &service.pending_find_nodes[&record.id]; + assert!(!request.answered); + assert_eq!(request.response_count, 0); + assert_eq!(ctx.inner.closest_nodes.borrow().len(), lookup_nodes); + assert!(service.pending_pings.is_empty()); + + // The identical signed packet from the queried IP must still reach the service. + handler.handle_packet(&packet, node_addr).await; + let IngressEvent::Packet(src, Packet { msg: Message::Neighbours(msg), node_id, .. }) = + rx.try_recv().expect("genuine reply was suppressed by the wrong-IP replay") + else { + panic!("expected a decoded Neighbours packet"); + }; + service.on_neighbours(msg, src, node_id); + let request = &service.pending_find_nodes[&record.id]; + assert!(request.answered); + assert_eq!(request.response_count, 1); + assert_eq!(ctx.inner.closest_nodes.borrow().len(), lookup_nodes + 1); + + // A port change or mapped address must not bypass deduplication for the same IP. + let mapped = SocketAddr::from((Ipv4Addr::new(10, 0, 0, 1).to_ipv6_mapped(), 40404)); + handler.handle_packet(&packet, mapped).await; + assert_matches::assert_matches!(rx.try_recv(), Err(mpsc::error::TryRecvError::Empty)); + } } diff --git a/crates/net/discv4/src/proto.rs b/crates/net/discv4/src/proto.rs index ca7e15e08b0..ee9968e9aaf 100644 --- a/crates/net/discv4/src/proto.rs +++ b/crates/net/discv4/src/proto.rs @@ -209,10 +209,8 @@ impl alloy_rlp::Decodable for PingNodeEndpoint { if !list { return Err(alloy_rlp::Error::UnexpectedString); } + // Payload length checked by Header::decode. let started_len = b.len(); - if started_len < payload_length { - return Err(alloy_rlp::Error::InputTooShort); - } // Geth allows the ipaddr to be possibly empty: // @@ -985,6 +983,7 @@ mod tests { mod eip8 { use super::*; + use alloy_primitives::b512; fn junk_enr_request() -> Vec { let mut buf = Vec::new(); @@ -1049,7 +1048,7 @@ mod tests { address: "99.33.22.55".parse().unwrap(), tcp_port: 4444, udp_port: 4445, - id: hex!("3155e1427f85f10a5c9a7755877748041af1bcd8d474ec065eb33df57a97babf54bfd2103575fa829115d224c523596b401065a97f74010610fce76382c0bf32").into(), + id: b512!("3155e1427f85f10a5c9a7755877748041af1bcd8d474ec065eb33df57a97babf54bfd2103575fa829115d224c523596b401065a97f74010610fce76382c0bf32"), }.length(); let expected_nodes: Vec = vec![ @@ -1057,25 +1056,25 @@ mod tests { address: "99.33.22.55".parse().unwrap(), udp_port: 4444, tcp_port: 4445, - id: hex!("3155e1427f85f10a5c9a7755877748041af1bcd8d474ec065eb33df57a97babf54bfd2103575fa829115d224c523596b401065a97f74010610fce76382c0bf32").into(), + id: b512!("3155e1427f85f10a5c9a7755877748041af1bcd8d474ec065eb33df57a97babf54bfd2103575fa829115d224c523596b401065a97f74010610fce76382c0bf32"), }, NodeRecord { address: "1.2.3.4".parse().unwrap(), udp_port: 1, tcp_port: 1, - id: hex!("312c55512422cf9b8a4097e9a6ad79402e87a15ae909a4bfefa22398f03d20951933beea1e4dfa6f968212385e829f04c2d314fc2d4e255e0d3bc08792b069db").into(), + id: b512!("312c55512422cf9b8a4097e9a6ad79402e87a15ae909a4bfefa22398f03d20951933beea1e4dfa6f968212385e829f04c2d314fc2d4e255e0d3bc08792b069db"), }, NodeRecord { address: "2001:db8:3c4d:15::abcd:ef12".parse().unwrap(), udp_port: 3333, tcp_port: 3333, - id: hex!("38643200b172dcfef857492156971f0e6aa2c538d8b74010f8e140811d53b98c765dd2d96126051913f44582e8c199ad7c6d6819e9a56483f637feaac9448aac").into(), + id: b512!("38643200b172dcfef857492156971f0e6aa2c538d8b74010f8e140811d53b98c765dd2d96126051913f44582e8c199ad7c6d6819e9a56483f637feaac9448aac"), }, NodeRecord { address: "2001:db8:85a3:8d3:1319:8a2e:370:7348".parse().unwrap(), udp_port: 999, tcp_port: 1000, - id: hex!("8dcab8618c3253b558d459da53bd8fa68935a719aff8b811197101a4b2b47dd2d47295286fc00cc081bb542d760717d1bdd6bec2c37cd72eca367d6dd3b9df73").into(), + id: b512!("8dcab8618c3253b558d459da53bd8fa68935a719aff8b811197101a4b2b47dd2d47295286fc00cc081bb542d760717d1bdd6bec2c37cd72eca367d6dd3b9df73"), }, ]; assert_matches!(decoded.msg, Message::Neighbours(Neighbours { nodes, expire: 1136239445 }) if nodes == expected_nodes); diff --git a/crates/net/discv4/src/test_utils.rs b/crates/net/discv4/src/test_utils.rs index 293c712cac2..8354db14748 100644 --- a/crates/net/discv4/src/test_utils.rs +++ b/crates/net/discv4/src/test_utils.rs @@ -49,7 +49,7 @@ impl MockDiscovery { /// Creates a new instance and opens a socket pub async fn new() -> io::Result<(Self, mpsc::Sender)> { let mut rng = thread_rng(); - let socket = SocketAddr::from_str("0.0.0.0:0").unwrap(); + let socket = SocketAddr::from_str("127.0.0.1:0").unwrap(); let (secret_key, pk) = SECP256K1.generate_keypair(&mut rng); let id = pk2id(&pk); let socket = Arc::new(UdpSocket::bind(socket).await?); @@ -69,7 +69,7 @@ impl MockDiscovery { tasks.spawn(receive_loop(udp, ingress_tx, local_enr.id)); let udp = Arc::clone(&socket); - tasks.spawn(send_loop(udp, egress_rx)); + tasks.spawn(send_loop(udp, None, local_addr.is_ipv4(), egress_rx)); let (tx, command_rx) = mpsc::channel(128); let this = Self { @@ -240,7 +240,7 @@ pub async fn create_discv4() -> (Discv4, Discv4Service) { /// Creates a new testing instance for [`Discv4`] and its service with the given config. pub async fn create_discv4_with_config(config: Discv4Config) -> (Discv4, Discv4Service) { let mut rng = thread_rng(); - let socket = SocketAddr::from_str("0.0.0.0:0").unwrap(); + let socket = SocketAddr::from_str("127.0.0.1:0").unwrap(); let (secret_key, pk) = SECP256K1.generate_keypair(&mut rng); let id = pk2id(&pk); let local_enr = diff --git a/crates/net/discv5/src/config.rs b/crates/net/discv5/src/config.rs index 07be6b9802a..f5b66288945 100644 --- a/crates/net/discv5/src/config.rs +++ b/crates/net/discv5/src/config.rs @@ -445,6 +445,23 @@ pub fn ipv6(listen_config: &ListenConfig) -> Option { } } +/// Returns the address for the opposite-family sibling socket in shared-port mode: that +/// family's configured listen address, rebound to the shared port that discv4 advertises +/// for both families. +pub fn sibling_addr( + listen_config: &ListenConfig, + primary: SocketAddr, + shared_port: u16, +) -> Option { + let mut addr = if primary.is_ipv4() { + SocketAddr::V6(ipv6(listen_config)?) + } else { + SocketAddr::V4(ipv4(listen_config)?) + }; + addr.set_port(shared_port); + Some(addr) +} + /// Returns the amended [`discv5::ListenConfig`] based on the `RLPx` IP address. The ENR is limited /// to one IP address per IP version (atm, may become spec'd how to advertise different addresses). /// The `RLPx` address overwrites the discv5 address w.r.t. IP version. @@ -638,4 +655,21 @@ mod test { assert_eq!(config_socket_ipv6.port(), DEFAULT_DISCOVERY_V5_PORT); assert_eq!(ipv4(&amended_config), ipv4(&listen_config)); } + + #[test] + fn sibling_addr_uses_shared_port() { + let listen_config = ListenConfig::DualStack { + ipv4: Ipv4Addr::UNSPECIFIED, + ipv4_port: 30303, + ipv6: Ipv6Addr::UNSPECIFIED, + ipv6_port: 9200, + }; + let primary: SocketAddr = "0.0.0.0:30303".parse().unwrap(); + + let addr = sibling_addr(&listen_config, primary, 30303).unwrap(); + assert_eq!(addr, "[::]:30303".parse::().unwrap()); + + let listen_config = ListenConfig::Ipv4 { ip: Ipv4Addr::UNSPECIFIED, port: 30303 }; + assert_eq!(sibling_addr(&listen_config, primary, 30303), None); + } } diff --git a/crates/net/discv5/src/filter.rs b/crates/net/discv5/src/filter.rs index def00f54dc3..e449b511753 100644 --- a/crates/net/discv5/src/filter.rs +++ b/crates/net/discv5/src/filter.rs @@ -66,7 +66,7 @@ impl MustNotIncludeKeys { /// Returns `true` if [`Enr`](discv5::Enr) passes filtering rules. pub fn filter(&self, enr: &discv5::Enr) -> FilterOutcome { for key in &self.keys { - if matches!(key.filter(enr), FilterOutcome::Ok) { + if key.filter(enr).is_ok() { return FilterOutcome::Ignore { reason: format!( "{} forks not allowed", diff --git a/crates/net/discv5/src/lib.rs b/crates/net/discv5/src/lib.rs index 06b9a14c88a..a8e98ee3a1c 100644 --- a/crates/net/discv5/src/lib.rs +++ b/crates/net/discv5/src/lib.rs @@ -363,19 +363,23 @@ impl Discv5 { enr: &discv5::Enr, socket: SocketAddr, ) -> Result { - // ignore UDP socket advertised in ENR, use sender socket instead + // Use the sender's address as received so discovery IP bans match source sockets. let address = socket.ip(); let udp_port = socket.port(); let id = enr_to_discv4_id(enr).ok_or(Error::IncompatibleKeyType)?; - let tcp_port = (match self.rlpx_ip_mode { - IpMode::Ip4 => enr.tcp4(), - IpMode::Ip6 => enr.tcp6(), - IpMode::DualStack => unimplemented!("dual-stack support not implemented for rlpx"), + // The port must pair with the session socket's address, so select the + // ENR field by the socket's address family, not by the local node's + // RLPx listen family: an IPv4 session dials the `tcp` port, an IPv6 + // session the `tcp6` port (falling back to `tcp`). + // Mapped IPv4 sessions use `tcp`, while the record retains the original socket address. + let tcp_port = (match address.to_canonical() { + IpAddr::V4(_) => enr.tcp4(), + IpAddr::V6(_) => enr.tcp6().or_else(|| enr.tcp4()), }) .unwrap_or( - // tcp socket is missing from ENR, or is wrong IP version. + // tcp socket is missing from ENR for the session's address family. // // by default geth runs discv5 and discv4 behind the same udp port (the discv4 default // port 30303), so rlpx has a chance of successfully dialing the peer on its discv5 @@ -908,6 +912,94 @@ mod test { ) } + #[test] + fn reachable_tcp_port_follows_session_address_family() { + // Regression test for https://github.com/paradigmxyz/reth/issues/27734: + // the TCP port paired with the session socket's IP must come from the + // ENR field of the *session's* address family, not the local node's + // RLPx listen family. + const TCP4_PORT: u16 = 30304; + const TCP6_PORT: u16 = 30306; + let remote_key = CombinedKey::generate_secp256k1(); + let remote_enr = Enr::builder().tcp4(TCP4_PORT).tcp6(TCP6_PORT).build(&remote_key).unwrap(); + + let v4_socket: SocketAddr = "127.0.0.1:30305".parse().unwrap(); + let v6_socket: SocketAddr = "[::1]:30307".parse().unwrap(); + + for local_mode in [IpMode::Ip4, IpMode::Ip6] { + let mut discv5 = discv5_noop(); + discv5.rlpx_ip_mode = local_mode; + + let record = discv5.try_into_reachable(&remote_enr, v4_socket).unwrap(); + assert_eq!( + record.tcp_port, TCP4_PORT, + "v4 session with local mode {local_mode:?} must dial the tcp4 port" + ); + assert_eq!(record.address, v4_socket.ip()); + assert_eq!(record.udp_port, v4_socket.port()); + + let record = discv5.try_into_reachable(&remote_enr, v6_socket).unwrap(); + assert_eq!( + record.tcp_port, TCP6_PORT, + "v6 session with local mode {local_mode:?} must dial the tcp6 port" + ); + assert_eq!(record.address, v6_socket.ip()); + assert_eq!(record.udp_port, v6_socket.port()); + } + } + + #[test] + fn reachable_ipv4_mapped_session_uses_tcp4() { + const TCP4_PORT: u16 = 30304; + const TCP6_PORT: u16 = 30306; + let remote_key = CombinedKey::generate_secp256k1(); + let remote_enr = Enr::builder().tcp4(TCP4_PORT).tcp6(TCP6_PORT).build(&remote_key).unwrap(); + let mapped_socket: SocketAddr = "[::ffff:104.28.44.25]:9000".parse().unwrap(); + + for local_mode in [IpMode::Ip4, IpMode::Ip6] { + let mut discv5 = discv5_noop(); + discv5.rlpx_ip_mode = local_mode; + + let record = discv5.try_into_reachable(&remote_enr, mapped_socket).unwrap(); + assert_eq!(record.tcp_port, TCP4_PORT); + assert_eq!(record.address, mapped_socket.ip()); + assert_eq!(record.udp_port, mapped_socket.port()); + + let peer = discv5.on_discovered_peer(&remote_enr, mapped_socket).unwrap(); + assert_eq!(peer.node_record, record); + } + } + + #[test] + fn reachable_tcp_port_v6_session_falls_back_to_tcp4() { + // A v6 session whose ENR carries only a `tcp` (v4) port uses that port + // rather than the session's UDP port. + const TCP4_PORT: u16 = 30304; + let remote_key = CombinedKey::generate_secp256k1(); + let remote_enr = Enr::builder().tcp4(TCP4_PORT).build(&remote_key).unwrap(); + + let v6_socket: SocketAddr = "[::1]:30307".parse().unwrap(); + + let mut discv5 = discv5_noop(); + discv5.rlpx_ip_mode = IpMode::Ip6; + let record = discv5.try_into_reachable(&remote_enr, v6_socket).unwrap(); + assert_eq!(record.tcp_port, TCP4_PORT); + } + + #[test] + fn reachable_tcp_port_missing_falls_back_to_udp_port() { + // No TCP port in the ENR at all: keep the long-standing fallback to + // the session's UDP port, for either session family. + let remote_key = CombinedKey::generate_secp256k1(); + let remote_enr = Enr::builder().build(&remote_key).unwrap(); + + let discv5 = discv5_noop(); + let v4_socket: SocketAddr = "127.0.0.1:30305".parse().unwrap(); + assert_eq!(discv5.try_into_reachable(&remote_enr, v4_socket).unwrap().tcp_port, 30305); + let v6_socket: SocketAddr = "[::1]:30307".parse().unwrap(); + assert_eq!(discv5.try_into_reachable(&remote_enr, v6_socket).unwrap().tcp_port, 30307); + } + // Copied from sigp/discv5 with slight modification (U256 type) // #[expect(unreachable_pub)] diff --git a/crates/net/dns/src/lib.rs b/crates/net/dns/src/lib.rs index 6d7c581d940..8486c4794a1 100644 --- a/crates/net/dns/src/lib.rs +++ b/crates/net/dns/src/lib.rs @@ -694,59 +694,6 @@ mod tests { handle.abort(); } - #[tokio::test] - async fn test_recheck_tree_survives_unchanged_root() { - reth_tracing::init_test_tracing(); - - let config = DnsDiscoveryConfig { - recheck_interval: Duration::from_millis(100), - // the default 3/s would leave the recheck lookup queued behind the initial tree walk - max_requests_per_sec: NonZeroUsize::new(50).unwrap(), - ..Default::default() - }; - - let secret_key = SecretKey::new(&mut thread_rng()); - let resolver = Arc::new(MapResolver::default()); - let s = "enrtree-root:v1 e=QFT4PBCRX4XQCV3VUYJ6BTCEPU l=JGUFMSAGI7KZYB3P7IZW4S5Y3A seq=3 sig=3FmXuVwpa8Y7OstZTx9PIb1mt8FrW7VpDOFv4AaGCsZ2EIHmhraWhe4NxYhQDlw5MjeFXYMbJjsPeKlHzmJREQE"; - let mut root: TreeRootEntry = s.parse().unwrap(); - root.sign(&secret_key).unwrap(); - - let link = - LinkEntry { domain: "nodes.example.org".to_string(), pubkey: secret_key.public() }; - resolver.insert(link.domain.clone(), root.to_string()); - - let mut service = DnsDiscoveryService::new(Arc::clone(&resolver), config.clone()); - service.sync_tree_with_link(link.clone()); - - // first recheck sees the very same root, which must not take the tree out of rotation - for _ in 0..10 { - poll_fn(|cx| { - let _ = service.poll(cx); - Poll::Ready(()) - }) - .await; - tokio::time::sleep(config.recheck_interval / 2).await; - } - - // now publish a change and expect the tree to still pick it up - let mut new_root = root.clone(); - new_root.sequence_number = new_root.sequence_number.saturating_add(1); - let enr = Enr::empty(&secret_key).unwrap(); - let enr_txt = enr.to_base64(); - new_root.enr_root = entry_hash(&enr_txt); - new_root.sign(&secret_key).unwrap(); - resolver.insert(link.domain.clone(), new_root.to_string()); - resolver.insert(format!("{}.{}", new_root.enr_root.clone(), link.domain), enr_txt); - - let event = tokio::time::timeout(Duration::from_secs(10), poll_fn(|cx| service.poll(cx))) - .await - .expect("tree stopped rechecking after an unchanged root"); - - match event { - DnsDiscoveryEvent::Enr(discovered) => assert_eq!(discovered, enr), - } - } - #[tokio::test] async fn test_hash_mismatch_is_not_cached_and_does_not_poison_same_hash() { let secret_key = SecretKey::new(&mut thread_rng()); diff --git a/crates/net/dns/src/tree.rs b/crates/net/dns/src/tree.rs index c9e8f3fb6b5..45936804d12 100644 --- a/crates/net/dns/src/tree.rs +++ b/crates/net/dns/src/tree.rs @@ -273,7 +273,7 @@ where impl Eq for LinkEntry where K: EnrKeyUnambiguous, - K::PublicKey: Eq + PartialEq, + K::PublicKey: Eq, { } impl Hash for LinkEntry diff --git a/crates/net/downloaders/src/headers/reverse_headers.rs b/crates/net/downloaders/src/headers/reverse_headers.rs index 4d1ea614c26..3522e7d84b2 100644 --- a/crates/net/downloaders/src/headers/reverse_headers.rs +++ b/crates/net/downloaders/src/headers/reverse_headers.rs @@ -279,50 +279,8 @@ where } // If the last (smallest) validated header attaches to the local head, validate it. - if let Some((last_header, head)) = validated - .last_mut() - .zip(self.local_head.as_ref()) - .filter(|(last, head)| last.number() == head.number() + 1) - { - // Every header must be valid on its own - if let Err(error) = self.consensus.validate_header(&*last_header) { - trace!(target: "downloaders::headers", %error, "Failed to validate header"); - return Err(HeadersResponseError { - request, - peer_id: Some(peer_id), - error: DownloadError::HeaderValidation { - hash: head.hash(), - number: head.number(), - error: Box::new(error), - }, - } - .into()) - } - - // If the header is valid on its own, but not against its parent, we return it as - // detached head error. - // In stage sync this will trigger an unwind because this means that the local head - // is not part of the chain the sync target is on. In other words, the downloader was - // unable to connect the sync target with the local head because the sync target and - // the local head or on different chains. - if let Err(error) = self.consensus.validate_header_against_parent(&*last_header, head) { - let local_head = head.clone(); - // Replace the last header with a detached variant - error!(target: "downloaders::headers", %error, number = last_header.number(), hash = ?last_header.hash(), "Header cannot be attached to known canonical chain"); - - // Reset trackers so that we can start over the next time the sync target is - // updated. - // The expected event flow when that happens is that the node will unwind the local - // chain and restart the downloader. - self.reset(); - - return Err(HeadersDownloaderError::DetachedHead { - local_head: Box::new(local_head), - header: Box::new(last_header.clone()), - error: Box::new(error), - } - .into()) - } + if let Some(last_header) = validated.last() { + self.validate_attachment(last_header, request, peer_id)?; } // update tracked block info (falling block number) @@ -333,6 +291,62 @@ where Ok(()) } + /// Validates `header` against the local head if it is the head's direct successor. + /// + /// Returns [`HeadersDownloaderError::DetachedHead`] if the header does not attach to it. + fn validate_attachment( + &mut self, + header: &SealedHeader, + request: HeadersRequest, + peer_id: PeerId, + ) -> Result<(), ReverseHeadersDownloaderError> { + let Some(head) = + self.local_head.as_ref().filter(|head| header.number() == head.number() + 1) + else { + return Ok(()) + }; + // Every header must be valid on its own + if let Err(error) = self.consensus.validate_header(header) { + trace!(target: "downloaders::headers", %error, "Failed to validate header"); + return Err(HeadersResponseError { + request, + peer_id: Some(peer_id), + error: DownloadError::HeaderValidation { + hash: head.hash(), + number: head.number(), + error: Box::new(error), + }, + } + .into()) + } + + // If the header is valid on its own, but not against its parent, we return it as + // detached head error. + // In stage sync this will trigger an unwind because this means that the local head + // is not part of the chain the sync target is on. In other words, the downloader was + // unable to connect the sync target with the local head because the sync target and + // the local head or on different chains. + if let Err(error) = self.consensus.validate_header_against_parent(header, head) { + let local_head = head.clone(); + // Replace the last header with a detached variant + error!(target: "downloaders::headers", %error, number = header.number(), hash = ?header.hash(), "Header cannot be attached to known canonical chain"); + + // Reset trackers so that we can start over the next time the sync target is + // updated. + // The expected event flow when that happens is that the node will unwind the local + // chain and restart the downloader. + self.reset(); + + return Err(HeadersDownloaderError::DetachedHead { + local_head: Box::new(local_head), + header: Box::new(header.clone()), + error: Box::new(error), + } + .into()) + } + Ok(()) + } + /// Updates the state based on the given `target_block_number` /// /// There are three different outcomes: @@ -430,6 +444,9 @@ where trace!(target: "downloaders::headers", head=?self.local_block_number(), hash=?target.hash(), number=%target.number(), "Received sync target"); + // A target right above the local head has no batch below it to attach. + self.validate_attachment(&target, request, peer_id)?; + // This is the next block we need to start issuing requests from let parent_block_number = target.number().saturating_sub(1); self.on_block_number_update(target.number(), parent_block_number); @@ -1541,6 +1558,32 @@ mod tests { assert_eq!(heap.pop().unwrap().block_number(), lo); } + #[tokio::test] + async fn rejects_a_detached_sync_target_above_the_local_head() { + let head = SealedHeader::

::default(); + let target = SealedHeader::seal_slow(Header { + number: 1, + parent_hash: B256::repeat_byte(1), + ..Default::default() + }); + let client = Arc::new(TestHeadersClient::default()); + client.extend(vec![target.as_ref().clone()]).await; + let consensus = TestConsensus::default(); + consensus.set_fail_header_against_parent(true); + let mut downloader = ReverseHeadersDownloaderBuilder::default() + .build(Arc::clone(&client), Arc::new(consensus)); + downloader.update_local_head(head.clone()); + downloader.update_sync_target(SyncTarget::Tip(target.hash())); + + let error = downloader.next().await.unwrap().unwrap_err(); + + assert_matches!( + error, + HeadersDownloaderError::DetachedHead { local_head, header, .. } + if *local_head == head && *header == target + ); + } + #[tokio::test] async fn download_at_fork_head() { reth_tracing::init_test_tracing(); diff --git a/crates/net/downloaders/src/receipt_file_client.rs b/crates/net/downloaders/src/receipt_file_client.rs index 60af1e70275..a00bac298e9 100644 --- a/crates/net/downloaders/src/receipt_file_client.rs +++ b/crates/net/downloaders/src/receipt_file_client.rs @@ -214,7 +214,7 @@ pub struct ReceiptWithBlockNumber { #[cfg(test)] mod test { use alloy_primitives::{ - address, b256, + address, b256, bytes, bytes::{Buf, BytesMut}, hex, Bytes, Log, LogData, }; @@ -339,9 +339,7 @@ mod test { b256!("0x0000000000000000000000000000000000000000000000000000000000014218"), b256!("0x00000000000000000000000070b17c0fe982ab4a7ac17a4c25485643151a1f2d"), ], - Bytes::from(hex!( - "00000000000000000000000000000000000000000000000000000000618d8837" - )), + bytes!("00000000000000000000000000000000000000000000000000000000618d8837"), ) .unwrap(), }; diff --git a/crates/net/downloaders/src/snap/block_access_list.rs b/crates/net/downloaders/src/snap/block_access_list.rs index d1d56110456..e96193abe61 100644 --- a/crates/net/downloaders/src/snap/block_access_list.rs +++ b/crates/net/downloaders/src/snap/block_access_list.rs @@ -1,7 +1,7 @@ -//! Downloads block access lists and authenticates them against header commitments. +//! Downloads block access lists and authenticates them against their headers' hashes. //! //! Responses are positional: entry `i` answers the `i`th requested block hash, and is accepted -//! only if it hashes to the commitment carried by that block's header, as defined by +//! only if it hashes to the block access list hash carried by that block's header, as defined by //! [EIP-8189](https://eips.ethereum.org/EIPS/eip-8189). use super::request::{SnapVerifier, VerifyingRequest}; @@ -23,7 +23,7 @@ use std::{ }; use tracing::debug; -/// Downloads block access lists and authenticates each against its header commitment. +/// Downloads block access lists and authenticates each against its header's hash. /// /// Invalid responses penalize their peer and retry. Decoding and hashing run on the blocking /// pool. @@ -33,27 +33,14 @@ pub struct BlockAccessListDownloader(VerifyingRequest BlockAccessListDownloader { /// Creates a downloader that verifies responses against `headers`. /// - /// Headers must match the requested block hashes in order and carry their block-access-list - /// commitments. + /// Headers must match the requested block hashes in order and carry their block access list + /// hashes. pub fn new( client: C, request: GetBlockAccessListsMessage, headers: &[SealedHeader], runtime: Runtime, ) -> Result { - if request.block_hashes.is_empty() { - return Err(InvalidBlockAccessListRequest::NoBlocks) - } - if request.block_hashes.len() != headers.len() { - return Err(InvalidBlockAccessListRequest::HeaderCount { - requested: request.block_hashes.len(), - supplied: headers.len(), - }) - } - - // Only authenticated block identities cross into blocking work, so the verifier stays - // free of the caller's header type. - let mut blocks = Vec::with_capacity(headers.len()); for (index, (requested, header)) in request.block_hashes.iter().zip(headers).enumerate() { if *requested != header.hash() { return Err(InvalidBlockAccessListRequest::HashMismatch { @@ -62,15 +49,43 @@ impl BlockAccessListDownloader { supplied: header.hash(), }) } - let Some(commitment) = header.block_access_list_hash() else { - return Err(InvalidBlockAccessListRequest::MissingCommitment { + } + let access_list_hashes = + headers.iter().map(|header| header.block_access_list_hash()).collect(); + Self::with_access_list_hashes(client, request, access_list_hashes, runtime) + } + + /// Creates a downloader that checks each requested list against its block's access list hash. + /// `access_list_hashes` must follow the request's block order, one hash per block. + pub fn with_access_list_hashes( + client: C, + request: GetBlockAccessListsMessage, + access_list_hashes: Vec>, + runtime: Runtime, + ) -> Result { + if request.block_hashes.is_empty() { + return Err(InvalidBlockAccessListRequest::NoBlocks) + } + if request.block_hashes.len() != access_list_hashes.len() { + return Err(InvalidBlockAccessListRequest::AccessListHashCount { + requested: request.block_hashes.len(), + supplied: access_list_hashes.len(), + }) + } + // Only authenticated block identities cross into blocking work, so the verifier stays + // free of the caller's header type. + let mut blocks = Vec::with_capacity(access_list_hashes.len()); + for (index, (requested, access_list_hash)) in + request.block_hashes.iter().zip(access_list_hashes).enumerate() + { + let Some(access_list_hash) = access_list_hash else { + return Err(InvalidBlockAccessListRequest::MissingAccessListHash { index, block_hash: *requested, }) }; - blocks.push((*requested, commitment)); + blocks.push((*requested, access_list_hash)); } - let verifier = BlockAccessListVerifier { request_id: request.request_id, response_bytes: request.response_bytes, @@ -99,16 +114,16 @@ pub enum BlockAccessListOutcome { /// The peer that answered. peer_id: PeerId, }, - /// Lists authenticated against their header commitments. + /// Lists authenticated against their headers' hashes. Verified(VerifiedBlockAccessLists), } -/// Positional block access lists authenticated against their header commitments. +/// Positional block access lists authenticated against their headers' hashes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct VerifiedBlockAccessLists { // Needed to avoid retrying unavailable entries against the peer that omitted them. peer_id: PeerId, - // Each list stays bound to the block whose header commitment authenticated it. + // Each list stays bound to the block whose header hash authenticated it. block_access_lists: Vec<(B256, Option)>, // Requested blocks the response left unanswered, in request order. missing: Vec, @@ -155,12 +170,12 @@ pub enum InvalidBlockAccessListRequest { /// The request asks for no blocks. #[error("block access list request has no block hashes")] NoBlocks, - /// A different number of headers was supplied than blocks requested. - #[error("requested {requested} block access lists but supplied {supplied} headers")] - HeaderCount { + /// A different number of block access list hashes was supplied than blocks requested. + #[error("requested {requested} block access lists but supplied {supplied} hashes")] + AccessListHashCount { /// Blocks the request asks for. requested: usize, - /// Headers supplied to authenticate them. + /// Block access list hashes supplied to authenticate them. supplied: usize, }, /// A requested hash does not match the header at the same position. @@ -173,17 +188,18 @@ pub enum InvalidBlockAccessListRequest { /// Hash of the header supplied for it. supplied: B256, }, - /// A supplied header carries no commitment, so its list could never be authenticated. - #[error("header for block {block_hash} at index {index} has no block access list commitment")] - MissingCommitment { - /// Position of the header without a commitment. + /// A supplied header carries no block access list hash, so its list could never be + /// authenticated. + #[error("header for block {block_hash} at index {index} has no block access list hash")] + MissingAccessListHash { + /// Position of the header without a block access list hash. index: usize, /// Block the header belongs to. block_hash: B256, }, } -// Authenticates each returned list against the commitment of the block it answers. +// Authenticates each returned list against the block access list hash of the block it answers. // // Keeps only response identity and authenticated block pairs, avoiding the caller's generic // header type on the blocking pool. @@ -193,7 +209,7 @@ struct BlockAccessListVerifier { request_id: u64, // Soft byte limit the request was sent with, carried into any follow-up. response_bytes: u64, - // Requested hashes paired with their header commitments, in wire order. + // Requested hashes paired with their headers' block access list hashes, in wire order. blocks: Vec<(B256, B256)>, } @@ -229,16 +245,15 @@ impl BlockAccessListVerifier { Ok(response) } - // Keeps each decoded entry tied to the commitment at the same request position. + // Keeps each decoded entry tied to the block access list hash at the same request position. fn authenticate_entries( &self, entries: Vec>, ) -> Result)>, RequestError> { let mut block_access_lists = Vec::with_capacity(entries.len()); for (index, entry) in entries.into_iter().enumerate() { - let (block_hash, commitment) = self.blocks[index]; - // An omitted list stays in place, so every later entry keeps the commitment it - // answers. + let (block_hash, access_list_hash) = self.blocks[index]; + // An omitted list stays in place, so every later entry keeps the hash it answers. let Some(raw) = entry else { block_access_lists.push((block_hash, None)); continue @@ -246,13 +261,13 @@ impl BlockAccessListVerifier { // Hashing the raw bytes settles authenticity without decoding, so a peer cannot // charge us the decode of a list it was never able to serve. let raw = RawBal::new(raw); - raw.ensure_hash(commitment).map_err(|error| { + raw.ensure_hash(access_list_hash).map_err(|error| { debug!( target: "downloaders::snap", %block_hash, expected = %error.expected, got = %error.computed, - "Block access list does not match its header commitment" + "Block access list does not match its header hash" ); RequestError::BadResponse })?; @@ -314,7 +329,7 @@ mod tests { use alloy_eips::eip7928::bal::Bal; use alloy_primitives::Bytes; use reth_eth_wire_types::{ - snap::{AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage}, + snap::{BlockAccessListsMessage, ByteCodesMessage}, BlockAccessLists, }; use reth_network_p2p::{error::PeerRequestResult, priority::Priority}; @@ -325,12 +340,12 @@ mod tests { Bytes::from(alloy_rlp::encode(Bal::default())) } - fn commitment(raw: Bytes) -> B256 { + fn access_list_hash(raw: Bytes) -> B256 { DecodedBal::from_rlp_bytes(raw).expect("test bal decodes").hash() } - // Headers committing to `entries`, sealed with distinct block hashes. A `None` entry gets a - // commitment no list can match, since nothing authenticates against it. + // Headers carrying the hashes of `entries`, sealed with distinct block hashes. A `None` entry + // gets a hash no list can match, since nothing authenticates against it. fn headers(entries: &[Option]) -> Vec> { entries .iter() @@ -338,7 +353,7 @@ mod tests { .map(|(index, raw)| { let header = Header { block_access_list_hash: Some( - raw.clone().map_or(B256::repeat_byte(0xee), commitment), + raw.clone().map_or(B256::repeat_byte(0xee), access_list_hash), ), ..Default::default() }; @@ -421,9 +436,9 @@ mod tests { .map(|(block_hash, entry)| (*block_hash, entry.as_ref().map(DecodedBal::hash))) .collect::>(), [ - (headers[0].hash(), Some(commitment(bal()))), + (headers[0].hash(), Some(access_list_hash(bal()))), (headers[1].hash(), None), - (headers[2].hash(), Some(commitment(bal()))), + (headers[2].hash(), Some(access_list_hash(bal()))), ] ); // The list after the omission stays authenticated, so only the gap is asked for again. @@ -529,8 +544,8 @@ mod tests { } #[tokio::test] - async fn a_list_that_misses_its_header_commitment_is_rejected() { - // The header commits to something this list cannot hash to. + async fn a_list_that_misses_its_header_hash_is_rejected() { + // The header carries a hash this list cannot hash to. let headers = vec![SealedHeader::new( Header { block_access_list_hash: Some(B256::repeat_byte(0xab)), ..Default::default() }, B256::repeat_byte(1), @@ -550,7 +565,7 @@ mod tests { #[tokio::test] async fn dropping_an_entry_instead_of_omitting_it_in_place_is_rejected() { // The peer holds no list for the first block and drops the slot rather than sending - // `None`, so the second block's list lands on the first block's commitment. + // `None`, so the second block's list lands on the first block's hash. let headers = headers(&[None, Some(bal())]); let peer = PeerId::random(); let client = Arc::new(TestSnapClient::new(always(peer, 1, vec![Some(bal())]))); @@ -618,7 +633,7 @@ mod tests { assert!(matches!( downloader(Arc::clone(&client), request(&headers), &[]).unwrap_err(), - InvalidBlockAccessListRequest::HeaderCount { requested: 1, supplied: 0 } + InvalidBlockAccessListRequest::AccessListHashCount { requested: 1, supplied: 0 } )); let mut wrong_block = request(&headers); @@ -628,29 +643,13 @@ mod tests { InvalidBlockAccessListRequest::HashMismatch { index: 0, .. } )); - let uncommitted = vec![SealedHeader::new(Header::default(), headers[0].hash())]; + let unhashed = vec![SealedHeader::new(Header::default(), headers[0].hash())]; assert!(matches!( - downloader(Arc::clone(&client), request(&headers), &uncommitted).unwrap_err(), - InvalidBlockAccessListRequest::MissingCommitment { index: 0, .. } + downloader(Arc::clone(&client), request(&headers), &unhashed).unwrap_err(), + InvalidBlockAccessListRequest::MissingAccessListHash { index: 0, .. } )); // Nothing reached the network. assert!(client.priorities().is_empty()); } - - #[test] - fn a_response_of_another_kind_is_rejected() { - let verifier = BlockAccessListVerifier { - request_id: 1, - response_bytes: 512 * 1024, - blocks: vec![(B256::repeat_byte(1), commitment(bal()))], - }; - let wrong = SnapResponse::AccountRange(AccountRangeMessage { - request_id: 1, - accounts: Vec::new(), - proof: Vec::new(), - }); - - assert_eq!(verifier.verify(PeerId::random(), wrong), Err(RequestError::BadResponse)); - } } diff --git a/crates/net/downloaders/src/snap/mod.rs b/crates/net/downloaders/src/snap/mod.rs index b8c32e65db6..32916b876aa 100644 --- a/crates/net/downloaders/src/snap/mod.rs +++ b/crates/net/downloaders/src/snap/mod.rs @@ -340,25 +340,21 @@ mod tests { use reth_eth_wire_types::snap::ByteCodesMessage; use reth_network_p2p::{error::PeerRequestResult, priority::Priority}; use reth_network_peers::WithPeerId; - use reth_trie_common::{proof::ProofRetainer, HashBuilder, Nibbles}; + use reth_trie_common::{proof::ProofRetainer, root::state_root, HashBuilder, Nibbles}; use std::sync::Arc; - const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); + const MAX_HASH: B256 = B256::repeat_byte(0xff); fn key(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) } fn account(nonce: u64) -> TrieAccount { - TrieAccount { nonce, balance: U256::from(1), ..Default::default() } + TrieAccount { nonce, balance: U256::ONE, ..Default::default() } } fn root(accounts: &[(B256, TrieAccount)]) -> B256 { - let mut builder = HashBuilder::default(); - for (key, account) in accounts { - builder.add_leaf(Nibbles::unpack(*key), &alloy_rlp::encode(account)); - } - builder.root() + state_root(accounts.to_vec()) } fn root_and_proof(accounts: &[(B256, TrieAccount)], targets: &[B256]) -> (B256, Vec) { @@ -473,28 +469,6 @@ mod tests { assert!(client.reported().is_empty()); } - #[test] - fn a_subrange_narrows_the_accounts_and_keeps_their_root() { - let accounts = vec![(key(1), account(7)), (key(2), account(8)), (key(3), account(9))]; - let root_hash = root(&accounts); - let range = VerifiedAccountRange { - state_root: root_hash, - origin: B256::ZERO, - accounts: accounts.clone(), - has_more: false, - next: None, - }; - - let batch = range.batch(); - let chunk = batch.range(1..3).expect("chunk is inside the batch"); - let expected = - accounts[1..3].iter().map(|(hash, account)| (*hash, account)).collect::>(); - assert_eq!(chunk.accounts(), expected); - assert_eq!(chunk.state_root(), root_hash); - - assert_eq!(batch.range(2..4), None); - } - #[test] fn code_hashes_are_listed_once_in_the_order_the_accounts_reference_them() { let shared = B256::repeat_byte(0x11); @@ -548,6 +522,9 @@ mod tests { vec![key(3)] ); assert_eq!(chunk.state_root(), root_hash); + + // The batch holds only the two accounts with storage, so this range is out of bounds. + assert_eq!(batch.range(1..3), None); } #[test] diff --git a/crates/net/downloaders/src/snap/storage.rs b/crates/net/downloaders/src/snap/storage.rs index 6b810820bd4..b1517ebae5c 100644 --- a/crates/net/downloaders/src/snap/storage.rs +++ b/crates/net/downloaders/src/snap/storage.rs @@ -26,7 +26,7 @@ use std::{ use tracing::debug; // Keeps storage requests inclusive through the full trie keyspace. -const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); +const MAX_HASH: B256 = B256::repeat_byte(0xff); /// Downloads storage ranges authenticated by a verified account range. #[derive(Debug)] @@ -58,7 +58,7 @@ impl StorageRangeDownloader { } // Servers disagree on whether a finite limit applies only to the first account or every // account, so the final proof cannot be assigned reliably for this request shape. - if request.account_hashes.len() > 1 && origin == B256::ZERO && limit != MAX_HASH { + if request.account_hashes.len() > 1 && origin.is_zero() && limit != MAX_HASH { return Err(InvalidStorageRangeRequest::LimitedMultipleAccounts { accounts: request.account_hashes.len(), }) diff --git a/crates/net/ecies/src/algorithm.rs b/crates/net/ecies/src/algorithm.rs index 020e0bf239e..2bbb525dfae 100644 --- a/crates/net/ecies/src/algorithm.rs +++ b/crates/net/ecies/src/algorithm.rs @@ -587,30 +587,30 @@ impl ECIES { } { hasher.update(el); } - let h_nonce = B256::from(hasher.finalize().as_ref()); + let h_nonce = hasher.finalize(); let iv = B128::default(); let shared_secret: B256 = { let mut hasher = Keccak256::new(); - hasher.update(self.ephemeral_shared_secret.unwrap().0.as_ref()); - hasher.update(h_nonce.0.as_ref()); - B256::from(hasher.finalize().as_ref()) + hasher.update(self.ephemeral_shared_secret.unwrap()); + hasher.update(h_nonce); + hasher.finalize() }; let aes_secret: B256 = { let mut hasher = Keccak256::new(); - hasher.update(self.ephemeral_shared_secret.unwrap().0.as_ref()); - hasher.update(shared_secret.0.as_ref()); - B256::from(hasher.finalize().as_ref()) + hasher.update(self.ephemeral_shared_secret.unwrap()); + hasher.update(shared_secret); + hasher.finalize() }; self.ingress_aes = Some(Ctr64BE::::new((&aes_secret.0).into(), (&iv.0).into())); self.egress_aes = Some(Ctr64BE::::new((&aes_secret.0).into(), (&iv.0).into())); let mac_secret: B256 = { let mut hasher = Keccak256::new(); - hasher.update(self.ephemeral_shared_secret.unwrap().0.as_ref()); - hasher.update(aes_secret.0.as_ref()); - B256::from(hasher.finalize().as_ref()) + hasher.update(self.ephemeral_shared_secret.unwrap()); + hasher.update(aes_secret); + hasher.finalize() }; self.ingress_mac = Some(MAC::new(mac_secret)); self.ingress_mac.as_mut().unwrap().update((mac_secret ^ self.nonce).as_ref()); @@ -741,7 +741,7 @@ impl ECIES { #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{b256, hex}; + use alloy_primitives::{b256, b512, hex}; #[test] fn ecdh() { @@ -749,7 +749,7 @@ mod tests { "202a36e24c3eb39513335ec99a7619bad0e7dc68d69401b016253c7d26dc92f8" )) .unwrap(); - let remote_public_key = id2pk(hex!("d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666").into()).unwrap(); + let remote_public_key = id2pk(b512!("d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666")).unwrap(); assert_eq!( ecdh_x(&remote_public_key, &our_secret_key), diff --git a/crates/net/eth-wire-types/src/block_access_lists.rs b/crates/net/eth-wire-types/src/block_access_lists.rs index a6c97fe20ec..222b5c4b946 100644 --- a/crates/net/eth-wire-types/src/block_access_lists.rs +++ b/crates/net/eth-wire-types/src/block_access_lists.rs @@ -53,15 +53,12 @@ impl Encodable for BlockAccessLists { impl Decodable for BlockAccessLists { fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - let header = Header::decode(buf)?; - if !header.list { + let Header { list, payload_length } = Header::decode(buf)?; + if !list { return Err(alloy_rlp::Error::UnexpectedString) } - if buf.len() < header.payload_length { - return Err(alloy_rlp::Error::InputTooShort) - } - - let (mut payload, rest) = buf.split_at(header.payload_length); + // Payload length checked by Header::decode. + let (mut payload, rest) = buf.split_at(payload_length); *buf = rest; let mut bals = Vec::new(); @@ -117,7 +114,7 @@ mod tests { fn elaborate_account_changes(seed: u8) -> Vec { vec![ AccountChanges { - address: Address::from([seed; 20]), + address: Address::repeat_byte(seed), storage_changes: vec![SlotChanges::new( U256::from_be_bytes([seed.wrapping_add(1); 32]), vec![ @@ -149,7 +146,7 @@ mod tests { )], }, AccountChanges { - address: Address::from([seed.wrapping_add(9); 20]), + address: Address::repeat_byte(seed.wrapping_add(9)), storage_changes: Vec::new(), storage_reads: vec![U256::from_be_bytes([seed.wrapping_add(10); 32])], balance_changes: vec![BalanceChange::new( diff --git a/crates/net/eth-wire-types/src/blocks.rs b/crates/net/eth-wire-types/src/blocks.rs index 23bb2a419ff..2b0e8f45383 100644 --- a/crates/net/eth-wire-types/src/blocks.rs +++ b/crates/net/eth-wire-types/src/blocks.rs @@ -128,7 +128,7 @@ mod tests { }; use alloy_consensus::{Header, TxLegacy}; use alloy_eips::BlockHashOrNumber; - use alloy_primitives::{hex, Signature, TxKind, U256}; + use alloy_primitives::{b256, hex, Address, Bloom, Signature, TxKind, B256, U256}; use alloy_rlp::{Decodable, Encodable}; use reth_ethereum_primitives::{BlockBody, Transaction, TransactionSigned}; use std::str::FromStr; @@ -195,9 +195,9 @@ mod tests { RequestPair:: { request_id: 1111, message: GetBlockHeaders { - start_block: BlockHashOrNumber::Hash( - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - ), + start_block: BlockHashOrNumber::Hash(b256!( + "00000000000000000000000000000000000000000000000000000000deadc0de" + )), limit: 5, skip: 5, direction: HeadersDirection::Rising, @@ -216,9 +216,9 @@ mod tests { let expected = RequestPair:: { request_id: 1111, message: GetBlockHeaders { - start_block: BlockHashOrNumber::Hash( - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - ), + start_block: BlockHashOrNumber::Hash(b256!( + "00000000000000000000000000000000000000000000000000000000deadc0de" + )), limit: 5, skip: 5, direction: HeadersDirection::Rising, @@ -273,34 +273,33 @@ mod tests { let mut data = vec![]; RequestPair { request_id: 1111, - message: BlockHeaders(vec![ - Header { - parent_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - ommers_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - beneficiary: hex!("0000000000000000000000000000000000000000").into(), - state_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - transactions_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - receipts_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - logs_bloom: hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").into(), - difficulty: U256::from(0x8aeu64), - number: 0xd05u64, - gas_limit: 0x115c, - gas_used: 0x15b3, - timestamp: 0x1a0au64, - extra_data: hex!("7788")[..].into(), - mix_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - nonce: 0x0000000000000000u64.into(), - base_fee_per_gas: None, - withdrawals_root: None, - blob_gas_used: None, - excess_blob_gas: None, - parent_beacon_block_root: None, - requests_hash: None, - block_access_list_hash: None, - slot_number: None, - }, - ]), - }.encode(&mut data); + message: BlockHeaders(vec![Header { + parent_hash: B256::ZERO, + ommers_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: B256::ZERO, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + difficulty: U256::from(0x8aeu64), + number: 0xd05u64, + gas_limit: 0x115c, + gas_used: 0x15b3, + timestamp: 0x1a0au64, + extra_data: hex!("7788")[..].into(), + mix_hash: B256::ZERO, + nonce: 0x0000000000000000u64.into(), + base_fee_per_gas: None, + withdrawals_root: None, + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root: None, + requests_hash: None, + block_access_list_hash: None, + slot_number: None, + }]), + } + .encode(&mut data); assert_eq!(data, expected); } @@ -312,33 +311,31 @@ mod tests { ); let expected = RequestPair { request_id: 1111, - message: BlockHeaders(vec![ - Header { - parent_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - ommers_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - beneficiary: hex!("0000000000000000000000000000000000000000").into(), - state_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - transactions_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - receipts_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - logs_bloom: hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").into(), - difficulty: U256::from(0x8aeu64), - number: 0xd05u64, - gas_limit: 0x115c, - gas_used: 0x15b3, - timestamp: 0x1a0au64, - extra_data: hex!("7788")[..].into(), - mix_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - nonce: 0x0000000000000000u64.into(), - base_fee_per_gas: None, - withdrawals_root: None, - blob_gas_used: None, - excess_blob_gas: None, - parent_beacon_block_root: None, - requests_hash: None, - block_access_list_hash: None, - slot_number: None, - }, - ]), + message: BlockHeaders(vec![Header { + parent_hash: B256::ZERO, + ommers_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: B256::ZERO, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, + difficulty: U256::from(0x8aeu64), + number: 0xd05u64, + gas_limit: 0x115c, + gas_used: 0x15b3, + timestamp: 0x1a0au64, + extra_data: hex!("7788")[..].into(), + mix_hash: B256::ZERO, + nonce: 0x0000000000000000u64.into(), + base_fee_per_gas: None, + withdrawals_root: None, + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root: None, + requests_hash: None, + block_access_list_hash: None, + slot_number: None, + }]), }; let result = RequestPair::decode(&mut &data[..]); assert_eq!(result.unwrap(), expected); @@ -354,8 +351,8 @@ mod tests { RequestPair { request_id: 1111, message: GetBlockBodies(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]), } .encode(&mut data); @@ -371,8 +368,8 @@ mod tests { let expected = RequestPair { request_id: 1111, message: GetBlockBodies(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]), }; let result = RequestPair::decode(&mut &data[..]); @@ -396,7 +393,7 @@ mod tests { nonce: 0x8u64, gas_price: 0x4a817c808, gas_limit: 0x2e248, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x200u64), input: Default::default(), }), Signature::new( @@ -410,7 +407,7 @@ mod tests { nonce: 0x9u64, gas_price: 0x4a817c809, gas_limit: 0x33450, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x2d9u64), input: Default::default(), }), Signature::new( @@ -422,20 +419,20 @@ mod tests { ], ommers: vec![ Header { - parent_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - ommers_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - beneficiary: hex!("0000000000000000000000000000000000000000").into(), - state_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - transactions_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - receipts_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - logs_bloom: hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").into(), + parent_hash: B256::ZERO, + ommers_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: B256::ZERO, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, difficulty: U256::from(0x8aeu64), number: 0xd05u64, gas_limit: 0x115c, gas_used: 0x15b3, timestamp: 0x1a0au64, extra_data: hex!("7788")[..].into(), - mix_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), + mix_hash: B256::ZERO, nonce: 0x0000000000000000u64.into(), base_fee_per_gas: None, withdrawals_root: None, @@ -472,7 +469,7 @@ mod tests { nonce: 0x8u64, gas_price: 0x4a817c808, gas_limit: 0x2e248, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x200u64), input: Default::default(), }), @@ -488,7 +485,7 @@ mod tests { nonce: 0x9u64, gas_price: 0x4a817c809, gas_limit: 0x33450, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x2d9u64), input: Default::default(), }), @@ -501,20 +498,20 @@ mod tests { ], ommers: vec![ Header { - parent_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - ommers_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - beneficiary: hex!("0000000000000000000000000000000000000000").into(), - state_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - transactions_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - receipts_root: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), - logs_bloom: hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").into(), + parent_hash: B256::ZERO, + ommers_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: B256::ZERO, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, + logs_bloom: Bloom::ZERO, difficulty: U256::from(0x8aeu64), number: 0xd05u64, gas_limit: 0x115c, gas_used: 0x15b3, timestamp: 0x1a0au64, extra_data: hex!("7788")[..].into(), - mix_hash: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), + mix_hash: B256::ZERO, nonce: 0x0000000000000000u64.into(), base_fee_per_gas: None, withdrawals_root: None, diff --git a/crates/net/eth-wire-types/src/broadcast.rs b/crates/net/eth-wire-types/src/broadcast.rs index 7e2e9e30874..9a3b770780d 100644 --- a/crates/net/eth-wire-types/src/broadcast.rs +++ b/crates/net/eth-wire-types/src/broadcast.rs @@ -169,18 +169,14 @@ pub fn decode_list_with_memory_budget( buf: &mut &[u8], memory_budget: usize, ) -> alloy_rlp::Result> { - let header = Header::decode(buf)?; - if !header.list { - return Err(alloy_rlp::Error::UnexpectedString); + let Header { list, payload_length } = Header::decode(buf)?; + if !list { + return Err(alloy_rlp::Error::UnexpectedString) } - if buf.len() < header.payload_length { - return Err(alloy_rlp::Error::InputTooShort); - } - - let (payload, rest) = buf.split_at(header.payload_length); - let mut payload = payload; + // Payload length checked by Header::decode. + let (mut payload, rest) = buf.split_at(payload_length); - let mut txs = Vec::with_capacity(estimated_transaction_list_capacity(header.payload_length)); + let mut txs = Vec::with_capacity(estimated_transaction_list_capacity(payload_length)); let mut total_size = 0usize; while !payload.is_empty() { @@ -279,9 +275,7 @@ impl Encodable for LazyEncoded { impl LazyEncoded { fn encode_uncached(&self) -> Bytes { - let mut out = Vec::with_capacity(self.value.length()); - self.value.encode(&mut out); - out.into() + alloy_rlp::encode(&self.value).into() } } @@ -315,7 +309,7 @@ pub type LazyEncodedTransaction = LazyEncoded; /// pool transaction references directly and cache each transaction's encoded bytes across per-peer /// messages. Queued messages retain the pool-backed value and the shared cached bytes until they /// are sent. -#[derive(Clone, Debug, Deref)] +#[derive(Clone, Debug, Deref, RlpEncodableWrapper)] pub struct BroadcastPoolTransactions(pub Vec); impl BroadcastPoolTransactions { @@ -325,16 +319,6 @@ impl BroadcastPoolTransactions { } } -impl Encodable for BroadcastPoolTransactions { - fn encode(&self, out: &mut dyn BufMut) { - self.0.encode(out); - } - - fn length(&self) -> usize { - self.0.length() - } -} - /// A wrapper type for all different new pooled transaction types #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -378,9 +362,7 @@ impl NewPooledTransactionHashes { EthVersion::Eth68 | EthVersion::Eth69 | EthVersion::Eth70 | EthVersion::Eth71 ) } - Self::Eth72(_) => { - matches!(version, EthVersion::Eth72) - } + Self::Eth72(_) => version.is_eth72(), } } @@ -755,10 +737,7 @@ impl Decodable for NewPooledTransactionHashes68 { if !list { return Err(alloy_rlp::Error::UnexpectedString) } - if buf.len() < payload_length { - return Err(alloy_rlp::Error::InputTooShort) - } - + // Payload length checked by Header::decode. let (mut payload, rest) = buf.split_at(payload_length); let (types, sizes, hashes) = decode_pooled_transaction_hashes_payload(&mut payload)?; @@ -771,10 +750,8 @@ impl Decodable for NewPooledTransactionHashes68 { ensure_pooled_transaction_hashes_lengths(hashes.len(), types.len(), sizes.len())?; - let msg = Self { types, sizes, hashes }; - *buf = rest; - Ok(msg) + Ok(Self { types, sizes, hashes }) } } @@ -944,10 +921,7 @@ impl Decodable for NewPooledTransactionHashes72 { if !list { return Err(alloy_rlp::Error::UnexpectedString) } - if buf.len() < payload_length { - return Err(alloy_rlp::Error::InputTooShort) - } - + // Payload length checked by Header::decode. let (mut payload, rest) = buf.split_at(payload_length); let (types, sizes, hashes) = decode_pooled_transaction_hashes_payload(&mut payload)?; let Some(first_byte) = payload.first().copied() else { @@ -973,7 +947,6 @@ impl Decodable for NewPooledTransactionHashes72 { ensure_pooled_transaction_hashes_lengths(hashes.len(), types.len(), sizes.len())?; *buf = rest; - Ok(Self { types, sizes, hashes, cell_mask }) } } @@ -1082,7 +1055,7 @@ mod tests { use super::*; use alloy_consensus::{transaction::TxHashRef, Typed2718}; use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{hex, Bytes, Signature, U256}; + use alloy_primitives::{b256, hex, Bytes, Signature, U256}; use alloy_rlp::{RlpDecodable, RlpEncodable}; use proptest::prelude::*; use reth_ethereum_primitives::{Transaction, TransactionSigned}; @@ -1237,10 +1210,7 @@ mod tests { types: vec![0x00], sizes: vec![0x00], hashes: vec![ - B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000000", - ) - .unwrap(), + B256::ZERO, ], }, &hex!( @@ -1252,14 +1222,8 @@ mod tests { types: vec![0x00, 0x00], sizes: vec![0x00, 0x00], hashes: vec![ - B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000000", - ) - .unwrap(), - B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000000", - ) - .unwrap(), + B256::ZERO, + B256::ZERO, ], }, &hex!( @@ -1271,10 +1235,7 @@ mod tests { types: vec![0x02], sizes: vec![0xb6], hashes: vec![ - B256::from_str( - "0xfecbed04c7b88d8e7221a0a3f5dc33f220212347fc167459ea5cc9c3eb4c1124", - ) - .unwrap(), + b256!("0xfecbed04c7b88d8e7221a0a3f5dc33f220212347fc167459ea5cc9c3eb4c1124"), ], }, &hex!( @@ -1286,14 +1247,8 @@ mod tests { types: vec![0xff, 0xff], sizes: vec![0xffffffff, 0xffffffff], hashes: vec![ - B256::from_str( - "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ) - .unwrap(), - B256::from_str( - "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - ) - .unwrap(), + B256::repeat_byte(0xff), + B256::repeat_byte(0xff), ], }, &hex!( @@ -1305,14 +1260,8 @@ mod tests { types: vec![0xff, 0xff], sizes: vec![0xffffffff, 0xffffffff], hashes: vec![ - B256::from_str( - "0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafe", - ) - .unwrap(), - B256::from_str( - "0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafe", - ) - .unwrap(), + b256!("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafe"), + b256!("0xbeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafebeefcafe"), ], }, &hex!( @@ -1324,14 +1273,8 @@ mod tests { types: vec![0x10, 0x10], sizes: vec![0xdeadc0de, 0xdeadc0de], hashes: vec![ - B256::from_str( - "0x3b9aca00f0671c9a2a1b817a0a78d3fe0c0f776cccb2a8c3c1b412a4f4e4d4e2", - ) - .unwrap(), - B256::from_str( - "0x3b9aca00f0671c9a2a1b817a0a78d3fe0c0f776cccb2a8c3c1b412a4f4e4d4e2", - ) - .unwrap(), + b256!("0x3b9aca00f0671c9a2a1b817a0a78d3fe0c0f776cccb2a8c3c1b412a4f4e4d4e2"), + b256!("0x3b9aca00f0671c9a2a1b817a0a78d3fe0c0f776cccb2a8c3c1b412a4f4e4d4e2"), ], }, &hex!( @@ -1343,14 +1286,8 @@ mod tests { types: vec![0x6f, 0x6f], sizes: vec![0x7fffffff, 0x7fffffff], hashes: vec![ - B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000002", - ) - .unwrap(), - B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000002", - ) - .unwrap(), + B256::with_last_byte(2), + B256::with_last_byte(2), ], }, &hex!( diff --git a/crates/net/eth-wire-types/src/header.rs b/crates/net/eth-wire-types/src/header.rs index e6a1c6c404a..d5472513444 100644 --- a/crates/net/eth-wire-types/src/header.rs +++ b/crates/net/eth-wire-types/src/header.rs @@ -86,11 +86,11 @@ impl From for bool { #[cfg(test)] mod tests { + use super::*; use alloy_consensus::{Header, EMPTY_OMMER_ROOT_HASH, EMPTY_ROOT_HASH}; - use alloy_primitives::{address, b256, bloom, bytes, hex, Bytes, B256, U256}; + use alloy_primitives::{address, b256, bytes, hex, Bloom, Bytes, B256, U256}; use alloy_rlp::{Decodable, Encodable}; - use std::str::FromStr; // Test vector from: https://eips.ethereum.org/EIPS/eip-2481 #[test] @@ -104,7 +104,7 @@ mod tests { gas_limit: 0x115c, gas_used: 0x15b3, timestamp: 0x1a0a_u64, - extra_data: Bytes::from_str("7788").unwrap(), + extra_data: bytes!("7788"), ommers_hash: B256::ZERO, state_root: B256::ZERO, transactions_root: B256::ZERO, @@ -123,29 +123,27 @@ mod tests { let expected_hash = b256!("0x6a251c7c3c5dca7b42407a3752ff48f3bbca1fab7f9868371d9918daf1988d1f"); let header = Header { - parent_hash: b256!("0xe0a94a7a3c9617401586b1a27025d2d9671332d22d540e0af72b069170380f2a"), + parent_hash: b256!( + "0xe0a94a7a3c9617401586b1a27025d2d9671332d22d540e0af72b069170380f2a" + ), ommers_hash: EMPTY_OMMER_ROOT_HASH, beneficiary: address!("0xba5e000000000000000000000000000000000000"), - state_root: b256!( - "0xec3c94b18b8a1cff7d60f8d258ec723312932928626b4c9355eb4ab3568ec7f7" - ), + state_root: b256!("0xec3c94b18b8a1cff7d60f8d258ec723312932928626b4c9355eb4ab3568ec7f7"), transactions_root: b256!( "0x50f738580ed699f0469702c7ccc63ed2e51bc034be9479b7bff4e68dee84accf" ), receipts_root: b256!( "0x29b0562f7140574dd0d50dee8a271b22e1a0a7b78fca58f7c60370d8317ba2a9" ), - logs_bloom: bloom!( - "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - ), + logs_bloom: Bloom::ZERO, difficulty: U256::from(0x020000), number: 0x01_u64, gas_limit: 0x016345785d8a0000, gas_used: 0x015534, timestamp: 0x079e, extra_data: bytes!("42"), - mix_hash: b256!("0x0000000000000000000000000000000000000000000000000000000000000000"), - nonce: 0u64.into() , + mix_hash: B256::ZERO, + nonce: 0u64.into(), base_fee_per_gas: Some(0x036b), withdrawals_root: None, blob_gas_used: None, @@ -170,7 +168,7 @@ mod tests { gas_limit: 0x115c, gas_used: 0x15b3, timestamp: 0x1a0au64, - extra_data: Bytes::from_str("7788").unwrap(), + extra_data: bytes!("7788"), ommers_hash: B256::ZERO, state_root: B256::ZERO, transactions_root: B256::ZERO, @@ -193,28 +191,20 @@ mod tests { "f9021ca018db39e19931515b30b16b3a92c292398039e31d6c267111529c3f2ba0a26c17a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347942adc25665018aa1fe0e6bc666dac8fc2697ff9baa095efce3d6972874ca8b531b233b7a1d1ff0a56f08b20c8f1b89bef1b001194a5a071e515dd89e8a7973402c2e11646081b4e2209b2d3a1550df5095289dabcb3fba0ed9c51ea52c968e552e370a77a41dac98606e98b915092fb5f949d6452fce1c4b90100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008001887fffffffffffffff830125b882079e42a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b42188000000000000000009a027f166f1d7c789251299535cb176ba34116e44894476a7886fe5d73d9be5c973" ); let expected = Header { - parent_hash: B256::from_str( - "18db39e19931515b30b16b3a92c292398039e31d6c267111529c3f2ba0a26c17", - ) - .unwrap(), + parent_hash: b256!("18db39e19931515b30b16b3a92c292398039e31d6c267111529c3f2ba0a26c17"), beneficiary: address!("0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba"), - state_root: B256::from_str( - "95efce3d6972874ca8b531b233b7a1d1ff0a56f08b20c8f1b89bef1b001194a5", - ) - .unwrap(), - transactions_root: B256::from_str( - "71e515dd89e8a7973402c2e11646081b4e2209b2d3a1550df5095289dabcb3fb", - ) - .unwrap(), - receipts_root: B256::from_str( - "ed9c51ea52c968e552e370a77a41dac98606e98b915092fb5f949d6452fce1c4", - ) - .unwrap(), + state_root: b256!("95efce3d6972874ca8b531b233b7a1d1ff0a56f08b20c8f1b89bef1b001194a5"), + transactions_root: b256!( + "71e515dd89e8a7973402c2e11646081b4e2209b2d3a1550df5095289dabcb3fb" + ), + receipts_root: b256!( + "ed9c51ea52c968e552e370a77a41dac98606e98b915092fb5f949d6452fce1c4" + ), number: 0x01, gas_limit: 0x7fffffffffffffff, gas_used: 0x0125b8, timestamp: 0x079e, - extra_data: Bytes::from_str("42").unwrap(), + extra_data: bytes!("42"), mix_hash: EMPTY_ROOT_HASH, base_fee_per_gas: Some(0x09), withdrawals_root: Some(b256!( @@ -237,26 +227,18 @@ mod tests { "f90221a03a9b485972e7353edd9152712492f0c58d89ef80623686b6bf947a4a6dce6cb6a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347942adc25665018aa1fe0e6bc666dac8fc2697ff9baa03c837fc158e3e93eafcaf2e658a02f5d8f99abc9f1c4c66cdea96c0ca26406aea04409cc4b699384ba5f8248d92b784713610c5ff9c1de51e9239da0dac76de9cea046cab26abf1047b5b119ecc2dda1296b071766c8b1307e1381fcecc90d513d86b90100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008001887fffffffffffffff8302a86582079e42a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b42188000000000000000009a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b4218302000080" ); let expected = Header { - parent_hash: B256::from_str( - "3a9b485972e7353edd9152712492f0c58d89ef80623686b6bf947a4a6dce6cb6", - ) - .unwrap(), + parent_hash: b256!("3a9b485972e7353edd9152712492f0c58d89ef80623686b6bf947a4a6dce6cb6"), ommers_hash: EMPTY_OMMER_ROOT_HASH, beneficiary: address!("0x2adc25665018aa1fe0e6bc666dac8fc2697ff9ba"), - state_root: B256::from_str( - "3c837fc158e3e93eafcaf2e658a02f5d8f99abc9f1c4c66cdea96c0ca26406ae", - ) - .unwrap(), - transactions_root: B256::from_str( - "4409cc4b699384ba5f8248d92b784713610c5ff9c1de51e9239da0dac76de9ce", - ) - .unwrap(), - receipts_root: B256::from_str( - "46cab26abf1047b5b119ecc2dda1296b071766c8b1307e1381fcecc90d513d86", - ) - .unwrap(), + state_root: b256!("3c837fc158e3e93eafcaf2e658a02f5d8f99abc9f1c4c66cdea96c0ca26406ae"), + transactions_root: b256!( + "4409cc4b699384ba5f8248d92b784713610c5ff9c1de51e9239da0dac76de9ce" + ), + receipts_root: b256!( + "46cab26abf1047b5b119ecc2dda1296b071766c8b1307e1381fcecc90d513d86" + ), logs_bloom: Default::default(), - difficulty: U256::from(0), + difficulty: U256::ZERO, number: 0x1, gas_limit: 0x7fffffffffffffff, gas_used: 0x02a865, @@ -278,8 +260,7 @@ mod tests { assert_eq!(header, expected); let expected_hash = - B256::from_str("0x10aca3ebb4cf6ddd9e945a5db19385f9c105ede7374380c50d56384c3d233785") - .unwrap(); + b256!("0x10aca3ebb4cf6ddd9e945a5db19385f9c105ede7374380c50d56384c3d233785"); assert_eq!(header.hash_slow(), expected_hash); } @@ -290,17 +271,14 @@ mod tests { "f90239a013a7ec98912f917b3e804654e37c9866092043c13eb8eab94eb64818e886cff5a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d4934794f97e180c050e5ab072211ad2c213eb5aee4df134a0ec229dbe85b0d3643ad0f471e6ec1a36bbc87deffbbd970762d22a53b35d068aa056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421b901000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080830305988401c9c380808464c40d5499d883010c01846765746888676f312e32302e35856c696e7578a070ccadc40b16e2094954b1064749cc6fbac783c1712f1b271a8aac3eda2f232588000000000000000007a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421808401600000" ); let expected = Header { - parent_hash: B256::from_str( - "13a7ec98912f917b3e804654e37c9866092043c13eb8eab94eb64818e886cff5", - ) - .unwrap(), + parent_hash: b256!("13a7ec98912f917b3e804654e37c9866092043c13eb8eab94eb64818e886cff5"), ommers_hash: EMPTY_OMMER_ROOT_HASH, beneficiary: address!("0xf97e180c050e5ab072211ad2c213eb5aee4df134"), state_root: b256!("0xec229dbe85b0d3643ad0f471e6ec1a36bbc87deffbbd970762d22a53b35d068a"), transactions_root: EMPTY_ROOT_HASH, receipts_root: EMPTY_ROOT_HASH, logs_bloom: Default::default(), - difficulty: U256::from(0), + difficulty: U256::ZERO, number: 0x30598, gas_limit: 0x1c9c380, gas_used: 0, diff --git a/crates/net/eth-wire-types/src/message.rs b/crates/net/eth-wire-types/src/message.rs index 1bff4f036ec..8ebf0f6b476 100644 --- a/crates/net/eth-wire-types/src/message.rs +++ b/crates/net/eth-wire-types/src/message.rs @@ -22,7 +22,7 @@ use alloy_primitives::{ bytes::{Buf, BufMut}, Bytes, }; -use alloy_rlp::{length_of_length, Decodable, Encodable, Header}; +use alloy_rlp::{Decodable, Encodable, Header}; use core::fmt::Debug; /// [`MAX_MESSAGE_SIZE`] is the maximum cap on the size of a protocol message. @@ -856,11 +856,8 @@ where } fn length(&self) -> usize { - let mut length = 0; - length += self.request_id.length(); - length += self.message.length(); - length += length_of_length(length); - length + let payload_length = self.request_id.length() + self.message.length(); + Header { list: true, payload_length }.length_with_payload() } } diff --git a/crates/net/eth-wire-types/src/receipts.rs b/crates/net/eth-wire-types/src/receipts.rs index c0733fbbf6d..c03afc5ad13 100644 --- a/crates/net/eth-wire-types/src/receipts.rs +++ b/crates/net/eth-wire-types/src/receipts.rs @@ -196,7 +196,7 @@ mod tests { use super::*; use crate::{message::RequestPair, GetReceipts, Receipts}; use alloy_consensus::TxType; - use alloy_primitives::{hex, Log}; + use alloy_primitives::{b256, hex, Address, Bloom, Log}; use alloy_rlp::{Decodable, Encodable}; #[test] @@ -225,8 +225,8 @@ mod tests { let request = RequestPair { request_id: 1111, message: GetReceipts(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]), }; request.encode(&mut data); @@ -245,8 +245,8 @@ mod tests { RequestPair { request_id: 1111, message: GetReceipts(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]), } ); @@ -261,26 +261,26 @@ mod tests { let mut data = vec![]; let request = RequestPair { request_id: 1111, - message: Receipts(vec![vec![ - ReceiptWithBloom { - receipt: Receipt { - tx_type: TxType::Legacy, - cumulative_gas_used: 0x1u64, - logs: vec![ - Log::new_unchecked( - hex!("0000000000000000000000000000000000000011").into(), - vec![ - hex!("000000000000000000000000000000000000000000000000000000000000dead").into(), - hex!("000000000000000000000000000000000000000000000000000000000000beef").into(), - ], - hex!("0100ff")[..].into(), + message: Receipts(vec![vec![ReceiptWithBloom { + receipt: Receipt { + tx_type: TxType::Legacy, + cumulative_gas_used: 0x1u64, + logs: vec![Log::new_unchecked( + Address::with_last_byte(0x11), + vec![ + b256!( + "000000000000000000000000000000000000000000000000000000000000dead" + ), + b256!( + "000000000000000000000000000000000000000000000000000000000000beef" ), ], - success: false, - }, - logs_bloom: hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").into(), + hex!("0100ff")[..].into(), + )], + success: false, }, - ]]), + logs_bloom: Bloom::ZERO, + }]]), }; request.encode(&mut data); assert_eq!(data, expected); @@ -305,17 +305,17 @@ mod tests { cumulative_gas_used: 0x1u64, logs: vec![ Log::new_unchecked( - hex!("0000000000000000000000000000000000000011").into(), + Address::with_last_byte(0x11), vec![ - hex!("000000000000000000000000000000000000000000000000000000000000dead").into(), - hex!("000000000000000000000000000000000000000000000000000000000000beef").into(), + b256!("000000000000000000000000000000000000000000000000000000000000dead"), + b256!("000000000000000000000000000000000000000000000000000000000000beef"), ], hex!("0100ff")[..].into(), ), ], success: false, }, - logs_bloom: hex!("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").into(), + logs_bloom: Bloom::ZERO, }, ], ]), @@ -349,8 +349,8 @@ mod tests { message: GetReceipts70 { first_block_receipt_index: 0, block_hashes: vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ], }, }; diff --git a/crates/net/eth-wire-types/src/snap.rs b/crates/net/eth-wire-types/src/snap.rs index 3a2c40f0920..e45bcde3ac4 100644 --- a/crates/net/eth-wire-types/src/snap.rs +++ b/crates/net/eth-wire-types/src/snap.rs @@ -564,7 +564,7 @@ impl Decodable for SlimAccountBody { if !payload.is_empty() { return Err(alloy_rlp::Error::UnexpectedLength) } - Ok(Self(TrieAccount { nonce, balance, storage_root, code_hash })) + Ok(Self(TrieAccount::new(nonce, balance, storage_root, code_hash))) } } @@ -573,12 +573,7 @@ impl<'a> arbitrary::Arbitrary<'a> for SlimAccountBody { fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { let storage_root = if u.arbitrary()? { u.arbitrary()? } else { EMPTY_ROOT_HASH }; let code_hash = if u.arbitrary()? { u.arbitrary()? } else { KECCAK256_EMPTY }; - Ok(Self(TrieAccount { - nonce: u.arbitrary()?, - balance: u.arbitrary()?, - storage_root, - code_hash, - })) + Ok(Self(TrieAccount::new(u.arbitrary()?, u.arbitrary()?, storage_root, code_hash))) } } @@ -918,7 +913,7 @@ mod tests { } fn trie_account(storage_root: B256, code_hash: B256) -> TrieAccount { - TrieAccount { nonce: 7, balance: U256::from(42), storage_root, code_hash } + TrieAccount::new(7, U256::from(42), storage_root, code_hash) } #[test] @@ -1030,20 +1025,9 @@ mod tests { assert!(alloy_rlp::decode_exact::(&bytes).is_err()); } - #[test] - fn storage_data_carries_the_trie_leaf_encoding() { - let value = U256::from(1234); - let slot = StorageData::from_value(B256::repeat_byte(4), value); - - // Clients verify range proofs against the RLP-encoded trie leaf, so the wire bytes must be - // exactly that rather than a fixed-width word. - assert_eq!(slot.data.as_ref(), alloy_rlp::encode(value)); - assert_eq!(slot.value().unwrap(), value); - } - #[test] fn storage_data_rejects_trailing_bytes() { - let mut slot = StorageData::from_value(B256::repeat_byte(4), U256::from(1)); + let mut slot = StorageData::from_value(B256::repeat_byte(4), U256::ONE); slot.data = [slot.data.as_ref(), &[0x00]].concat().into(); assert!(slot.value().is_err()); diff --git a/crates/net/eth-wire-types/src/state.rs b/crates/net/eth-wire-types/src/state.rs index 1f14535b2ea..aeb088deda5 100644 --- a/crates/net/eth-wire-types/src/state.rs +++ b/crates/net/eth-wire-types/src/state.rs @@ -48,7 +48,7 @@ pub struct NodeData(pub Vec); #[cfg(test)] mod tests { - use alloy_primitives::hex; + use alloy_primitives::{b256, hex}; use crate::{message::RequestPair, GetNodeData, NodeData}; use alloy_rlp::{Decodable, Encodable}; @@ -63,8 +63,8 @@ mod tests { let request = RequestPair { request_id: 1111, message: GetNodeData(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]), }; request.encode(&mut data); @@ -83,8 +83,8 @@ mod tests { RequestPair { request_id: 1111, message: GetNodeData(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]) } ); diff --git a/crates/net/eth-wire-types/src/status.rs b/crates/net/eth-wire-types/src/status.rs index 244bcd35ab4..e04e62da0d0 100644 --- a/crates/net/eth-wire-types/src/status.rs +++ b/crates/net/eth-wire-types/src/status.rs @@ -1,7 +1,7 @@ use crate::{BlockRangeUpdate, EthVersion}; -use alloy_chains::{Chain, NamedChain}; +use alloy_chains::Chain; use alloy_hardforks::{EthereumHardfork, ForkId, Head}; -use alloy_primitives::{hex, B256, U256}; +use alloy_primitives::{B256, U256}; use alloy_rlp::{BufMut, Encodable, RlpDecodable, RlpEncodable}; use core::fmt::{Debug, Display}; use reth_chainspec::{EthChainSpec, Hardforks, MAINNET}; @@ -36,7 +36,7 @@ impl Default for UnifiedStatus { let mainnet_genesis = MAINNET.genesis_hash(); Self { version: EthVersion::Eth68, - chain: Chain::from_named(NamedChain::Mainnet), + chain: Chain::mainnet(), genesis: mainnet_genesis, forkid: MAINNET .hardfork_fork_id(EthereumHardfork::Frontier) @@ -260,7 +260,7 @@ impl Default for Status { let mainnet_genesis = MAINNET.genesis_hash(); Self { version: EthVersion::Eth68, - chain: Chain::from_named(NamedChain::Mainnet), + chain: Chain::mainnet(), total_difficulty: U256::from(17_179_869_184u64), blockhash: mainnet_genesis, genesis: mainnet_genesis, @@ -273,16 +273,14 @@ impl Default for Status { impl Display for Status { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let hexed_blockhash = hex::encode(self.blockhash); - let hexed_genesis = hex::encode(self.genesis); write!( f, - "Status {{ version: {}, chain: {}, total_difficulty: {}, blockhash: {}, genesis: {}, forkid: {:X?} }}", + "Status {{ version: {}, chain: {}, total_difficulty: {}, blockhash: {:x}, genesis: {:x}, forkid: {:X?} }}", self.version, self.chain, self.total_difficulty, - hexed_blockhash, - hexed_genesis, + self.blockhash, + self.genesis, self.forkid ) } @@ -290,28 +288,26 @@ impl Display for Status { impl Debug for Status { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let hexed_blockhash = hex::encode(self.blockhash); - let hexed_genesis = hex::encode(self.genesis); if f.alternate() { write!( f, - "Status {{\n\tversion: {:?},\n\tchain: {:?},\n\ttotal_difficulty: {:?},\n\tblockhash: {},\n\tgenesis: {},\n\tforkid: {:X?}\n}}", + "Status {{\n\tversion: {:?},\n\tchain: {:?},\n\ttotal_difficulty: {:?},\n\tblockhash: {:x},\n\tgenesis: {:x},\n\tforkid: {:X?}\n}}", self.version, self.chain, self.total_difficulty, - hexed_blockhash, - hexed_genesis, + self.blockhash, + self.genesis, self.forkid ) } else { write!( f, - "Status {{ version: {:?}, chain: {:?}, total_difficulty: {:?}, blockhash: {}, genesis: {}, forkid: {:X?} }}", + "Status {{ version: {:?}, chain: {:?}, total_difficulty: {:?}, blockhash: {:x}, genesis: {:x}, forkid: {:X?} }}", self.version, self.chain, self.total_difficulty, - hexed_blockhash, - hexed_genesis, + self.blockhash, + self.genesis, self.forkid ) } @@ -355,37 +351,33 @@ pub struct StatusEth69 { impl Display for StatusEth69 { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let hexed_blockhash = hex::encode(self.blockhash); - let hexed_genesis = hex::encode(self.genesis); write!( f, - "StatusEth69 {{ version: {}, chain: {}, genesis: {}, forkid: {:X?}, earliest: {}, latest: {}, blockhash: {} }}", + "StatusEth69 {{ version: {}, chain: {}, genesis: {:x}, forkid: {:X?}, earliest: {}, latest: {}, blockhash: {:x} }}", self.version, self.chain, - hexed_genesis, + self.genesis, self.forkid, self.earliest, self.latest, - hexed_blockhash, + self.blockhash, ) } } impl Debug for StatusEth69 { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let hexed_blockhash = hex::encode(self.blockhash); - let hexed_genesis = hex::encode(self.genesis); if f.alternate() { write!( f, - "StatusEth69 {{\n\tversion: {:?},\n\tchain: {:?},\n\tgenesis: {},\n\tforkid: {:X?},\n\tearliest: {},\n\tlatest: {},\n\tblockhash: {}\n}}", - self.version, self.chain, hexed_genesis, self.forkid, self.earliest, self.latest, hexed_blockhash + "StatusEth69 {{\n\tversion: {:?},\n\tchain: {:?},\n\tgenesis: {:x},\n\tforkid: {:X?},\n\tearliest: {},\n\tlatest: {},\n\tblockhash: {:x}\n}}", + self.version, self.chain, self.genesis, self.forkid, self.earliest, self.latest, self.blockhash ) } else { write!( f, - "StatusEth69 {{ version: {:?}, chain: {:?}, genesis: {}, forkid: {:X?}, earliest: {}, latest: {}, blockhash: {} }}", - self.version, self.chain, hexed_genesis, self.forkid, self.earliest, self.latest, hexed_blockhash + "StatusEth69 {{ version: {:?}, chain: {:?}, genesis: {:x}, forkid: {:X?}, earliest: {}, latest: {}, blockhash: {:x} }}", + self.version, self.chain, self.genesis, self.forkid, self.earliest, self.latest, self.blockhash ) } } @@ -470,14 +462,15 @@ impl Display for StatusMessage { } #[cfg(test)] mod tests { + use crate::{BlockRangeUpdate, EthVersion, Status, StatusEth69, StatusMessage, UnifiedStatus}; use alloy_consensus::constants::MAINNET_GENESIS_HASH; use alloy_genesis::Genesis; use alloy_hardforks::{EthereumHardfork, ForkHash, ForkId, Head}; - use alloy_primitives::{b256, hex, B256, U256}; + use alloy_primitives::{b256, hex, U256}; use alloy_rlp::{Decodable, Encodable}; use rand::Rng; - use reth_chainspec::{Chain, ChainSpec, ForkCondition, NamedChain}; + use reth_chainspec::{Chain, ChainSpec, ForkCondition}; use std::str::FromStr; #[test] @@ -487,12 +480,9 @@ mod tests { ); let status = Status { version: EthVersion::Eth67, - chain: Chain::from_named(NamedChain::Mainnet), + chain: Chain::mainnet(), total_difficulty: U256::from(36206751599115524359527u128), - blockhash: B256::from_str( - "feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d", - ) - .unwrap(), + blockhash: b256!("feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d"), genesis: MAINNET_GENESIS_HASH, forkid: ForkId { hash: ForkHash([0xb7, 0x15, 0x07, 0x7d]), next: 0 }, }; @@ -509,12 +499,9 @@ mod tests { ); let expected = Status { version: EthVersion::Eth67, - chain: Chain::from_named(NamedChain::Mainnet), + chain: Chain::mainnet(), total_difficulty: U256::from(36206751599115524359527u128), - blockhash: B256::from_str( - "feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d", - ) - .unwrap(), + blockhash: b256!("feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d"), genesis: MAINNET_GENESIS_HASH, forkid: ForkId { hash: ForkHash([0xb7, 0x15, 0x07, 0x7d]), next: 0 }, }; @@ -606,16 +593,13 @@ mod tests { let expected = hex!("f8544501a0d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3c684b715077d8083ed14f2840112a880a0feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d"); let status = StatusEth69 { version: EthVersion::Eth69, - chain: Chain::from_named(NamedChain::Mainnet), + chain: Chain::mainnet(), genesis: MAINNET_GENESIS_HASH, forkid: ForkId { hash: ForkHash([0xb7, 0x15, 0x07, 0x7d]), next: 0 }, earliest: 15_537_394, latest: 18_000_000, - blockhash: B256::from_str( - "feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d", - ) - .unwrap(), + blockhash: b256!("feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d"), }; let mut rlp_status = vec![]; @@ -624,7 +608,7 @@ mod tests { let status = UnifiedStatus::builder() .version(EthVersion::Eth69) - .chain(Chain::from_named(NamedChain::Mainnet)) + .chain(Chain::mainnet()) .genesis(MAINNET_GENESIS_HASH) .forkid(ForkId { hash: ForkHash([0xb7, 0x15, 0x07, 0x7d]), next: 0 }) .blockhash(b256!("0xfeb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d")) @@ -643,22 +627,19 @@ mod tests { let data = hex!("f8544501a0d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3c684b715077d8083ed14f2840112a880a0feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d"); let expected = StatusEth69 { version: EthVersion::Eth69, - chain: Chain::from_named(NamedChain::Mainnet), + chain: Chain::mainnet(), genesis: MAINNET_GENESIS_HASH, forkid: ForkId { hash: ForkHash([0xb7, 0x15, 0x07, 0x7d]), next: 0 }, earliest: 15_537_394, latest: 18_000_000, - blockhash: B256::from_str( - "feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d", - ) - .unwrap(), + blockhash: b256!("feb27336ca7923f8fab3bd617fcb6e75841538f71c1bcfc267d7838489d9e13d"), }; let status = StatusEth69::decode(&mut &data[..]).unwrap(); assert_eq!(status, expected); let expected_message = UnifiedStatus::builder() .version(EthVersion::Eth69) - .chain(Chain::from_named(NamedChain::Mainnet)) + .chain(Chain::mainnet()) .genesis(MAINNET_GENESIS_HASH) .forkid(ForkId { hash: ForkHash([0xb7, 0x15, 0x07, 0x7d]), next: 0 }) .earliest_block(Some(15_537_394)) @@ -683,16 +664,10 @@ mod tests { ); let status = Status { version: EthVersion::Eth66, - chain: Chain::from_named(NamedChain::BinanceSmartChain), + chain: Chain::bsc_mainnet(), total_difficulty: U256::from(37851386u64), - blockhash: B256::from_str( - "f8514c4680ef27700751b08f37645309ce65a449616a3ea966bf39dd935bb27b", - ) - .unwrap(), - genesis: B256::from_str( - "0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b", - ) - .unwrap(), + blockhash: b256!("f8514c4680ef27700751b08f37645309ce65a449616a3ea966bf39dd935bb27b"), + genesis: b256!("0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b"), forkid: ForkId { hash: ForkHash([0x5d, 0x43, 0xd2, 0xfd]), next: 0 }, }; @@ -708,16 +683,10 @@ mod tests { ); let expected = Status { version: EthVersion::Eth66, - chain: Chain::from_named(NamedChain::BinanceSmartChain), + chain: Chain::bsc_mainnet(), total_difficulty: U256::from(37851386u64), - blockhash: B256::from_str( - "f8514c4680ef27700751b08f37645309ce65a449616a3ea966bf39dd935bb27b", - ) - .unwrap(), - genesis: B256::from_str( - "0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b", - ) - .unwrap(), + blockhash: b256!("f8514c4680ef27700751b08f37645309ce65a449616a3ea966bf39dd935bb27b"), + genesis: b256!("0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b"), forkid: ForkId { hash: ForkHash([0x5d, 0x43, 0xd2, 0xfd]), next: 0 }, }; let status = Status::decode(&mut &data[..]).unwrap(); @@ -736,14 +705,8 @@ mod tests { "0x000000000000000000000000006d68fcffffffffffffffffffffffffdeab81b8", ) .unwrap(), - blockhash: B256::from_str( - "523e8163a6d620a4cc152c547a05f28a03fec91a2a615194cb86df9731372c0c", - ) - .unwrap(), - genesis: B256::from_str( - "6499dccdc7c7def3ebb1ce4c6ee27ec6bd02aee570625ca391919faf77ef27bd", - ) - .unwrap(), + blockhash: b256!("523e8163a6d620a4cc152c547a05f28a03fec91a2a615194cb86df9731372c0c"), + genesis: b256!("6499dccdc7c7def3ebb1ce4c6ee27ec6bd02aee570625ca391919faf77ef27bd"), forkid: ForkId { hash: ForkHash([0x1a, 0x67, 0xcc, 0xd8]), next: 0 }, }; let status = Status::decode(&mut &data[..]).unwrap(); diff --git a/crates/net/eth-wire-types/src/transactions.rs b/crates/net/eth-wire-types/src/transactions.rs index c2f8e1c1074..fee2c59135f 100644 --- a/crates/net/eth-wire-types/src/transactions.rs +++ b/crates/net/eth-wire-types/src/transactions.rs @@ -156,7 +156,7 @@ pub struct Cells { mod tests { use crate::{message::RequestPair, GetPooledTransactions, PooledTransactions}; use alloy_consensus::{transaction::PooledTransaction, TxEip1559, TxLegacy}; - use alloy_primitives::{hex, Signature, TxKind, U256}; + use alloy_primitives::{address, b256, hex, Address, Signature, TxKind, U256}; use alloy_rlp::{Decodable, Encodable}; use reth_chainspec::MIN_TRANSACTION_GAS; use reth_ethereum_primitives::{Transaction, TransactionSigned}; @@ -172,8 +172,8 @@ mod tests { let request = RequestPair { request_id: 1111, message: GetPooledTransactions(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]), }; request.encode(&mut data); @@ -192,8 +192,8 @@ mod tests { RequestPair { request_id: 1111, message: GetPooledTransactions(vec![ - hex!("00000000000000000000000000000000000000000000000000000000deadc0de").into(), - hex!("00000000000000000000000000000000000000000000000000000000feedbeef").into(), + b256!("00000000000000000000000000000000000000000000000000000000deadc0de"), + b256!("00000000000000000000000000000000000000000000000000000000feedbeef"), ]) } ); @@ -213,7 +213,7 @@ mod tests { nonce: 0x8u64, gas_price: 0x4a817c808, gas_limit: 0x2e248, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x200u64), input: Default::default(), }), @@ -235,7 +235,7 @@ mod tests { nonce: 0x09u64, gas_price: 0x4a817c809, gas_limit: 0x33450, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x2d9u64), input: Default::default(), }), @@ -281,7 +281,7 @@ mod tests { nonce: 0x8u64, gas_price: 0x4a817c808, gas_limit: 0x2e248, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x200u64), input: Default::default(), }), @@ -303,7 +303,7 @@ mod tests { nonce: 0x09u64, gas_price: 0x4a817c809, gas_limit: 0x33450, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(0x2d9u64), input: Default::default(), }), @@ -347,7 +347,7 @@ mod tests { nonce: 15u64, gas_price: 2200000000, gas_limit: 34811, - to: TxKind::Call(hex!("cf7f9e66af820a19257a2108375b180b0ec49167").into()), + to: TxKind::Call(address!("cf7f9e66af820a19257a2108375b180b0ec49167")), value: U256::from(1234u64), input: Default::default(), }), @@ -370,7 +370,7 @@ mod tests { max_priority_fee_per_gas: 1500000000, max_fee_per_gas: 1500000013, gas_limit: MIN_TRANSACTION_GAS, - to: TxKind::Call(hex!("61815774383099e24810ab832a5b2a5425c154d5").into()), + to: TxKind::Call(address!("61815774383099e24810ab832a5b2a5425c154d5")), value: U256::from(3000000000000000000u64), input: Default::default(), access_list: Default::default(), @@ -393,7 +393,7 @@ mod tests { nonce: 3u64, gas_price: 2000000000, gas_limit: 10000000, - to: TxKind::Call(hex!("d3e8763675e4c425df46cc3b5c0f6cbdac396046").into()), + to: TxKind::Call(address!("d3e8763675e4c425df46cc3b5c0f6cbdac396046")), value: U256::from(1000000000000000u64), input: Default::default(), }), @@ -415,7 +415,7 @@ mod tests { nonce: 1u64, gas_price: 1000000000, gas_limit: 100000, - to: TxKind::Call(hex!("d3e8763675e4c425df46cc3b5c0f6cbdac396046").into()), + to: TxKind::Call(address!("d3e8763675e4c425df46cc3b5c0f6cbdac396046")), value: U256::from(693361000000000u64), input: Default::default(), }), @@ -437,7 +437,7 @@ mod tests { nonce: 2u64, gas_price: 1000000000, gas_limit: 100000, - to: TxKind::Call(hex!("d3e8763675e4c425df46cc3b5c0f6cbdac396046").into()), + to: TxKind::Call(address!("d3e8763675e4c425df46cc3b5c0f6cbdac396046")), value: U256::from(1000000000000000u64), input: Default::default(), }), @@ -486,7 +486,7 @@ mod tests { nonce: 15u64, gas_price: 2200000000, gas_limit: 34811, - to: TxKind::Call(hex!("cf7f9e66af820a19257a2108375b180b0ec49167").into()), + to: TxKind::Call(address!("cf7f9e66af820a19257a2108375b180b0ec49167")), value: U256::from(1234u64), input: Default::default(), }), @@ -509,7 +509,7 @@ mod tests { max_priority_fee_per_gas: 1500000000, max_fee_per_gas: 1500000013, gas_limit: MIN_TRANSACTION_GAS, - to: TxKind::Call(hex!("61815774383099e24810ab832a5b2a5425c154d5").into()), + to: TxKind::Call(address!("61815774383099e24810ab832a5b2a5425c154d5")), value: U256::from(3000000000000000000u64), input: Default::default(), access_list: Default::default(), @@ -532,7 +532,7 @@ mod tests { nonce: 3u64, gas_price: 2000000000, gas_limit: 10000000, - to: TxKind::Call(hex!("d3e8763675e4c425df46cc3b5c0f6cbdac396046").into()), + to: TxKind::Call(address!("d3e8763675e4c425df46cc3b5c0f6cbdac396046")), value: U256::from(1000000000000000u64), input: Default::default(), }), @@ -554,7 +554,7 @@ mod tests { nonce: 1u64, gas_price: 1000000000, gas_limit: 100000, - to: TxKind::Call(hex!("d3e8763675e4c425df46cc3b5c0f6cbdac396046").into()), + to: TxKind::Call(address!("d3e8763675e4c425df46cc3b5c0f6cbdac396046")), value: U256::from(693361000000000u64), input: Default::default(), }), @@ -576,7 +576,7 @@ mod tests { nonce: 2u64, gas_price: 1000000000, gas_limit: 100000, - to: TxKind::Call(hex!("d3e8763675e4c425df46cc3b5c0f6cbdac396046").into()), + to: TxKind::Call(address!("d3e8763675e4c425df46cc3b5c0f6cbdac396046")), value: U256::from(1000000000000000u64), input: Default::default(), }), diff --git a/crates/net/eth-wire/src/capability.rs b/crates/net/eth-wire/src/capability.rs index b8ae5acaeb6..4814e400b39 100644 --- a/crates/net/eth-wire/src/capability.rs +++ b/crates/net/eth-wire/src/capability.rs @@ -582,10 +582,9 @@ mod tests { fn relative_message_id_accounts_for_intermediate_capabilities() { let intermediate_cap = Capability::new_static("foo", 1); let intermediate = Protocol::new(intermediate_cap.clone(), 3); - let snap = Capability::snap(SnapVersion::V2); - let eth = Capability::eth(EthVersion::Eth69); - let local_capabilities = - vec![EthVersion::Eth69.into(), intermediate, Protocol::snap(SnapVersion::V2)]; + let snap = Capability::snap_2(); + let eth = Capability::eth_69(); + let local_capabilities = vec![EthVersion::Eth69.into(), intermediate, Protocol::snap_2()]; let peer_capabilities = vec![eth, intermediate_cap, snap.clone()]; let shared = SharedCapabilities::try_new(local_capabilities, peer_capabilities).unwrap(); @@ -599,11 +598,9 @@ mod tests { fn capability_message_id_rejects_other_capability_range() { let intermediate_cap = Capability::new_static("foo", 1); let intermediate = Protocol::new(intermediate_cap.clone(), 3); - let snap = Capability::snap(SnapVersion::V2); - let local_capabilities = - vec![EthVersion::Eth69.into(), intermediate, Protocol::snap(SnapVersion::V2)]; - let peer_capabilities = - vec![Capability::eth(EthVersion::Eth69), intermediate_cap.clone(), snap.clone()]; + let snap = Capability::snap_2(); + let local_capabilities = vec![EthVersion::Eth69.into(), intermediate, Protocol::snap_2()]; + let peer_capabilities = vec![Capability::eth_69(), intermediate_cap.clone(), snap.clone()]; let shared = SharedCapabilities::try_new(local_capabilities, peer_capabilities).unwrap(); let intermediate_id = shared.relative_message_id(&intermediate_cap, 1).unwrap(); diff --git a/crates/net/eth-wire/src/ethstream.rs b/crates/net/eth-wire/src/ethstream.rs index a42f6d3a471..b9f2d8634d5 100644 --- a/crates/net/eth-wire/src/ethstream.rs +++ b/crates/net/eth-wire/src/ethstream.rs @@ -50,7 +50,7 @@ impl UnauthedEthStream { impl UnauthedEthStream where - S: Stream> + CanDisconnect + Send + Unpin, + S: Stream> + CanDisconnect + Send, EthStreamError: From + From<>::Error>, { /// Consumes the [`UnauthedEthStream`] and returns an [`EthStream`] after the `Status` @@ -301,7 +301,7 @@ where impl Sink> for EthStream where - S: CanDisconnect + Unpin, + S: CanDisconnect, EthStreamError: From<>::Error>, N: NetworkPrimitives, { @@ -436,7 +436,7 @@ mod tests { let status = Status { version: EthVersion::Eth67, chain: NamedChain::Mainnet.into(), - total_difficulty: U256::from(2).pow(U256::from(100)) - U256::from(1), + total_difficulty: U256::from(2).pow(U256::from(100)) - U256::ONE, blockhash: B256::random(), genesis, // Pass the current fork id. diff --git a/crates/net/eth-wire/src/handshake.rs b/crates/net/eth-wire/src/handshake.rs index 0a91c9ea5d4..1dcf9dd9825 100644 --- a/crates/net/eth-wire/src/handshake.rs +++ b/crates/net/eth-wire/src/handshake.rs @@ -33,7 +33,6 @@ pub trait UnauthEth: Stream> + Sink + CanDisconnect - + Unpin + Send { } @@ -42,7 +41,6 @@ impl UnauthEth for T where T: Stream> + Sink + CanDisconnect - + Unpin + Send { } @@ -74,9 +72,9 @@ impl EthRlpxHandshake for EthHandshake { #[derive(Debug)] pub struct EthereumEthHandshake<'a, S: ?Sized>(pub &'a mut S); -impl EthereumEthHandshake<'_, S> +impl EthereumEthHandshake<'_, S> where - S: Stream> + CanDisconnect + Send + Unpin, + S: ?Sized + Stream> + CanDisconnect + Send, EthStreamError: From + From<>::Error>, { /// Performs the `eth` rlpx protocol handshake using the given input stream. diff --git a/crates/net/eth-wire/src/multiplex.rs b/crates/net/eth-wire/src/multiplex.rs index b06fc8f5d14..63c29e23ed4 100644 --- a/crates/net/eth-wire/src/multiplex.rs +++ b/crates/net/eth-wire/src/multiplex.rs @@ -181,6 +181,8 @@ impl RlpxProtocolMultiplexer { let f = handshake(proxy); let mut f = pin!(f); let mut inbound_messages = 0; + let mut handshake_satellite_bytes = 0usize; + let mut handshake_satellite_messages = 0usize; // this polls the connection and the primary stream concurrently until the handshake is // complete @@ -230,7 +232,19 @@ impl RlpxProtocolMultiplexer { // delegate to primary to_primary.try_send(msg).map_err(Into::into)?; } else { - // delegate to satellite + // Satellite consumers may wait for authentication, so keep their + // aggregate pre-handshake retention much smaller than active queues. + handshake_satellite_bytes = handshake_satellite_bytes + .saturating_add(msg.capacity().max(msg.len())); + handshake_satellite_messages += 1; + if handshake_satellite_bytes > MAX_HANDSHAKE_SATELLITE_BYTES || + handshake_satellite_messages > MAX_HANDSHAKE_SATELLITE_MESSAGES + { + counter!("p2pstream.subprotocol_inbound_buffer_full").increment(1); + return Err(P2PStreamError::SubprotocolInboundBufferFull { + capability: cap.capability().into_owned(), + }.into()) + } self.inner.delegate_message(&cap, msg).map_err(Into::into)?; } } else { @@ -1035,6 +1049,11 @@ fn yield_once() -> impl Future { /// Aggregate byte budget for inbound messages queued across all protocols on one connection. const MAX_MUX_IN_BUFFER_BYTES: usize = 32 * 1024 * 1024; +/// Aggregate satellite traffic accepted while the primary handshake is pending. These limits +/// bound both payload retention and queue metadata before application consumers may start. +const MAX_HANDSHAKE_SATELLITE_BYTES: usize = 256 * 1024; +const MAX_HANDSHAKE_SATELLITE_MESSAGES: usize = 64; + /// Maximum network messages delegated before yielding the connection task. const MAX_INBOUND_MESSAGES_PER_POLL: usize = 32; diff --git a/crates/net/eth-wire/src/p2pstream.rs b/crates/net/eth-wire/src/p2pstream.rs index 721102a0ed1..62dac05ef9b 100644 --- a/crates/net/eth-wire/src/p2pstream.rs +++ b/crates/net/eth-wire/src/p2pstream.rs @@ -474,8 +474,7 @@ impl DisconnectP2P for P2PStream { // clear any buffered messages and queue in self.outgoing_messages.clear(); let disconnect = P2PMessage::Disconnect(reason); - let mut buf = Vec::with_capacity(disconnect.length()); - disconnect.encode(&mut buf); + let buf = alloy_rlp::encode(&disconnect); // we do not add the capability offset because the disconnect message is a `p2p` reserved // message @@ -1098,11 +1097,8 @@ mod tests { } fn eth_shared_capabilities() -> SharedCapabilities { - SharedCapabilities::try_new( - vec![EthVersion::Eth68.into()], - vec![Capability::eth(EthVersion::Eth68)], - ) - .unwrap() + SharedCapabilities::try_new(vec![EthVersion::Eth68.into()], vec![Capability::eth_68()]) + .unwrap() } fn stream_with_incoming(frame: BytesMut) -> P2PStream { diff --git a/crates/net/eth-wire/src/protocol.rs b/crates/net/eth-wire/src/protocol.rs index cd4bd2fd413..667a600bff0 100644 --- a/crates/net/eth-wire/src/protocol.rs +++ b/crates/net/eth-wire/src/protocol.rs @@ -173,12 +173,12 @@ mod tests { fn test_protocol_eth_message_count() { // Test that Protocol::eth() returns correct message counts for each version // This ensures that EthMessageID::message_count() produces the expected results - assert_eq!(Protocol::eth(EthVersion::Eth66).messages(), 17); - assert_eq!(Protocol::eth(EthVersion::Eth67).messages(), 17); - assert_eq!(Protocol::eth(EthVersion::Eth68).messages(), 17); + assert_eq!(Protocol::eth_66().messages(), 17); + assert_eq!(Protocol::eth_67().messages(), 17); + assert_eq!(Protocol::eth_68().messages(), 17); assert_eq!(Protocol::eth(EthVersion::Eth69).messages(), 18); assert_eq!(Protocol::eth(EthVersion::Eth70).messages(), 18); assert_eq!(Protocol::eth(EthVersion::Eth71).messages(), 20); - assert_eq!(Protocol::snap(SnapVersion::V2).messages(), 10); + assert_eq!(Protocol::snap_2().messages(), 10); } } diff --git a/crates/net/eth-wire/tests/fuzz_roundtrip.rs b/crates/net/eth-wire/tests/fuzz_roundtrip.rs index 91799251fa7..bfbab8f3818 100644 --- a/crates/net/eth-wire/tests/fuzz_roundtrip.rs +++ b/crates/net/eth-wire/tests/fuzz_roundtrip.rs @@ -10,7 +10,7 @@ use std::fmt::Debug; /// The test will create a random instance of the type, encode it, and then decode it. fn roundtrip_encoding(thing: T) where - T: Encodable + Decodable + Debug + PartialEq + Eq, + T: Encodable + Decodable + Debug + Eq, { let mut encoded = Vec::new(); thing.encode(&mut encoded); @@ -28,7 +28,7 @@ where /// Default, Clone, or Serialize, and fuzz the wrapper type instead. fn roundtrip_fuzz(thing: T) where - T: Encodable + Decodable + Clone + Serialize + Debug + PartialEq + Eq, + T: Encodable + Decodable + Clone + Serialize + Debug + Eq, { roundtrip_encoding::(thing) } diff --git a/crates/net/nat/src/lib.rs b/crates/net/nat/src/lib.rs index a02f10c53d4..d3d780ac46a 100644 --- a/crates/net/nat/src/lib.rs +++ b/crates/net/nat/src/lib.rs @@ -360,20 +360,6 @@ mod tests { } } - #[tokio::test(start_paused = true)] - async fn interval_skips_missed_attempts() { - let period = Duration::from_secs(5); - let ip: IpAddr = "203.0.113.7".parse().unwrap(); - let mut interval = ResolveNatInterval::interval(NatResolver::ExternalIp(ip), period); - assert_eq!(interval.tick().await, Some(ip)); - - tokio::time::advance(period * 3).await; - assert_eq!(interval.tick().await, Some(ip)); - assert!(interval.tick().now_or_never().is_none()); - tokio::time::advance(period).await; - assert_eq!(interval.tick().await, Some(ip)); - } - #[test] fn netif_resolution_does_not_block_the_runtime() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/crates/net/network-api/src/downloaders.rs b/crates/net/network-api/src/downloaders.rs index 78cfb9bd1ae..10006141128 100644 --- a/crates/net/network-api/src/downloaders.rs +++ b/crates/net/network-api/src/downloaders.rs @@ -10,12 +10,7 @@ use tokio::sync::oneshot; #[auto_impl::auto_impl(&, Arc)] pub trait BlockDownloaderProvider { /// The client this type can provide. - type Client: BlockClient - + BlockAccessListsClient - + Send - + Sync - + Clone - + 'static; + type Client: BlockClient + BlockAccessListsClient + 'static; /// Returns a new [`BlockClient`], used for fetching blocks from peers. /// diff --git a/crates/net/network-api/src/lib.rs b/crates/net/network-api/src/lib.rs index 5dad5dd90b0..f7c1bed4039 100644 --- a/crates/net/network-api/src/lib.rs +++ b/crates/net/network-api/src/lib.rs @@ -59,7 +59,6 @@ pub trait FullNetwork: + PeersHandleProvider + Clone + Unpin - + 'static { } @@ -73,7 +72,6 @@ impl FullNetwork for T where + PeersHandleProvider + Clone + Unpin - + 'static { } diff --git a/crates/net/network-api/src/noop.rs b/crates/net/network-api/src/noop.rs index f9f069dafe5..85774b1690b 100644 --- a/crates/net/network-api/src/noop.rs +++ b/crates/net/network-api/src/noop.rs @@ -244,18 +244,3 @@ where &self.peers_handle } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn configures_client_version() { - let network = NoopNetwork::default().with_client_version("custom-client/v1.0.0"); - - assert_eq!( - futures::executor::block_on(network.network_status()).unwrap().client_version, - "custom-client/v1.0.0" - ); - } -} diff --git a/crates/net/network-types/src/peers/config.rs b/crates/net/network-types/src/peers/config.rs index a766674952f..79e9e34e52e 100644 --- a/crates/net/network-types/src/peers/config.rs +++ b/crates/net/network-types/src/peers/config.rs @@ -93,7 +93,7 @@ impl Default for PeerBackoffDurations { #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(default))] pub struct ConnectionsConfig { - /// Maximum allowed outbound connections. + /// Maximum allowed outbound connections. Trusted peers are dialed regardless. pub max_outbound: usize, /// Maximum allowed inbound connections. pub max_inbound: usize, diff --git a/crates/net/network-types/src/peers/mod.rs b/crates/net/network-types/src/peers/mod.rs index a86841068b7..a296de97241 100644 --- a/crates/net/network-types/src/peers/mod.rs +++ b/crates/net/network-types/src/peers/mod.rs @@ -152,13 +152,13 @@ impl Peer { /// Returns whether this peer is trusted #[inline] pub const fn is_trusted(&self) -> bool { - matches!(self.kind, PeerKind::Trusted) + self.kind.is_trusted() } /// Returns whether this peer is static #[inline] pub const fn is_static(&self) -> bool { - matches!(self.kind, PeerKind::Static) + self.kind.is_static() } } diff --git a/crates/net/network-types/src/peers/state.rs b/crates/net/network-types/src/peers/state.rs index aeacd539d6d..2e379db286f 100644 --- a/crates/net/network-types/src/peers/state.rs +++ b/crates/net/network-types/src/peers/state.rs @@ -52,7 +52,7 @@ impl PeerConnectionState { /// Returns if there's currently no connection to that peer. #[inline] pub const fn is_unconnected(&self) -> bool { - matches!(self, Self::Idle) + self.is_idle() } /// Returns true if there's currently an outbound dial to that peer. diff --git a/crates/net/network/src/config.rs b/crates/net/network/src/config.rs index f604c42108a..6bb5de64bef 100644 --- a/crates/net/network/src/config.rs +++ b/crates/net/network/src/config.rs @@ -16,13 +16,13 @@ use reth_eth_wire::{ EthNetworkPrimitives, HelloMessage, HelloMessageWithProtocols, NetworkPrimitives, UnifiedStatus, }; -use reth_eth_wire_types::message::MAX_MESSAGE_SIZE; +use reth_eth_wire_types::{message::MAX_MESSAGE_SIZE, SnapVersion}; use reth_ethereum_forks::{ForkFilter, Head}; use reth_network_peers::{mainnet_nodes, pk2id, sepolia_nodes, PeerId, TrustedPeer}; use reth_network_types::{PeersConfig, SessionsConfig}; use reth_storage_api::{ - noop::NoopProvider, BalProvider, BlockNumReader, BlockReader, HeaderProvider, - StateProviderFactory, StateRangeProviderFactory, + noop::NoopProvider, BalProvider, BlockNumReader, BlockReader, StateProviderFactory, + StateRangeProviderFactory, }; use reth_tasks::Runtime; use secp256k1::SECP256K1; @@ -167,7 +167,6 @@ where + StateProviderFactory + StateRangeProviderFactory + BlockReader - + HeaderProvider + Clone + Unpin + 'static, @@ -647,7 +646,7 @@ impl NetworkConfigBuilder { let Self { secret_key, mut dns_discovery_config, - discovery_v4_builder, + mut discovery_v4_builder, mut discovery_v5_builder, boot_nodes, discovery_addr, @@ -684,6 +683,12 @@ impl NetworkConfigBuilder { // as the ENR address. let advertised_ip = nat.clone().and_then(|nat| nat.as_external_ip(listener_addr.port())); + // EIP-8189: DNS discovery lists select snap servers by the versions in this ENR entry. + let snap_versions = snap_enabled.then(|| vec![SnapVersion::V2 as u64]); + if let (Some(builder), Some(versions)) = (discovery_v4_builder.as_mut(), &snap_versions) { + builder.add_eip868_pair("snap", versions); + } + discovery_v5_builder = discovery_v5_builder.map(|mut builder| { let fork_id = chain_spec.fork_id(&head); if let Some(network_stack_id) = NetworkStackId::id(&chain_spec) { @@ -698,6 +703,10 @@ impl NetworkConfigBuilder { builder = builder.advertised_ip(ip); } + if let Some(versions) = &snap_versions { + builder = builder.add_enr_kv_pair(b"snap", alloy_rlp::encode(versions).into()); + } + builder }); @@ -828,6 +837,45 @@ mod tests { assert!(config.hello_message.protocols.iter().any(|p| p.cap.name == "eth")); } + #[test] + fn test_snap_enr_entry_lists_served_versions() { + let config = builder() + .with_snap(true) + .discovery_v5(reth_discv5::Config::builder((Ipv4Addr::LOCALHOST, 30303).into())) + .build(NoopProvider::default()); + + let discv4 = config.discovery_v4_config.expect("should build discv4 config"); + assert_eq!( + discv4.additional_eip868_rlp_pairs.get(b"snap".as_slice()).map(|rlp| rlp.as_ref()), + Some([0xc1, 0x02].as_slice()) + ); + + let (local_enr, _, _, _) = build_local_enr( + &config.secret_key, + &config.discovery_v5_config.expect("should build discv5 config"), + ); + assert_eq!( + local_enr.get_decodable::>("snap").expect("should read 'snap'").unwrap(), + vec![2] + ); + } + + #[test] + fn test_snap_enr_entry_absent_by_default() { + let config = builder() + .discovery_v5(reth_discv5::Config::builder((Ipv4Addr::LOCALHOST, 30303).into())) + .build(NoopProvider::default()); + + let discv4 = config.discovery_v4_config.expect("should build discv4 config"); + assert!(!discv4.additional_eip868_rlp_pairs.contains_key(b"snap".as_slice())); + + let (local_enr, _, _, _) = build_local_enr( + &config.secret_key, + &config.discovery_v5_config.expect("should build discv5 config"), + ); + assert!(local_enr.get_raw_rlp("snap").is_none()); + } + #[test] fn test_network_dns_defaults() { let config = builder().build(NoopProvider::default()); diff --git a/crates/net/network/src/discovery.rs b/crates/net/network/src/discovery.rs index bdaa96cb323..0c037a4935e 100644 --- a/crates/net/network/src/discovery.rs +++ b/crates/net/network/src/discovery.rs @@ -17,6 +17,7 @@ use reth_network_api::{DiscoveredEvent, DiscoveryEvent}; use reth_network_peers::{NodeRecord, PeerId}; use reth_network_types::PeerAddr; use secp256k1::SecretKey; +use socket2::{Domain, Protocol, Socket, Type}; use std::{ collections::VecDeque, net::{IpAddr, SocketAddr}, @@ -105,16 +106,13 @@ impl Discovery { let bind_socket = async |addr: SocketAddr| { let result = match addr { SocketAddr::V4(_) => UdpSocket::bind(addr).await, - SocketAddr::V6(_) => { - use socket2::{Domain, Protocol, Socket, Type}; - (|| { - let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?; - socket.set_only_v6(true)?; - socket.set_nonblocking(true)?; - socket.bind(&addr.into())?; - UdpSocket::from_std(socket.into()) - })() - } + SocketAddr::V6(_) => (|| { + let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_only_v6(true)?; + socket.set_nonblocking(true)?; + socket.bind(&addr.into())?; + UdpSocket::from_std(socket.into()) + })(), }; result .map(Arc::new) @@ -122,16 +120,53 @@ impl Discovery { }; // In shared-port mode, bind the shared socket and start discv4 without its own receive - // loop. Unrecognized frames from discv5 will be forwarded to the ingress handler. - let (discv4, discv4_updates, _discv4_service, discv4_ingress, shared_socket) = - if let Some(config) = discv4_config { + // loop. Unrecognized frames from discv5 will be forwarded to the ingress handler, and + // discv5's opposite-family sibling socket doubles as discv4's secondary-family egress. + // The sibling binds at the shared port that discv4 advertises for both families, not at + // discv5's configured opposite-family port, and serves egress and replies only. Node + // config has no second-family external IP source, so the discv4 ENR advertises only the + // discovery address family. The discv5 ENR carries the dual-stack advertisement. + let (discv4, discv4_updates, _discv4_service, discv4_ingress, shared_sockets) = + if let Some(mut config) = discv4_config { if let Some(discv5_config) = &mut discv5_config && discv5_config.has_matching_socket(discovery_v4_addr) { let socket = bind_socket(discovery_v4_addr).await?; + let shared_port = socket + .local_addr() + .map_err(|err| { + NetworkError::from_io_error( + err, + ServiceKind::Discovery(discovery_v4_addr), + ) + })? + .port(); + + let listen_config = &discv5_config.discv5_config_mut().listen_config; + let secondary_addr = reth_discv5::config::sibling_addr( + listen_config, + discovery_v4_addr, + shared_port, + ); + let secondary_socket = match secondary_addr { + Some(addr) => Some(bind_socket(addr).await?), + None => None, + }; + + if secondary_socket.is_some() { + // The TCP listener's family can differ from the primary discovery socket. + let (tcp4, tcp6) = if tcp_addr.is_ipv4() { + (tcp_addr.port(), 0) + } else { + (0, tcp_addr.port()) + }; + config.add_eip868_pair("tcp", tcp4); + config.add_eip868_pair("tcp6", tcp6); + } let (discv4, mut discv4_service, ingress) = Discv4::bind_shared( socket.clone(), + secondary_socket.clone(), local_enr, sk, config, @@ -148,7 +183,7 @@ impl Discovery { Some(discv4_updates), Some(discv4_service), Some(ingress), - Some(socket), + Some((socket, secondary_socket)), ) } else { let (discv4, mut discv4_service) = @@ -195,25 +230,14 @@ impl Discovery { // Set OS-assigned advertised RLPx ports to the bound listener port. set_bound_rlpx_port_if_unset(&mut config, tcp_addr.port()); - if let Some(socket) = shared_socket { - let discv5_cfg = config.discv5_config_mut(); - - // The shared socket covers discv4's address family; bind the opposite family - // only if discv5 was configured for dual-stack. - let (mut ipv4, mut ipv6) = (None, None); - if discovery_v4_addr.is_ipv4() { - ipv4 = Some(socket); - if let Some(addr) = reth_discv5::config::ipv6(&discv5_cfg.listen_config) { - ipv6 = Some(bind_socket(SocketAddr::V6(addr)).await?); - } + if let Some((primary, secondary)) = shared_sockets { + let (ipv4, ipv6) = if discovery_v4_addr.is_ipv4() { + (Some(primary), secondary) } else { - ipv6 = Some(socket); - if let Some(addr) = reth_discv5::config::ipv4(&discv5_cfg.listen_config) { - ipv4 = Some(bind_socket(SocketAddr::V4(addr)).await?); - } - } - - discv5_cfg.listen_config = discv5::ListenConfig::FromSockets { ipv4, ipv6 }; + (secondary, Some(primary)) + }; + config.discv5_config_mut().listen_config = + discv5::ListenConfig::FromSockets { ipv4, ipv6 }; } let (discv5, discv5_updates) = Discv5::start(&sk, config).await?; @@ -541,7 +565,10 @@ impl Discovery { mod tests { use super::*; use secp256k1::SECP256K1; - use std::net::{Ipv4Addr, SocketAddrV4}; + use std::{ + net::{Ipv4Addr, Ipv6Addr, SocketAddrV4}, + time::Duration, + }; #[tokio::test(flavor = "multi_thread")] async fn test_discovery_setup() { @@ -966,4 +993,128 @@ mod tests { .await .expect("discovery should start with shared port + dual-stack"); } + + /// A discv4 ping over the opposite family must complete the endpoint proof: the sibling + /// socket bound for discv5's dual-stack listen config is discv4's secondary-family egress. + #[tokio::test(flavor = "multi_thread")] + async fn test_shared_port_dual_stack_answers_opposite_family_discv4() { + reth_tracing::init_test_tracing(); + + let Ok(probe6) = UdpSocket::bind("[::1]:0").await else { + // No IPv6 loopback on this host. + return; + }; + drop(probe6); + + let ips = [IpAddr::V4(Ipv4Addr::LOCALHOST), IpAddr::V6(Ipv6Addr::LOCALHOST)]; + for primary_ip in ips { + for tcp_ip in ips { + assert_shared_port_dual_stack(primary_ip, tcp_ip).await; + } + } + } + + async fn assert_shared_port_dual_stack(primary_ip: IpAddr, tcp_ip: IpAddr) { + let secondary_ip = if primary_ip.is_ipv4() { + IpAddr::V6(Ipv6Addr::LOCALHOST) + } else { + IpAddr::V4(Ipv4Addr::LOCALHOST) + }; + let probe = UdpSocket::bind((primary_ip, 0)).await.expect("probe bind"); + let port = probe.local_addr().unwrap().port(); + drop(probe); + + let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let node_id = reth_network_peers::pk2id(&secret_key.public_key(SECP256K1)); + let discovery_addr = SocketAddr::new(primary_ip, port); + let tcp_addr = SocketAddr::new(tcp_ip, 30303); + + let discv4_config = Discv4ConfigBuilder::default().external_ip_resolver(None).build(); + // The sibling must use discv4's shared port despite a different configured port. + let other_port = if port == u16::MAX { port - 1 } else { port + 1 }; + let discv5_listen_config = discv5::ListenConfig::DualStack { + ipv4: Ipv4Addr::LOCALHOST, + ipv4_port: if primary_ip.is_ipv4() { port } else { other_port }, + ipv6: Ipv6Addr::LOCALHOST, + ipv6_port: if primary_ip.is_ipv6() { port } else { other_port }, + }; + let discv5_config = reth_discv5::Config::builder(tcp_addr) + .discv5_config(discv5::ConfigBuilder::new(discv5_listen_config).build()) + .build(); + + let _node = Discovery::new( + tcp_addr, + discovery_addr, + secret_key, + Some(discv4_config), + Some(discv5_config), + None, + None, + ) + .await + .expect("discovery should start with shared port + dual-stack"); + + // Seed the discv4-only prober with the secondary endpoint. + let probe_sk = SecretKey::new(&mut rand_08::thread_rng()); + let secondary_node = + NodeRecord { address: secondary_ip, udp_port: port, tcp_port: port, id: node_id }; + let probe_config = Discv4ConfigBuilder::default() + .external_ip_resolver(None) + .add_boot_node(secondary_node) + .build(); + let probe_addr = SocketAddr::new(secondary_ip, 0); + let probe_enr = NodeRecord::from_secret_key(probe_addr, &probe_sk); + let (_probe, mut probe_service) = + Discv4::bind(probe_addr, probe_enr, probe_sk, probe_config).await.unwrap(); + let mut updates = probe_service.update_stream(); + probe_service.spawn(); + + // The bootnode only becomes `Added` once its pong arrives over the secondary socket. + let added = tokio::time::timeout(Duration::from_secs(5), async { + while let Some(update) = updates.next().await { + if let DiscoveryUpdate::Added(record) = update && + record.id == node_id + { + return true + } + } + false + }) + .await + .unwrap_or(false); + assert!(added, "node should answer discv4 over its secondary family"); + + for peer_ip in [primary_ip, secondary_ip] { + let node = NodeRecord { address: peer_ip, ..secondary_node }; + let socket = UdpSocket::bind((peer_ip, 0)).await.unwrap(); + let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let record = NodeRecord::from_secret_key(socket.local_addr().unwrap(), &secret_key); + let ping = reth_discv4::proto::Message::Ping(reth_discv4::proto::Ping { + from: record.into(), + to: node.into(), + expire: u32::MAX.into(), + enr_sq: None, + }); + let (packet, _) = ping.encode(&secret_key); + socket.send_to(&packet, node.udp_addr()).await.unwrap(); + let ping = tokio::time::timeout(Duration::from_secs(5), async { + let mut buf = [0u8; 1280]; + loop { + let (len, _) = socket.recv_from(&mut buf).await.unwrap(); + if let reth_discv4::proto::Message::Ping(ping) = + reth_discv4::proto::Message::decode(&buf[..len]).unwrap().msg + { + break ping + } + } + }) + .await + .expect("node should ping the peer"); + assert_eq!( + ping.from.tcp_port, + if peer_ip == tcp_ip { tcp_addr.port() } else { 0 }, + "primary discovery IP: {primary_ip}, TCP IP: {tcp_ip}, peer IP: {peer_ip}" + ); + } + } } diff --git a/crates/net/network/src/eth_requests.rs b/crates/net/network/src/eth_requests.rs index b549b31720e..13c91ba1957 100644 --- a/crates/net/network/src/eth_requests.rs +++ b/crates/net/network/src/eth_requests.rs @@ -7,9 +7,10 @@ use crate::{ use alloy_consensus::{constants::KECCAK_EMPTY, BlockHeader, ReceiptWithBloom}; use alloy_eips::BlockHashOrNumber; use alloy_primitives::{Bytes, B256}; -use alloy_rlp::Encodable; +use alloy_rlp::{length_of_length, Encodable}; use futures::StreamExt; use reth_eth_wire::{ + message::MAX_MESSAGE_SIZE, snap::{ AccountData, AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage, GetAccountRangeMessage, GetStorageRangesMessage, SnapProtocolMessage, StorageData, @@ -301,7 +302,11 @@ where /// Handles partial responses for [`GetReceipts70`] queries. /// - /// This will adhere to the soft limit but allow filling the last vec partially. + /// This will adhere to the soft limit but allow filling the last vec partially. A response + /// never ends with an empty incomplete block: if not even the next receipt fits, the response + /// ends after the last complete block, except when it would otherwise be empty, in which case + /// the first receipt is served even if it exceeds the soft limit. If it cannot fit within + /// [`MAX_MESSAGE_SIZE`], including the response framing, the response is empty and complete. fn on_receipts70_request( &self, _peer_id: PeerId, @@ -317,6 +322,11 @@ where let mut total_bytes = 0usize; let mut last_block_incomplete = false; + // The request ID is unavailable here. Reserve the message code, the largest request ID, + // the incomplete flag, and the three RLP list headers to bound the full encoded response. + let max_first_receipt_size = MAX_MESSAGE_SIZE - + (1 + u64::MAX.length() + false.length() + 3 * length_of_length(MAX_MESSAGE_SIZE)); + for (idx, hash) in block_hashes.into_iter().enumerate() { if idx >= MAX_RECEIPTS_SERVE { break @@ -348,18 +358,30 @@ where continue; } + // If nothing has been added to the response yet, serve the first receipt even if it + // exceeds the soft limit, provided the framed response fits within the hard limit. + let always_serve_first = receipts.is_empty(); + let remaining = block_receipts.len(); let mut partial_block = Vec::new(); for receipt in block_receipts { let receipt_size = receipt.length(); - if total_bytes + receipt_size > SOFT_RESPONSE_LIMIT { + if total_bytes + receipt_size > SOFT_RESPONSE_LIMIT && + !(always_serve_first && + partial_block.is_empty() && + receipt_size <= max_first_receipt_size) + { break; } total_bytes += receipt_size; partial_block.push(receipt); } - receipts.push(partial_block); - last_block_incomplete = true; + // An empty partial block delivers nothing, so it is never sent. The requester can + // simply continue with this block in its next request. + if !partial_block.is_empty() { + last_block_incomplete = partial_block.len() < remaining; + receipts.push(partial_block); + } break; } @@ -582,7 +604,7 @@ where let RangeResponse { items: accounts, end } = state.account_range(req.starting_hash, req.limit_hash, response_bytes)?; - let proof = if req.starting_hash == B256::ZERO && end == RangeEnd::Exhausted { + let proof = if req.starting_hash.is_zero() && end == RangeEnd::Exhausted { Vec::new() } else { let boundary_keys = boundary_proof_keys(req.starting_hash, accounts.last()); @@ -647,7 +669,7 @@ where remaining_bytes = remaining_bytes.saturating_sub(account_slots.len() * 64); let last = account_slots.last().map(|(hash, _)| *hash); - let needs_proof = origin != B256::ZERO || end != RangeEnd::Exhausted; + let needs_proof = !origin.is_zero() || end != RangeEnd::Exhausted; slots.push( account_slots .into_iter() @@ -855,12 +877,14 @@ pub enum IncomingEthRequest { #[cfg(test)] mod tests { use super::*; - use alloy_consensus::constants::EMPTY_ROOT_HASH; + use alloy_consensus::{constants::EMPTY_ROOT_HASH, Header, TxType}; use alloy_eips::{ eip4844::{BlobAndProofV1, BlobAndProofV2, BlobCellsAndProofsV1}, eip7594::{BlobCellMask, BlobTransactionSidecarVariant, Cell}, }; - use alloy_primitives::{keccak256, Address, TxHash, B128, U256}; + use alloy_primitives::{keccak256, Address, Log, LogData, TxHash, B128, U256}; + use reth_eth_wire::{message::RequestPair, EthMessage, EthStreamInner, EthVersion}; + use reth_ethereum_primitives::Receipt; use reth_network_api::test_utils::PeersHandle; use reth_primitives_traits::Account; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; @@ -1173,7 +1197,7 @@ mod tests { let missing_storage_proof = MockEthProvider::default(); missing_storage_proof - .push_snap_storage_range(vec![(B256::ZERO, U256::from(1))], RangeEnd::ByteLimit); + .push_snap_storage_range(vec![(B256::ZERO, U256::ONE)], RangeEnd::ByteLimit); let storage_disappears = MockEthProvider::default(); storage_disappears.push_snap_storage_range(Vec::new(), RangeEnd::Exhausted); @@ -1493,7 +1517,7 @@ mod tests { let proof = vec![Bytes::from_static(&[0xcc])]; // More entries exist beyond `limit_hash`, so the cursor stopped at the hash limit // rather than exhausting the trie -- a proof is required even though origin is zero. - provider.push_snap_storage_range(vec![(hash, U256::from(1))], RangeEnd::HashLimit); + provider.push_snap_storage_range(vec![(hash, U256::ONE)], RangeEnd::HashLimit); provider.set_snap_storage_proof(Some(proof.clone())); let handler = snap_handler(provider.clone()); @@ -1526,7 +1550,7 @@ mod tests { let provider = MockEthProvider::default(); provider.push_missing_snap_storage_account(); provider.push_snap_storage_range( - vec![(B256::repeat_byte(0x01), U256::from(1))], + vec![(B256::repeat_byte(0x01), U256::ONE)], RangeEnd::Exhausted, ); let missing_account = B256::repeat_byte(0x01); @@ -1559,4 +1583,139 @@ mod tests { // first missing account instead of skipping it and shifting later positions. assert_eq!(provider.snap_storage_ranges_remaining(), 1); } + + /// Creates a receipt whose single log carries `data_len` bytes of data. + fn receipt_with_data(data_len: usize) -> Receipt { + Receipt { + tx_type: TxType::Legacy, + success: true, + cumulative_gas_used: 21_000, + logs: vec![Log { + address: Address::ZERO, + data: LogData::new_unchecked(Vec::new(), vec![0u8; data_len].into()), + }], + } + } + + /// Stores the receipts as block `number` and returns the block hash. + fn insert_receipts_block( + provider: &MockEthProvider, + number: u64, + receipts: Vec, + ) -> B256 { + let hash = B256::with_last_byte(number as u8); + provider.add_header(hash, Header { number, ..Default::default() }); + provider.add_receipts(number, receipts); + hash + } + + /// Sends a [`GetReceipts70`] request to the handler and returns the response. + async fn get_receipts70( + provider: MockEthProvider, + first_block_receipt_index: u64, + block_hashes: Vec, + ) -> Receipts70 { + let handler = snap_handler(provider); + let (response, rx) = oneshot::channel(); + handler.on_receipts70_request( + PeerId::default(), + GetReceipts70 { first_block_receipt_index, block_hashes }, + response, + ); + rx.await.unwrap().unwrap() + } + + #[tokio::test] + async fn receipts70_serves_only_oversized_first_receipt_of_partial_block() { + let provider = MockEthProvider::default(); + let oversized = receipt_with_data(SOFT_RESPONSE_LIMIT + 1024); + let hash = + insert_receipts_block(&provider, 1, vec![oversized.clone(), receipt_with_data(16)]); + + let resp = get_receipts70(provider, 0, vec![hash]).await; + + // The oversized receipt exhausts the budget, so the rest of the block is left out. + assert_eq!( + resp, + Receipts70 { last_block_incomplete: true, receipts: vec![vec![oversized]] } + ); + } + + #[tokio::test] + async fn receipts70_omits_empty_incomplete_block() { + let provider = MockEthProvider::default(); + let first = vec![receipt_with_data(SOFT_RESPONSE_LIMIT - 1024)]; + let first_hash = insert_receipts_block(&provider, 1, first.clone()); + // The first receipt of the second block does not fit into the remaining budget. + let second_hash = insert_receipts_block(&provider, 2, vec![receipt_with_data(4096)]); + + let resp = get_receipts70(provider, 0, vec![first_hash, second_hash]).await; + + assert_eq!(resp, Receipts70 { last_block_incomplete: false, receipts: vec![first] }); + } + + #[tokio::test] + async fn receipts70_serves_partial_block() { + let provider = MockEthProvider::default(); + let receipts = vec![receipt_with_data(16 * 1024); 200]; + let hash = insert_receipts_block(&provider, 1, receipts.clone()); + + let resp = get_receipts70(provider, 0, vec![hash]).await; + + assert!(resp.last_block_incomplete); + assert_eq!(resp.receipts.len(), 1); + let partial = &resp.receipts[0]; + assert!(!partial.is_empty() && partial.len() < receipts.len()); + assert_eq!(partial.as_slice(), &receipts[..partial.len()]); + assert!(partial.iter().map(Encodable::length).sum::() <= SOFT_RESPONSE_LIMIT); + } + + #[test_case(0, 0; "first receipt at hard limit")] + #[test_case(1, 0; "continuation receipt at hard limit")] + #[test_case(0, 1; "first receipt framing exceeds hard limit")] + #[test_case(1, 1; "continuation receipt framing exceeds hard limit")] + #[tokio::test] + async fn receipts70_enforces_framed_response_limit(first_receipt_index: u64, excess: usize) { + // Measure the actual wire encoding with the largest request ID. At these sizes, changing + // the log data length does not change the width of any RLP length prefix. + let data_len = MAX_MESSAGE_SIZE - 1024; + let encoded = encode_receipts70_response(Receipts70 { + last_block_incomplete: false, + receipts: vec![vec![receipt_with_data(data_len)]], + }); + let receipt = receipt_with_data(data_len + MAX_MESSAGE_SIZE - encoded.len() + excess); + assert!(receipt.length() < MAX_MESSAGE_SIZE); + let expected = + Receipts70 { last_block_incomplete: false, receipts: vec![vec![receipt.clone()]] }; + assert_eq!(encode_receipts70_response(expected.clone()).len(), MAX_MESSAGE_SIZE + excess); + + let provider = MockEthProvider::default(); + let mut receipts = vec![receipt_with_data(16); first_receipt_index as usize]; + receipts.push(receipt); + let hash = insert_receipts_block(&provider, 1, receipts); + + let resp = get_receipts70(provider, first_receipt_index, vec![hash]).await; + + if excess == 0 { + assert_eq!(resp, expected); + } else { + assert_eq!(resp, Receipts70 { last_block_incomplete: false, receipts: Vec::new() }); + } + let encoded = encode_receipts70_response(resp); + assert!(encoded.len() <= MAX_MESSAGE_SIZE); + EthStreamInner::::new(EthVersion::Eth70) + .decode_message(encoded.as_ref().into()) + .unwrap(); + } + + /// Encodes a receipts response with the largest possible request ID. + fn encode_receipts70_response(response: Receipts70) -> Bytes { + EthStreamInner::::new(EthVersion::Eth70) + .encode_message(EthMessage::Receipts70(RequestPair { + request_id: u64::MAX, + message: response, + })) + .unwrap() + .into() + } } diff --git a/crates/net/network/src/fetch/mod.rs b/crates/net/network/src/fetch/mod.rs index d6074df6420..5488935d2cc 100644 --- a/crates/net/network/src/fetch/mod.rs +++ b/crates/net/network/src/fetch/mod.rs @@ -485,7 +485,22 @@ impl StateFetcher { peer_id: PeerId, res: RequestResult, ) -> Option { - let is_likely_bad_response = res.is_err(); + // Unavailable data is a valid answer, but another peer should serve subsequent requests. + // Missing BAL entries can also block ordered catch-up despite lists later in the response. + let is_likely_bad_response = match &res { + Ok(SnapResponse::AccountRange(response)) => { + response.accounts.is_empty() && response.proof.is_empty() + } + Ok(SnapResponse::StorageRanges(response)) => { + response.slots.is_empty() && response.proof.is_empty() + } + Ok(SnapResponse::ByteCodes(response)) => response.codes.is_empty(), + Ok(SnapResponse::BlockAccessLists(response)) => { + response.block_access_lists.0.is_empty() || + response.block_access_lists.0.iter().any(Option::is_none) + } + Err(_) => true, + }; if let Some(resp) = self.inflight_snap_requests.remove(&peer_id) { let _ = resp.response.send(res.map(|r| (peer_id, r).into())); @@ -849,10 +864,14 @@ mod tests { use super::*; use crate::{peers::PeersManager, PeersConfig}; use alloy_consensus::Header; - use alloy_primitives::B512; + use alloy_primitives::{Bytes, B512}; use reth_eth_wire::Capability; - use reth_eth_wire_types::snap::{AccountRangeMessage, GetAccountRangeMessage}; + use reth_eth_wire_types::snap::{ + AccountRangeMessage, BlockAccessListsMessage, ByteCodesMessage, GetAccountRangeMessage, + StorageRangesMessage, + }; use std::future::poll_fn; + use test_case::test_case; #[tokio::test(flavor = "multi_thread")] async fn test_poll_fetcher() { @@ -2062,26 +2081,6 @@ mod tests { assert_eq!(fetcher.next_best_peer(BestPeerRequirements::SupportsSnap), None); } - #[tokio::test] - async fn test_next_best_peer_snap_supported() { - let manager = PeersManager::new(PeersConfig::default()); - let mut fetcher = - StateFetcher::::new(manager.handle(), Default::default()); - - let peer = B512::random(); - fetcher.new_active_peer(NewPeerInfo { - peer_id: peer, - best_hash: B256::random(), - best_number: 100, - capabilities: Arc::new(Capabilities::new(vec![])), - timeout: Arc::new(AtomicU64::new(10)), - range_info: None, - supports_snap: true, - }); - - assert_eq!(fetcher.next_best_peer(BestPeerRequirements::SupportsSnap), Some(peer)); - } - #[tokio::test] async fn test_next_best_peer_snap_filters_correctly() { let manager = PeersManager::new(PeersConfig::default()); @@ -2161,8 +2160,21 @@ mod tests { assert_eq!(rx.await.unwrap().unwrap_err(), RequestError::UnsupportedCapability); } + #[test_case(SnapResponse::AccountRange(AccountRangeMessage { + request_id: 1, accounts: vec![], proof: vec![Bytes::from_static(&[0xc0])], + }); "account proof")] + #[test_case(SnapResponse::StorageRanges(StorageRangesMessage { + request_id: 1, slots: vec![], proof: vec![Bytes::from_static(&[0xc0])], + }); "storage proof")] + #[test_case(SnapResponse::ByteCodes(ByteCodesMessage { + request_id: 1, codes: vec![Bytes::new()], + }); "empty bytecode")] + #[test_case(SnapResponse::BlockAccessLists(BlockAccessListsMessage { + request_id: 1, + block_access_lists: BlockAccessLists(vec![Some(Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE]))]), + }); "present empty BAL")] #[tokio::test] - async fn test_snap_response_triggers_followup() { + async fn test_snap_response_triggers_followup(resp: SnapResponse) { let manager = PeersManager::new(PeersConfig::default()); let mut fetcher = StateFetcher::::new(manager.handle(), Default::default()); @@ -2196,11 +2208,6 @@ mod tests { fetcher.inflight_snap_requests.insert(peer_id, Request { request: (), response: tx }); fetcher.peers.get_mut(&peer_id).unwrap().state = PeerState::GetSnap; - let resp = SnapResponse::AccountRange(AccountRangeMessage { - request_id: 1, - accounts: vec![], - proof: vec![], - }); let outcome = fetcher.on_snap_response(peer_id, Ok(resp)); assert!(matches!(outcome, Some(BlockResponseOutcome::Request(pid, _)) if pid == peer_id)); @@ -2259,4 +2266,82 @@ mod tests { assert!(fetcher.queued_requests.is_empty()); assert_eq!(rx.await.unwrap().unwrap_err(), RequestError::UnsupportedCapability); } + + #[tokio::test] + async fn unavailable_snap_responses_yield_to_another_peer() { + let responses = [ + SnapResponse::BlockAccessLists(BlockAccessListsMessage { + request_id: 1, + block_access_lists: BlockAccessLists(vec![None, None]), + }), + SnapResponse::AccountRange(AccountRangeMessage { + request_id: 1, + accounts: vec![], + proof: vec![], + }), + SnapResponse::StorageRanges(StorageRangesMessage { + request_id: 1, + slots: vec![], + proof: vec![], + }), + SnapResponse::ByteCodes(ByteCodesMessage { request_id: 1, codes: vec![] }), + SnapResponse::BlockAccessLists(BlockAccessListsMessage { + request_id: 1, + block_access_lists: BlockAccessLists(vec![]), + }), + SnapResponse::BlockAccessLists(BlockAccessListsMessage { + request_id: 1, + block_access_lists: BlockAccessLists(vec![ + None, + Some(Bytes::from_static(&[alloy_rlp::EMPTY_LIST_CODE])), + ]), + }), + ]; + for response in responses { + let manager = PeersManager::new(PeersConfig::default()); + let mut fetcher = + StateFetcher::::new(manager.handle(), Default::default()); + let fast = B512::random(); + let other = B512::random(); + for (peer_id, timeout) in [(fast, 10), (other, 50)] { + fetcher.new_active_peer(NewPeerInfo { + peer_id, + best_hash: B256::random(), + best_number: 100, + capabilities: Arc::new(Capabilities::new(vec![])), + timeout: Arc::new(AtomicU64::new(timeout)), + range_info: None, + supports_snap: true, + }); + } + assert_eq!(fetcher.next_best_peer(BestPeerRequirements::SupportsSnap), Some(fast)); + let (tx, rx) = oneshot::channel(); + fetcher.inflight_snap_requests.insert(fast, Request { request: (), response: tx }); + fetcher.peers.get_mut(&fast).unwrap().state = PeerState::GetSnap; + let (followup_tx, _followup_rx) = oneshot::channel(); + fetcher.queued_requests.push_back(DownloadRequest::GetSnap { + request: SnapProtocolMessage::GetAccountRange(GetAccountRangeMessage { + request_id: 2, + root_hash: B256::random(), + starting_hash: B256::ZERO, + limit_hash: B256::repeat_byte(0xff), + response_bytes: 1024, + }), + response: followup_tx, + priority: Priority::Normal, + }); + + assert_eq!(fetcher.on_snap_response(fast, Ok(response.clone())), None); + assert_eq!(rx.await.unwrap().unwrap().1, response); + assert_eq!( + fetcher.next_best_peer(BestPeerRequirements::SupportsSnap), + Some(other), + "an unavailable response must not monopolize requests: {response:?}" + ); + assert!(matches!( + fetcher.poll_action(), + PollAction::Ready(FetchAction::BlockRequest { peer_id, .. }) if peer_id == other + )); + } + } } diff --git a/crates/net/network/src/manager.rs b/crates/net/network/src/manager.rs index 733f9ef6ba4..f608f553abc 100644 --- a/crates/net/network/src/manager.rs +++ b/crates/net/network/src/manager.rs @@ -824,6 +824,7 @@ impl NetworkManager { SwarmEvent::IncomingTcpConnection { remote_addr, session_id } => { trace!(target: "net", ?session_id, ?remote_addr, "Incoming connection"); self.metrics.total_incoming_connections.increment(1); + self.update_pending_connection_metrics(); self.metrics .incoming_connections .set(self.swarm.peers().num_inbound_connections() as f64); @@ -868,6 +869,7 @@ impl NetworkManager { } self.update_active_connection_metrics(); + self.update_pending_connection_metrics(); let peer_kind = self .swarm @@ -967,6 +969,7 @@ impl NetworkManager { .on_incoming_pending_session_gracefully_closed(); } self.closed_sessions_metrics.incoming_pending.increment(1); + self.update_pending_connection_metrics(); self.metrics .incoming_connections .set(self.swarm.peers().num_inbound_connections() as f64); @@ -1208,3 +1211,175 @@ struct NetworkManagerPollDurations { acc_network_handle: Duration, acc_swarm: Duration, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{NetworkConfigBuilder, PeersConfig, SessionsConfig}; + use futures::{future::poll_fn, task::noop_waker_ref}; + use metrics::{atomics::AtomicU64, Gauge}; + use reth_network_types::{PeerAddr, PeerKind, SessionLimits}; + use reth_storage_api::noop::NoopProvider; + use reth_tasks::Runtime; + use tokio::net::{TcpListener, TcpStream}; + + #[tokio::test] + async fn rejected_dials_release_slots_and_refill() { + for limit in [Some(0), Some(1), None] { + let mut networks = Vec::new(); + for index in 0..3 { + let sessions = SessionsConfig { + limits: SessionLimits { + max_pending_outbound: if index == 0 { limit } else { None }, + ..Default::default() + }, + ..Default::default() + }; + let config = NetworkConfigBuilder::::new( + SecretKey::new(&mut rand_08::thread_rng()), + Runtime::test(), + ) + .disable_discovery() + .listener_addr("127.0.0.1:0".parse().unwrap()) + .peer_config(PeersConfig::test().with_max_outbound(2)) + .sessions_config(sessions) + .build(NoopProvider::default()); + networks.push(NetworkManager::new(config).await.unwrap()); + } + let (local, remotes) = networks.split_first_mut().unwrap(); + for remote in remotes.iter() { + local.swarm.peers_mut().add_and_connect_kind( + *remote.peer_id(), + PeerKind::Basic, + PeerAddr::from_tcp(remote.swarm.listener().local_address()), + None, + ); + } + let mut cx = Context::from_waker(noop_waker_ref()); + assert!(Pin::new(&mut *local).poll(&mut cx).is_pending()); + assert_eq!( + local.swarm.peers().num_pending_outbound_connections(), + limit.map_or(2, |limit| limit as usize), + ); + assert_eq!(local.swarm.peers().num_outbound_connections(), 0); + if limit == Some(0) { + continue + } + + tokio::time::timeout( + Duration::from_secs(5), + poll_fn(|cx| { + assert!(Pin::new(&mut *local).poll(cx).is_pending()); + for remote in remotes.iter_mut() { + assert!(Pin::new(remote).poll(cx).is_pending()); + } + if local.swarm.peers().num_outbound_connections() == 2 { + Poll::Ready(()) + } else { + Poll::Pending + } + }), + ) + .await + .expect("rejected peers must be retried after session capacity becomes available"); + assert_eq!(local.swarm.peers().num_pending_outbound_connections(), 0); + } + } + + #[tokio::test] + async fn inbound_replacement_refreshes_pending_connection_metrics() { + let config = || { + NetworkConfigBuilder::::new( + SecretKey::new(&mut rand_08::thread_rng()), + Runtime::test(), + ) + .disable_discovery() + .listener_addr("127.0.0.1:0".parse().unwrap()) + .peer_config(PeersConfig::test()) + .build(NoopProvider::default()) + }; + let mut local = NetworkManager::new(config()).await.unwrap(); + let mut remote = NetworkManager::new(config()).await.unwrap(); + let stalled = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let pending_outgoing = Arc::new(AtomicU64::new(0)); + let total_pending = Arc::new(AtomicU64::new(0)); + local.metrics.pending_outgoing_connections = Gauge::from_arc(pending_outgoing.clone()); + local.metrics.total_pending_connections = Gauge::from_arc(total_pending.clone()); + local.swarm.peers_mut().add_and_connect_kind( + *remote.peer_id(), + PeerKind::Basic, + PeerAddr::from_tcp(stalled.local_addr().unwrap()), + None, + ); + assert!(futures::poll!(Pin::new(&mut local)).is_pending()); + assert_eq!(f64::from_bits(pending_outgoing.load(Ordering::Relaxed)), 1.0); + remote.swarm.peers_mut().add_and_connect_kind( + *local.peer_id(), + PeerKind::Basic, + PeerAddr::from_tcp(local.local_addr()), + None, + ); + tokio::time::timeout( + Duration::from_secs(5), + poll_fn(|cx| { + assert!(Pin::new(&mut local).poll(cx).is_pending()); + assert!(Pin::new(&mut remote).poll(cx).is_pending()); + if local.swarm.peers().num_inbound_connections() == 1 { + Poll::Ready(()) + } else { + Poll::Pending + } + }), + ) + .await + .unwrap(); + assert_eq!(local.swarm.peers().num_pending_outbound_connections(), 0); + assert_eq!(f64::from_bits(pending_outgoing.load(Ordering::Relaxed)), 0.0); + assert_eq!(f64::from_bits(total_pending.load(Ordering::Relaxed)), 0.0); + } + + #[tokio::test] + async fn incoming_handshake_failure_refreshes_pending_connection_metrics() { + let config = NetworkConfigBuilder::::new( + SecretKey::new(&mut rand_08::thread_rng()), + Runtime::test(), + ) + .disable_discovery() + .listener_addr("127.0.0.1:0".parse().unwrap()) + .peer_config(PeersConfig::test()) + .build(NoopProvider::default()); + let mut network = NetworkManager::new(config).await.unwrap(); + let total_pending = Arc::new(AtomicU64::new(0)); + network.metrics.total_pending_connections = Gauge::from_arc(total_pending.clone()); + let stream = TcpStream::connect(network.local_addr()).await.unwrap(); + tokio::time::timeout( + Duration::from_secs(5), + poll_fn(|cx| { + assert!(Pin::new(&mut network).poll(cx).is_pending()); + if network.swarm.sessions().num_pending_connections() == 1 { + Poll::Ready(()) + } else { + Poll::Pending + } + }), + ) + .await + .unwrap(); + assert_eq!(f64::from_bits(total_pending.load(Ordering::Relaxed)), 1.0); + drop(stream); + tokio::time::timeout( + Duration::from_secs(5), + poll_fn(|cx| { + assert!(Pin::new(&mut network).poll(cx).is_pending()); + if network.swarm.sessions().num_pending_connections() == 0 { + Poll::Ready(()) + } else { + Poll::Pending + } + }), + ) + .await + .unwrap(); + assert_eq!(f64::from_bits(total_pending.load(Ordering::Relaxed)), 0.0); + } +} diff --git a/crates/net/network/src/peers.rs b/crates/net/network/src/peers.rs index 39c0d090a10..44fe868dfa7 100644 --- a/crates/net/network/src/peers.rs +++ b/crates/net/network/src/peers.rs @@ -1,6 +1,7 @@ //! Peer related implementations use crate::{ + cache::LruMap, error::SessionError, session::{Direction, PendingSessionHandshakeError}, swarm::NetworkConnectionState, @@ -10,9 +11,12 @@ use alloy_primitives::map::{hash_map::Entry, FbBuildHasher, HashMap, HashSet}; use futures::StreamExt; use rand::Rng; -use reth_eth_wire::{errors::EthStreamError, DisconnectReason}; +use reth_eth_wire::{ + errors::{EthStreamError, P2PHandshakeError, P2PStreamError}, + DisconnectReason, +}; use reth_ethereum_forks::ForkId; -use reth_net_banlist::BanList; +use reth_net_banlist::{is_global, BanList}; use reth_network_api::test_utils::{PeerCommand, PeersHandle}; use reth_network_peers::{NodeRecord, PeerId, TrustedPeer}; use reth_network_types::{ @@ -21,7 +25,7 @@ use reth_network_types::{ config::{PeerBackoffDurations, PEER_ROTATION_MIN_UPTIME}, reputation::{DEFAULT_REPUTATION, MAX_TRUSTED_PEER_REPUTATION_CHANGE}, }, - ConnectionsConfig, Peer, PeerAddr, PeerConnectionState, PeerKind, PeersConfig, + BackoffKind, ConnectionsConfig, Peer, PeerAddr, PeerConnectionState, PeerKind, PeersConfig, PersistedPeerInfo, ReputationChangeKind, ReputationChangeOutcome, ReputationChangeWeights, }; use std::{ @@ -41,6 +45,9 @@ use tokio::{ use tokio_stream::wrappers::UnboundedReceiverStream; use tracing::{trace, warn}; +/// Bound admission-time throttling independently of long-lived security bans. +const MAX_INCOMING_IP_THROTTLE_ENTRIES: u32 = 16_384; + /// Maintains the state of _all_ the peers known to the network. /// /// This is supposed to be owned by the network itself, but can be reached via the [`PeersHandle`]. @@ -73,6 +80,8 @@ pub struct PeersManager { connection_info: ConnectionInfo, /// Tracks unwanted ips/peer ids. ban_list: BanList, + /// Bounded recent-source throttle, separate from security bans. + incoming_ip_throttle: LruMap, /// Tracks currently backed off peers. backed_off_peers: HashMap>, /// Interval at which to check for peers to unban and release from the backoff map. @@ -162,6 +171,9 @@ impl PeersManager { } let NodeRecord { address, tcp_port, udp_port, id } = record; peers.entry(id).or_insert_with(|| { + if kind.is_trusted() { + trusted_peer_ids.insert(id); + } let mut peer = Peer::with_kind( PeerAddr::new_with_ports(address, tcp_port, Some(udp_port)), kind, @@ -195,6 +207,7 @@ impl PeersManager { release_interval: tokio::time::interval_at(now + unban_interval, unban_interval), connection_info: ConnectionInfo::new(connection_info), ban_list, + incoming_ip_throttle: LruMap::new(MAX_INCOMING_IP_THROTTLE_ENTRIES), backed_off_peers: Default::default(), ban_duration, backoff_durations, @@ -328,7 +341,11 @@ impl PeersManager { return Err(InboundConnectionError::IpBanned) } - if self.ban_list.is_banned_ip(&addr) { + if self.ban_list.is_banned_ip(&addr) || + self.incoming_ip_throttle + .get(&addr) + .is_some_and(|until| *until > std::time::Instant::now()) + { return Err(InboundConnectionError::IpBanned) } @@ -349,6 +366,9 @@ impl PeersManager { let max_inbound = self.trusted_peer_ids.len().max(self.connection_info.config.max_inbound); if self.connection_info.num_pending_in < max_inbound { + // Rate limit these admissions as well, otherwise a single host could keep + // the pending slots occupied once the inbound slots are taken. + self.throttle_incoming_ip(addr); self.connection_info.inc_pending_in(); return Ok(()) } @@ -416,7 +436,7 @@ impl PeersManager { } // check if the peer is trustable or not - let mut is_trusted = self.trusted_peer_ids.contains(&peer_id); + let is_trusted = self.trusted_peer_ids.contains(&peer_id); if self.trusted_nodes_only && !is_trusted { self.queued_actions.push_back(PeerAction::DisconnectUntrustedIncoming { peer_id }); return @@ -440,8 +460,6 @@ impl PeersManager { peer.state = PeerConnectionState::In; peer.mark_connected(); - - is_trusted = is_trusted || peer.is_trusted(); } Entry::Vacant(entry) => { // peer is missing in the table, we add it but mark it as to be removed after @@ -454,12 +472,12 @@ impl PeersManager { } } - let has_in_capacity = self.connection_info.has_in_capacity(); + let has_in_capacity = self.has_inbound_capacity(peer_id); // increment new incoming connection self.connection_info.inc_in(); // disconnect the peer if we don't have capacity for more inbound connections - if !is_trusted && !has_in_capacity { + if !has_in_capacity { self.queued_actions.push_back(PeerAction::Disconnect { peer_id, reason: Some(DisconnectReason::TooManyPeers), @@ -467,6 +485,13 @@ impl PeersManager { } } + /// Checks capacity before an incoming session can supersede an outbound attempt. + pub(crate) fn has_inbound_capacity(&self, peer_id: PeerId) -> bool { + self.connection_info.has_in_capacity() || + self.trusted_peer_ids.contains(&peer_id) || + self.peers.get(&peer_id).is_some_and(|peer| peer.is_trusted()) + } + /// Bans the peer temporarily with the configured ban timeout fn ban_peer(&mut self, peer_id: PeerId) { let ban_duration = if let Some(peer) = self.peers.get(&peer_id) && @@ -488,10 +513,14 @@ impl PeersManager { self.ban_list.ban_ip_until(ip, std::time::Instant::now() + self.ban_duration); } - /// Bans the IP temporarily to rate limit inbound connection attempts per IP. + /// Rate limits recent global sources without allowing the throttle state to grow unbounded. fn throttle_incoming_ip(&mut self, ip: IpAddr) { - self.ban_list - .ban_ip_until(ip, std::time::Instant::now() + self.incoming_ip_throttle_duration); + if is_global(&ip) { + // Evict the least recently used throttle when full. Security bans are never evicted, + // and a new source still gets throttled without blocking unrelated admissions. + self.incoming_ip_throttle + .insert(ip, std::time::Instant::now() + self.incoming_ip_throttle_duration); + } } /// Temporarily puts the peer in timeout by inserting it into the backedoff peers set @@ -602,7 +631,9 @@ impl PeersManager { /// Gracefully disconnected a pending _outgoing_ session pub(crate) fn on_outgoing_pending_session_gracefully_closed(&mut self, peer_id: &PeerId) { - if let Some(peer) = self.peers.get_mut(peer_id) { + if let Some(peer) = self.peers.get_mut(peer_id) && + peer.state.is_pending_out() + { self.connection_info.decr_state(peer.state); peer.state = PeerConnectionState::Idle; peer.mark_disconnected(); @@ -617,6 +648,31 @@ impl PeersManager { peer_id: &PeerId, err: &PendingSessionHandshakeError, ) { + if matches!( + err, + PendingSessionHandshakeError::Ecies(_) | PendingSessionHandshakeError::Timeout + ) || matches!( + err, + PendingSessionHandshakeError::Eth(EthStreamError::P2PStreamError( + P2PStreamError::HandshakeError( + P2PHandshakeError::Timeout | P2PHandshakeError::NoResponse + ) + )) + ) || matches!(err, PendingSessionHandshakeError::Eth(error) if error.as_io().is_some()) + { + // ACK, transport, and Hello timeout errors can precede the first authenticated frame. + // The outer timeout carries no handshake phase, so it is also treated conservatively. + // Back off the attempt without penalizing that identity or its discovery record. + if self.peers.get(peer_id).is_some_and(|peer| peer.state.is_pending_out()) { + self.on_outgoing_pending_session_gracefully_closed(peer_id); + self.backoff_peer_until( + *peer_id, + self.backoff_durations.backoff_until(BackoffKind::Low, 0), + ); + self.fill_outbound_slots(); + } + return + } self.on_connection_failure(remote_addr, peer_id, err, ReputationChangeKind::FailedToConnect) } @@ -660,12 +716,23 @@ impl PeersManager { /// Called when a _pending_ outbound connection is successful. pub(crate) fn on_active_outgoing_established(&mut self, peer_id: PeerId) { + let at_capacity = self.connection_info.is_outbound_at_capacity(); if let Some(peer) = self.peers.get_mut(&peer_id) { trace!(target: "net::peers", ?peer_id, "established active outgoing connection"); self.connection_info.decr_state(peer.state); self.connection_info.inc_out(); peer.state = PeerConnectionState::Out; peer.mark_connected(); + if at_capacity && + !peer.is_trusted() && + !peer.is_static() && + !self.trusted_peer_ids.contains(&peer_id) + { + self.queued_actions.push_back(PeerAction::Disconnect { + peer_id, + reason: Some(DisconnectReason::TooManyPeers), + }); + } } } @@ -914,6 +981,20 @@ impl PeersManager { } } + /// Called for a peer that was explicitly requested, e.g. via `admin_addPeer`. + /// + /// Same as [`Self::add_peer_kind`], but the peer is dialed right away if there's an outbound + /// slot instead of on the next refill tick like discovered peers. + pub(crate) fn add_requested_peer( + &mut self, + peer_id: PeerId, + kind: Option, + addr: PeerAddr, + ) { + self.add_peer_kind(peer_id, kind, addr, None); + self.fill_outbound_slots(); + } + /// Removes the tracked node from the set. pub(crate) fn remove_peer(&mut self, peer_id: PeerId) { let Entry::Occupied(entry) = self.peers.entry(peer_id) else { return }; @@ -1008,7 +1089,7 @@ impl PeersManager { peer.fork_id = fork_id.map(Box::new); peer.addr = addr; - if peer.state == PeerConnectionState::Idle { + if peer.state.is_idle() { // Try connecting again. peer.state = PeerConnectionState::PendingOut; self.connection_info.inc_pending_out(); @@ -1036,19 +1117,12 @@ impl PeersManager { /// Removes the tracked node from the trusted set. pub(crate) fn remove_peer_from_trusted_set(&mut self, peer_id: PeerId) { self.trusted_peers_resolver.remove(peer_id); - - let Entry::Occupied(mut entry) = self.peers.entry(peer_id) else { - self.trusted_peer_ids.remove(&peer_id); - return - }; - if !entry.get().is_trusted() { - return - } - - let peer = entry.get_mut(); - peer.kind = PeerKind::Basic; - self.trusted_peer_ids.remove(&peer_id); + if let Some(peer) = self.peers.get_mut(&peer_id) && + peer.is_trusted() + { + peer.kind = PeerKind::Basic; + } } /// Returns the best idle peer to connect to. @@ -1060,27 +1134,36 @@ impl PeersManager { /// equal, a peer with a discovered `fork_id` is preferred since it indicates a compatible fork. /// /// If `trusted_nodes_only` is enabled, see [`PeersConfig`], then this will only consider - /// `trusted` peers. + /// `trusted` peers, including registered trusted IDs whose addresses were discovered before + /// hostname resolution. The same restriction applies when `trusted_only` is set. /// /// Returns `None` if no peer is available. - fn best_unconnected(&mut self) -> Option<(PeerId, &mut Peer)> { - let mut unconnected = self.peers.iter_mut().filter(|(_, peer)| { + fn best_unconnected(&mut self, trusted_only: bool) -> Option<(PeerId, &mut Peer)> { + let mut unconnected = self.peers.iter_mut().filter(|(peer_id, peer)| { !peer.is_backed_off() && !peer.is_banned() && peer.state.is_unconnected() && - (!self.trusted_nodes_only || peer.is_trusted()) + (!(self.trusted_nodes_only || trusted_only) || + peer.is_trusted() || + self.trusted_peer_ids.contains(*peer_id)) }); // keep track of the best peer, if there's one let mut best_peer = unconnected.next()?; - if best_peer.1.is_trusted() || best_peer.1.is_static() { + if best_peer.1.is_trusted() || + best_peer.1.is_static() || + self.trusted_peer_ids.contains(best_peer.0) + { return Some((*best_peer.0, best_peer.1)) } for maybe_better in unconnected { // if the peer is trusted or static, return it immediately - if maybe_better.1.is_trusted() || maybe_better.1.is_static() { + if maybe_better.1.is_trusted() || + maybe_better.1.is_static() || + self.trusted_peer_ids.contains(maybe_better.0) + { return Some((*maybe_better.0, maybe_better.1)) } @@ -1159,12 +1242,12 @@ impl PeersManager { } // as long as there are slots available fill them with the best peers - while self.connection_info.has_out_capacity() { + while self.connection_info.has_out_dial_capacity() { + // Trusted peers are dialed even when all outbound slots are taken, so discovered + // peers can never crowd them out; only the concurrent dial limit applies to them. + let has_out_capacity = self.connection_info.has_out_capacity(); let action = { - let (peer_id, peer) = match self.best_unconnected() { - Some(peer) => peer, - _ => break, - }; + let Some((peer_id, peer)) = self.best_unconnected(!has_out_capacity) else { break }; trace!(target: "net::peers", ?peer_id, addr=?peer.addr, "schedule outbound connection"); @@ -1230,7 +1313,7 @@ impl PeersManager { while let Poll::Ready(Some(cmd)) = self.handle_rx.poll_next_unpin(cx) { match cmd { PeerCommand::Add(peer_id, addr) => { - self.add_peer(peer_id, PeerAddr::from_tcp(addr), None); + self.add_requested_peer(peer_id, None, PeerAddr::from_tcp(addr)); } PeerCommand::Remove(peer) => self.remove_peer(peer), PeerCommand::ReputationChange(peer_id, rep) => { @@ -1331,8 +1414,8 @@ impl ConnectionInfo { /// Returns `true` if there's still capacity to perform an outgoing connection. const fn has_out_capacity(&self) -> bool { - self.num_pending_out < self.config.max_concurrent_outbound_dials && - self.num_outbound < self.config.max_outbound + self.has_out_dial_capacity() && + self.num_pending_out < self.config.max_outbound.saturating_sub(self.num_outbound) } /// Returns `true` if all active outbound slots are occupied (ignoring pending dials). @@ -1355,6 +1438,12 @@ impl ConnectionInfo { self.num_pending_in < self.config.max_inbound } + /// Returns `true` if another outgoing dial can be started without exceeding the concurrent + /// dial limit. + const fn has_out_dial_capacity(&self) -> bool { + self.num_pending_out < self.config.max_concurrent_outbound_dials + } + const fn decr_state(&mut self, state: PeerConnectionState) { match state { PeerConnectionState::Idle => {} @@ -1501,7 +1590,9 @@ fn jitter_rotation_interval(mean: Duration) -> Duration { #[cfg(test)] mod tests { + use super::*; use alloy_primitives::B512; + use reth_ecies::ECIESErrorImpl; use reth_eth_wire::{ errors::{EthHandshakeError, EthStreamError, P2PHandshakeError, P2PStreamError}, DisconnectReason, @@ -1511,24 +1602,23 @@ mod tests { use reth_network_api::Direction; use reth_network_peers::{NodeRecord, PeerId, TrustedPeer}; use reth_network_types::{ - peers::reputation::DEFAULT_REPUTATION, BackoffKind, Peer, ReputationChangeKind, + peers::reputation::DEFAULT_REPUTATION, BackoffKind, Peer, PeerKind, ReputationChangeKind, }; use std::{ future::{poll_fn, Future}, io, net::{IpAddr, Ipv4Addr, SocketAddr}, pin::Pin, - task::{Context, Poll}, + task::{Context, Poll, Waker}, time::Duration, }; use url::Host; - use super::PeersManager; use crate::{ error::SessionError, peers::{ ConnectionInfo, InboundConnectionError, PeerAction, PeerAddr, PeerBackoffDurations, - PeerConnectionState, + PeerConnectionState, MAX_INCOMING_IP_THROTTLE_ENTRIES, }, session::PendingSessionHandshakeError, PeersConfig, @@ -1560,6 +1650,11 @@ mod tests { peers.peers.get_mut(&peer_id).expect("peer exists").connected_at = Some(connected_at); } + /// Polls the manager once without waiting for an action. + fn poll_now(peers: &mut PeersManager) -> Poll { + peers.poll(&mut Context::from_waker(Waker::noop())) + } + #[tokio::test] async fn test_insert() { let peer = PeerId::random(); @@ -1978,12 +2073,10 @@ mod tests { }) .await; - peers.on_outgoing_pending_session_dropped( + peers.on_outgoing_connection_failure( &socket_addr, &peer, - &PendingSessionHandshakeError::Eth( - io::Error::new(io::ErrorKind::ConnectionRefused, "peer unreachable").into(), - ), + &io::Error::new(io::ErrorKind::ConnectionRefused, "peer unreachable"), ); match event!(peers) { @@ -2188,7 +2281,11 @@ mod tests { async fn test_dropped_incoming() { let socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 0, 1, 2)), 8008); let ban_duration = Duration::from_millis(500); - let config = PeersConfig { ban_duration, ..PeersConfig::test() }; + let config = PeersConfig { + ban_duration, + incoming_ip_throttle_duration: ban_duration, + ..PeersConfig::test() + }; let mut peers = PeersManager::new(config); assert!(peers.on_incoming_pending_session(socket_addr.ip()).is_ok()); @@ -2753,6 +2850,55 @@ mod tests { } } + #[tokio::test] + async fn test_trusted_peers_bypass_outbound_capacity() { + let mut peers = PeersManager::new(PeersConfig::test().with_max_outbound(1)); + + // an untrusted peer takes the only outbound slot + let basic_peer = PeerId::random(); + let basic_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 1, 2)), 8008); + peers.add_peer(basic_peer, PeerAddr::from_tcp(basic_sock), None); + peers.fill_outbound_slots(); + peers.on_active_outgoing_established(basic_peer); + assert!(!peers.connection_info.has_out_capacity()); + peers.queued_actions.clear(); + + let static_peer = PeerId::random(); + peers.add_peer_kind( + static_peer, + Some(PeerKind::Static), + PeerAddr::from_tcp(basic_sock), + None, + ); + assert!(peers.best_unconnected(true).is_none()); + peers.queued_actions.clear(); + + // a trusted peer is dialed regardless + let trusted_peer = PeerId::random(); + let trusted_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 1, 3)), 8008); + peers.add_trusted_peer(trusted_peer, PeerAddr::from_tcp(trusted_sock)); + peers.queued_actions.clear(); + peers.fill_outbound_slots(); + match peers.queued_actions.pop_front() { + Some(PeerAction::Connect { peer_id, remote_addr }) => { + assert_eq!(peer_id, trusted_peer); + assert_eq!(remote_addr, trusted_sock); + } + action => panic!("expected the trusted peer to be dialed, got {action:?}"), + } + assert!(peers.queued_actions.is_empty()); + peers.on_active_outgoing_established(trusted_peer); + + // untrusted peers still wait for a free slot + let other_peer = PeerId::random(); + let other_sock = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 1, 4)), 8008); + peers.add_peer(other_peer, PeerAddr::from_tcp(other_sock), None); + peers.queued_actions.clear(); + peers.fill_outbound_slots(); + assert!(peers.queued_actions.is_empty()); + assert!(peers.peers[&other_peer].state.is_idle()); + } + #[tokio::test] async fn test_connect_trusted_nodes_only() { let trusted_peer = PeerId::random(); @@ -2872,35 +3018,75 @@ mod tests { #[tokio::test] async fn test_incoming_rate_limit() { - let config = PeersConfig { - incoming_ip_throttle_duration: Duration::from_millis(100), - ..PeersConfig::test() - }; - let mut peers = PeersManager::new(config); + for max_inbound in [0, 1] { + let config = PeersConfig { + incoming_ip_throttle_duration: Duration::from_millis(100), + ..PeersConfig::test().with_max_inbound(max_inbound) + }; + let mut peers = PeersManager::new(config); + peers.add_trusted_peer_id(PeerId::random()); + + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(168, 0, 1, 2)), 8009); + peers.on_incoming_pending_session(addr.ip()).unwrap(); + peers.on_incoming_pending_session_gracefully_closed(); + assert_eq!( + peers.on_incoming_pending_session(addr.ip()).unwrap_err(), + InboundConnectionError::IpBanned + ); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(168, 0, 1, 2)), 8009); - assert!(peers.on_incoming_pending_session(addr.ip()).is_ok()); - assert_eq!( - peers.on_incoming_pending_session(addr.ip()).unwrap_err(), - InboundConnectionError::IpBanned - ); + peers.release_interval.reset_immediately(); + tokio::time::sleep(peers.incoming_ip_throttle_duration).await; - peers.release_interval.reset_immediately(); - tokio::time::sleep(peers.incoming_ip_throttle_duration).await; + // await unban + poll_fn(|cx| loop { + if peers.poll(cx).is_pending() { + return Poll::Ready(()); + } + }) + .await; - // await unban - poll_fn(|cx| loop { - if peers.poll(cx).is_pending() { - return Poll::Ready(()); + peers.on_incoming_pending_session(addr.ip()).unwrap(); + peers.on_incoming_pending_session_gracefully_closed(); + assert_eq!( + peers.on_incoming_pending_session(addr.ip()).unwrap_err(), + InboundConnectionError::IpBanned + ); + } + } + + #[tokio::test] + async fn test_incoming_throttle_capacity() { + for max_inbound in [0, 1] { + let mut peers = PeersManager::new(PeersConfig::test().with_max_inbound(max_inbound)); + peers.add_trusted_peer_id(PeerId::random()); + peers.ban_list = BanList::new( + [], + (0..MAX_INCOMING_IP_THROTTLE_ENTRIES) + .map(|i| IpAddr::V4(Ipv4Addr::from(0x0800_0000 + i))), + ); + for i in 0..MAX_INCOMING_IP_THROTTLE_ENTRIES { + peers.throttle_incoming_ip(IpAddr::V4(Ipv4Addr::from(0x0900_0000 + i))); } - }) - .await; + let ip = IpAddr::from([1, 1, 1, 1]); + assert!(peers.on_incoming_pending_session(ip).is_ok()); + assert_eq!(peers.connection_info.num_pending_in, 1); + assert_eq!( + peers.incoming_ip_throttle.len(), + usize::try_from(MAX_INCOMING_IP_THROTTLE_ENTRIES).unwrap() + ); + peers.on_incoming_pending_session_gracefully_closed(); + assert_eq!(peers.connection_info.num_pending_in, 0); + assert_eq!( + peers.on_incoming_pending_session(ip), + Err(InboundConnectionError::IpBanned) + ); - assert!(peers.on_incoming_pending_session(addr.ip()).is_ok()); - assert_eq!( - peers.on_incoming_pending_session(addr.ip()).unwrap_err(), - InboundConnectionError::IpBanned - ); + // A saturated throttle table must not weaken existing security bans. + assert_eq!( + peers.on_incoming_pending_session(IpAddr::from([8, 0, 0, 1])), + Err(InboundConnectionError::IpBanned) + ); + } } #[tokio::test] @@ -3231,7 +3417,7 @@ mod tests { let num_pendingout_states = peer_manager .peers .iter() - .filter(|(_, peer)| peer.state == PeerConnectionState::PendingOut) + .filter(|(_, peer)| peer.state.is_pending_out()) .map(|(peer_id, _)| *peer_id) .collect::>(); assert_eq!( @@ -3445,7 +3631,7 @@ mod tests { let config = PeersConfig::test().with_ip_filter(ip_filter); let mut peers = PeersManager::new(config); - let peer_id = PeerId::new([1; 64]); + let peer_id = PeerId::repeat_byte(1); // Try to add a peer with an allowed IP let allowed_addr: SocketAddr = "192.168.1.100:30303".parse().unwrap(); @@ -3453,7 +3639,7 @@ mod tests { assert!(peers.peers.contains_key(&peer_id)); // Try to add a peer with a disallowed IP - let peer_id2 = PeerId::new([2; 64]); + let peer_id2 = PeerId::repeat_byte(2); let disallowed_addr: SocketAddr = "10.0.0.1:30303".parse().unwrap(); peers.add_peer(peer_id2, PeerAddr::from_tcp(disallowed_addr), None); assert!(!peers.peers.contains_key(&peer_id2)); @@ -3533,7 +3719,7 @@ mod tests { peers.add_peer(with_fork, PeerAddr::from_tcp(addr), None); peers.peers.get_mut(&with_fork).unwrap().fork_id = Some(Box::new(fork_id)); - let (best_id, _) = peers.best_unconnected().unwrap(); + let (best_id, _) = peers.best_unconnected(false).unwrap(); assert_eq!(best_id, with_fork, "fork_id should break tie when reputation is equal"); } @@ -3831,4 +4017,360 @@ mod tests { _ => unreachable!(), } } + + // Ensures explicitly added peers are dialed right away, while discovered peers wait for the + // next refill tick. + #[tokio::test] + async fn test_requested_peer_is_dialed_before_refill_tick() { + let mut peers = PeersManager::new(PeersConfig { + refill_slots_interval: Duration::from_secs(60), + ..PeersConfig::test() + }); + // consume the initial refill tick + assert!(poll_now(&mut peers).is_pending()); + + let discovered = PeerId::random(); + let discovered_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 1, 1)), 8008); + peers.add_peer(discovered, PeerAddr::from_tcp(discovered_addr), None); + assert!( + matches!(poll_now(&mut peers), Poll::Ready(PeerAction::PeerAdded(id)) if id == discovered) + ); + assert!(poll_now(&mut peers).is_pending()); + + let requested = PeerId::random(); + let requested_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 1, 2)), 8008); + peers.handle().add_peer(requested, requested_addr); + assert!( + matches!(poll_now(&mut peers), Poll::Ready(PeerAction::PeerAdded(id)) if id == requested) + ); + + // the requested peer fills all free outbound slots, including one for the discovered peer + let mut dialed = Vec::new(); + while let Poll::Ready(PeerAction::Connect { peer_id, .. }) = poll_now(&mut peers) { + dialed.push(peer_id); + } + dialed.sort(); + let mut expected = vec![discovered, requested]; + expected.sort(); + assert_eq!(dialed, expected); + } + + #[tokio::test] + async fn test_revoke_trust_independent_of_peer_kind() { + for kind in [None, Some(PeerKind::Basic), Some(PeerKind::Static), Some(PeerKind::Trusted)] { + let mut peers = PeersManager::new(PeersConfig::test()); + let peer_id = PeerId::random(); + peers.add_trusted_peer_id(peer_id); + if let Some(kind) = kind { + peers.add_peer_kind( + peer_id, + Some(kind), + PeerAddr::from_tcp("127.0.0.1:8008".parse().unwrap()), + None, + ); + } + peers.remove_peer_from_trusted_set(peer_id); + assert!(!peers.trusted_peer_ids.contains(&peer_id)); + assert_eq!( + peers.peers.get(&peer_id).map(|peer| peer.kind), + kind.map(|kind| if kind == PeerKind::Trusted { PeerKind::Basic } else { kind }) + ); + // Revocation also removes the administrative ban exemption. + peers.ban_peer_by_admin(peer_id); + assert!(peers.ban_list.is_banned_peer(&peer_id)); + } + } + + #[tokio::test] + async fn test_persisted_trust_controls_inbound_admission() { + for kind in [PeerKind::Basic, PeerKind::Static, PeerKind::Trusted] { + for trusted_only in [false, true] { + let peer_id = PeerId::random(); + let addr = "127.0.0.1:8008".parse::().unwrap(); + let mut config = PeersConfig::test().with_trusted_nodes_only(trusted_only); + config.connection_info.max_inbound = 0; + config.persisted_peers.push(PersistedPeerInfo { + record: NodeRecord::new(addr, peer_id), + kind, + fork_id: None, + reputation: 0, + }); + let mut peers = PeersManager::new(config); + assert_eq!(peers.trusted_peer_ids.contains(&peer_id), kind.is_trusted()); + peers.connection_info.inc_pending_in(); + peers.on_incoming_session_established(peer_id, addr); + if kind.is_trusted() { + assert!(peers.queued_actions.is_empty()); + assert_eq!(peers.connection_info.num_inbound, 1); + } else if trusted_only { + assert!(matches!(peers.queued_actions.pop_front(), + Some(PeerAction::DisconnectUntrustedIncoming { peer_id: id }) if id == peer_id)); + } else { + assert!(matches!(peers.queued_actions.pop_front(), + Some(PeerAction::Disconnect { peer_id: id, reason: Some(DisconnectReason::TooManyPeers) }) if id == peer_id)); + } + } + } + } + + #[tokio::test] + async fn test_filtered_persisted_peer_does_not_restore_trust() { + let peer_id = PeerId::random(); + let mut config = PeersConfig::test(); + config.enforce_enr_fork_id = true; + config.persisted_peers.push(PersistedPeerInfo { + record: NodeRecord::new("127.0.0.1:8008".parse().unwrap(), peer_id), + kind: PeerKind::Trusted, + fork_id: None, + reputation: 0, + }); + let peers = PeersManager::new(config); + assert!(!peers.trusted_peer_ids.contains(&peer_id)); + assert!(!peers.peers.contains_key(&peer_id)); + } + #[tokio::test] + async fn test_pending_dials_reserve_outbound_capacity() { + for max_outbound in [0, 1, 3] { + let mut peers = PeersManager::new(PeersConfig::test().with_max_outbound(max_outbound)); + let addr = PeerAddr::from_tcp("127.0.0.1:8008".parse().unwrap()); + for _ in 0..30 { + peers.add_peer(PeerId::random(), addr, None); + } + peers.fill_outbound_slots(); + let dialed = peers + .queued_actions + .iter() + .filter_map(|action| match action { + PeerAction::Connect { peer_id, .. } => Some(*peer_id), + _ => None, + }) + .collect::>(); + assert_eq!(dialed.len(), max_outbound); + peers.queued_actions.clear(); + for peer in dialed { + peers.on_active_outgoing_established(peer); + peers.fill_outbound_slots(); + assert!(peers.queued_actions.is_empty()); + } + assert_eq!(peers.connection_info.num_outbound, max_outbound); + assert_eq!(peers.connection_info.num_pending_out, 0); + } + } + + #[tokio::test] + async fn test_surplus_outbound_completion() { + for kind in [PeerKind::Basic, PeerKind::Trusted, PeerKind::Static] { + let mut peers = PeersManager::new(PeersConfig::test().with_max_outbound(0)); + let peer = PeerId::random(); + let addr = PeerAddr::from_tcp("127.0.0.1:8008".parse().unwrap()); + peers.add_and_connect_kind(peer, kind, addr, None); + peers.queued_actions.clear(); + peers.on_active_outgoing_established(peer); + if kind == PeerKind::Basic { + assert!(matches!( + peers.queued_actions.pop_front(), + Some(PeerAction::Disconnect { + peer_id, + reason: Some(DisconnectReason::TooManyPeers), + }) if peer_id == peer + )); + } else { + assert!(peers.queued_actions.is_empty()); + } + // The closing session releases the accounting reserved at establishment. + peers.on_active_session_gracefully_closed(peer); + assert_eq!(peers.connection_info.num_outbound, 0); + assert_eq!(peers.connection_info.num_pending_out, 0); + } + } + + #[tokio::test] + async fn test_surplus_outbound_preserves_id_trusted_peers() { + for hostname in [false, true] { + let mut peers = PeersManager::new(PeersConfig::test().with_max_outbound(0)); + let peer_id = PeerId::random(); + let addr = PeerAddr::from_tcp("127.0.0.1:8008".parse().unwrap()); + if hostname { + peers.add_peer(peer_id, addr, None); + peers.add_trusted_peer_node(TrustedPeer { + host: Host::Domain("example.invalid".to_string()), + tcp_port: 8008, + udp_port: 8008, + id: peer_id, + }); + } else { + peers.add_trusted_peer_id(peer_id); + } + peers.add_and_connect_kind(peer_id, PeerKind::Basic, addr, None); + assert_eq!(peers.peers[&peer_id].kind, PeerKind::Basic); + peers.queued_actions.clear(); + + peers.on_active_outgoing_established(peer_id); + + assert!(peers.queued_actions.is_empty()); + assert_eq!(peers.num_outbound_connections(), 1); + peers.on_active_session_gracefully_closed(peer_id); + assert_eq!(peers.num_outbound_connections(), 0); + assert_eq!(peers.num_pending_outbound_connections(), 0); + } + } + + #[tokio::test] + async fn test_pending_outbound_cleanup_preserves_incoming_session() { + let mut peers = PeersManager::new(PeersConfig::test()); + let peer_id = PeerId::random(); + let addr = "127.0.0.1:8008".parse::().unwrap(); + peers.add_and_connect_kind(peer_id, PeerKind::Basic, PeerAddr::from_tcp(addr), None); + peers.on_incoming_pending_session(addr.ip()).unwrap(); + peers.on_incoming_session_established(peer_id, addr); + let connected_at = peers.peers[&peer_id].connected_at; + + peers.on_outgoing_pending_session_gracefully_closed(&peer_id); + + assert_eq!(peers.peers[&peer_id].state, PeerConnectionState::In); + assert_eq!(peers.peers[&peer_id].connected_at, connected_at); + assert_eq!(peers.connection_info.num_inbound, 1); + assert_eq!(peers.num_pending_outbound_connections(), 0); + } + + #[tokio::test] + async fn test_inbound_capacity_preserves_pending_outbound_and_trusted_exemption() { + for trusted in [false, true] { + let mut peers = PeersManager::new(PeersConfig::test().with_max_inbound(0)); + let peer_id = PeerId::random(); + let addr = "127.0.0.1:8008".parse().unwrap(); + peers.add_and_connect_kind(peer_id, PeerKind::Basic, PeerAddr::from_tcp(addr), None); + if trusted { + peers.add_trusted_peer_id(peer_id); + } + assert_eq!(peers.has_inbound_capacity(peer_id), trusted); + assert_eq!(peers.peers[&peer_id].state, PeerConnectionState::PendingOut); + assert_eq!(peers.connection_info.num_pending_out, 1); + } + } + + #[tokio::test] + async fn test_unauthenticated_outbound_errors_do_not_ban_identity() { + for error in [ + PendingSessionHandshakeError::Timeout, + PendingSessionHandshakeError::Eth(EthStreamError::P2PStreamError( + P2PStreamError::HandshakeError(P2PHandshakeError::Timeout), + )), + PendingSessionHandshakeError::Eth(EthStreamError::P2PStreamError( + P2PStreamError::HandshakeError(P2PHandshakeError::NoResponse), + )), + PendingSessionHandshakeError::Ecies(ECIESErrorImpl::InvalidAckData.into()), + PendingSessionHandshakeError::Ecies(ECIESErrorImpl::TagCheckDecryptFailed.into()), + PendingSessionHandshakeError::Eth(EthStreamError::P2PStreamError(P2PStreamError::Io( + io::Error::other("invalid frame MAC"), + ))), + ] { + let mut peers = PeersManager::new(PeersConfig::test()); + let peer_id = PeerId::random(); + let addr = "8.8.8.8:30303".parse::().unwrap(); + peers.add_and_connect_kind(peer_id, PeerKind::Basic, PeerAddr::from_tcp(addr), None); + peers.queued_actions.clear(); + let reputation = peers.peers[&peer_id].reputation; + for _ in 0..=peers.max_backoff_count + 1 { + peers.add_and_connect_kind( + peer_id, + PeerKind::Basic, + PeerAddr::from_tcp(addr), + None, + ); + peers.queued_actions.clear(); + peers.on_outgoing_pending_session_dropped(&addr, &peer_id, &error); + assert_eq!(peers.peers[&peer_id].reputation, reputation); + } + let peer = &peers.peers[&peer_id]; + assert_eq!(peer.state, PeerConnectionState::Idle); + assert_eq!(peer.reputation, reputation); + assert_eq!(peer.severe_backoff_counter, 0); + assert!(peer.backed_off); + assert!(!peers.ban_list.is_banned_peer(&peer_id)); + assert!(peers.queued_actions.is_empty()); + assert_eq!(peers.connection_info.num_pending_out, 0); + + // Inbound errors remain attributable to the actual remote IP. + if matches!(&error, PendingSessionHandshakeError::Ecies(_)) { + peers.connection_info.inc_pending_in(); + peers.on_incoming_pending_session_dropped(addr, &error); + assert!(peers.ban_list.is_banned_ip(&addr.ip())); + } + } + } + + #[tokio::test] + async fn test_unattributable_backoff_respects_maximum() { + let mut config = PeersConfig::test(); + config.backoff_durations.low = Duration::from_secs(60); + config.backoff_durations.max = Duration::from_secs(1); + let mut peers = PeersManager::new(config); + let peer_id = PeerId::random(); + let addr = "8.8.8.8:30303".parse::().unwrap(); + peers.add_and_connect_kind(peer_id, PeerKind::Basic, PeerAddr::from_tcp(addr), None); + peers.peers.get_mut(&peer_id).unwrap().severe_backoff_counter = 3; + let before = std::time::Instant::now(); + peers.on_outgoing_pending_session_dropped( + &addr, + &peer_id, + &PendingSessionHandshakeError::Timeout, + ); + let after = std::time::Instant::now(); + let until = peers.backed_off_peers[&peer_id]; + assert!(until >= before + Duration::from_secs(1)); + assert!(until <= after + Duration::from_secs(1)); + assert_eq!(peers.peers[&peer_id].severe_backoff_counter, 3); + } + + #[tokio::test] + async fn test_dns_trusted_ids_bypass_outbound_capacity() { + for register_first in [false, true] { + let mut peers = PeersManager::new(PeersConfig::test().with_max_outbound(0)); + let peer_id = PeerId::random(); + let addr = "127.0.0.1:30303".parse::().unwrap(); + let trusted = + TrustedPeer::new(Host::Domain("trusted.invalid".into()), addr.port(), peer_id); + if register_first { + peers.add_trusted_peer_node(trusted.clone()); + } + peers.add_peer(peer_id, PeerAddr::from_tcp(addr), None); + if !register_first { + peers.add_trusted_peer_node(trusted); + } + assert_eq!(peers.peers[&peer_id].kind, PeerKind::Basic); + peers.queued_actions.clear(); + peers.fill_outbound_slots(); + assert!( + matches!(peers.queued_actions.pop_front(), Some(PeerAction::Connect { peer_id: id, remote_addr }) if id == peer_id && remote_addr == addr) + ); + assert!(peers.queued_actions.is_empty()); + } + } + + #[tokio::test] + async fn test_trusted_bypass_respects_shared_dial_limit() { + let mut peers = PeersManager::new( + PeersConfig::test().with_max_outbound(1).with_max_concurrent_dials(1), + ); + let addr = "127.0.0.1:30303".parse().unwrap(); + let basic = PeerId::random(); + peers.add_peer(basic, PeerAddr::from_tcp(addr), None); + peers.fill_outbound_slots(); + assert_eq!(peers.connection_info.num_pending_out, 1); + for _ in 0..2 { + peers.add_trusted_peer(PeerId::random(), PeerAddr::from_tcp(addr)); + } + peers.queued_actions.clear(); + peers.fill_outbound_slots(); + assert!(peers.queued_actions.is_empty()); + peers.on_active_outgoing_established(basic); + peers.fill_outbound_slots(); + assert!( + matches!(peers.queued_actions.pop_front(), Some(PeerAction::Connect { peer_id, .. }) if peers.peers[&peer_id].is_trusted()) + ); + assert!(peers.queued_actions.is_empty()); + assert_eq!(peers.connection_info.num_pending_out, 1); + assert_eq!(peers.connection_info.num_outbound, 1); + } } diff --git a/crates/net/network/src/protocol.rs b/crates/net/network/src/protocol.rs index 09dc804131b..7d0e844c70f 100644 --- a/crates/net/network/src/protocol.rs +++ b/crates/net/network/src/protocol.rs @@ -66,7 +66,12 @@ pub trait ConnectionHandler: Send + Sync + 'static { peer_id: PeerId, ) -> OnNotSupported; - /// Invoked when the `RLPx` connection was established. + /// Invoked when the `RLPx` connection was established and the eth `Status` handshake + /// succeeded. + /// + /// Messages arriving before `Status` completes are queued within [`Self::inbound_limits`] + /// and an aggregate handshake budget of 256 KiB or 64 messages, whichever is reached first. + /// The callback runs when the authenticated connection is first polled. /// /// The returned future should resolve when the connection should disconnect. fn into_connection( @@ -99,7 +104,7 @@ pub trait IntoRlpxSubProtocol { impl IntoRlpxSubProtocol for T where - T: ProtocolHandler + Send + Sync + 'static, + T: ProtocolHandler, { fn into_rlpx_sub_protocol(self) -> RlpxSubProtocol { RlpxSubProtocol(Box::new(self)) diff --git a/crates/net/network/src/required_block_filter.rs b/crates/net/network/src/required_block_filter.rs index e7bcbf3850e..1d80e1203e2 100644 --- a/crates/net/network/src/required_block_filter.rs +++ b/crates/net/network/src/required_block_filter.rs @@ -27,7 +27,7 @@ pub struct RequiredBlockFilter { impl RequiredBlockFilter where - N: NetworkEventListenerProvider + Peers + Clone + Send + Sync + 'static, + N: NetworkEventListenerProvider + Peers + Clone + 'static, { /// Creates a new required block peer filter. pub const fn new(network: N, block_num_hashes: Vec) -> Self { @@ -158,23 +158,18 @@ where #[cfg(test)] mod tests { + use super::*; use alloy_eips::BlockNumHash; - use alloy_primitives::{b256, B256}; + use alloy_primitives::B256; use reth_network_api::noop::NoopNetwork; #[test] fn test_required_block_filter_creation() { let network = NoopNetwork::default(); let block_num_hashes = vec![ - BlockNumHash::new( - 0, - b256!("0x1111111111111111111111111111111111111111111111111111111111111111"), - ), - BlockNumHash::new( - 23115201, - b256!("0x2222222222222222222222222222222222222222222222222222222222222222"), - ), + BlockNumHash::new(0, B256::repeat_byte(0x11)), + BlockNumHash::new(23115201, B256::repeat_byte(0x22)), ]; let filter = RequiredBlockFilter::new(network, block_num_hashes.clone()); diff --git a/crates/net/network/src/session/active.rs b/crates/net/network/src/session/active.rs index 8f7b38e0684..0f854053606 100644 --- a/crates/net/network/src/session/active.rs +++ b/crates/net/network/src/session/active.rs @@ -22,7 +22,7 @@ use crate::{ BlockRangeInfo, EthVersion, SessionId, }, }; -use alloy_eips::merge::EPOCH_SLOTS; +use alloy_eips::merge::{EPOCH_DURATION, EPOCH_SLOTS}; use alloy_primitives::Sealable; use futures::{stream::Fuse, SinkExt, StreamExt}; use metrics::{Counter, Gauge}; @@ -56,7 +56,7 @@ use tracing::{debug, trace}; /// /// Updates are only sent when the block height has advanced by at least one epoch (32 blocks) /// since the last update. The interval is set to one epoch duration in seconds. -pub(super) const RANGE_UPDATE_INTERVAL: Duration = Duration::from_secs(EPOCH_SLOTS * 12); +pub(super) const RANGE_UPDATE_INTERVAL: Duration = EPOCH_DURATION; // Constants for timeout updating. @@ -1793,7 +1793,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn snap_request_times_out() { + async fn late_snap_response_is_consumed_without_penalty() { let mut builder = snap_session_builder(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let local_addr = listener.local_addr().unwrap(); @@ -1805,40 +1805,12 @@ mod tests { let (incoming, _) = listener.accept().await.unwrap(); let mut session = builder.connect_incoming(incoming).await; - // Tiny timeout so the deadline (computed at insert) is already in the past. session.internal_request_timeout.store(1, Ordering::Relaxed); let (id, rx) = dispatch_snap_request(&mut session, 0); - - // The first check resolves the caller with a timeout but keeps the entry so the session - // can escalate to a protocol breach. tokio::time::sleep(Duration::from_millis(20)).await; assert!(!session.check_timed_out_requests(Instant::now())); - assert!(session.inflight_requests.contains_key(&id)); assert_eq!(rx.await.unwrap().unwrap_err(), RequestError::Timeout); - // Once the breach timeout passes without a response, the session flags a protocol breach. - session.protocol_breach_request_timeout = Duration::from_millis(1); - assert!(session.check_timed_out_requests(Instant::now())); - } - - #[tokio::test(flavor = "multi_thread")] - async fn late_snap_response_is_consumed_without_penalty() { - let mut builder = snap_session_builder(); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let local_addr = listener.local_addr().unwrap(); - let fut = builder.with_client_stream(local_addr, async move |client_stream| { - let _client_stream = client_stream; - tokio::time::sleep(Duration::from_secs(60)).await; - }); - tokio::task::spawn(fut); - let (incoming, _) = listener.accept().await.unwrap(); - let mut session = builder.connect_incoming(incoming).await; - - session.internal_request_timeout.store(1, Ordering::Relaxed); - let (id, _rx) = dispatch_snap_request(&mut session, 0); - tokio::time::sleep(Duration::from_millis(20)).await; - assert!(!session.check_timed_out_requests(Instant::now())); - // A response arriving after the timeout clears the entry without a bad-message report. let outcome = session.on_incoming_snap_message(SnapProtocolMessage::BlockAccessLists( BlockAccessListsMessage { @@ -2141,7 +2113,7 @@ mod tests { let local_addr = listener.local_addr().unwrap(); let fut = builder.with_client_stream(local_addr, async move |mut client_stream| { - let _ = tokio::time::timeout(Duration::from_secs(5), client_stream.next()).await; + let _ = tokio::time::timeout(Duration::from_secs(1), client_stream.next()).await; client_stream.into_inner().disconnect(DisconnectReason::UselessPeer).await.unwrap(); }); diff --git a/crates/net/network/src/session/mod.rs b/crates/net/network/src/session/mod.rs index 9a5ea88d09a..4c4b8f3e914 100644 --- a/crates/net/network/src/session/mod.rs +++ b/crates/net/network/src/session/mod.rs @@ -18,7 +18,10 @@ use crate::{ use active::QueuedOutgoingMessages; use alloy_primitives::map::{FbBuildHasher, HashMap}; use counter::SessionCounter; -use futures::{future::Either, io, FutureExt, StreamExt}; +use futures::{ + future::{self, Either}, + io, stream, FutureExt, StreamExt, +}; use reth_ecies::{stream::ECIESStream, ECIESError}; use reth_eth_wire::{ errors::EthStreamError, handshake::EthRlpxHandshake, multiplex::RlpxProtocolMultiplexer, @@ -45,7 +48,7 @@ use std::{ use tokio::{ io::{AsyncRead, AsyncWrite}, net::TcpStream, - sync::{mpsc, oneshot}, + sync::{mpsc, oneshot, watch}, }; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::PollSender; @@ -339,46 +342,57 @@ impl SessionManager { } /// Starts a new pending session from the local node to the given remote node. + /// Skips the dial if the pending outbound session limit is reached. pub fn dial_outbound(&mut self, remote_addr: SocketAddr, remote_peer_id: PeerId) { - // The error can be dropped because no dial will be made if it would exceed the limit - if self.counter.ensure_pending_outbound().is_ok() { - let session_id = self.next_id(); - let (disconnect_tx, disconnect_rx) = oneshot::channel(); - let pending_events = self.pending_sessions_tx.clone(); - let secret_key = self.secret_key; - let hello_message = self.hello_message.clone(); - let fork_filter = self.fork_filter.clone(); - let status = self.status; - let extra_handlers = self.extra_protocols.on_outgoing(remote_addr, remote_peer_id); - self.spawn(pending_session_with_timeout( - self.pending_session_timeout, + if let Err(err) = self.try_dial_outbound(remote_addr, remote_peer_id) { + trace!(target: "net", %err, "Outgoing connection rejected, capacity already reached."); + } + } + + /// Starts an outbound session or reports that no dial was made due to the session limit. + pub(crate) fn try_dial_outbound( + &mut self, + remote_addr: SocketAddr, + remote_peer_id: PeerId, + ) -> Result<(), ExceedsSessionLimit> { + self.counter.ensure_pending_outbound()?; + let session_id = self.next_id(); + let (disconnect_tx, disconnect_rx) = oneshot::channel(); + let pending_events = self.pending_sessions_tx.clone(); + let secret_key = self.secret_key; + let hello_message = self.hello_message.clone(); + let fork_filter = self.fork_filter.clone(); + let status = self.status; + let extra_handlers = self.extra_protocols.on_outgoing(remote_addr, remote_peer_id); + self.spawn(pending_session_with_timeout( + self.pending_session_timeout, + session_id, + remote_addr, + Direction::Outgoing(remote_peer_id), + pending_events.clone(), + start_pending_outbound_session( + self.handshake.clone(), + self.eth_max_message_size, + disconnect_rx, + pending_events, session_id, remote_addr, - Direction::Outgoing(remote_peer_id), - pending_events.clone(), - start_pending_outbound_session( - self.handshake.clone(), - self.eth_max_message_size, - disconnect_rx, - pending_events, - session_id, - remote_addr, - remote_peer_id, - secret_key, - hello_message, - status, - fork_filter, - extra_handlers, - ), - )); + remote_peer_id, + secret_key, + hello_message, + status, + fork_filter, + extra_handlers, + ), + )); - let handle = PendingSessionHandle { - disconnect_tx: Some(disconnect_tx), - direction: Direction::Outgoing(remote_peer_id), - }; - self.pending_sessions.insert(session_id, handle); - self.counter.inc_pending_outbound(); - } + let handle = PendingSessionHandle { + disconnect_tx: Some(disconnect_tx), + direction: Direction::Outgoing(remote_peer_id), + }; + self.pending_sessions.insert(session_id, handle); + self.counter.inc_pending_outbound(); + Ok(()) } /// Initiates a shutdown of the channel. @@ -429,6 +443,26 @@ impl SessionManager { Some(session) } + /// Cancels outgoing attempts superseded by an incoming session for this peer. + fn cancel_pending_outbound(&mut self, peer_id: PeerId) { + self.pending_sessions.retain(|_, session| { + if session.direction == Direction::Outgoing(peer_id) { + session.disconnect(); + self.counter.dec_pending(&session.direction); + return false + } + true + }); + } + + /// Closes a completed handshake that will not become an active session. + fn disconnect_pending_connection(&self, conn: EthRlpxConnection, reason: DisconnectReason) { + self.spawn(async move { + let _ = + tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.into_inner().disconnect(reason)).await; + }); + } + /// Removes the [`PendingSessionHandle`] if it exists. fn remove_active_session(&mut self, id: &PeerId) -> Option> { let session = self.active_sessions.remove(id)?; @@ -468,7 +502,11 @@ impl SessionManager { /// This polls all the session handles and returns [`SessionEvent`]. /// /// Active sessions are prioritized. - pub(crate) fn poll(&mut self, cx: &mut Context<'_>) -> Poll> { + pub(crate) fn poll( + &mut self, + cx: &mut Context<'_>, + has_inbound_capacity: impl Fn(PeerId) -> bool, + ) -> Poll> { // Poll events from active sessions match self.active_session_rx.poll_next_unpin(cx) { Poll::Pending => {} @@ -513,10 +551,25 @@ impl SessionManager { } // Poll the pending session event stream - let event = match self.pending_session_rx.poll_next_unpin(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(None) => unreachable!("Manager holds both channel halves."), - Poll::Ready(Some(event)) => event, + let event = loop { + let event = match self.pending_session_rx.poll_next_unpin(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(None) => unreachable!("Manager holds both channel halves."), + Poll::Ready(Some(event)) => event, + }; + let session_id = match &event { + PendingSessionEvent::Established { session_id, .. } | + PendingSessionEvent::Disconnected { session_id, .. } | + PendingSessionEvent::OutgoingConnectionError { session_id, .. } | + PendingSessionEvent::EciesAuthError { session_id, .. } => session_id, + }; + // Canceled attempts must not affect a replacement session for the same peer. + if self.remove_pending_session(session_id).is_some() { + break event + } + if let PendingSessionEvent::Established { conn, .. } = event { + self.disconnect_pending_connection(conn, DisconnectReason::AlreadyConnected); + } }; match event { PendingSessionEvent::Established { @@ -531,9 +584,6 @@ impl SessionManager { client_id, peer_listen_port, } => { - // move from pending to established. - self.remove_pending_session(&session_id); - // If there's already a session to the peer then we disconnect right away if self.active_sessions.contains_key(&peer_id) { trace!( @@ -545,11 +595,7 @@ impl SessionManager { "already connected" ); - self.spawn(async move { - // send a disconnect message - let _ = - conn.into_inner().disconnect(DisconnectReason::AlreadyConnected).await; - }); + self.disconnect_pending_connection(conn, DisconnectReason::AlreadyConnected); return Poll::Ready(SessionEvent::AlreadyConnected { peer_id, @@ -558,6 +604,17 @@ impl SessionManager { }) } + if direction.is_incoming() { + if !has_inbound_capacity(peer_id) { + self.disconnect_pending_connection(conn, DisconnectReason::TooManyPeers); + return Poll::Ready(SessionEvent::IncomingPendingSessionClosed { + remote_addr, + error: None, + }) + } + self.cancel_pending_outbound(peer_id); + } + let (commands_tx, commands_rx) = mpsc::channel(self.session_command_buffer); let (unbounded_tx, unbounded_rx) = mpsc::unbounded_channel(); @@ -676,7 +733,6 @@ impl SessionManager { ?error, "disconnected pending session" ); - self.remove_pending_session(&session_id); match direction { Direction::Incoming => { Poll::Ready(SessionEvent::IncomingPendingSessionClosed { @@ -707,7 +763,6 @@ impl SessionManager { ?peer_id, "connection refused" ); - self.remove_pending_session(&session_id); Poll::Ready(SessionEvent::OutgoingConnectionError { remote_addr, peer_id, error }) } PendingSessionEvent::EciesAuthError { remote_addr, session_id, error, direction } => { @@ -718,7 +773,6 @@ impl SessionManager { ?remote_addr, "ecies auth failed" ); - self.remove_pending_session(&session_id); match direction { Direction::Incoming => { Poll::Ready(SessionEvent::IncomingPendingSessionClosed { @@ -979,7 +1033,7 @@ pub(crate) async fn start_pending_incoming_session( async fn start_pending_outbound_session( handshake: Arc, eth_max_message_size: usize, - disconnect_rx: oneshot::Receiver<()>, + mut disconnect_rx: oneshot::Receiver<()>, events: mpsc::Sender>, session_id: SessionId, remote_addr: SocketAddr, @@ -990,7 +1044,19 @@ async fn start_pending_outbound_session( fork_filter: ForkFilter, extra_handlers: RlpxSubProtocolHandlers, ) { - let stream = match TcpStream::connect(remote_addr).await { + let connection = tokio::select! { + _ = &mut disconnect_rx => { + let _ = events.send(PendingSessionEvent::Disconnected { + remote_addr, + session_id, + direction: Direction::Outgoing(remote_peer_id), + error: None, + }).await; + return + }, + connection = TcpStream::connect(remote_addr) => connection, + }; + let stream = match connection { Ok(stream) => { if let Err(err) = stream.set_nodelay(true) { tracing::warn!(target: "net::session", "set nodelay failed: {:?}", err); @@ -1045,36 +1111,36 @@ async fn authenticate( extra_handlers: RlpxSubProtocolHandlers, ) { let local_addr = stream.local_addr().ok(); - let stream = match get_ecies_stream(stream, secret_key, direction).await { - Ok(stream) => stream, - Err(error) => { - let _ = events - .send(PendingSessionEvent::EciesAuthError { + let auth = async move { + let stream = match get_ecies_stream(stream, secret_key, direction).await { + Ok(stream) => stream, + Err(error) => { + return PendingSessionEvent::EciesAuthError { remote_addr, session_id, error, direction, - }) - .await; - return - } - }; + } + } + }; - let unauthed = UnauthedP2PStream::new(stream); + let unauthed = UnauthedP2PStream::new(stream); - let auth = authenticate_stream( - handshake, - eth_max_message_size, - unauthed, - session_id, - remote_addr, - local_addr, - direction, - hello, - status, - fork_filter, - extra_handlers, - ) + authenticate_stream( + handshake, + eth_max_message_size, + unauthed, + session_id, + remote_addr, + local_addr, + direction, + hello, + status, + fork_filter, + extra_handlers, + ) + .await + } .boxed(); match futures::future::select(disconnect_rx, auth).await { @@ -1256,21 +1322,30 @@ async fn authenticate_stream( } } } else { - // Multiplex the stream with the extra protocols let mut multiplex_stream = RlpxProtocolMultiplexer::new(p2p_stream); + let (protocol_start, protocol_ready) = watch::channel(false); - // install additional handlers + // Register bounded inbound queues and frame limits before reading early satellite frames. + // The application callbacks stay dormant until the eth Status handshake succeeds. for handler in extra_handlers.into_iter() { let protocol = handler.protocol(); let limits = handler.inbound_limits(); let remote_peer_id = authenticated_peer_id; - - // The unsupported-handler pass above guarantees that every remaining handler has a - // matching negotiated capability. The multiplexer retains the same immutable set of - // shared capabilities, so installing one of these handlers cannot fail. + let mut protocol_ready = protocol_ready.clone(); + // The unsupported-handler pass guarantees a matching negotiated capability. The + // multiplexer retains that immutable capability set, so installation cannot fail. multiplex_stream .install_protocol_with_limits(&protocol.cap, limits, move |conn| { - handler.into_connection(direction, remote_peer_id, conn) + stream::once(async move { + // A closed gate means authentication failed and the connection is dropped. + protocol_ready + .wait_for(|started| *started) + .await + .is_ok() + .then(|| handler.into_connection(direction, remote_peer_id, conn)) + }) + .filter_map(future::ready) + .flatten() }) .expect("remaining handler capability was negotiated"); } @@ -1289,6 +1364,7 @@ async fn authenticate_stream( } } }; + protocol_start.send_replace(true); (multiplex_stream.into(), their_status) }; diff --git a/crates/net/network/src/session/tests.rs b/crates/net/network/src/session/tests.rs index d661f78ef80..039aa3517b3 100644 --- a/crates/net/network/src/session/tests.rs +++ b/crates/net/network/src/session/tests.rs @@ -5,24 +5,30 @@ use crate::{ error::SessionError, protocol::{ConnectionHandler, DynConnectionHandler}, }; -use alloy_primitives::bytes::BytesMut; -use futures::{stream::Pending, StreamExt}; +use alloy_primitives::bytes::{Bytes, BytesMut}; +use futures::{stream::BoxStream, SinkExt, StreamExt}; use reth_chainspec::MAINNET; use reth_eth_wire::{ - capability::SharedCapabilities, handshake::EthHandshake, multiplex::ProtocolConnection, - protocol::Protocol, Capability, EthNetworkPrimitives, StatusBuilder, UnauthedEthStream, + capability::SharedCapabilities, + errors::P2PStreamError, + handshake::EthHandshake, + multiplex::ProtocolConnection, + protocol::{Protocol, ProtocolIngressLimits}, + Capability, EthNetworkPrimitives, StatusBuilder, UnauthedEthStream, }; use reth_eth_wire_types::message::MAX_MESSAGE_SIZE; use reth_ethereum_forks::EthereumHardfork; use reth_network_peers::pk2id; use secp256k1::SECP256K1; -use std::sync::Mutex; -use tokio::net::TcpListener; +use std::{sync::Mutex, task::Waker}; +use tokio::{net::TcpListener, sync::Notify}; /// Extra `RLPx` protocol that records which identity the session hands to it. #[derive(Clone, Debug, Default)] struct ProtocolSpy { seen: Arc>, + limits: ProtocolIngressLimits, + message_received: Arc, } /// The peers a [`ProtocolSpy`] was told about, per callback. @@ -30,6 +36,7 @@ struct ProtocolSpy { struct SeenPeers { connected: Vec, unsupported: Vec, + messages: Vec, } impl ProtocolSpy { @@ -51,12 +58,16 @@ fn spy_protocol() -> Protocol { } impl ConnectionHandler for ProtocolSpy { - type Connection = Pending; + type Connection = BoxStream<'static, BytesMut>; fn protocol(&self) -> Protocol { spy_protocol() } + fn inbound_limits(&self) -> ProtocolIngressLimits { + self.limits + } + fn on_unsupported_by_peer( self, _supported: &SharedCapabilities, @@ -71,10 +82,15 @@ impl ConnectionHandler for ProtocolSpy { self, _direction: Direction, peer_id: PeerId, - _conn: ProtocolConnection, + conn: ProtocolConnection, ) -> Self::Connection { self.seen.lock().unwrap().connected.push(peer_id); - futures::stream::pending() + conn.filter_map(move |message| { + self.seen.lock().unwrap().messages.push(message); + self.message_received.notify_one(); + futures::future::ready(None) + }) + .boxed() } } @@ -260,9 +276,16 @@ async fn matching_identity_establishes_session() { let remote = tokio::spawn(async move { let outgoing = TcpStream::connect(local_addr).await.unwrap(); let ecies = ECIESStream::connect(outgoing, remote_key, local_id).await.unwrap(); - let (p2p_stream, _) = + let (mut p2p_stream, _) = UnauthedP2PStream::new(ecies).handshake(remote_hello(remote_id)).await.unwrap(); + let offset = p2p_stream + .shared_capabilities() + .find(&spy_protocol().cap) + .unwrap() + .relative_message_id_offset(); + p2p_stream.send(Bytes::from(vec![offset, 0x80])).await.unwrap(); + let mut status = status(); status.set_eth_version(p2p_stream.shared_capabilities().eth_version().unwrap()); let _eth_stream = UnauthedEthStream::new(p2p_stream) @@ -278,13 +301,342 @@ async fn matching_identity_establishes_session() { event => panic!("unexpected event {event:?}"), }; assert_eq!(peer_id, remote_id); - assert_eq!(spy.connected(), vec![remote_id]); // From here on a real session owns and polls the connection. Without that the status we queued // for the remote never reaches the socket and its handshake times out. let session = tokio::spawn(async move { conn.next().await }); + tokio::time::timeout(Duration::from_secs(5), spy.message_received.notified()).await.unwrap(); + assert_eq!(spy.connected(), vec![remote_id]); + assert_eq!(spy.seen.lock().unwrap().messages, vec![BytesMut::from(&[0, 0x80][..])]); let _ = keep_alive_tx.send(()); remote.await.unwrap(); session.abort(); } + +/// A remote that completes `Hello` but never sends `Status` must not reach an extra protocol, +/// even if it already sends messages of that protocol. +#[tokio::test(flavor = "multi_thread")] +async fn extra_protocol_waits_for_status_handshake() { + for (max_frame_bytes, frame_bytes, frame_count) in + [(2, 2, 1), (1, 2, 1), (102_400, 102_400, 3), (2, 2, 65)] + { + let (local_key, local_id) = new_peer(); + let (remote_key, remote_id) = new_peer(); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local_addr = listener.local_addr().unwrap(); + let spy = ProtocolSpy { + limits: ProtocolIngressLimits::new(max_frame_bytes), + ..Default::default() + }; + let handlers = spy_handlers(&spy); + + let (events_tx, mut events_rx) = mpsc::channel(1); + let (_disconnect_tx, disconnect_rx) = oneshot::channel(); + + tokio::spawn(async move { + let (incoming, remote_addr) = listener.accept().await.unwrap(); + start_pending_incoming_session::( + Arc::new(EthHandshake::default()), + MAX_MESSAGE_SIZE, + disconnect_rx, + SessionId(0), + incoming, + events_tx, + remote_addr, + local_key, + HelloMessageWithProtocols::builder(local_id).build(), + status(), + fork_filter(), + handlers, + ) + .await + }); + + let (keep_alive_tx, keep_alive_rx) = oneshot::channel(); + let remote = tokio::spawn(async move { + let outgoing = TcpStream::connect(local_addr).await.unwrap(); + let ecies = ECIESStream::connect(outgoing, remote_key, local_id).await.unwrap(); + let (mut p2p_stream, _) = + UnauthedP2PStream::new(ecies).handshake(remote_hello(remote_id)).await.unwrap(); + + let offset = p2p_stream + .shared_capabilities() + .find(&spy_protocol().cap) + .unwrap() + .relative_message_id_offset(); + for _ in 0..frame_count { + let mut frame = vec![0x80; frame_bytes]; + frame[0] = offset; + p2p_stream.send(Bytes::from(frame)).await.unwrap(); + } + if max_frame_bytes == 2 && frame_count == 1 { + p2p_stream.disconnect(DisconnectReason::ClientQuitting).await.unwrap(); + } else { + // Keep unread Status data from causing an early TCP reset before the local + // handshake reports the ingress-limit error. + let _ = keep_alive_rx.await; + } + }); + + let event = events_rx.recv().await.unwrap(); + let PendingSessionEvent::Disconnected { + error: Some(PendingSessionHandshakeError::Eth(error)), + .. + } = event + else { + panic!("unexpected event {event:?}") + }; + if max_frame_bytes == 1 { + assert!(matches!( + error, + EthStreamError::P2PStreamError(P2PStreamError::SubprotocolMessageTooBig { + message_size: 2, + max_size: 1, + .. + }) + )); + } + if frame_count > 1 { + assert!(matches!( + error, + EthStreamError::P2PStreamError(P2PStreamError::SubprotocolInboundBufferFull { .. }) + )); + } + let _ = keep_alive_tx.send(()); + remote.await.unwrap(); + + assert!( + spy.connected().is_empty(), + "the extra protocol must wait for the status handshake" + ); + } +} + +fn session_manager() -> SessionManager { + let (key, id) = new_peer(); + SessionManager::new( + key, + SessionsConfig::default(), + Runtime::test(), + status(), + remote_hello(id), + fork_filter(), + RlpxSubProtocols::default(), + Arc::new(EthHandshake::default()), + MAX_MESSAGE_SIZE, + false, + ) +} + +#[tokio::test] +async fn canceled_dial_events_do_not_reach_replacement_sessions() { + let mut sessions = session_manager(); + let (_, peer_id) = new_peer(); + let remote_addr = "127.0.0.1:8008".parse().unwrap(); + let (disconnect_tx, disconnect_rx) = oneshot::channel(); + let old_id = sessions.next_id(); + sessions.pending_sessions.insert( + old_id, + PendingSessionHandle { + disconnect_tx: Some(disconnect_tx), + direction: Direction::Outgoing(peer_id), + }, + ); + sessions.counter.inc_pending_outbound(); + sessions.cancel_pending_outbound(peer_id); + assert_eq!(disconnect_rx.await, Ok(())); + assert!(sessions.pending_sessions.is_empty()); + + let new_id = sessions.next_id(); + let (disconnect_tx, _disconnect_rx) = oneshot::channel(); + sessions.pending_sessions.insert( + new_id, + PendingSessionHandle { + disconnect_tx: Some(disconnect_tx), + direction: Direction::Outgoing(peer_id), + }, + ); + sessions.counter.inc_pending_outbound(); + for error in [None, Some(PendingSessionHandshakeError::Timeout)] { + sessions + .pending_sessions_tx + .send(PendingSessionEvent::Disconnected { + remote_addr, + session_id: old_id, + direction: Direction::Outgoing(peer_id), + error, + }) + .await + .unwrap(); + assert!(sessions.poll(&mut Context::from_waker(Waker::noop()), |_| true).is_pending()); + assert!(sessions.pending_sessions.contains_key(&new_id)); + } + sessions + .pending_sessions_tx + .send(PendingSessionEvent::OutgoingConnectionError { + remote_addr, + session_id: old_id, + peer_id, + error: std::io::ErrorKind::ConnectionRefused.into(), + }) + .await + .unwrap(); + assert!(sessions.poll(&mut Context::from_waker(Waker::noop()), |_| true).is_pending()); + sessions + .pending_sessions_tx + .send(PendingSessionEvent::Disconnected { + remote_addr, + session_id: new_id, + direction: Direction::Outgoing(peer_id), + error: None, + }) + .await + .unwrap(); + assert!(matches!(sessions.poll(&mut Context::from_waker(Waker::noop()), |_| true), + Poll::Ready(SessionEvent::OutgoingPendingSessionClosed { peer_id: id, error: None, .. }) if id == peer_id)); + assert!(sessions.pending_sessions.is_empty()); +} + +#[tokio::test] +async fn cancellation_interrupts_stalled_ecies() { + let (key, id) = new_peer(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let remote_addr = listener.local_addr().unwrap(); + let stream = TcpStream::connect(remote_addr).await.unwrap(); + let (_remote, _) = listener.accept().await.unwrap(); + let (disconnect_tx, disconnect_rx) = oneshot::channel(); + let (events, mut rx) = mpsc::channel(1); + let mut auth = Box::pin(authenticate::( + Arc::new(EthHandshake::default()), + MAX_MESSAGE_SIZE, + disconnect_rx, + events, + stream, + SessionId(0), + remote_addr, + key, + Direction::Incoming, + remote_hello(id), + status(), + fork_filter(), + RlpxSubProtocolHandlers::default(), + )); + assert!(futures::poll!(auth.as_mut()).is_pending()); + disconnect_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(1), auth).await.unwrap(); + assert!(matches!( + rx.recv().await.unwrap(), + PendingSessionEvent::Disconnected { error: None, .. } + )); +} + +async fn pending_connection_pair( +) -> (SessionManager, SessionManager) { + let mut incoming = session_manager(); + let mut outgoing = session_manager(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + outgoing.dial_outbound(listener.local_addr().unwrap(), incoming.hello_message.id); + let (stream, addr) = + tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap(); + incoming.on_incoming(stream, addr).unwrap(); + (incoming, outgoing) +} + +#[tokio::test] +async fn canceled_completed_dial_sends_disconnect() { + let (mut incoming, mut outgoing) = pending_connection_pair().await; + let event = tokio::time::timeout(Duration::from_secs(5), outgoing.pending_session_rx.next()) + .await + .unwrap() + .unwrap(); + let PendingSessionEvent::Established { peer_id, .. } = &event else { + panic!("expected established") + }; + let peer_id = *peer_id; + let remote = tokio::time::timeout(Duration::from_secs(5), incoming.pending_session_rx.next()) + .await + .unwrap() + .unwrap(); + let PendingSessionEvent::Established { conn, .. } = remote else { + panic!("expected established") + }; + let mut conn = conn.into_inner(); + outgoing.cancel_pending_outbound(peer_id); + let replacement = outgoing.next_id(); + let (tx, _rx) = oneshot::channel(); + outgoing.pending_sessions.insert( + replacement, + PendingSessionHandle { disconnect_tx: Some(tx), direction: Direction::Outgoing(peer_id) }, + ); + outgoing.counter.inc_pending_outbound(); + outgoing.pending_sessions_tx.send(event).await.unwrap(); + assert!(outgoing.poll(&mut Context::from_waker(Waker::noop()), |_| true).is_pending()); + assert!(outgoing.pending_sessions.contains_key(&replacement)); + assert!(outgoing.active_sessions.is_empty()); + let error = tokio::time::timeout(Duration::from_secs(5), conn.next()) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(matches!(error, P2PStreamError::Disconnected(DisconnectReason::AlreadyConnected))); +} + +#[tokio::test] +async fn inbound_capacity_rejection_preserves_outbound_attempt() { + for allow in [false, true] { + let (mut incoming, mut outgoing) = pending_connection_pair().await; + let peer_id = outgoing.hello_message.id; + let outbound_id = incoming.next_id(); + let (tx, mut rx) = oneshot::channel(); + incoming.pending_sessions.insert( + outbound_id, + PendingSessionHandle { + disconnect_tx: Some(tx), + direction: Direction::Outgoing(peer_id), + }, + ); + incoming.counter.inc_pending_outbound(); + let event = tokio::time::timeout( + Duration::from_secs(5), + futures::future::poll_fn(|cx| { + incoming.poll(cx, |id| { + assert_eq!(id, peer_id); + allow + }) + }), + ) + .await + .unwrap(); + if allow { + assert!(matches!(event, SessionEvent::SessionEstablished { .. })); + assert_eq!(rx.await, Ok(())); + assert!(!incoming.pending_sessions.contains_key(&outbound_id)); + assert!(incoming.active_sessions.contains_key(&peer_id)); + } else { + assert!(matches!( + event, + SessionEvent::IncomingPendingSessionClosed { error: None, .. } + )); + assert!(matches!(rx.try_recv(), Err(oneshot::error::TryRecvError::Empty))); + assert!(incoming.pending_sessions.contains_key(&outbound_id)); + assert!(incoming.active_sessions.is_empty()); + let remote = + tokio::time::timeout(Duration::from_secs(5), outgoing.pending_session_rx.next()) + .await + .unwrap() + .unwrap(); + let PendingSessionEvent::Established { conn, .. } = remote else { + panic!("expected established") + }; + let mut conn = conn.into_inner(); + let error = tokio::time::timeout(Duration::from_secs(5), conn.next()) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(matches!(error, P2PStreamError::Disconnected(DisconnectReason::TooManyPeers))); + } + } +} diff --git a/crates/net/network/src/state.rs b/crates/net/network/src/state.rs index 549e158ba72..8df58ea1d54 100644 --- a/crates/net/network/src/state.rs +++ b/crates/net/network/src/state.rs @@ -318,14 +318,15 @@ impl NetworkState { self.peers_manager.add_trusted_peer_node(trusted) } - /// Adds a peer and its address with the given kind to the peerset. + /// Adds a peer and its address with the given kind to the peerset and dials it if there's an + /// outbound slot. pub(crate) fn add_peer_kind( &mut self, peer_id: PeerId, kind: Option, addr: PeerAddr, ) { - self.peers_manager.add_peer_kind(peer_id, kind, addr, None) + self.peers_manager.add_requested_peer(peer_id, kind, addr) } /// Connects a peer and its address with the given kind diff --git a/crates/net/network/src/swarm.rs b/crates/net/network/src/swarm.rs index 7c0354ba6e3..986a5ee0b35 100644 --- a/crates/net/network/src/swarm.rs +++ b/crates/net/network/src/swarm.rs @@ -111,9 +111,15 @@ impl Swarm { } impl Swarm { - /// Triggers a new outgoing connection to the given node - pub(crate) fn dial_outbound(&mut self, remote_addr: SocketAddr, remote_id: PeerId) { - self.sessions.dial_outbound(remote_addr, remote_id) + /// Triggers an outgoing connection, returning whether a session was started. + pub(crate) fn dial_outbound(&mut self, remote_addr: SocketAddr, remote_id: PeerId) -> bool { + if let Err(err) = self.sessions.try_dial_outbound(remote_addr, remote_id) { + trace!(target: "net", %err, "Outgoing connection rejected, capacity already reached."); + // Leave retries to the refill timer so a saturated session manager cannot busy-loop. + self.peers_mut().on_outgoing_pending_session_gracefully_closed(&remote_id); + return false + } + true } /// Handles a polled [`SessionEvent`] @@ -239,8 +245,9 @@ impl Swarm { fn on_state_action(&mut self, event: StateAction) -> Option> { match event { StateAction::Connect { remote_addr, peer_id } => { - self.dial_outbound(remote_addr, peer_id); - return Some(SwarmEvent::OutgoingTcpConnection { remote_addr, peer_id }) + if self.dial_outbound(remote_addr, peer_id) { + return Some(SwarmEvent::OutgoingTcpConnection { remote_addr, peer_id }) + } } StateAction::Disconnect { peer_id, reason } => { self.sessions.disconnect(peer_id, reason); @@ -328,7 +335,8 @@ impl Stream for Swarm { } // poll all sessions - match this.sessions.poll(cx) { + match this.sessions.poll(cx, |peer_id| this.state.peers().has_inbound_capacity(peer_id)) + { Poll::Pending => {} Poll::Ready(event) => { if let Some(event) = this.on_session_event(event) { diff --git a/crates/net/network/src/test_utils/mod.rs b/crates/net/network/src/test_utils/mod.rs index aca2594fb06..12f9d64fbd4 100644 --- a/crates/net/network/src/test_utils/mod.rs +++ b/crates/net/network/src/test_utils/mod.rs @@ -8,5 +8,8 @@ pub use init::{ enr_to_peer_id, unused_port, unused_tcp_addr, unused_tcp_and_udp_port, unused_tcp_udp, unused_udp_addr, unused_udp_port, }; -pub use testnet::{NetworkEventStream, Peer, PeerConfig, PeerHandle, Testnet, TestnetHandle}; +pub use testnet::{ + NetworkEventStream, Peer, PeerConfig, PeerHandle, Testnet, TestnetHandle, TestnetPool, + TestnetProvider, +}; pub use transactions::{buffer_hash_to_tx_fetcher, new_mock_session, new_tx_manager}; diff --git a/crates/net/network/src/test_utils/testnet.rs b/crates/net/network/src/test_utils/testnet.rs index 0b5d6b9b2a6..7d4af469fb3 100644 --- a/crates/net/network/src/test_utils/testnet.rs +++ b/crates/net/network/src/test_utils/testnet.rs @@ -2,16 +2,19 @@ use crate::{ builder::ETH_REQUEST_CHANNEL_CAPACITY, + config::rng_secret_key, error::NetworkError, eth_requests::EthRequestHandler, protocol::IntoRlpxSubProtocol, transactions::{ config::{StrictEthAnnouncementFilter, TransactionPropagationKind}, + constants::tx_manager::DEFAULT_TX_MANAGER_CHANNEL_MEMORY_LIMIT_BYTES, policy::NetworkPolicies, TransactionsHandle, TransactionsManager, TransactionsManagerConfig, }, - NetworkConfig, NetworkConfigBuilder, NetworkHandle, NetworkManager, PeersConfig, + NetworkConfigBuilder, NetworkHandle, NetworkManager, PeersConfig, }; +use alloy_eips::eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M; use futures::{FutureExt, StreamExt}; use pin_project::pin_project; use reth_chainspec::{ChainSpecProvider, EthereumHardforks, Hardforks}; @@ -24,19 +27,19 @@ use reth_metrics::common::mpsc::memory_bounded_channel; use reth_network_api::{ events::{PeerEvent, SessionInfo}, test_utils::{PeersHandle, PeersHandleProvider}, - NetworkEvent, NetworkEventListenerProvider, NetworkInfo, Peers, + NetworkEvent, NetworkEventListenerProvider, NetworkInfo, PeerRequest, Peers, }; +use reth_network_p2p::error::{RequestError, RequestResult}; use reth_network_peers::PeerId; use reth_storage_api::{ - noop::NoopProvider, BalProvider, BlockReader, BlockReaderIdExt, HeaderProvider, + noop::NoopProvider, BalProvider, BlockNumReader, BlockReader, BlockReaderIdExt, HeaderProvider, StateProviderFactory, StateRangeProviderFactory, }; use reth_tasks::Runtime; use reth_tokio_util::EventStream; use reth_transaction_pool::{ - blobstore::InMemoryBlobStore, - test_utils::{TestPool, TestPoolBuilder}, - EthTransactionPool, PoolTransaction, TransactionPool, TransactionValidationTaskExecutor, + blobstore::InMemoryBlobStore, test_utils::TestPool, EthTransactionPool, PoolTransaction, + TransactionPool, TransactionValidationTaskExecutor, }; use secp256k1::SecretKey; use std::{ @@ -51,8 +54,6 @@ use tokio::{ task::JoinHandle, }; -use crate::transactions::constants::tx_manager::DEFAULT_TX_MANAGER_CHANNEL_MEMORY_LIMIT_BYTES; - /// A test network consisting of multiple peers. pub struct Testnet { /// All running peers in the network. @@ -63,41 +64,55 @@ pub struct Testnet { impl Testnet where - C: BlockReader + HeaderProvider + Clone + 'static + ChainSpecProvider, + C: BlockNumReader + ChainSpecProvider + Clone + 'static, { - /// Same as [`Self::try_create_with`] but panics on error - pub async fn create_with(num_peers: usize, provider: C) -> Self { - Self::try_create_with(num_peers, provider).await.unwrap() + /// Creates a new [`Testnet`] with a peer for each of the given [`PeerConfig`]s. + /// + /// The peers are launched concurrently. + /// + /// # Panics + /// + /// If a peer fails to launch. + pub async fn from_configs(configs: impl IntoIterator>) -> Self { + let peers = futures::future::try_join_all(configs.into_iter().map(PeerConfig::launch)) + .await + .expect("failed to launch testnet peers"); + Self { peers } } - /// Creates a new [`Testnet`] with the given number of peers and the provider. - pub async fn try_create_with(num_peers: usize, provider: C) -> Result { - let mut this = Self { peers: Vec::with_capacity(num_peers) }; - for _ in 0..num_peers { - let config = PeerConfig::new(provider.clone()); - this.add_peer_with_config(config).await?; - } - Ok(this) + /// Creates a new [`Testnet`] with the given number of peers that serve data from `provider`. + /// + /// # Panics + /// + /// If a peer fails to launch. + pub async fn create_with(num_peers: usize, provider: C) -> Self { + Self::from_configs((0..num_peers).map(|_| PeerConfig::new(provider.clone()))).await } - /// Extend the list of peers with new peers that are configured with each of the given - /// [`PeerConfig`]s. - pub async fn extend_peer_with_config( + /// Add a peer to the [`Testnet`] with the given [`PeerConfig`]. + pub async fn add_peer_with_config( &mut self, - configs: impl IntoIterator>, + config: PeerConfig, ) -> Result<(), NetworkError> { - let peers = configs.into_iter().map(|c| c.launch()).collect::>(); - let peers = futures::future::join_all(peers).await; - for peer in peers { - self.peers.push(peer?); - } + self.peers.push(config.launch().await?); Ok(()) } } +impl Testnet { + /// Creates a new [`Testnet`] with the given number of peers. + /// + /// # Panics + /// + /// If a peer fails to launch. + pub async fn create(num_peers: usize) -> Self { + Self::create_with(num_peers, NoopProvider::default()).await + } +} + impl Testnet where - C: BlockReader + HeaderProvider + Clone + 'static, + C: BlockReader + Clone + 'static, Pool: TransactionPool, { /// Return a mutable slice of all peers. @@ -110,49 +125,6 @@ where &self.peers } - /// Remove a peer from the [`Testnet`] and return it. - /// - /// # Panics - /// If the index is out of bounds. - pub fn remove_peer(&mut self, index: usize) -> Peer { - self.peers.remove(index) - } - - /// Return a mutable iterator over all peers. - pub fn peers_iter_mut(&mut self) -> impl Iterator> + '_ { - self.peers.iter_mut() - } - - /// Return an iterator over all peers. - pub fn peers_iter(&self) -> impl Iterator> + '_ { - self.peers.iter() - } - - /// Add a peer to the [`Testnet`] with the given [`PeerConfig`]. - pub async fn add_peer_with_config( - &mut self, - config: PeerConfig, - ) -> Result<(), NetworkError> { - let PeerConfig { config, client, secret_key } = config; - - let network = NetworkManager::new(config).await?; - let peer = Peer { - network, - client, - secret_key, - request_handler: None, - transactions_manager: None, - pool: None, - }; - self.peers.push(peer); - Ok(()) - } - - /// Returns all handles to the networks - pub fn handles(&self) -> impl Iterator> + '_ { - self.peers.iter().map(|p| p.handle()) - } - /// Maps the pool of each peer with the given closure pub fn map_pool(self, f: F) -> Testnet where @@ -163,19 +135,20 @@ where } /// Apply a closure on each peer - pub fn for_each(&self, f: F) + pub fn for_each_mut(&mut self, f: F) where - F: Fn(&Peer), + F: FnMut(&mut Peer), { - self.peers.iter().for_each(f) + self.peers.iter_mut().for_each(f) } - /// Apply a closure on each peer - pub fn for_each_mut(&mut self, f: F) + /// Installs an eth request handler on each peer. + pub fn with_request_handlers(mut self) -> Self where - F: FnMut(&mut Peer), + C: BalProvider, { - self.peers.iter_mut().for_each(f) + self.for_each_mut(Peer::install_request_handler); + self } } @@ -193,20 +166,7 @@ where pub fn with_eth_pool( self, ) -> Testnet> { - self.map_pool(|peer| { - let blob_store = InMemoryBlobStore::default(); - let pool = TransactionValidationTaskExecutor::eth( - peer.client.clone(), - EthEvmConfig::mainnet(), - blob_store.clone(), - Runtime::test(), - ); - peer.map_transactions_manager(EthTransactionPool::eth_pool( - pool, - blob_store, - Default::default(), - )) - }) + self.with_eth_pool_config(Default::default()) } /// Installs an eth pool on each peer with custom transaction manager config @@ -225,116 +185,54 @@ where ) -> Testnet> { self.map_pool(|peer| { let blob_store = InMemoryBlobStore::default(); - let pool = TransactionValidationTaskExecutor::eth( + // Test peers usually sit on mainnet's genesis, whose 5,000 gas limit would reject + // every transaction. + let validator = TransactionValidationTaskExecutor::eth_builder( peer.client.clone(), EthEvmConfig::mainnet(), - blob_store.clone(), - Runtime::test(), - ); - - peer.map_transactions_manager_with( - EthTransactionPool::eth_pool(pool, blob_store, Default::default()), - tx_manager_config.clone(), - policy, ) + .set_block_gas_limit(ETHEREUM_BLOCK_GAS_LIMIT_30M) + .build_with_tasks(Runtime::test(), blob_store.clone()); + let pool = EthTransactionPool::eth_pool(validator, blob_store, Default::default()); + peer.map_transactions_manager(pool, tx_manager_config.clone(), policy) }) } } impl Testnet where - C: BlockReader< - Block = reth_ethereum_primitives::Block, - Receipt = reth_ethereum_primitives::Receipt, - Header = alloy_consensus::Header, - > + HeaderProvider - + BalProvider - + StateProviderFactory - + StateRangeProviderFactory - + Clone - + Unpin - + 'static, - Pool: TransactionPool< - Transaction: PoolTransaction< - Consensus = TransactionSigned, - Pooled = PooledTransactionVariant, - >, - > + Unpin - + 'static, + C: TestnetProvider + Clone, + Pool: TestnetPool, { /// Spawns the testnet to a separate task pub fn spawn(self) -> TestnetHandle { - let (tx, rx) = oneshot::channel::>(); - let peers = self.peers.iter().map(|peer| peer.peer_handle()).collect::>(); + let (terminate, rx) = oneshot::channel::>(); + let peers = self.peers.iter().map(Peer::peer_handle).collect(); let mut net = self; let handle = tokio::task::spawn(async move { - let mut tx = None; - tokio::select! { - _ = &mut net => {} - inc = rx => { - tx = inc.ok(); - } - } + let tx = tokio::select! { + _ = &mut net => None, + tx = rx => tx.ok(), + }; if let Some(tx) = tx { let _ = tx.send(net); } }); - TestnetHandle { _handle: handle, peers, terminate: tx } - } -} - -impl Testnet { - /// Same as [`Self::try_create`] but panics on error - pub async fn create(num_peers: usize) -> Self { - Self::try_create(num_peers).await.unwrap() - } - - /// Creates a new [`Testnet`] with the given number of peers - pub async fn try_create(num_peers: usize) -> Result { - let mut this = Self::default(); - - this.extend_peer_with_config((0..num_peers).map(|_| Default::default())).await?; - Ok(this) - } - - /// Add a peer to the [`Testnet`] - pub async fn add_peer(&mut self) -> Result<(), NetworkError> { - self.add_peer_with_config(Default::default()).await - } -} - -impl Default for Testnet { - fn default() -> Self { - Self { peers: Vec::new() } + TestnetHandle { _handle: handle, peers, terminate } } } impl fmt::Debug for Testnet { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Testnet {{}}").finish_non_exhaustive() + f.debug_struct("Testnet").finish_non_exhaustive() } } impl Future for Testnet where - C: BlockReader< - Block = reth_ethereum_primitives::Block, - Receipt = reth_ethereum_primitives::Receipt, - Header = alloy_consensus::Header, - > + HeaderProvider - + BalProvider - + StateProviderFactory - + StateRangeProviderFactory - + Unpin - + 'static, - Pool: TransactionPool< - Transaction: PoolTransaction< - Consensus = TransactionSigned, - Pooled = PooledTransactionVariant, - >, - > + Unpin - + 'static, + C: TestnetProvider, + Pool: TestnetPool, { type Output = (); @@ -370,6 +268,18 @@ impl TestnetHandle { &self.peers } + /// Returns the [`PeerHandle`]s of this [`Testnet`] as an array, so they can be destructured: + /// `let [peer0, peer1] = net.peers_array();`. + /// + /// # Panics + /// + /// If the testnet does not have exactly `N` peers. + pub fn peers_array(&self) -> &[PeerHandle; N] { + self.peers.as_slice().try_into().unwrap_or_else(|_| { + panic!("expected {N} peers, but the testnet has {}", self.peers.len()) + }) + } + /// Connects all peers with each other. /// /// This establishes sessions concurrently between all peers. @@ -381,13 +291,12 @@ impl TestnetHandle { } // add an event stream for _each_ peer - let streams = - self.peers.iter().map(|handle| NetworkEventStream::new(handle.event_listener())); + let streams = self.peers.iter().map(PeerHandle::event_stream).collect::>(); // add all peers to each other for (idx, handle) in self.peers.iter().enumerate().take(self.peers.len() - 1) { for neighbour in &self.peers[idx + 1..] { - handle.network.add_peer(*neighbour.peer_id(), neighbour.local_addr()); + handle.add_peer(neighbour); } } @@ -413,14 +322,13 @@ pub struct Peer { transactions_manager: Option>, pool: Option, client: C, - secret_key: SecretKey, } // === impl Peer === impl Peer where - C: BlockReader + HeaderProvider + Clone + 'static, + C: BlockReader + Clone + 'static, Pool: TransactionPool, { /// Returns the number of connected peers. @@ -479,64 +387,21 @@ where self.request_handler = Some(request_handler); } - /// Set a new transactions manager that's connected to the peer's network + /// Set a new transactions manager with the default config that's connected to the peer's + /// network. pub fn install_transactions_manager(&mut self, pool: Pool) { - let (tx, rx) = memory_bounded_channel( - DEFAULT_TX_MANAGER_CHANNEL_MEMORY_LIMIT_BYTES, - "test_tx_channel", - ); - self.network.set_transactions(tx); - let transactions_manager = TransactionsManager::new( - self.handle(), + self.transactions_manager = Some(new_transactions_manager( + &mut self.network, pool.clone(), - rx, - TransactionsManagerConfig::default(), - ); - self.transactions_manager = Some(transactions_manager); + Default::default(), + Default::default(), + )); self.pool = Some(pool); } - /// Set a new transactions manager that's connected to the peer's network - pub fn map_transactions_manager

(self, pool: P) -> Peer - where - P: TransactionPool, - { - let Self { mut network, request_handler, client, secret_key, .. } = self; - let (tx, rx) = memory_bounded_channel( - DEFAULT_TX_MANAGER_CHANNEL_MEMORY_LIMIT_BYTES, - "test_tx_channel", - ); - network.set_transactions(tx); - let transactions_manager = TransactionsManager::new( - network.handle().clone(), - pool.clone(), - rx, - TransactionsManagerConfig::default(), - ); - Peer { - network, - request_handler, - transactions_manager: Some(transactions_manager), - pool: Some(pool), - client, - secret_key, - } - } - - /// Map transactions manager with custom config - pub fn map_transactions_manager_with_config

( - self, - pool: P, - config: TransactionsManagerConfig, - ) -> Peer - where - P: TransactionPool, - { - self.map_transactions_manager_with(pool, config, Default::default()) - } - - /// Map transactions manager with custom config and the given policy. - pub fn map_transactions_manager_with

( + /// Replaces the pool with `pool` and connects a new transactions manager with the given config + /// and propagation policy to the peer's network. + pub fn map_transactions_manager

( self, pool: P, config: TransactionsManagerConfig, @@ -545,64 +410,23 @@ where where P: TransactionPool, { - let Self { mut network, request_handler, client, secret_key, .. } = self; - let (tx, rx) = memory_bounded_channel( - DEFAULT_TX_MANAGER_CHANNEL_MEMORY_LIMIT_BYTES, - "test_tx_channel", - ); - network.set_transactions(tx); - - let announcement_policy = StrictEthAnnouncementFilter::default(); - let policies = NetworkPolicies::new(policy, announcement_policy); - - let transactions_manager = TransactionsManager::with_policy( - network.handle().clone(), - pool.clone(), - rx, - config, - policies, - ); - + let Self { mut network, request_handler, client, .. } = self; + let transactions_manager = + new_transactions_manager(&mut network, pool.clone(), config, policy); Peer { network, request_handler, transactions_manager: Some(transactions_manager), pool: Some(pool), client, - secret_key, } } } -impl Peer -where - C: BlockReader + HeaderProvider + Clone + 'static, -{ - /// Installs a new [`TestPool`] - pub fn install_test_pool(&mut self) { - self.install_transactions_manager(TestPoolBuilder::default().into()) - } -} - impl Future for Peer where - C: BlockReader< - Block = reth_ethereum_primitives::Block, - Receipt = reth_ethereum_primitives::Receipt, - Header = alloy_consensus::Header, - > + HeaderProvider - + BalProvider - + StateProviderFactory - + StateRangeProviderFactory - + Unpin - + 'static, - Pool: TransactionPool< - Transaction: PoolTransaction< - Consensus = TransactionSigned, - Pooled = PooledTransactionVariant, - >, - > + Unpin - + 'static, + C: TestnetProvider, + Pool: TestnetPool, { type Output = (); @@ -622,11 +446,14 @@ where } /// A helper config for setting up the reth networking stack. +/// +/// The network config is built when the peer is launched. #[derive(Debug)] pub struct PeerConfig { - config: NetworkConfig, client: C, secret_key: SecretKey, + protocols: Option>, + peers_config: PeersConfig, } /// A handle to a peer in the [`Testnet`]. @@ -674,76 +501,93 @@ impl PeerHandle { pub const fn network(&self) -> &NetworkHandle { &self.network } + + /// Adds `other` to the peer set, so this peer connects to it. + pub fn add_peer

(&self, other: &PeerHandle

) { + self.network.add_peer(*other.peer_id(), other.local_addr()); + } + + /// Adds `other` to the peer set as a trusted peer, so this peer connects to it. + pub fn add_trusted_peer

(&self, other: &PeerHandle

) { + self.network.add_trusted_peer(*other.peer_id(), other.local_addr()); + } + + /// Creates a new [`NetworkEventStream`] of this peer's network events. + pub fn event_stream(&self) -> NetworkEventStream { + NetworkEventStream::new(self.event_listener()) + } + + /// Sends the request created by `request` to `peer_id` and awaits the response. + /// + /// Returns [`RequestError::ChannelClosed`] if the response channel is dropped. + pub async fn request( + &self, + peer_id: PeerId, + request: impl FnOnce(oneshot::Sender>) -> PeerRequest, + ) -> RequestResult { + let (tx, rx) = oneshot::channel(); + self.network.send_request(peer_id, request(tx)); + rx.await.unwrap_or(Err(RequestError::ChannelClosed)) + } } // === impl PeerConfig === -impl PeerConfig -where - C: BlockReader + HeaderProvider + Clone + 'static, -{ - /// Launches the network and returns the [Peer] that manages it - pub async fn launch(self) -> Result, NetworkError> { - let Self { config, client, secret_key } = self; - let network = NetworkManager::new(config).await?; - let peer = Peer { - network, +impl PeerConfig { + /// Creates a config for a peer with a random secret key that serves data from `client`. + /// + /// The peer's devp2p and discovery bind to any available port. + pub fn new(client: C) -> Self { + Self { client, - secret_key, - request_handler: None, - transactions_manager: None, - pool: None, - }; - Ok(peer) + secret_key: rng_secret_key(), + protocols: None, + peers_config: PeersConfig::test(), + } } - /// Initialize the network with a random secret key, allowing the devp2p and discovery to bind - /// to any available IP and port. - pub fn new(client: C) -> Self - where - C: ChainSpecProvider, - { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let config = Self::network_config_builder(secret_key).build(client.clone()); - Self { config, client, secret_key } + /// Sets the secret key, which determines the peer's [`PeerId`]. + pub const fn with_secret_key(mut self, secret_key: SecretKey) -> Self { + self.secret_key = secret_key; + self } - /// Initialize the network with a given secret key, allowing devp2p and discovery to bind any - /// available IP and port. - pub fn with_secret_key(client: C, secret_key: SecretKey) -> Self - where - C: ChainSpecProvider, - { - let config = Self::network_config_builder(secret_key).build(client.clone()); - Self { config, client, secret_key } + /// Sets the protocols advertised in the hello message, e.g. the supported eth versions. + pub fn with_protocols(mut self, protocols: impl IntoIterator>) -> Self { + self.protocols = Some(protocols.into_iter().map(Into::into).collect()); + self } - /// Initialize the network with a given capabilities. - pub fn with_protocols(client: C, protocols: impl IntoIterator) -> Self - where - C: ChainSpecProvider, - { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let protocols: Vec = protocols.into_iter().collect(); - // `NetworkConfigBuilder::build` re-derives snap advertisement from `snap_enabled`, which - // would otherwise silently strip a manually included `snap` capability. - let snap_enabled = protocols.iter().any(|p| p.cap.name == Protocol::snap_2().cap.name); - - let builder = Self::network_config_builder(secret_key).with_snap(snap_enabled); - let hello_message = - HelloMessageWithProtocols::builder(builder.get_peer_id()).protocols(protocols).build(); - let config = builder.hello_message(hello_message).build(client.clone()); - - Self { config, client, secret_key } + /// Sets the [`PeersConfig`], which defaults to [`PeersConfig::test`]. + pub fn with_peers_config(mut self, peers_config: PeersConfig) -> Self { + self.peers_config = peers_config; + self } - fn network_config_builder(secret_key: SecretKey) -> NetworkConfigBuilder { - NetworkConfigBuilder::new(secret_key, Runtime::test()) + /// Launches the network and returns the [`Peer`] that manages it. + pub async fn launch(self) -> Result, NetworkError> + where + C: BlockNumReader + ChainSpecProvider + Clone + 'static, + { + let Self { client, secret_key, protocols, peers_config } = self; + let mut builder = NetworkConfigBuilder::new(secret_key, Runtime::test()) .listener_addr(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))) .discovery_addr(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))) .disable_dns_discovery() .disable_discv4_discovery() - .peer_config(PeersConfig::test()) + .peer_config(peers_config); + if let Some(protocols) = protocols { + // `NetworkConfigBuilder::build` re-derives snap advertisement from `snap_enabled`, + // which would otherwise silently strip a manually included `snap` capability. + let snap_enabled = protocols.iter().any(|p| p.cap.name == Protocol::snap_2().cap.name); + let hello_message = HelloMessageWithProtocols::builder(builder.get_peer_id()) + .protocols(protocols) + .build(); + builder = builder.with_snap(snap_enabled).hello_message(hello_message); + } + + let network = NetworkManager::new(builder.build(client.clone())).await?; + Ok(Peer { network, client, request_handler: None, transactions_manager: None, pool: None }) } } @@ -811,47 +655,84 @@ impl NetworkEventStream { peers } - /// Ensures that the first two events are a [`NetworkEvent::Peer`] and - /// [`PeerEvent::PeerAdded`][`NetworkEvent::ActivePeerSession`], returning the [`PeerId`] of the - /// established session. - pub async fn peer_added_and_established(&mut self) -> Option { - let peer_id = match self.inner.next().await { - Some(NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id))) => peer_id, - _ => return None, - }; + /// Returns the peer of the next event if it is a [`PeerEvent::PeerAdded`]. + pub async fn peer_added(&mut self) -> Option { + match self.inner.next().await { + Some(NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id))) => Some(peer_id), + _ => None, + } + } + /// Returns the peer of the next event if it is a [`PeerEvent::PeerRemoved`]. + pub async fn peer_removed(&mut self) -> Option { match self.inner.next().await { - Some(NetworkEvent::ActivePeerSession { - info: SessionInfo { peer_id: peer_id2, .. }, - .. - }) => { - debug_assert_eq!( - peer_id, peer_id2, - "PeerAdded peer_id {peer_id} does not match SessionEstablished peer_id {peer_id2}" - ); - Some(peer_id) - } + Some(NetworkEvent::Peer(PeerEvent::PeerRemoved(peer_id))) => Some(peer_id), _ => None, } } +} - /// Awaits the next event for a peer added. - pub async fn peer_added(&mut self) -> Option { - let peer_id = match self.inner.next().await { - Some(NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id))) => peer_id, - _ => return None, - }; +/// A provider that can serve all eth requests of a [`Peer`] in a spawned [`Testnet`]. +pub trait TestnetProvider: + BlockReader< + Block = reth_ethereum_primitives::Block, + Receipt = reth_ethereum_primitives::Receipt, + Header = alloy_consensus::Header, + > + BalProvider + + StateProviderFactory + + StateRangeProviderFactory + + Unpin + + 'static +{ +} - Some(peer_id) - } +impl TestnetProvider for T where + T: BlockReader< + Block = reth_ethereum_primitives::Block, + Receipt = reth_ethereum_primitives::Receipt, + Header = alloy_consensus::Header, + > + BalProvider + + StateProviderFactory + + StateRangeProviderFactory + + Unpin + + 'static +{ +} - /// Awaits the next event for a peer removed. - pub async fn peer_removed(&mut self) -> Option { - let peer_id = match self.inner.next().await { - Some(NetworkEvent::Peer(PeerEvent::PeerRemoved(peer_id))) => peer_id, - _ => return None, - }; +/// A transaction pool that can back the transactions manager of a [`Peer`] in a spawned +/// [`Testnet`]. +pub trait TestnetPool: + TransactionPool< + Transaction: PoolTransaction< + Consensus = TransactionSigned, + Pooled = PooledTransactionVariant, + >, + > + Unpin + + 'static +{ +} - Some(peer_id) - } +impl TestnetPool for T where + T: TransactionPool< + Transaction: PoolTransaction< + Consensus = TransactionSigned, + Pooled = PooledTransactionVariant, + >, + > + Unpin + + 'static +{ +} + +/// Creates a transactions manager for `pool` that is connected to `network`. +fn new_transactions_manager( + network: &mut NetworkManager, + pool: Pool, + config: TransactionsManagerConfig, + policy: TransactionPropagationKind, +) -> TransactionsManager { + let (tx, rx) = + memory_bounded_channel(DEFAULT_TX_MANAGER_CHANNEL_MEMORY_LIMIT_BYTES, "test_tx_channel"); + network.set_transactions(tx); + let policies = NetworkPolicies::new(policy, StrictEthAnnouncementFilter::default()); + TransactionsManager::with_policy(network.handle().clone(), pool, rx, config, policies) } diff --git a/crates/net/network/src/transactions/announcement.rs b/crates/net/network/src/transactions/announcement.rs index bec79dce9db..ce7dfe0d07e 100644 --- a/crates/net/network/src/transactions/announcement.rs +++ b/crates/net/network/src/transactions/announcement.rs @@ -186,7 +186,7 @@ mod tests { assert_eq!(announcement.iter().next().unwrap().hash, hashes[3]); assert_eq!( announcement.cell_mask(), - if msg.version() == EthVersion::Eth72 { mask } else { None } + if msg.version().is_eth72() { mask } else { None } ); } } diff --git a/crates/net/network/src/transactions/fetcher.rs b/crates/net/network/src/transactions/fetcher.rs index d32d738ed7c..e02ab7a87e0 100644 --- a/crates/net/network/src/transactions/fetcher.rs +++ b/crates/net/network/src/transactions/fetcher.rs @@ -1439,15 +1439,11 @@ mod tests { use crate::test_utils::transactions::new_mock_session_with_capacity; use alloy_consensus::transaction::Recovered; use alloy_primitives::B256; - use futures::task::{noop_waker_ref, waker, ArcWake}; + use futures::task::noop_waker_ref; use rand::{rngs::StdRng, seq::IndexedRandom, Rng, SeedableRng}; use reth_eth_wire::EthVersion; use reth_ethereum_primitives::{PooledTransactionVariant, TransactionSigned}; use reth_transaction_pool::test_utils::MockTransactionFactory; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }; use tokio::sync::mpsc; type Fetcher = TransactionFetcher; @@ -1457,7 +1453,7 @@ mod tests { const KIB: usize = 1024; fn peer(n: u8) -> PeerId { - PeerId::new([n; 64]) + PeerId::repeat_byte(n) } fn hash(n: u64) -> TxHash { @@ -1470,22 +1466,6 @@ mod tests { range.map(hash).collect() } - /// Counts how often a task is woken. - #[derive(Default)] - struct WakeCounter(AtomicUsize); - - impl ArcWake for WakeCounter { - fn wake_by_ref(arc_self: &Arc) { - arc_self.0.fetch_add(1, Ordering::SeqCst); - } - } - - impl WakeCounter { - fn wakes(&self) -> usize { - self.0.load(Ordering::SeqCst) - } - } - fn pooled_txs(count: usize) -> Vec { let mut factory = MockTransactionFactory::default(); (0..count) @@ -1723,34 +1703,6 @@ mod tests { assert_eq!(requested, hashes[1..]); } - #[test] - fn oversized_transaction_is_requested_alone() { - let mut rig = Rig::new(); - let peer_a = peer(1); - rig.add_peer(peer_a); - let hashes = hashes(0..3); - - rig.announce_with_sizes( - peer_a, - [(hashes[0], 100), (hashes[1], 1024 * KIB), (hashes[2], 100)], - ); - - let mut requests = Vec::new(); - for _ in 0..3 { - assert_eq!(rig.dispatch(), 1); - let (requested, response) = rig.take_request(peer_a).unwrap(); - requests.push(requested); - // A bad response keeps other hashes pending but drops the requested ones. - response.send(Err(RequestError::BadResponse)).unwrap(); - rig.next_event().unwrap(); - } - assert_eq!( - requests, - vec![hashes[..1].to_vec(), hashes[1..2].to_vec(), hashes[2..].to_vec()] - ); - assert_eq!(rig.fetcher.num_hashes(), 0); - } - #[test] fn announced_size_is_per_peer() { let mut rig = Rig::new(); @@ -2644,25 +2596,6 @@ mod tests { assert_eq!(requested, hashes(40..50)); } - #[test] - fn stalled_peers_do_not_shrink_requests_to_an_idle_peer() { - let mut rig = Rig::new(); - for n in 0..16 { - let p = peer(n + 1); - rig.add_peer(p); - rig.announce(p, &hashes(u64::from(n) * 256..u64::from(n + 1) * 256)); - } - assert_eq!(rig.dispatch_with_budget(4096), 16); - assert_eq!(rig.fetcher.num_fetching_hashes(), 4096); - // All earlier peers keep their requests open. The newly ready peer still gets a - // full request instead of being throttled by their inflight hashes. - let honest = peer(17); - rig.add_peer(honest); - rig.announce(honest, &hashes(4096..4352)); - assert_eq!(rig.dispatch_with_budget(4096), 1); - assert_eq!(rig.take_request(honest).unwrap().0, hashes(4096..4352)); - } - #[test] fn full_session_channel_rolls_back_request() { let mut rig = Rig::new(); @@ -2916,32 +2849,6 @@ mod tests { assert_eq!(rig.fetcher.num_inflight_requests(), 0); } - #[test] - fn requests_sent_after_a_poll_register_wakers_on_the_next_poll() { - let mut rig = Rig::new(); - let peer_a = peer(1); - rig.add_peer(peer_a); - let counter = Arc::new(WakeCounter::default()); - let waker = waker(counter.clone()); - let mut cx = Context::from_waker(&waker); - - assert!(rig.fetcher.poll_next_unpin(&mut cx).is_pending()); - rig.announce(peer_a, &hashes(0..1)); - assert_eq!(rig.dispatch(), 1); - - // the caller has to poll again after dispatching, only then the request is polled and - // registers the waker that its response wakes - assert!(rig.fetcher.poll_next_unpin(&mut cx).is_pending()); - let wakes = counter.wakes(); - let (_, response) = rig.take_request(peer_a).unwrap(); - response.send(Err(RequestError::Timeout)).unwrap(); - assert_eq!(counter.wakes(), wakes + 1); - assert!(matches!( - rig.fetcher.poll_next_unpin(&mut cx), - Poll::Ready(Some(FetchEvent::FetchError { .. })) - )); - } - #[test] fn global_capacity_bounds_tracked_hashes_across_peers() { let config = TransactionFetcherConfig { diff --git a/crates/net/network/src/transactions/mod.rs b/crates/net/network/src/transactions/mod.rs index 7627e080e00..5a2e8bdbaff 100644 --- a/crates/net/network/src/transactions/mod.rs +++ b/crates/net/network/src/transactions/mod.rs @@ -802,9 +802,9 @@ impl TransactionsManager where Pool: TransactionPool + Unpin + 'static, N: NetworkPrimitives< - BroadcastedTransaction: SignedTransaction, - PooledTransaction: SignedTransaction, - > + Unpin, + BroadcastedTransaction: SignedTransaction, + PooledTransaction: SignedTransaction, + >, Pool::Transaction: PoolTransaction, { @@ -1389,7 +1389,7 @@ where // EIP-8070, so their sidecars can never validate. Drop them before touching the pool; // geth equivalently diverts these bodies into a buffer that is completed with cells // fetched via `GetCells`, which is not implemented yet. - if version == EthVersion::Eth72 { + if version.is_eth72() { let len_before = transactions.len(); transactions.retain(|tx| !tx.is_eip4844()); let dropped = len_before - transactions.len(); @@ -1559,9 +1559,9 @@ where impl< Pool: TransactionPool + Unpin + 'static, N: NetworkPrimitives< - BroadcastedTransaction: SignedTransaction, - PooledTransaction: SignedTransaction, - > + Unpin, + BroadcastedTransaction: SignedTransaction, + PooledTransaction: SignedTransaction, + >, > Future for TransactionsManager where Pool::Transaction: @@ -2349,7 +2349,7 @@ mod tests { }; use alloy_consensus::{Transaction as _, TxEip1559, TxLegacy}; use alloy_eips::{eip2718::Encodable2718, eip4844::BlobTransactionValidationError}; - use alloy_primitives::{hex, Signature, TxKind, B256, U256}; + use alloy_primitives::{address, hex, Signature, TxKind, B256, U256}; use alloy_rlp::Decodable; use futures::FutureExt; use reth_chainspec::MIN_TRANSACTION_GAS; @@ -2411,7 +2411,7 @@ mod tests { let policy = RecordingPolicy::default(); manager.policies = NetworkPolicies::new(TransactionPropagationKind::default(), policy.clone()); - let peer_id = PeerId::new([1; 64]); + let peer_id = PeerId::repeat_byte(1); let (peer, mut rx) = new_mock_session(peer_id, EthVersion::Eth68); manager.peers.insert(peer_id, peer); let [pending, accepted, ignored, rejected, later, bad] = @@ -2503,17 +2503,11 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_ignored_tx_broadcasts_while_initially_syncing() { reth_tracing::init_test_tracing(); - let net = Testnet::create(3).await; - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - - drop(handles); - let handle = net.spawn(); + let net = Testnet::create(3).await.spawn(); + let [peer0, peer1, _] = net.peers_array(); - let listener0 = handle0.event_listener(); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); + let listener0 = peer0.event_listener(); + peer0.add_peer(peer1); let secret_key = SecretKey::new(&mut rand_08::thread_rng()); let client = NoopProvider::default(); @@ -2558,7 +2552,7 @@ mod tests { ); let signed_tx = TransactionSigned::decode(&mut &input[..]).unwrap(); transactions.on_network_tx_event(NetworkTransactionEvent::IncomingTransactions { - peer_id: *handle1.peer_id(), + peer_id: *peer1.peer_id(), msg: Transactions(vec![signed_tx.clone()]), }); poll_fn(|cx| { @@ -2567,23 +2561,16 @@ mod tests { }) .await; assert!(pool.is_empty()); - handle.terminate().await; } #[tokio::test(flavor = "multi_thread")] async fn test_tx_broadcasts_through_two_syncs() { reth_tracing::init_test_tracing(); - let net = Testnet::create(3).await; + let net = Testnet::create(3).await.spawn(); + let [peer0, peer1, _] = net.peers_array(); - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - - drop(handles); - let handle = net.spawn(); - - let listener0 = handle0.event_listener(); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); + let listener0 = peer0.event_listener(); + peer0.add_peer(peer1); let secret_key = SecretKey::new(&mut rand_08::thread_rng()); let client = NoopProvider::default(); @@ -2631,7 +2618,7 @@ mod tests { ); let signed_tx = TransactionSigned::decode(&mut &input[..]).unwrap(); transactions.on_network_tx_event(NetworkTransactionEvent::IncomingTransactions { - peer_id: *handle1.peer_id(), + peer_id: *peer1.peer_id(), msg: Transactions(vec![signed_tx.clone()]), }); poll_fn(|cx| { @@ -2642,7 +2629,6 @@ mod tests { assert!(!NetworkInfo::is_initially_syncing(&network_handle)); assert!(NetworkInfo::is_syncing(&network_handle)); assert!(!pool.is_empty()); - handle.terminate().await; } // Ensure that the transaction manager correctly handles the `IncomingPooledTransactionHashes` @@ -2670,7 +2656,7 @@ mod tests { .transactions(pool.clone(), transactions_manager_config) .split_with_handle(); - let peer_id_1 = PeerId::new([1; 64]); + let peer_id_1 = PeerId::repeat_byte(1); let eth_version = EthVersion::Eth66; let txs = vec![TransactionSigned::new_unhashed( @@ -2679,7 +2665,7 @@ mod tests { nonce: 15u64, gas_price: 2200000000, gas_limit: 34811, - to: TxKind::Call(hex!("cf7f9e66af820a19257a2108375b180b0ec49167").into()), + to: TxKind::Call(address!("cf7f9e66af820a19257a2108375b180b0ec49167")), value: U256::from(1234u64), input: Default::default(), }), @@ -2753,18 +2739,12 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_handle_incoming_transactions() { reth_tracing::init_test_tracing(); - let net = Testnet::create(3).await; - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); + let net = Testnet::create(3).await.spawn(); + let [peer0, peer1, _] = net.peers_array(); - drop(handles); - let handle = net.spawn(); + let listener0 = peer0.event_listener(); - let listener0 = handle0.event_listener(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); + peer0.add_peer(peer1); let secret_key = SecretKey::new(&mut rand_08::thread_rng()); let client = NoopProvider::default(); @@ -2807,14 +2787,14 @@ mod tests { ); let signed_tx = TransactionSigned::decode(&mut &input[..]).unwrap(); transactions.on_network_tx_event(NetworkTransactionEvent::IncomingTransactions { - peer_id: *handle1.peer_id(), + peer_id: *peer1.peer_id(), msg: Transactions(vec![signed_tx.clone()]), }); assert!(transactions .transactions_by_peers .get(signed_tx.tx_hash()) .unwrap() - .contains(handle1.peer_id())); + .contains(peer1.peer_id())); // advance the transaction manager future poll_fn(|cx| { @@ -2825,14 +2805,13 @@ mod tests { assert!(!pool.is_empty()); assert!(pool.get(signed_tx.tx_hash()).is_some()); - handle.terminate().await; } #[tokio::test(flavor = "multi_thread")] async fn test_session_closed_cleans_transaction_peer_state() { let (mut tx_manager, _network) = new_tx_manager().await; - let peer_id = PeerId::new([1; 64]); - let fallback_peer = PeerId::new([2; 64]); + let peer_id = PeerId::repeat_byte(1); + let fallback_peer = PeerId::repeat_byte(2); let (peer, _) = new_mock_session(peer_id, EthVersion::Eth66); let (fallback, _) = new_mock_session(fallback_peer, EthVersion::Eth66); let hash_shared = B256::from_slice(&[1; 32]); @@ -2869,7 +2848,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_bad_blob_sidecar_not_cached_as_bad_import() { let (mut tx_manager, _network) = new_tx_manager().await; - let peer_id = PeerId::new([1; 64]); + let peer_id = PeerId::repeat_byte(1); let hash = B256::from_slice(&[1; 32]); tx_manager.network.update_sync_state(SyncState::Idle); @@ -2890,7 +2869,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_missing_blob_sidecar_not_cached_as_bad_import() { let (mut tx_manager, _network) = new_tx_manager().await; - let peer_id = PeerId::new([1; 64]); + let peer_id = PeerId::repeat_byte(1); let hash = B256::from_slice(&[3; 32]); tx_manager.network.update_sync_state(SyncState::Idle); @@ -2911,7 +2890,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_non_blob_sidecar_error_still_cached_as_bad_import() { let (mut tx_manager, _network) = new_tx_manager().await; - let peer_id = PeerId::new([1; 64]); + let peer_id = PeerId::repeat_byte(1); let hash = B256::from_slice(&[2; 32]); tx_manager.network.update_sync_state(SyncState::Idle); @@ -2930,18 +2909,12 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_on_get_pooled_transactions_network() { reth_tracing::init_test_tracing(); - let net = Testnet::create(2).await; - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - - drop(handles); - let handle = net.spawn(); + let net = Testnet::create(2).await.spawn(); + let [peer0, peer1] = net.peers_array(); - let listener0 = handle0.event_listener(); + let listener0 = peer0.event_listener(); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); + peer0.add_peer(peer1); let secret_key = SecretKey::new(&mut rand_08::thread_rng()); let client = NoopProvider::default(); @@ -2977,7 +2950,6 @@ mod tests { } } } - handle.terminate().await; let tx = MockTransaction::eip1559(); let _ = transactions @@ -2991,7 +2963,7 @@ mod tests { oneshot::channel::>>(); transactions.on_network_tx_event(NetworkTransactionEvent::GetPooledTransactions { - peer_id: *handle1.peer_id(), + peer_id: *peer1.peer_id(), request, response: send, }); @@ -3016,7 +2988,7 @@ mod tests { let mut tx_manager = new_tx_manager().await.0; let tx_fetcher = &mut tx_manager.transaction_fetcher; - let peer_id_1 = PeerId::new([1; 64]); + let peer_id_1 = PeerId::repeat_byte(1); let eth_version = EthVersion::Eth66; let txs = vec![ @@ -3026,7 +2998,7 @@ mod tests { nonce: 15u64, gas_price: 2200000000, gas_limit: 34811, - to: TxKind::Call(hex!("cf7f9e66af820a19257a2108375b180b0ec49167").into()), + to: TxKind::Call(address!("cf7f9e66af820a19257a2108375b180b0ec49167")), value: U256::from(1234u64), input: Default::default(), }), @@ -3049,7 +3021,7 @@ mod tests { max_priority_fee_per_gas: 1500000000, max_fee_per_gas: 1500000013, gas_limit: MIN_TRANSACTION_GAS, - to: TxKind::Call(hex!("61815774383099e24810ab832a5b2a5425c154d5").into()), + to: TxKind::Call(address!("61815774383099e24810ab832a5b2a5425c154d5")), value: U256::from(3000000000000000000u64), input: Default::default(), access_list: Default::default(), @@ -3132,8 +3104,8 @@ mod tests { let mut tx_manager = new_tx_manager().await.0; let tx_fetcher = &mut tx_manager.transaction_fetcher; - let peer_id_1 = PeerId::new([1; 64]); - let peer_id_2 = PeerId::new([2; 64]); + let peer_id_1 = PeerId::repeat_byte(1); + let peer_id_2 = PeerId::repeat_byte(2); let eth_version = EthVersion::Eth66; let seen_hashes = [B256::from_slice(&[1; 32]), B256::from_slice(&[2; 32])]; diff --git a/crates/net/network/src/transactions/test_harness.rs b/crates/net/network/src/transactions/test_harness.rs index bad9cf7653c..9eb11335192 100644 --- a/crates/net/network/src/transactions/test_harness.rs +++ b/crates/net/network/src/transactions/test_harness.rs @@ -187,17 +187,17 @@ impl Wake for WakeFlag { #[cfg(test)] mod tests { use super::*; - use alloy_primitives::map::{B256Map, B256Set}; + use alloy_primitives::map::B256Set; use futures::StreamExt; use reth_eth_wire::NewPooledTransactionHashes68; use reth_network_p2p::error::RequestError; use reth_transaction_pool::{test_utils::TransactionGenerator, TransactionPool}; - const PEER_A: PeerId = PeerId::new([1; 64]); - const PEER_B: PeerId = PeerId::new([2; 64]); + const PEER_A: PeerId = PeerId::repeat_byte(1); + const PEER_B: PeerId = PeerId::repeat_byte(2); fn peer(n: u8) -> PeerId { - PeerId::new([n; 64]) + PeerId::repeat_byte(n) } fn hash(n: u64) -> TxHash { @@ -296,40 +296,6 @@ mod tests { assert!(harness.take_requests().is_empty()); } - #[tokio::test] - async fn responses_are_processed_beyond_the_cooperative_budget() { - // tokio's channels stop making progress after 128 successful polls within one task poll, - // the harness must not be fooled by that into reporting an idle manager - let txs = pooled_txs(200); - let hashes = txs.iter().map(|tx| *tx.tx_hash()).collect::>(); - let by_hash = txs.iter().map(|tx| (*tx.tx_hash(), tx.clone())).collect::>(); - let peers = (1..=200).map(peer).collect::>(); - let mut harness = TxFetchHarness::new(peers.iter().copied()).await; - - // every peer announces one hash, so every hash needs its own response - for (peer_id, hash) in peers.iter().zip(&hashes) { - harness.announce(*peer_id, announcement(std::slice::from_ref(hash))); - } - harness.poll_until_idle(); - let mut responses = 0; - loop { - let requests = harness.take_requests(); - if requests.is_empty() { - break - } - for request in requests { - let txs = request.request.0.iter().map(|hash| by_hash[hash].clone()).collect(); - request.response.send(Ok(PooledTransactions(txs))).unwrap(); - responses += 1; - harness.poll_until_idle(); - } - } - - assert_eq!(responses, 200); - assert_eq!(harness.num_tracked_hashes(), 0); - assert_eq!(harness.pool().get_all(hashes).len(), 200, "all transactions are imported"); - } - #[tokio::test] async fn concurrent_responses_wait_for_pool_import_capacity() { let txs = pooled_txs(2); diff --git a/crates/net/network/tests/it/big_pooled_txs_req.rs b/crates/net/network/tests/it/big_pooled_txs_req.rs index 691ef5d379d..f10cb52d7e0 100644 --- a/crates/net/network/tests/it/big_pooled_txs_req.rs +++ b/crates/net/network/tests/it/big_pooled_txs_req.rs @@ -1,12 +1,7 @@ use alloy_primitives::{Signature, B256}; use reth_eth_wire::{GetPooledTransactions, PooledTransactions}; use reth_ethereum_primitives::TransactionSigned; -use reth_network::{ - test_utils::{NetworkEventStream, Testnet}, - NetworkEventListenerProvider, PeerRequest, -}; -use reth_network_api::{NetworkInfo, Peers}; -use reth_network_p2p::sync::{NetworkSyncUpdater, SyncState}; +use reth_network::{test_utils::Testnet, PeerRequest}; use reth_primitives_traits::SignedTransaction; use reth_provider::test_utils::MockEthProvider; use reth_transaction_pool::{ @@ -14,7 +9,6 @@ use reth_transaction_pool::{ TransactionPool, }; -use tokio::sync::oneshot; // peer0: `GetPooledTransactions` requester // peer1: `GetPooledTransactions` responder #[tokio::test(flavor = "multi_thread")] @@ -36,10 +30,7 @@ async fn test_large_tx_req() { let txs_hashes: Vec = txs.iter().map(|tx| *tx.get_hash()).collect(); // setup testnet - let mut net = Testnet::create_with(2, MockEthProvider::default()).await; - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); + let mut net = Testnet::create_with(2, MockEthProvider::default()).await.with_request_handlers(); // insert generated txs into responding peer's pool let pool1 = testing_pool(); @@ -49,41 +40,22 @@ async fn test_large_tx_req() { net.peers_mut()[0].install_transactions_manager(testing_pool()); net.peers_mut()[1].install_transactions_manager(pool1); - // connect peers together and check for connection existence - let handle0 = net.peers()[0].handle(); - let handle1 = net.peers()[1].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let _handle = net.spawn(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - // stop syncing - handle0.update_sync_state(SyncState::Idle); - handle1.update_sync_state(SyncState::Idle); - assert!(!handle0.is_syncing() && !handle1.is_syncing()); + let net = net.spawn(); + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); // make `GetPooledTransactions` request - let (send, receive) = oneshot::channel(); - handle0.send_request( - *handle1.peer_id(), - PeerRequest::GetPooledTransactions { - request: GetPooledTransactions(txs_hashes.clone()), - response: send, - }, - ); + let request = GetPooledTransactions(txs_hashes.clone()); + let PooledTransactions(txs) = peer0 + .request(*peer1.peer_id(), |response| PeerRequest::GetPooledTransactions { + request, + response, + }) + .await + .unwrap(); // check all txs have been received - match receive.await.unwrap() { - Ok(PooledTransactions(txs)) => { - for tx in txs { - assert!(txs_hashes.contains(tx.hash())); - } - } - Err(e) => { - panic!("error: {e:?}"); - } + for tx in txs { + assert!(txs_hashes.contains(tx.hash())); } } diff --git a/crates/net/network/tests/it/connect.rs b/crates/net/network/tests/it/connect.rs index 0567bf5680a..d1586a534bf 100644 --- a/crates/net/network/tests/it/connect.rs +++ b/crates/net/network/tests/it/connect.rs @@ -2,59 +2,44 @@ use alloy_primitives::map::HashSet; use futures::StreamExt; -use reth_chainspec::{MAINNET, SEPOLIA}; -use reth_discv4::Discv4Config; -use reth_eth_wire::{DisconnectReason, EthNetworkPrimitives, HeadersDirection}; +use reth_chainspec::SEPOLIA; +use reth_eth_wire::DisconnectReason; use reth_network::{ - test_utils::{NetworkEventStream, PeerConfig, Testnet}, - BlockDownloaderProvider, NetworkConfigBuilder, NetworkEvent, NetworkEventListenerProvider, - NetworkManager, PeersConfig, + config::rng_secret_key, + test_utils::{PeerConfig, Testnet}, + BlockDownloaderProvider, NetworkEvent, PeersConfig, }; use reth_network_api::{ events::{PeerEvent, SessionInfo}, - NetworkInfo, PeerKind, Peers, PeersInfo, + PeerKind, Peers, PeersInfo, }; use reth_network_p2p::{ headers::client::{HeadersClient, HeadersRequest}, sync::{NetworkSyncUpdater, SyncState}, }; -use reth_network_peers::{mainnet_nodes, NodeRecord, TrustedPeer}; +use reth_network_peers::{NodeRecord, TrustedPeer}; use reth_provider::test_utils::MockEthProvider; use reth_storage_api::noop::NoopProvider; -use reth_tasks::Runtime; use reth_tracing::init_test_tracing; use reth_transaction_pool::test_utils::testing_pool; -use secp256k1::SecretKey; use std::time::Duration; -use tokio::task; -use url::Host; #[tokio::test(flavor = "multi_thread")] async fn test_establish_connections() { reth_tracing::init_test_tracing(); for _ in 0..3 { - let net = Testnet::create(3).await; + let handle = Testnet::create(3).await.spawn(); + let [peer0, peer1, peer2] = handle.peers_array(); - net.for_each(|peer| assert_eq!(0, peer.num_peers())); + let listener0 = peer0.event_listener(); + let mut listener1 = peer1.event_listener(); + let mut listener2 = peer2.event_listener(); - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - let handle2 = handles.next().unwrap(); + peer0.add_peer(peer1); + peer0.add_peer(peer2); - drop(handles); - let handle = net.spawn(); - - let listener0 = handle0.event_listener(); - - let mut listener1 = handle1.event_listener(); - let mut listener2 = handle2.event_listener(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - handle0.add_peer(*handle2.peer_id(), handle2.local_addr()); - - let mut expected_connections = HashSet::from([*handle1.peer_id(), *handle2.peer_id()]); + let mut expected_connections = HashSet::from([*peer1.peer_id(), *peer2.peer_id()]); let mut expected_peers = expected_connections.clone(); // wait for all initiator connections @@ -91,226 +76,89 @@ async fn test_establish_connections() { #[tokio::test(flavor = "multi_thread")] async fn test_already_connected() { reth_tracing::init_test_tracing(); - let mut net = Testnet::default(); - - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let client = NoopProvider::default(); - let p1 = PeerConfig::default(); // initialize two peers with the same identifier - let p2 = PeerConfig::with_secret_key(client.clone(), secret_key); - let p3 = PeerConfig::with_secret_key(client, secret_key); - - net.extend_peer_with_config(vec![p1, p2, p3]).await.unwrap(); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - let handle2 = handles.next().unwrap(); + let secret_key = rng_secret_key(); + let net = Testnet::from_configs([ + PeerConfig::default(), + PeerConfig::default().with_secret_key(secret_key), + PeerConfig::default().with_secret_key(secret_key), + ]) + .await + .spawn(); + let [peer0, peer1, peer2] = net.peers_array(); - drop(handles); - let _handle = net.spawn(); + let mut listener0 = peer0.event_stream(); + let mut listener2 = peer2.event_stream(); - let mut listener0 = NetworkEventStream::new(handle0.event_listener()); - let mut listener2 = NetworkEventStream::new(handle2.event_listener()); + peer0.add_peer(peer1); + assert_eq!(listener0.next_session_established().await, Some(*peer1.peer_id())); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - let peer = listener0.next_session_established().await.unwrap(); - assert_eq!(peer, *handle1.peer_id()); - - handle2.add_peer(*handle0.peer_id(), handle0.local_addr()); - let peer = listener2.next_session_established().await.unwrap(); - assert_eq!(peer, *handle0.peer_id()); + peer2.add_peer(peer0); + assert_eq!(listener2.next_session_established().await, Some(*peer0.peer_id())); let (peer, reason) = listener2.next_session_closed().await.unwrap(); - assert_eq!(peer, *handle0.peer_id()); - let reason = reason.unwrap(); - assert_eq!(reason, DisconnectReason::AlreadyConnected); + assert_eq!(peer, *peer0.peer_id()); + assert_eq!(reason, Some(DisconnectReason::AlreadyConnected)); - assert_eq!(handle0.num_connected_peers(), 1); - assert_eq!(handle1.num_connected_peers(), 1); + assert_eq!(peer0.network().num_connected_peers(), 1); + assert_eq!(peer1.network().num_connected_peers(), 1); } #[tokio::test(flavor = "multi_thread")] async fn test_get_peer() { reth_tracing::init_test_tracing(); - let mut net = Testnet::default(); - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let secret_key_1 = SecretKey::new(&mut rand_08::thread_rng()); - let client = NoopProvider::default(); - - let p1 = PeerConfig::default(); - let p2 = PeerConfig::with_secret_key(client.clone(), secret_key); - let p3 = PeerConfig::with_secret_key(client, secret_key_1); - net.extend_peer_with_config(vec![p1, p2, p3]).await.unwrap(); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - let handle2 = handles.next().unwrap(); + let net = Testnet::create(3).await.spawn(); + let [peer0, peer1, peer2] = net.peers_array(); + let mut listener0 = peer0.event_stream(); - drop(handles); - let _handle = net.spawn(); + peer0.add_peer(peer1); + listener0.next_session_established().await.unwrap(); - let mut listener0 = NetworkEventStream::new(handle0.event_listener()); + peer0.add_peer(peer2); + listener0.next_session_established().await.unwrap(); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let _ = listener0.next_session_established().await.unwrap(); - - handle0.add_peer(*handle2.peer_id(), handle2.local_addr()); - let _ = listener0.next_session_established().await.unwrap(); - - let peers = handle0.get_all_peers().await.unwrap(); - assert_eq!(handle0.num_connected_peers(), peers.len()); + let peers = peer0.network().get_all_peers().await.unwrap(); + assert_eq!(peer0.network().num_connected_peers(), peers.len()); } #[tokio::test(flavor = "multi_thread")] async fn test_get_peer_by_id() { reth_tracing::init_test_tracing(); - let mut net = Testnet::default(); - - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let secret_key_1 = SecretKey::new(&mut rand_08::thread_rng()); - let client = NoopProvider::default(); - let p1 = PeerConfig::default(); - let p2 = PeerConfig::with_secret_key(client.clone(), secret_key); - let p3 = PeerConfig::with_secret_key(client, secret_key_1); - net.extend_peer_with_config(vec![p1, p2, p3]).await.unwrap(); + let net = Testnet::create(3).await.spawn(); + let [peer0, peer1, peer2] = net.peers_array(); + let mut listener0 = peer0.event_stream(); - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - let handle2 = handles.next().unwrap(); + peer0.add_peer(peer1); + listener0.next_session_established().await.unwrap(); - drop(handles); - let _handle = net.spawn(); - - let mut listener0 = NetworkEventStream::new(handle0.event_listener()); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let _ = listener0.next_session_established().await.unwrap(); - - let peer = handle0.get_peer_by_id(*handle1.peer_id()).await.unwrap(); + let peer = peer0.network().get_peer_by_id(*peer1.peer_id()).await.unwrap(); assert!(peer.is_some()); - let peer = handle0.get_peer_by_id(*handle2.peer_id()).await.unwrap(); + let peer = peer0.network().get_peer_by_id(*peer2.peer_id()).await.unwrap(); assert!(peer.is_none()); } -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_connect_with_boot_nodes() { - reth_tracing::init_test_tracing(); - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let mut discv4 = Discv4Config::builder(); - discv4.add_boot_nodes(mainnet_nodes()); - - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .discovery(discv4) - .build(NoopProvider::default()); - let network = NetworkManager::new(config).await.unwrap(); - - let handle = network.handle().clone(); - let mut events = handle.event_listener(); - tokio::task::spawn(network); - - while let Some(ev) = events.next().await { - dbg!(ev); - } -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_connect_with_builder() { - reth_tracing::init_test_tracing(); - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let mut discv4 = Discv4Config::builder(); - discv4.add_boot_nodes(mainnet_nodes()); - - let client = NoopProvider::default(); - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .discovery(discv4) - .build(client.clone()); - let (handle, network, _, requests) = NetworkManager::new(config) - .await - .unwrap() - .into_builder() - .request_handler(client) - .split_with_handle(); - - let mut events = handle.event_listener(); - - tokio::task::spawn(async move { - tokio::join!(network, requests); - }); - - let h = handle.clone(); - task::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(5)).await; - dbg!(h.num_connected_peers()); - } - }); - - while let Some(ev) = events.next().await { - dbg!(ev); - } -} - // expects a `ENODE="enode://"` env var that holds the record #[tokio::test(flavor = "multi_thread")] #[ignore] async fn test_connect_to_trusted_peer() { reth_tracing::init_test_tracing(); - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let discv4 = Discv4Config::builder(); - - let client = NoopProvider::default(); - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .discovery(discv4) - .build(client.clone()); - let transactions_manager_config = config.transactions_manager_config.clone(); - let (handle, network, transactions, requests) = NetworkManager::new(config) - .await - .unwrap() - .into_builder() - .request_handler(client) - .transactions(testing_pool(), transactions_manager_config) - .split_with_handle(); - - let mut events = handle.event_listener(); - - tokio::task::spawn(async move { - tokio::join!(network, requests, transactions); - }); - - let node: NodeRecord = std::env::var("ENODE").unwrap().parse().unwrap(); - - handle.add_trusted_peer(node.id, node.tcp_addr()); - let h = handle.clone(); - h.update_sync_state(SyncState::Syncing); + let mut net = Testnet::create(1).await.with_request_handlers(); + net.peers_mut()[0].install_transactions_manager(testing_pool()); + let net = net.spawn(); + let [peer] = net.peers_array(); + let mut events = peer.event_listener(); - task::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(5)).await; - dbg!(h.num_connected_peers()); - } - }); - - let fetcher = handle.fetch_client().await.unwrap(); - - let headers = fetcher - .get_headers(HeadersRequest { - start: 73174u64.into(), - limit: 10, - direction: HeadersDirection::Falling, - }) - .await; + let node: NodeRecord = std::env::var("ENODE").unwrap().parse().unwrap(); + peer.network().add_trusted_peer(node.id, node.tcp_addr()); + peer.network().update_sync_state(SyncState::Syncing); + let fetcher = peer.network().fetch_client().await.unwrap(); + let headers = fetcher.get_headers(HeadersRequest::falling(73174u64.into(), 10)).await; dbg!(&headers); while let Some(ev) = events.next().await { @@ -321,341 +169,226 @@ async fn test_connect_to_trusted_peer() { #[tokio::test(flavor = "multi_thread")] async fn test_shutdown() { reth_tracing::init_test_tracing(); - let net = Testnet::create(3).await; - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - let handle2 = handles.next().unwrap(); + let net = Testnet::create(3).await.spawn(); + let [peer0, peer1, peer2] = net.peers_array(); + let mut listener0 = peer0.event_stream(); + let mut listener1 = peer1.event_stream(); - drop(handles); - let _handle = net.spawn(); + peer0.add_peer(peer1); + peer0.add_peer(peer2); + peer1.add_peer(peer2); - let mut listener0 = NetworkEventStream::new(handle0.event_listener()); - let mut listener1 = NetworkEventStream::new(handle1.event_listener()); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - handle0.add_peer(*handle2.peer_id(), handle2.local_addr()); - handle1.add_peer(*handle2.peer_id(), handle2.local_addr()); - - let mut expected_connections = HashSet::from([*handle1.peer_id(), *handle2.peer_id()]); + let mut expected_connections = HashSet::from([*peer1.peer_id(), *peer2.peer_id()]); // Before shutting down, we have two connected peers - let peer1 = listener0.next_session_established().await.unwrap(); - let peer2 = listener0.next_session_established().await.unwrap(); - assert_eq!(handle0.num_connected_peers(), 2); - assert!(expected_connections.contains(&peer1)); - assert!(expected_connections.contains(&peer2)); + let established1 = listener0.next_session_established().await.unwrap(); + let established2 = listener0.next_session_established().await.unwrap(); + assert_eq!(peer0.network().num_connected_peers(), 2); + assert!(expected_connections.contains(&established1)); + assert!(expected_connections.contains(&established2)); - handle0.shutdown().await.unwrap(); + peer0.network().shutdown().await.unwrap(); // All sessions get disconnected - let (peer1, _reason) = listener0.next_session_closed().await.unwrap(); - let (peer2, _reason) = listener0.next_session_closed().await.unwrap(); - assert_eq!(handle0.num_connected_peers(), 0); - assert!(expected_connections.remove(&peer1)); - assert!(expected_connections.remove(&peer2)); + let (closed1, _reason) = listener0.next_session_closed().await.unwrap(); + let (closed2, _reason) = listener0.next_session_closed().await.unwrap(); + assert_eq!(peer0.network().num_connected_peers(), 0); + assert!(expected_connections.remove(&closed1)); + assert!(expected_connections.remove(&closed2)); // Connected peers receive a shutdown signal let (_peer, reason) = listener1.next_session_closed().await.unwrap(); assert_eq!(reason, Some(DisconnectReason::ClientQuitting)); // New connections ignored - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - assert_eq!(handle0.num_connected_peers(), 0); + peer0.add_peer(peer1); + assert_eq!(peer0.network().num_connected_peers(), 0); } #[tokio::test(flavor = "multi_thread")] async fn test_trusted_peer_only() { init_test_tracing(); - let net = Testnet::create(2).await; - let mut handles = net.handles(); - - // handle0 is used to test that: - // * outgoing connections to untrusted peers are not allowed - // * outgoing connections to trusted peers are allowed and succeed - let handle0 = handles.next().unwrap(); - - // handle1 is used to test that: - // * incoming connections from untrusted peers are not allowed - // * incoming connections from trusted peers are allowed and succeed - let handle1 = handles.next().unwrap(); - - drop(handles); - let _handle = net.spawn(); - - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let peers_config = PeersConfig::test().with_trusted_nodes_only(true); - - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .peer_config(peers_config) - .build(NoopProvider::default()); - - let network = NetworkManager::new(config).await.unwrap(); - - let handle = network.handle().clone(); - tokio::task::spawn(network); - // create networkeventstream to get the next session event easily. - let events = handle.event_listener(); - let mut event_stream = NetworkEventStream::new(events); - - // only connect to trusted peers. + let net = Testnet::from_configs([ + PeerConfig::default(), + PeerConfig::default(), + PeerConfig::default().with_peers_config(PeersConfig::test().with_trusted_nodes_only(true)), + ]) + .await + .spawn(); + // `peer` only accepts trusted peers, and: + // * peer0 is used to test that outgoing connections to untrusted peers are not allowed, and + // outgoing connections to trusted peers are allowed and succeed + // * peer1 is used to test that incoming connections from untrusted peers are not allowed, and + // incoming connections from trusted peers are allowed and succeed + let [peer0, peer1, peer] = net.peers_array(); + let mut event_stream = peer.event_stream(); // connect to an untrusted peer should fail. - handle.add_peer(*handle0.peer_id(), handle0.local_addr()); + peer.add_peer(peer0); // wait 500ms, the number of connection is still 0. tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 0); + assert_eq!(peer.network().num_connected_peers(), 0); // add to trusted peer. - handle.add_trusted_peer(*handle0.peer_id(), handle0.local_addr()); + peer.add_trusted_peer(peer0); - let outgoing_peer_id = event_stream.next_session_established().await.unwrap(); - assert_eq!(outgoing_peer_id, *handle0.peer_id()); - assert_eq!(handle.num_connected_peers(), 1); + assert_eq!(event_stream.next_session_established().await, Some(*peer0.peer_id())); + assert_eq!(peer.network().num_connected_peers(), 1); // only receive connections from trusted peers. - handle1.add_peer(*handle.peer_id(), handle.local_addr()); + peer1.add_peer(peer); // wait 500ms, the number of connections is still 1, because peer1 is untrusted. tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 1); + assert_eq!(peer.network().num_connected_peers(), 1); - // remove handle from handle1's peer list to prevent a competing outgoing connection attempt - // from handle1 racing with handle's outgoing connection below, which can cause duplicate + // remove peer from peer1's peer list to prevent a competing outgoing connection attempt + // from peer1 racing with peer's outgoing connection below, which can cause duplicate // session resolution to drop a connection - handle1.remove_peer(*handle.peer_id(), PeerKind::Basic); + peer1.network().remove_peer(*peer.peer_id(), PeerKind::Basic); - handle.add_trusted_peer(*handle1.peer_id(), handle1.local_addr()); + peer.add_trusted_peer(peer1); - // wait for the next session established event to check the handle1 incoming connection - let outgoing_peer_id1 = event_stream.next_session_established().await.unwrap(); - assert_eq!(outgoing_peer_id1, *handle1.peer_id()); + // wait for the next session established event to check the peer1 incoming connection + assert_eq!(event_stream.next_session_established().await, Some(*peer1.peer_id())); tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 2); + assert_eq!(peer.network().num_connected_peers(), 2); - // check that handle0 and handle1 both have peers. - assert_eq!(handle0.num_connected_peers(), 1); - assert_eq!(handle1.num_connected_peers(), 1); + // check that peer0 and peer1 both have peers. + assert_eq!(peer0.network().num_connected_peers(), 1); + assert_eq!(peer1.network().num_connected_peers(), 1); } #[tokio::test(flavor = "multi_thread")] async fn test_network_state_change() { - let net = Testnet::create(1).await; - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let peers_config = PeersConfig::test(); - - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .peer_config(peers_config) - .build(NoopProvider::default()); - - let network = NetworkManager::new(config).await.unwrap(); - - let handle = network.handle().clone(); - tokio::task::spawn(network); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - - drop(handles); - let _handle = net.spawn(); + let net = Testnet::create(2).await.spawn(); + let [peer0, peer] = net.peers_array(); + let mut event_stream = peer.event_stream(); // Set network state to Hibernate. - handle.set_network_hibernate(); + peer.network().set_network_hibernate(); - handle.add_peer(*handle0.peer_id(), handle0.local_addr()); + peer.add_peer(peer0); // wait 500ms, the number of connections is still 0, because network is Hibernate. tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 0); + assert_eq!(peer.network().num_connected_peers(), 0); // Set network state to Active. - handle.set_network_active(); + peer.network().set_network_active(); - // wait 500ms, the number of connections should be 1, because network is Active and outbound - // slot should be filled. - tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 1); + // the outbound slot should be filled now that the network is Active. + assert_eq!(event_stream.next_session_established().await, Some(*peer0.peer_id())); + assert_eq!(peer.network().num_connected_peers(), 1); } #[tokio::test(flavor = "multi_thread")] async fn test_exceed_outgoing_connections() { - let net = Testnet::create(2).await; - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let peers_config = PeersConfig::test().with_max_outbound(1); - - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .peer_config(peers_config) - .build(NoopProvider::default()); - - let network = NetworkManager::new(config).await.unwrap(); - - let handle = network.handle().clone(); - tokio::task::spawn(network); - - // create networkeventstream to get the next session event easily. - let events = handle.event_listener(); - let mut event_stream = NetworkEventStream::new(events); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - - drop(handles); - let _handle = net.spawn(); - - handle.add_peer(*handle0.peer_id(), handle0.local_addr()); + let net = Testnet::from_configs([ + PeerConfig::default(), + PeerConfig::default(), + PeerConfig::default().with_peers_config(PeersConfig::test().with_max_outbound(1)), + ]) + .await + .spawn(); + let [peer0, peer1, peer] = net.peers_array(); + let mut event_stream = peer.event_stream(); - let outgoing_peer_id = event_stream.next_session_established().await.unwrap(); - assert_eq!(outgoing_peer_id, *handle0.peer_id()); + peer.add_peer(peer0); + assert_eq!(event_stream.next_session_established().await, Some(*peer0.peer_id())); - handle.add_peer(*handle1.peer_id(), handle1.local_addr()); + peer.add_peer(peer1); // wait 500ms, the number of connections is still 1, indicating that the max outbound is in // effect. tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 1); + assert_eq!(peer.network().num_connected_peers(), 1); } #[tokio::test(flavor = "multi_thread")] async fn test_disconnect_incoming_when_exceeded_incoming_connections() { - let net = Testnet::create(1).await; - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let peers_config = PeersConfig::test().with_max_inbound(0); + let net = Testnet::from_configs([PeerConfig::default(), max_inbound(0)]).await.spawn(); + let [other_peer, peer] = net.peers_array(); - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .peer_config(peers_config) - .build(NoopProvider::default()); - - let network = NetworkManager::new(config).await.unwrap(); - - let other_peer_handle = net.handles().next().unwrap(); - - let handle = network.handle().clone(); - - other_peer_handle.add_peer(*handle.peer_id(), handle.local_addr()); - - tokio::task::spawn(network); - let net_handle = net.spawn(); + other_peer.add_peer(peer); tokio::time::sleep(Duration::from_millis(500)).await; - - assert_eq!(handle.num_connected_peers(), 0); - - net_handle.terminate().await; + assert_eq!(peer.network().num_connected_peers(), 0); } #[tokio::test(flavor = "multi_thread")] async fn test_always_accept_incoming_connections_from_trusted_peers() { reth_tracing::init_test_tracing(); - let peer1 = new_random_peer(10, vec![]).await; - let peer2 = new_random_peer(0, vec![]).await; - - // setup the peer with max_inbound = 1, and add other_peer_3 as trust nodes - let trusted_peer2 = TrustedPeer { - host: Host::Ipv4(peer2.local_addr().ip().to_string().parse().unwrap()), - tcp_port: peer2.local_addr().port(), - udp_port: peer2.local_addr().port(), - id: *peer2.peer_id(), - }; - - let peer = new_random_peer(0, vec![trusted_peer2.clone()]).await; - let handle = peer.handle().clone(); - let peer1_handle = peer1.handle().clone(); - let peer2_handle = peer2.handle().clone(); + let mut net = Testnet::from_configs([max_inbound(10), max_inbound(0)]).await; - tokio::task::spawn(peer); - tokio::task::spawn(peer1); - tokio::task::spawn(peer2); + // setup the peer with max_inbound = 0, and add peer2 as trusted node + let trusted_peer2 = TrustedPeer::from(net.peers()[1].handle().local_node_record()); + let peer_config = + PeersConfig::test().with_max_inbound(0).with_trusted_nodes(vec![trusted_peer2]); + net.add_peer_with_config(PeerConfig::default().with_peers_config(peer_config)).await.unwrap(); - let mut events = NetworkEventStream::new(handle.event_listener()); - let mut events_peer1 = NetworkEventStream::new(peer1_handle.event_listener()); + let net = net.spawn(); + let [peer1, peer2, peer] = net.peers_array(); + let mut events = peer.event_stream(); + let mut events_peer1 = peer1.event_stream(); // incoming connection should fail because exceeding max_inbound - peer1_handle.add_peer(*handle.peer_id(), handle.local_addr()); - - let (peer_id, reason) = events_peer1.next_session_closed().await.unwrap(); - assert_eq!(peer_id, *handle.peer_id()); + peer1.add_peer(peer); + + let (peer_id, reason) = + tokio::time::timeout(Duration::from_secs(10), events_peer1.next_session_closed()) + .await + .expect("untrusted peer was not rejected in time") + .unwrap(); + assert_eq!(peer_id, *peer.peer_id()); assert_eq!(reason, Some(DisconnectReason::TooManyPeers)); - let peer_id = events.next_session_established().await.unwrap(); - assert_eq!(peer_id, *peer1_handle.peer_id()); + assert_eq!(peer.network().num_connected_peers(), 0); // outbound connection from `peer2` should succeed - peer2_handle.add_peer(*handle.peer_id(), handle.local_addr()); - let peer_id = events.next_session_established().await.unwrap(); - assert_eq!(peer_id, *peer2_handle.peer_id()); - - assert_eq!(handle.num_connected_peers(), 1); + peer2.add_peer(peer); + // The rejected peer must not emit an established event before the trusted peer. + let established = + tokio::time::timeout(Duration::from_secs(10), events.next_session_established()) + .await + .expect("trusted peer was not accepted in time"); + assert_eq!(established, Some(*peer2.peer_id())); + + assert_eq!(peer.network().num_connected_peers(), 1); } #[tokio::test(flavor = "multi_thread")] async fn test_rejected_by_already_connect() { reth_tracing::init_test_tracing(); - let other_peer1 = new_random_peer(10, vec![]).await; - let other_peer2 = new_random_peer(10, vec![]).await; // setup the peer with max_inbound = 2 - let peer = new_random_peer(2, vec![]).await; - - let handle = peer.handle().clone(); - let other_peer_handle1 = other_peer1.handle().clone(); - let other_peer_handle2 = other_peer2.handle().clone(); - - tokio::task::spawn(peer); - tokio::task::spawn(other_peer1); - tokio::task::spawn(other_peer2); - - let mut events = NetworkEventStream::new(handle.event_listener()); + let net = + Testnet::from_configs([max_inbound(10), max_inbound(10), max_inbound(2)]).await.spawn(); + let [other_peer1, other_peer2, peer] = net.peers_array(); + let mut events = peer.event_stream(); // incoming connection should succeed - other_peer_handle1.add_peer(*handle.peer_id(), handle.local_addr()); - let peer_id = events.next_session_established().await.unwrap(); - assert_eq!(peer_id, *other_peer_handle1.peer_id()); - assert_eq!(handle.num_connected_peers(), 1); + other_peer1.add_peer(peer); + assert_eq!(events.next_session_established().await, Some(*other_peer1.peer_id())); + assert_eq!(peer.network().num_connected_peers(), 1); // incoming connection from the same peer should be rejected by already connected // and num_inbount should still be 1 - other_peer_handle1.add_peer(*handle.peer_id(), handle.local_addr()); + other_peer1.add_peer(peer); tokio::time::sleep(Duration::from_millis(500)).await; // incoming connection from other_peer2 should succeed - other_peer_handle2.add_peer(*handle.peer_id(), handle.local_addr()); - let peer_id = events.next_session_established().await.unwrap(); - assert_eq!(peer_id, *other_peer_handle2.peer_id()); + other_peer2.add_peer(peer); + assert_eq!(events.next_session_established().await, Some(*other_peer2.peer_id())); // wait 500ms and check that other_peer2 is not rejected by TooManyPeers tokio::time::sleep(Duration::from_millis(500)).await; - assert_eq!(handle.num_connected_peers(), 2); -} - -async fn new_random_peer( - max_in_bound: usize, - trusted_nodes: Vec, -) -> NetworkManager { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - let peers_config = - PeersConfig::test().with_max_inbound(max_in_bound).with_trusted_nodes(trusted_nodes); - - let config = NetworkConfigBuilder::new(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .peer_config(peers_config) - .build_with_noop_provider(MAINNET.clone()); - - NetworkManager::new(config).await.unwrap() + assert_eq!(peer.network().num_connected_peers(), 2); } #[tokio::test(flavor = "multi_thread")] @@ -663,16 +396,12 @@ async fn test_connect_many() { reth_tracing::init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(5, provider).await; - - // install request handlers - let net = net.with_eth_pool(); - let handle = net.spawn(); + let net = Testnet::create_with(5, provider).await.with_eth_pool().spawn(); // connect all the peers - handle.connect_peers().await; + net.connect_peers().await; // check that all the peers are connected - for peer in handle.peers() { + for peer in net.peers() { assert_eq!(peer.network().num_connected_peers(), 4); } } @@ -681,114 +410,92 @@ async fn test_connect_many() { async fn test_disconnect_then_connect() { reth_tracing::init_test_tracing(); - let net = Testnet::create(2).await; - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - - drop(handles); - let _handle = net.spawn(); + let net = Testnet::create(2).await.spawn(); + let [peer0, peer1] = net.peers_array(); + let mut listener0 = peer0.event_stream(); - let mut listener0 = NetworkEventStream::new(handle0.event_listener()); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let peer = listener0.next_session_established().await.unwrap(); - assert_eq!(peer, *handle1.peer_id()); + peer0.add_peer(peer1); + assert_eq!(listener0.next_session_established().await, Some(*peer1.peer_id())); - handle0.disconnect_peer(*handle1.peer_id()); + peer0.network().disconnect_peer(*peer1.peer_id()); let (peer, _) = listener0.next_session_closed().await.unwrap(); - assert_eq!(peer, *handle1.peer_id()); + assert_eq!(peer, *peer1.peer_id()); - handle0.connect_peer(*handle1.peer_id(), handle1.local_addr()); - let peer = listener0.next_session_established().await.unwrap(); - assert_eq!(peer, *handle1.peer_id()); + peer0.network().connect_peer(*peer1.peer_id(), peer1.local_addr()); + assert_eq!(listener0.next_session_established().await, Some(*peer1.peer_id())); } #[tokio::test(flavor = "multi_thread")] async fn test_connect_peer_in_different_network_should_fail() { reth_tracing::init_test_tracing(); - // peer in mainnet. - let peer = new_random_peer(10, vec![]).await; - let peer_handle = peer.handle().clone(); - tokio::task::spawn(peer); - - // peer in sepolia. - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - // If the remote disconnect first, then we would not get a fatal protocol error. So set - // max_backoff_count to 0 to speed up the removal of the peer. - let peers_config = PeersConfig::default().with_max_backoff_count(0); - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .peer_config(peers_config) - .build_with_noop_provider(SEPOLIA.clone()); - - let network = NetworkManager::new(config).await.unwrap(); - let handle = network.handle().clone(); - tokio::task::spawn(network); - - // create networkeventstream to get the next session event easily. - let events = handle.event_listener(); - - let mut event_stream = NetworkEventStream::new(events); - - handle.add_peer(*peer_handle.peer_id(), peer_handle.local_addr()); - - let added_peer_id = event_stream.peer_added().await.unwrap(); - assert_eq!(added_peer_id, *peer_handle.peer_id()); - - let removed_peer_id = event_stream.peer_removed().await.unwrap(); - assert_eq!(removed_peer_id, *peer_handle.peer_id()); + let net = Testnet::from_configs([ + // peer in mainnet. + max_inbound(10), + // peer in sepolia. If the remote disconnect first, then we would not get a fatal protocol + // error. So set max_backoff_count to 0 to speed up the removal of the peer. + PeerConfig::new(NoopProvider::eth(SEPOLIA.clone())) + .with_peers_config(PeersConfig::default().with_max_backoff_count(0)), + ]) + .await + .spawn(); + let [mainnet_peer, sepolia_peer] = net.peers_array(); + let mut event_stream = sepolia_peer.event_stream(); + + sepolia_peer.add_peer(mainnet_peer); + + assert_eq!(event_stream.peer_added().await, Some(*mainnet_peer.peer_id())); + assert_eq!(event_stream.peer_removed().await, Some(*mainnet_peer.peer_id())); } #[tokio::test(flavor = "multi_thread")] async fn test_reconnect_trusted() { reth_tracing::init_test_tracing(); - let net = Testnet::create(2).await; - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - - drop(handles); - let _handle = net.spawn(); - - let mut listener0 = NetworkEventStream::new(handle0.event_listener()); - - // Connect the two peers - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - handle1.add_peer(*handle0.peer_id(), handle0.local_addr()); - let peer = listener0.next_session_established().await.unwrap(); - assert_eq!(peer, *handle1.peer_id()); - assert_eq!(handle0.num_connected_peers(), 1); - - // Add handle1 as a trusted peer - handle0.add_trusted_peer(*handle1.peer_id(), handle1.local_addr()); - - // Trigger disconnect from handle0 - handle0.disconnect_peer(*handle1.peer_id()); - - // Wait for the session to close - let (peer, reason) = listener0.next_session_closed().await.unwrap(); - assert_eq!(peer, *handle1.peer_id()); - assert_eq!(handle0.num_connected_peers(), 0); - println!("Disconnect reason: {:?}", reason); - - // Await that handle1 (trusted peer) reconnects automatically - let reconnect_result = - tokio::time::timeout(Duration::from_secs(10), listener0.next_session_established()).await; + // A gracefully closed session is backed off for `incoming_ip_throttle_duration` (30s by + // default) before the peer is dialed again, so shorten it to observe the redial. + let peers_config = PeersConfig { + incoming_ip_throttle_duration: Duration::from_millis(100), + ..PeersConfig::test() + }; + let net = Testnet::from_configs([ + PeerConfig::default().with_peers_config(peers_config), + PeerConfig::default(), + ]) + .await + .spawn(); + let [peer0, peer1] = net.peers_array(); + let mut listener0 = peer0.event_stream(); + + // Only peer0 knows peer1's listener address, so peer0 has to dial any reconnect itself. + peer0.add_peer(peer1); + assert_eq!(listener0.next_session_established().await, Some(*peer1.peer_id())); + assert_eq!(peer0.network().num_connected_peers(), 1); + + // Add peer1 as a trusted peer. + peer0.add_trusted_peer(peer1); + + // Trigger disconnect from peer0. + peer0.network().disconnect_peer(*peer1.peer_id()); + + // Wait for the session to close. + let (peer, _) = listener0.next_session_closed().await.unwrap(); + assert_eq!(peer, *peer1.peer_id()); + assert_eq!(peer0.network().num_connected_peers(), 0); + + // Await that peer0 redials its trusted peer once the backoff expired. + let reconnected = + tokio::time::timeout(Duration::from_secs(10), listener0.next_session_established()) + .await + .expect("trusted peer was not redialed in time"); + assert_eq!(reconnected, Some(*peer1.peer_id())); + let peer = peer0.network().get_peer_by_id(*peer1.peer_id()).await.unwrap().unwrap(); + assert!(peer.direction.is_outgoing()); + assert_eq!(peer.kind, PeerKind::Trusted); +} - match reconnect_result { - Ok(Some(peer)) => { - assert_eq!(peer, *handle1.peer_id()); - assert_eq!(handle0.num_connected_peers(), 1); - } - Ok(None) => panic!("Event stream ended without reconnection"), - Err(_) => panic!("Trusted peer did not reconnect in time"), - } +/// A peer that accepts at most `max_inbound` incoming connections. +fn max_inbound(max_inbound: usize) -> PeerConfig { + PeerConfig::default().with_peers_config(PeersConfig::test().with_max_inbound(max_inbound)) } diff --git a/crates/net/network/tests/it/eth72.rs b/crates/net/network/tests/it/eth72.rs index dfa1a5230df..8483a48bd31 100644 --- a/crates/net/network/tests/it/eth72.rs +++ b/crates/net/network/tests/it/eth72.rs @@ -4,12 +4,14 @@ //! announcement cell mask in both directions as well as a blob-elided `PooledTransactions` //! response per [EIP-8070](https://eips.ethereum.org/EIPS/eip-8070). +use crate::utils::funded_transaction; use alloy_consensus::{ constants::EIP4844_TX_TYPE_ID, transaction::TxEip4844WithSidecar, Header, SignableTransaction, TxEip4844, }; use alloy_eips::{ eip2718::Encodable2718, + eip4844::VERSIONED_HASH_VERSION_KZG, eip7594::{BlobTransactionSidecarEip7594, BlobTransactionSidecarVariant}, }; use alloy_primitives::{Address, B256, U256}; @@ -24,17 +26,16 @@ use reth_eth_wire_types::{message::RequestPair, PooledTransactions}; use reth_ethereum_forks::EthereumHardfork; use reth_ethereum_primitives::{Block, PooledTransactionVariant}; use reth_network::{ - test_utils::{NetworkEventStream, PeerConfig, Testnet}, + config::rng_secret_key, + test_utils::{PeerConfig, Testnet}, transactions::config::{TransactionPropagationMode, TransactionsManagerConfig}, - NetworkEventListenerProvider, PeersInfo, + PeersInfo, }; use reth_network_peers::pk2id; use reth_primitives_traits::{crypto::secp256k1::sign_message, SignerRecoverable}; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; -use reth_transaction_pool::{ - test_utils::TransactionGenerator, AddedTransactionOutcome, PoolTransaction, TransactionPool, -}; -use secp256k1::{SecretKey, SECP256K1}; +use reth_transaction_pool::TransactionPool; +use secp256k1::SECP256K1; use std::time::Duration; use tokio::net::TcpStream; @@ -59,7 +60,7 @@ async fn next_message(stream: &mut RawEthStream) -> EthMessage PooledTransactionVariant { let mut versioned_hash = B256::random(); - versioned_hash.0[0] = 0x01; + versioned_hash.0[0] = VERSIONED_HASH_VERSION_KZG; let tx = TxEip4844 { chain_id: 1, @@ -107,30 +108,25 @@ async fn test_eth72_blob_announcements_and_elided_pooled_response() { }; provider.add_block(tip.hash_slow(), Block { header: tip, body: Default::default() }); - let mut net = Testnet::create_with(0, provider.clone()).await; - net.add_peer_with_config(PeerConfig::with_protocols( - provider.clone(), - Some(EthVersion::Eth72.into()), - )) - .await - .unwrap(); - // Announce hashes to every peer instead of broadcasting transactions in full, so the raw // peer receives `NewPooledTransactionHashes` for non-blob transactions as well. let tx_manager_config = TransactionsManagerConfig { propagation_mode: TransactionPropagationMode::Max(0), ..Default::default() }; - let net = net.with_eth_pool_config(tx_manager_config); - let handle = net.spawn(); - - let node = &handle.peers()[0]; + let net = Testnet::from_configs([ + PeerConfig::new(provider.clone()).with_protocols([EthVersion::Eth72]) + ]) + .await + .with_eth_pool_config(tx_manager_config) + .spawn(); + let [node] = net.peers_array(); let node_id = *node.peer_id(); let node_pool = node.pool().unwrap(); - let mut events = NetworkEventStream::new(node.network().event_listener()); + let mut events = node.event_stream(); // connect a raw eth/72 peer - let raw_key = SecretKey::new(&mut rand_08::thread_rng()); + let raw_key = rng_secret_key(); let raw_id = pk2id(&raw_key.public_key(SECP256K1)); let tcp = TcpStream::connect(node.local_addr()).await.unwrap(); @@ -154,11 +150,8 @@ async fn test_eth72_blob_announcements_and_elided_pooled_response() { let baseline_reputation = node.peer_handle().peer_by_id(raw_id).await.unwrap().reputation(); // 1) node -> raw peer: a plain pending transaction is announced with the eth/72 message - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::from(100_000_000u64))); - let AddedTransactionOutcome { hash: pending_hash, .. } = - node_pool.add_external_transaction(tx).await.unwrap(); + let pending_hash = + node_pool.add_external_transaction(funded_transaction(&provider)).await.unwrap().hash; let announcement = match next_message(&mut eth_stream).await { EthMessage::NewPooledTransactionHashes72(announcement) => announcement, diff --git a/crates/net/network/tests/it/main.rs b/crates/net/network/tests/it/main.rs index 939ff89f3b8..731af277d14 100644 --- a/crates/net/network/tests/it/main.rs +++ b/crates/net/network/tests/it/main.rs @@ -10,5 +10,6 @@ mod snap; mod startup; mod transaction_hash_fetching; mod txgossip; +mod utils; const fn main() {} diff --git a/crates/net/network/tests/it/multiplex.rs b/crates/net/network/tests/it/multiplex.rs index 9646282002c..25d19a2a94d 100644 --- a/crates/net/network/tests/it/multiplex.rs +++ b/crates/net/network/tests/it/multiplex.rs @@ -1,5 +1,5 @@ #![allow(unreachable_pub)] -//! Testing gossiping of transactions. +//! Testing `RLPx` sub-protocol multiplexing. use std::{ net::SocketAddr, @@ -14,13 +14,10 @@ use reth_eth_wire::{ }; use reth_network::{ protocol::{ConnectionHandler, OnNotSupported, ProtocolHandler}, - test_utils::{NetworkEventStream, Testnet}, - NetworkConfigBuilder, NetworkEventListenerProvider, NetworkManager, + test_utils::Testnet, }; -use reth_network_api::{Direction, NetworkInfo, PeerId, Peers}; -use reth_provider::{noop::NoopProvider, test_utils::MockEthProvider}; -use reth_tasks::Runtime; -use secp256k1::SecretKey; +use reth_network_api::{Direction, PeerId}; +use reth_provider::test_utils::MockEthProvider; use tokio::sync::{mpsc, oneshot}; use tokio_stream::wrappers::UnboundedReceiverStream; @@ -282,41 +279,19 @@ impl Stream for PingPongProtoConnection { async fn test_connect_to_non_multiplex_peer() { reth_tracing::init_test_tracing(); - let net = Testnet::create(1).await; - - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); - - let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) - .listener_port(0) - .disable_discovery() - .build(NoopProvider::default()); - - let mut network = NetworkManager::new(config).await.unwrap(); - + let mut net = Testnet::create(2).await; let (tx, _) = mpsc::unbounded_channel(); - network.add_rlpx_sub_protocol(PingPongProtoHandler { state: ProtocolState { events: tx } }); - - let handle = network.handle().clone(); - tokio::task::spawn(network); - - // create networkeventstream to get the next session event easily. - let events = handle.event_listener(); - let mut event_stream = NetworkEventStream::new(events); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - drop(handles); - - let _handle = net.spawn(); - - handle.add_peer(*handle0.peer_id(), handle0.local_addr()); + net.peers_mut()[1] + .add_rlpx_sub_protocol(PingPongProtoHandler { state: ProtocolState { events: tx } }); + let net = net.spawn(); + let [peer0, peer] = net.peers_array(); + let mut event_stream = peer.event_stream(); - let added_peer_id = event_stream.peer_added().await.unwrap(); - assert_eq!(added_peer_id, *handle0.peer_id()); + peer.add_peer(peer0); - // peer with mismatched capability version should fail to connect and be removed. - let removed_peer_id = event_stream.peer_removed().await.unwrap(); - assert_eq!(removed_peer_id, *handle0.peer_id()); + assert_eq!(event_stream.peer_added().await, Some(*peer0.peer_id())); + // peer without the multiplexed protocol should fail to connect and be removed. + assert_eq!(event_stream.peer_removed().await, Some(*peer0.peer_id())); } #[tokio::test(flavor = "multi_thread")] @@ -333,25 +308,18 @@ async fn test_proto_multiplex() { net.peers_mut()[1] .add_rlpx_sub_protocol(PingPongProtoHandler { state: ProtocolState { events: tx } }); - let handle = net.spawn(); + let net = net.spawn(); // connect all the peers - handle.connect_peers().await; + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); - let peer0_to_peer1 = from_peer0.recv().await.unwrap(); - let peer0_conn = match peer0_to_peer1 { - ProtocolEvent::Established { direction: _, peer_id, to_connection } => { - assert_eq!(peer_id, *handle.peers()[1].peer_id()); - to_connection - } - }; + let ProtocolEvent::Established { peer_id, to_connection: peer0_conn, .. } = + from_peer0.recv().await.unwrap(); + assert_eq!(peer_id, *peer1.peer_id()); - let peer1_to_peer0 = from_peer1.recv().await.unwrap(); - let peer1_conn = match peer1_to_peer0 { - ProtocolEvent::Established { direction: _, peer_id, to_connection } => { - assert_eq!(peer_id, *handle.peers()[0].peer_id()); - to_connection - } - }; + let ProtocolEvent::Established { peer_id, to_connection: peer1_conn, .. } = + from_peer1.recv().await.unwrap(); + assert_eq!(peer_id, *peer0.peer_id()); let (tx, rx) = oneshot::channel(); // send a ping message from peer0 to peer1 diff --git a/crates/net/network/tests/it/requests.rs b/crates/net/network/tests/it/requests.rs index 905c23d9a49..4f82729af62 100644 --- a/crates/net/network/tests/it/requests.rs +++ b/crates/net/network/tests/it/requests.rs @@ -1,18 +1,17 @@ #![allow(unreachable_pub)] //! Tests for eth related requests -use alloy_consensus::Header; +use alloy_consensus::{Header, Sealed}; use alloy_eips::NumHash; use alloy_primitives::{BlockHash, BlockNumber, Bytes, B256}; use rand::Rng; -use reth_eth_wire::{BlockAccessLists, EthVersion, GetBlockAccessLists, HeadersDirection}; -use reth_ethereum_primitives::Block; +use reth_eth_wire::{BlockAccessLists, EthVersion, GetBlockAccessLists, GetReceipts}; +use reth_ethereum_primitives::{Block, Receipt}; use reth_network::{ eth_requests::{MAX_BLOCK_ACCESS_LISTS_SERVE, SOFT_RESPONSE_LIMIT}, - test_utils::{NetworkEventStream, PeerConfig, Testnet, TestnetHandle}, - BlockDownloaderProvider, NetworkEventListenerProvider, + test_utils::{PeerConfig, Testnet, TestnetHandle}, + BlockDownloaderProvider, PeerRequest, }; -use reth_network_api::{NetworkInfo, Peers}; use reth_network_p2p::{ bodies::client::BodiesClient, error::RequestError, @@ -25,43 +24,26 @@ use reth_provider::{ }; use reth_transaction_pool::test_utils::{TestPool, TransactionGenerator}; use std::sync::Arc; -use tokio::sync::oneshot; +use test_case::test_case; -type BalTestnetHandle = TestnetHandle, TestPool>; +type RequestsTestnet = TestnetHandle, TestPool>; +#[test_case(None; "default")] +#[test_case(Some(EthVersion::Eth69); "eth69")] #[tokio::test(flavor = "multi_thread")] -async fn test_get_body() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); +async fn test_get_body(version: Option) { + let (provider, net) = spawn_testnet(MockEthProvider::default(), version).await; + let fetch0 = net.peers()[0].network().fetch_client().await.unwrap(); let mut tx_gen = TransactionGenerator::new(rand::rng()); - let mut net = Testnet::create_with(2, mock_provider.clone()).await; - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - // request some blocks for _ in 0..100 { // Set a new random block to the mock storage and request it via the network - let block_hash = rng.random(); - let mut block: Block = Block::default(); + let block_hash = B256::random(); + let mut block = Block::default(); block.body.transactions.push(tx_gen.gen_eip4844()); - mock_provider.add_block(block_hash, block.clone()); + provider.add_block(block_hash, block.clone()); let res = fetch0.get_block_bodies(vec![block_hash]).await; assert!(res.is_ok(), "{res:?}"); @@ -74,38 +56,19 @@ async fn test_get_body() { #[tokio::test(flavor = "multi_thread")] async fn test_get_body_range() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); + let (provider, net) = spawn_testnet(MockEthProvider::default(), None).await; + let fetch0 = net.peers()[0].network().fetch_client().await.unwrap(); let mut tx_gen = TransactionGenerator::new(rand::rng()); - let mut net = Testnet::create_with(2, mock_provider.clone()).await; - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - let mut all_blocks = Vec::new(); let mut block_hashes = Vec::new(); // add some blocks for _ in 0..100 { - let block_hash = rng.random(); - let mut block: Block = Block::default(); + let block_hash = B256::random(); + let mut block = Block::default(); block.body.transactions.push(tx_gen.gen_eip4844()); - mock_provider.add_block(block_hash, block.clone()); + provider.add_block(block_hash, block.clone()); all_blocks.push(block); block_hashes.push(block_hash); } @@ -126,29 +89,13 @@ async fn test_get_body_range() { } } +#[test_case(None; "default")] +#[test_case(Some(EthVersion::Eth69); "eth69")] #[tokio::test(flavor = "multi_thread")] -async fn test_get_header() { - reth_tracing::init_test_tracing(); +async fn test_get_header(version: Option) { + let (provider, net) = spawn_testnet(MockEthProvider::default(), version).await; + let fetch0 = net.peers()[0].network().fetch_client().await.unwrap(); let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); - - let mut net = Testnet::create_with(2, mock_provider.clone()).await; - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); let start: u64 = rng.random(); let mut hash = rng.random(); @@ -158,10 +105,9 @@ async fn test_get_header() { let header = Header { number: start + idx, parent_hash: hash, ..Default::default() }; hash = rng.random(); - mock_provider.add_header(hash, header.clone()); + provider.add_header(hash, header.clone()); - let req = - HeadersRequest { start: hash.into(), limit: 1, direction: HeadersDirection::Falling }; + let req = HeadersRequest::falling(hash.into(), 1); let res = fetch0.get_headers(req).await; assert!(res.is_ok(), "{res:?}"); @@ -174,49 +120,15 @@ async fn test_get_header() { #[tokio::test(flavor = "multi_thread")] async fn test_get_header_range() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); - - let mut net = Testnet::create_with(2, mock_provider.clone()).await; - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - let start: u64 = rng.random(); - let mut hash = rng.random(); - let mut all_headers = Vec::new(); - // add some headers - for idx in 0..100 { - // Set a new random header to the mock storage and request it via the network - let header = Header { number: start + idx, parent_hash: hash, ..Default::default() }; - hash = rng.random(); - mock_provider.add_header(hash, header.clone()); - all_headers.push(header.seal(hash)); - } + let (provider, net) = spawn_testnet(MockEthProvider::default(), None).await; + let fetch0 = net.peers()[0].network().fetch_client().await.unwrap(); + let (start, all_headers) = add_header_chain(&provider); // ensure we can fetch the correct headers for idx in 0..100 { let count = 100 - idx; let header = &all_headers[idx]; - let req = HeadersRequest { - start: header.hash().into(), - limit: count as u64, - direction: HeadersDirection::Rising, - }; + let req = HeadersRequest::rising(header.hash().into(), count as u64); let res = fetch0.get_headers(req).await; assert!(res.is_ok(), "{res:?}"); @@ -232,51 +144,17 @@ async fn test_get_header_range() { #[tokio::test(flavor = "multi_thread")] async fn test_get_header_range_falling() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); - - let mut net = Testnet::create_with(2, mock_provider.clone()).await; - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - let start: u64 = rng.random(); - let mut hash = rng.random(); - let mut all_headers = Vec::new(); - // add some headers - for idx in 0..100 { - // Set a new random header to the mock storage - let header = Header { number: start + idx, parent_hash: hash, ..Default::default() }; - hash = rng.random(); - mock_provider.add_header(hash, header.clone()); - all_headers.push(header.seal(hash)); - } + let (provider, net) = spawn_testnet(MockEthProvider::default(), None).await; + let fetch0 = net.peers()[0].network().fetch_client().await.unwrap(); + let (start, all_headers) = add_header_chain(&provider); // ensure we can fetch the correct headers in falling direction // start from the last header and work backwards for idx in (0..100).rev() { - let count = std::cmp::min(idx + 1, 100); // Can't fetch more than idx+1 headers when going - // backwards + // Can't fetch more than idx+1 headers when going backwards + let count = idx + 1; let header = &all_headers[idx]; - let req = HeadersRequest { - start: header.hash().into(), - limit: count as u64, - direction: HeadersDirection::Falling, - }; + let req = HeadersRequest::falling(header.hash().into(), count as u64); let res = fetch0.get_headers(req).await; assert!(res.is_ok(), "{res:?}"); @@ -293,255 +171,48 @@ async fn test_get_header_range_falling() { #[tokio::test(flavor = "multi_thread")] async fn test_eth68_get_receipts() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); + let (provider, net) = spawn_testnet(MockEthProvider::default(), Some(EthVersion::Eth68)).await; + let [peer0, peer1] = net.peers_array(); - let mut net: Testnet, TestPool> = Testnet::default(); - - // Create peers with ETH68 protocol explicitly - let p0 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth68.into())); - net.add_peer_with_config(p0).await.unwrap(); - - let p1 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth68.into())); - net.add_peer_with_config(p1).await.unwrap(); - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - // Create test receipts and add them to the mock provider for block_num in 1..=10 { - let block_hash = rng.random(); - let header = Header { number: block_num, ..Default::default() }; - - // Create some test receipts - let receipts = vec![ - reth_ethereum_primitives::Receipt { - cumulative_gas_used: 21000, - success: true, - ..Default::default() - }, - reth_ethereum_primitives::Receipt { - cumulative_gas_used: 42000, - success: false, - ..Default::default() - }, - ]; - - mock_provider.add_header(block_hash, header.clone()); - mock_provider.add_receipts(header.number, receipts); - - // Test receipt request via low-level peer request - let (tx, rx) = oneshot::channel(); - handle0.send_request( - *handle1.peer_id(), - reth_network::PeerRequest::GetReceipts { - request: reth_eth_wire::GetReceipts(vec![block_hash]), - response: tx, - }, - ); - - let result = rx.await.unwrap(); - let receipts_response = result.unwrap(); - assert_eq!(receipts_response.0.len(), 1); - assert_eq!(receipts_response.0[0].len(), 2); + let request = GetReceipts(vec![add_receipts(&provider, block_num)]); + let receipts = peer0 + .request(*peer1.peer_id(), |response| PeerRequest::GetReceipts { request, response }) + .await + .unwrap(); + + assert_eq!(receipts.0.len(), 1); + assert_eq!(receipts.0[0].len(), 2); // Eth68 receipts should have bloom filters - verify the structure - assert_eq!(receipts_response.0[0][0].receipt.cumulative_gas_used, 21000); - assert_eq!(receipts_response.0[0][1].receipt.cumulative_gas_used, 42000); - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_eth69_get_headers() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); - - let mut net: Testnet, TestPool> = Testnet::default(); - - // Create peers with ETH69 protocol - let p0 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p0).await.unwrap(); - - let p1 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p1).await.unwrap(); - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - let start: u64 = rng.random(); - let mut hash = rng.random(); - // request some headers via eth69 connection - for idx in 0..50 { - let header = Header { number: start + idx, parent_hash: hash, ..Default::default() }; - hash = rng.random(); - - mock_provider.add_header(hash, header.clone()); - - let req = - HeadersRequest { start: hash.into(), limit: 1, direction: HeadersDirection::Falling }; - - let res = fetch0.get_headers(req).await; - assert!(res.is_ok(), "{res:?}"); - - let headers = res.unwrap().1; - assert_eq!(headers.len(), 1); - assert_eq!(headers[0], header); - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_eth69_get_bodies() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); - let mut tx_gen = TransactionGenerator::new(rand::rng()); - - let mut net: Testnet, TestPool> = Testnet::default(); - - // Create peers with ETH69 protocol - let p0 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p0).await.unwrap(); - - let p1 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p1).await.unwrap(); - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); - - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - let fetch0 = handle0.fetch_client().await.unwrap(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - // request some blocks via eth69 connection - for _ in 0..50 { - let block_hash = rng.random(); - let mut block: Block = Block::default(); - block.body.transactions.push(tx_gen.gen_eip4844()); - - mock_provider.add_block(block_hash, block.clone()); - - let res = fetch0.get_block_bodies(vec![block_hash]).await; - assert!(res.is_ok(), "{res:?}"); - - let blocks = res.unwrap().1; - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0], block.body); + assert_eq!(receipts.0[0][0].receipt.cumulative_gas_used, 21000); + assert_eq!(receipts.0[0][1].receipt.cumulative_gas_used, 42000); } } #[tokio::test(flavor = "multi_thread")] async fn test_eth69_get_receipts() { - reth_tracing::init_test_tracing(); - let mut rng = rand::rng(); - let mock_provider = Arc::new(MockEthProvider::default()); - - let mut net: Testnet, TestPool> = Testnet::default(); - - // Create peers with ETH69 protocol - let p0 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p0).await.unwrap(); - - let p1 = PeerConfig::with_protocols(mock_provider.clone(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p1).await.unwrap(); - - // install request handlers - net.for_each_mut(|peer| peer.install_request_handler()); - - let handle0 = net.peers()[0].handle(); - let mut events0 = NetworkEventStream::new(handle0.event_listener()); + let (provider, net) = spawn_testnet(MockEthProvider::default(), Some(EthVersion::Eth69)).await; + let [peer0, peer1] = net.peers_array(); - let handle1 = net.peers()[1].handle(); - - let _handle = net.spawn(); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - // Wait for the session to be established - let connected = events0.next_session_established().await.unwrap(); - assert_eq!(connected, *handle1.peer_id()); - - // Create test receipts and add them to the mock provider for block_num in 1..=10 { - let block_hash = rng.random(); - let header = Header { number: block_num, ..Default::default() }; - - // Create some test receipts - let receipts = vec![ - reth_ethereum_primitives::Receipt { - cumulative_gas_used: 21000, - success: true, - ..Default::default() - }, - reth_ethereum_primitives::Receipt { - cumulative_gas_used: 42000, - success: false, - ..Default::default() - }, - ]; - - mock_provider.add_header(block_hash, header.clone()); - mock_provider.add_receipts(header.number, receipts); - - let (tx, rx) = oneshot::channel(); - handle0.send_request( - *handle1.peer_id(), - reth_network::PeerRequest::GetReceipts69 { - request: reth_eth_wire::GetReceipts(vec![block_hash]), - response: tx, - }, - ); - - let result = rx.await.unwrap(); - let receipts_response = match result { - Ok(resp) => resp, - Err(e) => panic!("Failed to get receipts response: {e:?}"), - }; - assert_eq!(receipts_response.0.len(), 1); - assert_eq!(receipts_response.0[0].len(), 2); + let request = GetReceipts(vec![add_receipts(&provider, block_num)]); + let receipts = peer0 + .request(*peer1.peer_id(), |response| PeerRequest::GetReceipts69 { request, response }) + .await + .unwrap(); + + assert_eq!(receipts.0.len(), 1); + assert_eq!(receipts.0[0].len(), 2); // ETH69 receipts do not include bloom filters - verify the structure - assert_eq!(receipts_response.0[0][0].cumulative_gas_used, 21000); - assert_eq!(receipts_response.0[0][1].cumulative_gas_used, 42000); + assert_eq!(receipts.0[0][0].cumulative_gas_used, 21000); + assert_eq!(receipts.0[0][1].cumulative_gas_used, 42000); } } #[tokio::test(flavor = "multi_thread")] async fn test_eth71_get_block_access_lists() { - reth_tracing::init_test_tracing(); - let (net, bal_store) = spawn_eth71_bal_testnet().await; + let (provider, net) = + spawn_testnet(bal_provider(InMemoryBalStore::default()), Some(EthVersion::Eth71)).await; let hash0 = B256::random(); let hash1 = B256::random(); @@ -549,8 +220,8 @@ async fn test_eth71_get_block_access_lists() { let bal0 = Bytes::from_static(&[0xc1, 0x01]); let bal2 = Bytes::from_static(&[0xc1, 0x02]); - bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); - bal_store.insert(NumHash::new(3, hash2), RawBal::from(bal2.clone())).unwrap(); + provider.bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); + provider.bal_store.insert(NumHash::new(3, hash2), RawBal::from(bal2.clone())).unwrap(); let response = request_block_access_lists(&net, vec![hash0, hash1, hash2]).await; assert_eq!(response, BlockAccessLists(vec![Some(bal0), None, Some(bal2)])); @@ -559,8 +230,8 @@ async fn test_eth71_get_block_access_lists() { // Ensures BAL responses stop at the soft response limit while keeping the item that crosses it. #[tokio::test(flavor = "multi_thread")] async fn test_eth71_get_block_access_lists_respects_response_soft_limit() { - reth_tracing::init_test_tracing(); - let (net, bal_store) = spawn_eth71_bal_testnet().await; + let (provider, net) = + spawn_testnet(bal_provider(InMemoryBalStore::default()), Some(EthVersion::Eth71)).await; let hash0 = B256::random(); let hash1 = B256::random(); @@ -570,9 +241,9 @@ async fn test_eth71_get_block_access_lists_respects_response_soft_limit() { let bal2 = raw_bal_with_len(2); assert!(bal0.len() + bal1.len() > SOFT_RESPONSE_LIMIT); - bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); - bal_store.insert(NumHash::new(2, hash1), RawBal::from(bal1.clone())).unwrap(); - bal_store.insert(NumHash::new(3, hash2), RawBal::from(bal2)).unwrap(); + provider.bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); + provider.bal_store.insert(NumHash::new(2, hash1), RawBal::from(bal1.clone())).unwrap(); + provider.bal_store.insert(NumHash::new(3, hash2), RawBal::from(bal2)).unwrap(); let response = request_block_access_lists(&net, vec![hash0, hash1, hash2]).await; @@ -582,16 +253,16 @@ async fn test_eth71_get_block_access_lists_respects_response_soft_limit() { // Ensures a single BAL larger than the soft limit is still returned. #[tokio::test(flavor = "multi_thread")] async fn test_eth71_get_block_access_lists_returns_single_oversized_bal() { - reth_tracing::init_test_tracing(); - let (net, bal_store) = spawn_eth71_bal_testnet().await; + let (provider, net) = + spawn_testnet(bal_provider(InMemoryBalStore::default()), Some(EthVersion::Eth71)).await; let hash0 = B256::random(); let hash1 = B256::random(); let bal0 = raw_bal_with_len(SOFT_RESPONSE_LIMIT + 1); let bal1 = raw_bal_with_len(2); - bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); - bal_store.insert(NumHash::new(2, hash1), RawBal::from(bal1)).unwrap(); + provider.bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); + provider.bal_store.insert(NumHash::new(2, hash1), RawBal::from(bal1)).unwrap(); let response = request_block_access_lists(&net, vec![hash0, hash1]).await; @@ -601,8 +272,8 @@ async fn test_eth71_get_block_access_lists_returns_single_oversized_bal() { // Ensures an empty BAL request roundtrips to an empty response. #[tokio::test(flavor = "multi_thread")] async fn test_eth71_get_block_access_lists_empty_request() { - reth_tracing::init_test_tracing(); - let (net, _) = spawn_eth71_bal_testnet().await; + let (_, net) = + spawn_testnet(bal_provider(InMemoryBalStore::default()), Some(EthVersion::Eth71)).await; let response = request_block_access_lists(&net, Vec::new()).await; @@ -612,8 +283,8 @@ async fn test_eth71_get_block_access_lists_empty_request() { // Ensures BAL responses are capped at MAX_BLOCK_ACCESS_LISTS_SERVE entries. #[tokio::test(flavor = "multi_thread")] async fn test_eth71_get_block_access_lists_caps_count() { - reth_tracing::init_test_tracing(); - let (net, bal_store) = spawn_eth71_bal_testnet().await; + let (provider, net) = + spawn_testnet(bal_provider(InMemoryBalStore::default()), Some(EthVersion::Eth71)).await; // Request more hashes than the count cap. let request_count = MAX_BLOCK_ACCESS_LISTS_SERVE + 100; @@ -622,7 +293,7 @@ async fn test_eth71_get_block_access_lists_caps_count() { // Insert one BAL so the store isn't entirely empty (not strictly needed, // but keeps the test path closer to real usage). let bal = Bytes::from_static(&[0xc1, 0x01]); - bal_store.insert(NumHash::new(1, hashes[0]), RawBal::from(bal)).unwrap(); + provider.bal_store.insert(NumHash::new(1, hashes[0]), RawBal::from(bal)).unwrap(); let response = request_block_access_lists(&net, hashes).await; @@ -632,14 +303,14 @@ async fn test_eth71_get_block_access_lists_caps_count() { // Ensures the fetch client can request BALs through an eth/71 peer. #[tokio::test(flavor = "multi_thread")] async fn test_eth71_fetch_client_get_block_access_lists() { - reth_tracing::init_test_tracing(); - let (net, bal_store) = spawn_eth71_bal_testnet().await; + let (provider, net) = + spawn_testnet(bal_provider(InMemoryBalStore::default()), Some(EthVersion::Eth71)).await; let hash0 = B256::random(); let hash1 = B256::random(); let bal0 = Bytes::from_static(&[0xc1, 0x01]); - bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); + provider.bal_store.insert(NumHash::new(1, hash0), RawBal::from(bal0.clone())).unwrap(); let fetch = net.peers()[0].network().fetch_client().await.unwrap(); let response = fetch.get_block_access_lists(vec![hash0, hash1]).await.unwrap().into_data(); @@ -651,12 +322,8 @@ async fn test_eth71_fetch_client_get_block_access_lists() { // entries. #[tokio::test(flavor = "multi_thread")] async fn test_eth71_get_block_access_lists_returns_empty_on_store_error() { - reth_tracing::init_test_tracing(); - let (net, _) = spawn_bal_testnet_with_store( - [EthVersion::Eth71, EthVersion::Eth71], - BalStoreHandle::new(FailingLookupBalStore), - ) - .await; + let (_, net) = + spawn_testnet(bal_provider(FailingLookupBalStore), Some(EthVersion::Eth71)).await; let response = request_block_access_lists(&net, vec![B256::random(), B256::random()]).await; @@ -666,8 +333,7 @@ async fn test_eth71_get_block_access_lists_returns_empty_on_store_error() { // Ensures default fetch client BAL requests are rejected when no eth/71 peer is available. #[tokio::test(flavor = "multi_thread")] async fn test_eth70_fetch_client_rejects_default_block_access_lists_request() { - reth_tracing::init_test_tracing(); - let (net, _) = spawn_bal_testnet([EthVersion::Eth70, EthVersion::Eth70]).await; + let (_, net) = spawn_testnet(MockEthProvider::default(), Some(EthVersion::Eth70)).await; let fetch = net.peers()[0].network().fetch_client().await.unwrap(); let err = fetch.get_block_access_lists(vec![B256::random()]).await.unwrap_err(); @@ -675,39 +341,63 @@ async fn test_eth70_fetch_client_rejects_default_block_access_lists_request() { assert_eq!(err, RequestError::UnsupportedCapability); } -async fn spawn_eth71_bal_testnet() -> (BalTestnetHandle, BalStoreHandle) { - spawn_bal_testnet([EthVersion::Eth71, EthVersion::Eth71]).await -} - -// Spawns a BAL testnet with one peer per requested eth protocol version. -async fn spawn_bal_testnet( - versions: impl IntoIterator, -) -> (BalTestnetHandle, BalStoreHandle) { - spawn_bal_testnet_with_store(versions, BalStoreHandle::new(InMemoryBalStore::default())).await -} - -// Spawns a BAL testnet with one peer per requested eth protocol version and the given BAL store. -async fn spawn_bal_testnet_with_store( - versions: impl IntoIterator, - bal_store: BalStoreHandle, -) -> (BalTestnetHandle, BalStoreHandle) { - let mut mock_provider = MockEthProvider::default(); - mock_provider.bal_store = bal_store.clone(); - let mock_provider = Arc::new(mock_provider); +/// Spawns two connected peers that serve requests from `provider`, speaking only `version` if set. +async fn spawn_testnet( + provider: MockEthProvider, + version: Option, +) -> (Arc, RequestsTestnet) { + reth_tracing::init_test_tracing(); - let mut net: Testnet, TestPool> = Testnet::default(); + let provider = Arc::new(provider); + let peer = || { + let peer = PeerConfig::new(provider.clone()); + match version { + Some(version) => peer.with_protocols([version]), + None => peer, + } + }; + let net = Testnet::from_configs([peer(), peer()]).await.with_request_handlers().spawn(); + net.connect_peers().await; - for version in versions { - let peer = PeerConfig::with_protocols(mock_provider.clone(), Some(version.into())); - net.add_peer_with_config(peer).await.unwrap(); - } + (provider, net) +} - net.for_each_mut(|peer| peer.install_request_handler()); +/// Returns a provider that serves BALs from `bal_store`. +fn bal_provider(bal_store: impl BalStore) -> MockEthProvider { + let mut provider = MockEthProvider::default(); + provider.bal_store = BalStoreHandle::new(bal_store); + provider +} - let net = net.spawn(); - net.connect_peers().await; +/// Adds a chain of 100 headers with random hashes to the provider, returning the number of the +/// first header and the sealed headers. +fn add_header_chain(provider: &MockEthProvider) -> (u64, Vec>) { + let mut rng = rand::rng(); + let start: u64 = rng.random(); + let mut hash = rng.random(); + let headers = (0..100) + .map(|idx| { + let header = Header { number: start + idx, parent_hash: hash, ..Default::default() }; + hash = rng.random(); + provider.add_header(hash, header.clone()); + header.seal(hash) + }) + .collect(); + (start, headers) +} - (net, bal_store) +/// Adds a header with two receipts at `number` to the provider and returns its hash. +fn add_receipts(provider: &MockEthProvider, number: BlockNumber) -> B256 { + let hash = B256::random(); + provider.add_header(hash, Header { number, ..Default::default() }); + provider.add_receipts( + number, + vec![ + Receipt { cumulative_gas_used: 21000, success: true, ..Default::default() }, + Receipt { cumulative_gas_used: 42000, success: false, ..Default::default() }, + ], + ); + hash } #[derive(Debug)] @@ -728,20 +418,16 @@ impl BalStore for FailingLookupBalStore { } // Sends a GetBlockAccessLists request from peer 0 to peer 1. -async fn request_block_access_lists(net: &BalTestnetHandle, hashes: Vec) -> BlockAccessLists { - let requester = &net.peers()[0]; - let responder = &net.peers()[1]; - let (tx, rx) = oneshot::channel(); - - requester.network().send_request( - *responder.peer_id(), - reth_network::PeerRequest::GetBlockAccessLists { - request: GetBlockAccessLists(hashes), - response: tx, - }, - ); - - rx.await.unwrap().unwrap() +async fn request_block_access_lists(net: &RequestsTestnet, hashes: Vec) -> BlockAccessLists { + let [requester, responder] = net.peers_array(); + let request = GetBlockAccessLists(hashes); + requester + .request(*responder.peer_id(), |response| PeerRequest::GetBlockAccessLists { + request, + response, + }) + .await + .unwrap() } // Builds a complete raw RLP list item with the requested encoded byte length. diff --git a/crates/net/network/tests/it/session.rs b/crates/net/network/tests/it/session.rs index 24875a0f410..84941aa07b0 100644 --- a/crates/net/network/tests/it/session.rs +++ b/crates/net/network/tests/it/session.rs @@ -3,485 +3,175 @@ use futures::StreamExt; use reth_eth_wire::EthVersion; use reth_network::{ - test_utils::{NetworkEventStream, PeerConfig, Testnet}, - NetworkEvent, NetworkEventListenerProvider, + test_utils::{PeerConfig, PeerHandle, Testnet}, + NetworkEvent, }; -use reth_network_api::{ - events::{PeerEvent, SessionInfo}, - NetworkInfo, Peers, -}; -use reth_storage_api::noop::NoopProvider; +use reth_network_api::events::PeerEvent; #[tokio::test(flavor = "multi_thread")] async fn test_session_established_with_highest_version() { - reth_tracing::init_test_tracing(); - - let net = Testnet::create(2).await; - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - assert_eq!(status.version, EthVersion::LATEST); - } - ev => { - panic!("unexpected event {ev:?}") - } - } - } - handle.terminate().await; + let version = negotiated_version(PeerConfig::default(), PeerConfig::default()).await; + assert_eq!(version, Some(EthVersion::LATEST)); } #[tokio::test(flavor = "multi_thread")] async fn test_session_established_with_different_capability() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(1).await; - - let p1 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth66.into())); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - assert_eq!(status.version, EthVersion::Eth66); - } - ev => { - panic!("unexpected event: {ev:?}") - } - } - } - - handle.terminate().await; + let version = negotiated_version( + PeerConfig::default(), + PeerConfig::default().with_protocols([EthVersion::Eth66]), + ) + .await; + assert_eq!(version, Some(EthVersion::Eth66)); } #[tokio::test(flavor = "multi_thread")] async fn test_capability_version_mismatch() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - let p0 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth66.into())); - net.add_peer_with_config(p0).await.unwrap(); - - let p1 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth67.into())); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let events = handle0.event_listener(); - - let mut event_stream = NetworkEventStream::new(events); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - let added_peer_id = event_stream.peer_added().await.unwrap(); - assert_eq!(added_peer_id, *handle1.peer_id()); - + let version = negotiated_version( + PeerConfig::default().with_protocols([EthVersion::Eth66]), + PeerConfig::default().with_protocols([EthVersion::Eth67]), + ) + .await; // peer with mismatched capability version should fail to connect and be removed. - let removed_peer_id = event_stream.peer_removed().await.unwrap(); - assert_eq!(removed_peer_id, *handle1.peer_id()); - - handle.terminate().await; + assert_eq!(version, None); } #[tokio::test(flavor = "multi_thread")] async fn test_eth69_peers_can_connect() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - // Create two peers that only support ETH69 - let p0 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p0).await.unwrap(); - - let p1 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - // Both peers support only ETH69, so they should connect with ETH69 - assert_eq!(status.version, EthVersion::Eth69); - } - ev => { - panic!("unexpected event: {ev:?}") - } - } - } - - handle.terminate().await; + let version = negotiated_version( + PeerConfig::default().with_protocols([EthVersion::Eth69]), + PeerConfig::default().with_protocols([EthVersion::Eth69]), + ) + .await; + assert_eq!(version, Some(EthVersion::Eth69)); } #[tokio::test(flavor = "multi_thread")] async fn test_peers_negotiate_highest_version_eth69() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - // Create one peer with multiple ETH versions including ETH69 - let p0 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![ - EthVersion::Eth69.into(), - EthVersion::Eth68.into(), - EthVersion::Eth67.into(), - EthVersion::Eth66.into(), - ], - ); - net.add_peer_with_config(p0).await.unwrap(); - - // Create another peer with multiple ETH versions including ETH69 - let p1 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth69.into(), EthVersion::Eth68.into(), EthVersion::Eth67.into()], - ); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - // Both peers support ETH69, so they should negotiate to the highest version: ETH69 - assert_eq!(status.version, EthVersion::Eth69); - } - ev => { - panic!("unexpected event: {ev:?}") - } - } - } - - handle.terminate().await; + let version = negotiated_version( + PeerConfig::default().with_protocols([ + EthVersion::Eth69, + EthVersion::Eth68, + EthVersion::Eth67, + EthVersion::Eth66, + ]), + PeerConfig::default().with_protocols([ + EthVersion::Eth69, + EthVersion::Eth68, + EthVersion::Eth67, + ]), + ) + .await; + assert_eq!(version, Some(EthVersion::Eth69)); } #[tokio::test(flavor = "multi_thread")] async fn test_eth69_vs_eth68_incompatible() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - // Create one peer that only supports ETH69 - let p0 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth69.into())); - net.add_peer_with_config(p0).await.unwrap(); - - // Create another peer that only supports ETH68 - let p1 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth68.into())); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let events = handle0.event_listener(); - let mut event_stream = NetworkEventStream::new(events); - - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - let added_peer_id = event_stream.peer_added().await.unwrap(); - assert_eq!(added_peer_id, *handle1.peer_id()); - + let version = negotiated_version( + PeerConfig::default().with_protocols([EthVersion::Eth69]), + PeerConfig::default().with_protocols([EthVersion::Eth68]), + ) + .await; // Peers with no shared ETH version should fail to connect and be removed. - let removed_peer_id = event_stream.peer_removed().await.unwrap(); - assert_eq!(removed_peer_id, *handle1.peer_id()); - - handle.terminate().await; + assert_eq!(version, None); } #[tokio::test(flavor = "multi_thread")] async fn test_eth69_mixed_version_negotiation() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - // Create one peer that supports ETH69 + ETH68 - let p0 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth69.into(), EthVersion::Eth68.into()], - ); - net.add_peer_with_config(p0).await.unwrap(); - - // Create another peer that only supports ETH68 - let p1 = PeerConfig::with_protocols(NoopProvider::default(), Some(EthVersion::Eth68.into())); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - // Should negotiate to ETH68 (highest common version) - assert_eq!(status.version, EthVersion::Eth68); - } - ev => { - panic!("unexpected event: {ev:?}") - } - } - } - - handle.terminate().await; + let version = negotiated_version( + PeerConfig::default().with_protocols([EthVersion::Eth69, EthVersion::Eth68]), + PeerConfig::default().with_protocols([EthVersion::Eth68]), + ) + .await; + assert_eq!(version, Some(EthVersion::Eth68)); } #[tokio::test(flavor = "multi_thread")] async fn test_multiple_peers_different_eth_versions() { reth_tracing::init_test_tracing(); - let mut net = Testnet::create(0).await; - - // Create a peer that supports all versions (ETH66-ETH69) - let p0 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![ - EthVersion::Eth69.into(), - EthVersion::Eth68.into(), - EthVersion::Eth67.into(), - EthVersion::Eth66.into(), - ], - ); - net.add_peer_with_config(p0).await.unwrap(); - - // Create a peer that only supports newer versions (ETH68-ETH69) - let p1 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth69.into(), EthVersion::Eth68.into()], - ); - net.add_peer_with_config(p1).await.unwrap(); - - // Create a peer that only supports older versions (ETH66-ETH67) - let p2 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth67.into(), EthVersion::Eth66.into()], - ); - net.add_peer_with_config(p2).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); // All versions peer - let handle1 = handles.next().unwrap(); // Newer versions peer - let handle2 = handles.next().unwrap(); // Older versions peer - drop(handles); - - let handle = net.spawn(); - - let events = handle0.event_listener(); - let mut event_stream = NetworkEventStream::new(events); - - // Connect peer0 (all versions) to peer1 (newer versions) - should negotiate ETH69 - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - let added_peer_id = event_stream.peer_added().await.unwrap(); - assert_eq!(added_peer_id, *handle1.peer_id()); - - let established_peer_id = event_stream.next_session_established().await.unwrap(); - assert_eq!(established_peer_id, *handle1.peer_id()); - - // Connect peer0 (all versions) to peer2 (older versions) - should negotiate ETH67 - handle0.add_peer(*handle2.peer_id(), handle2.local_addr()); - - let added_peer_id = event_stream.peer_added().await.unwrap(); - assert_eq!(added_peer_id, *handle2.peer_id()); - - let established_peer_id = event_stream.next_session_established().await.unwrap(); - assert_eq!(established_peer_id, *handle2.peer_id()); - - // Both connections should be established successfully - - handle.terminate().await; + let net = Testnet::from_configs([ + // supports all versions + PeerConfig::default().with_protocols([ + EthVersion::Eth69, + EthVersion::Eth68, + EthVersion::Eth67, + EthVersion::Eth66, + ]), + // only supports newer versions + PeerConfig::default().with_protocols([EthVersion::Eth69, EthVersion::Eth68]), + // only supports older versions + PeerConfig::default().with_protocols([EthVersion::Eth67, EthVersion::Eth66]), + ]) + .await + .spawn(); + let [all, newer, older] = net.peers_array(); + + assert_eq!(connect(all, newer).await, Some(EthVersion::Eth69)); + assert_eq!(connect(all, older).await, Some(EthVersion::Eth67)); } #[tokio::test(flavor = "multi_thread")] async fn test_eth69_capability_negotiation_fallback() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - // Create a peer that prefers ETH69 but supports fallback to ETH67 - let p0 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth69.into(), EthVersion::Eth67.into()], - ); - net.add_peer_with_config(p0).await.unwrap(); - - // Create a peer that skips ETH68 and only supports ETH67/ETH66 - let p1 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth67.into(), EthVersion::Eth66.into()], - ); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); - - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); - - let handle = net.spawn(); - - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); - - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - // Should fallback to ETH67 (skipping ETH68 which neither supports) - assert_eq!(status.version, EthVersion::Eth67); - } - ev => { - panic!("unexpected event: {ev:?}") - } - } - } - - handle.terminate().await; + let version = negotiated_version( + PeerConfig::default().with_protocols([EthVersion::Eth69, EthVersion::Eth67]), + PeerConfig::default().with_protocols([EthVersion::Eth67, EthVersion::Eth66]), + ) + .await; + // Should fallback to ETH67 (skipping ETH68 which neither supports) + assert_eq!(version, Some(EthVersion::Eth67)); } #[tokio::test(flavor = "multi_thread")] async fn test_overlapping_version_sets_negotiation() { - reth_tracing::init_test_tracing(); - - let mut net = Testnet::create(0).await; - - // Peer 0: supports ETH69, ETH67, ETH66 (skips ETH68) - let p0 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth69.into(), EthVersion::Eth67.into(), EthVersion::Eth66.into()], - ); - net.add_peer_with_config(p0).await.unwrap(); - - // Peer 1: supports ETH68, ETH67, ETH66 (skips ETH69) - let p1 = PeerConfig::with_protocols( - NoopProvider::default(), - vec![EthVersion::Eth68.into(), EthVersion::Eth67.into(), EthVersion::Eth66.into()], - ); - net.add_peer_with_config(p1).await.unwrap(); - - net.for_each(|peer| assert_eq!(0, peer.num_peers())); + let version = negotiated_version( + PeerConfig::default().with_protocols([ + EthVersion::Eth69, + EthVersion::Eth67, + EthVersion::Eth66, + ]), + PeerConfig::default().with_protocols([ + EthVersion::Eth68, + EthVersion::Eth67, + EthVersion::Eth66, + ]), + ) + .await; + // Should negotiate to ETH67 (highest common version between ETH69,67,66 and ETH68,67,66) + assert_eq!(version, Some(EthVersion::Eth67)); +} - let mut handles = net.handles(); - let handle0 = handles.next().unwrap(); - let handle1 = handles.next().unwrap(); - drop(handles); +/// Launches a peer from each config, connects the first to the second and returns the negotiated +/// eth version. +async fn negotiated_version(a: PeerConfig, b: PeerConfig) -> Option { + reth_tracing::init_test_tracing(); - let handle = net.spawn(); + let net = Testnet::from_configs([a, b]).await.spawn(); + let [a, b] = net.peers_array(); + connect(a, b).await +} - let mut events = handle0.event_listener().take(2); - handle0.add_peer(*handle1.peer_id(), handle1.local_addr()); +/// Connects `peer` to `other` and returns the negotiated eth version, or `None` if `other` was +/// removed because no session could be established. +async fn connect(peer: &PeerHandle, other: &PeerHandle) -> Option { + let mut events = peer.event_listener(); + peer.add_peer(other); - while let Some(event) = events.next().await { - match event { - NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id)) => { - assert_eq!(handle1.peer_id(), &peer_id); - } - NetworkEvent::ActivePeerSession { info, .. } => { - let SessionInfo { peer_id, status, .. } = info; - assert_eq!(handle1.peer_id(), &peer_id); - // Should negotiate to ETH67 (highest common version between ETH69,67,66 and - // ETH68,67,66) - assert_eq!(status.version, EthVersion::Eth67); - } - ev => { - panic!("unexpected event: {ev:?}") - } + match events.next().await { + Some(NetworkEvent::Peer(PeerEvent::PeerAdded(peer_id))) => { + assert_eq!(peer_id, *other.peer_id()) } + event => panic!("unexpected event {event:?}"), } - handle.terminate().await; + match events.next().await { + Some(NetworkEvent::ActivePeerSession { info, .. }) if info.peer_id == *other.peer_id() => { + Some(info.status.version) + } + Some(NetworkEvent::Peer(PeerEvent::PeerRemoved(peer_id))) + if peer_id == *other.peer_id() => + { + None + } + event => panic!("unexpected event {event:?}"), + } } diff --git a/crates/net/network/tests/it/snap/mod.rs b/crates/net/network/tests/it/snap/mod.rs index 61597cf8cb2..e4166016b9e 100644 --- a/crates/net/network/tests/it/snap/mod.rs +++ b/crates/net/network/tests/it/snap/mod.rs @@ -4,6 +4,8 @@ //! `SnapClient` request encoding, `RLPx` session transport, `EthRequestHandler`/ //! `StateRangeProviderFactory` serving, and response decoding. +#![allow(clippy::clone_on_copy)] + use alloy_consensus::{ constants::{EMPTY_ROOT_HASH, KECCAK_EMPTY}, Header, @@ -26,7 +28,7 @@ use reth_eth_wire::{ }; use reth_network::{ eth_requests::SOFT_RESPONSE_LIMIT, - test_utils::{PeerConfig, Testnet, TestnetHandle}, + test_utils::{PeerConfig, Testnet, TestnetHandle, TestnetProvider}, BlockDownloaderProvider, }; use reth_network_p2p::snap::client::{SnapClient, SnapResponse}; @@ -36,9 +38,9 @@ use reth_provider::{ test_utils::{ create_test_provider_factory, ExtendedAccount, MockEthProvider, MockNodeTypesWithDB, }, - BalProvider, BalStoreHandle, BlockReader, BlockWriter, ChainSpecProvider, HashingWriter, - HeaderProvider, InMemoryBalStore, ProviderFactory, RawBal, StageCheckpointWriter, - StateProviderFactory, StateRangeProviderFactory, StateRootProvider, StorageRootProvider, + BalStoreHandle, BlockWriter, ChainSpecProvider, HashingWriter, HeaderProvider, + InMemoryBalStore, ProviderFactory, RawBal, StageCheckpointWriter, StateProviderFactory, + StateRootProvider, StorageRootProvider, }; use reth_stages_types::{StageCheckpoint, StageId}; use reth_testing_utils::generators::{self, random_block, BlockParams}; @@ -48,69 +50,21 @@ use std::{sync::Arc, time::Duration}; mod protocol; -type SnapTestnetHandle = TestnetHandle; - -/// Protocols a snap/2-capable peer advertises: `eth/71` plus `snap/2`. -/// -/// A session only negotiates the dedicated snap-carrying connection variant for exactly this -/// pair; anything else falls back to a satellite connection that can't serve `GetSnap`. -fn snap_protocols() -> Vec { - vec![EthVersion::Eth71.into(), Protocol::snap_2()] -} - -/// A provider usable by the snap/2 testnet helpers: real block, header, state, bal, and range -/// access. -trait SnapTestProvider: - BlockReader< - Block = reth_ethereum_primitives::Block, - Receipt = reth_ethereum_primitives::Receipt, - Header = alloy_consensus::Header, - > + HeaderProvider - + BalProvider - + StateProviderFactory - + StateRangeProviderFactory - + ChainSpecProvider - + Clone - + Unpin - + 'static -{ -} - -impl SnapTestProvider for T where - T: BlockReader< - Block = reth_ethereum_primitives::Block, - Receipt = reth_ethereum_primitives::Receipt, - Header = alloy_consensus::Header, - > + HeaderProvider - + BalProvider - + StateProviderFactory - + StateRangeProviderFactory - + ChainSpecProvider - + Clone - + Unpin - + 'static -{ -} - /// Spawns a 2-peer testnet where both peers are snap/2-capable and serve requests against /// `provider`. -async fn spawn_snap_testnet(provider: C) -> SnapTestnetHandle { - spawn_snap_testnet_with_protocols(provider, snap_protocols()).await -} - -/// Like [`spawn_snap_testnet`], but with a caller-chosen protocol list. -async fn spawn_snap_testnet_with_protocols( - provider: C, - protocols: Vec, -) -> SnapTestnetHandle { - let mut net: Testnet = Testnet::default(); - for _ in 0..2 { - let peer = PeerConfig::with_protocols(provider.clone(), protocols.clone()); - net.add_peer_with_config(peer).await.unwrap(); - } - net.for_each_mut(|peer| peer.install_request_handler()); - - let net = net.spawn(); +/// +/// The peers advertise `eth/71` plus `snap/2`: a session only negotiates the dedicated +/// snap-carrying connection variant for exactly this pair; anything else falls back to a satellite +/// connection that can't serve `GetSnap`. +async fn spawn_snap_testnet(provider: C) -> TestnetHandle +where + C: TestnetProvider + ChainSpecProvider + Clone, +{ + let peer = || { + PeerConfig::new(provider.clone()) + .with_protocols([EthVersion::Eth71.into(), Protocol::snap_2()]) + }; + let net = Testnet::from_configs([peer(), peer()]).await.with_request_handlers().spawn(); net.connect_peers().await; net } @@ -207,7 +161,7 @@ fn assert_boundary_proof(root: B256, key: B256, expected_value: Option>, /// A valid RLP-encoded EIP-7928 block access list for `address`, with its commitment hash. fn valid_bal(address: Address) -> (Bytes, B256) { let mut change = AccountChanges::new(address); - change.balance_changes.push(BalanceChange::new(BlockAccessIndex::PRE_EXECUTION, U256::from(1))); + change.balance_changes.push(BalanceChange::new(BlockAccessIndex::PRE_EXECUTION, U256::ONE)); let bal = vec![change]; let mut buf = Vec::new(); @@ -229,7 +183,7 @@ async fn account_range_roundtrip_carries_slim_encoding_and_proof() { let provider_rw = factory.provider_rw().unwrap(); provider_rw .insert_account_for_hashing( - accounts.iter().map(|(address, account)| (*address, Some(*account))), + accounts.iter().map(|(address, account)| (*address, Some(account.clone()))), ) .unwrap(); provider_rw.commit().unwrap(); @@ -241,7 +195,7 @@ async fn account_range_roundtrip_carries_slim_encoding_and_proof() { let fetch = net.peers()[0].network().fetch_client().await.unwrap(); let mut expected: Vec<_> = - accounts.iter().map(|(address, account)| (keccak256(address), *account)).collect(); + accounts.iter().map(|(address, account)| (keccak256(address), account.clone())).collect(); expected.sort_by_key(|(hash, _)| *hash); let response = fetch @@ -284,7 +238,7 @@ async fn account_range_roundtrip_carries_slim_encoding_and_proof() { assert_boundary_proof( state_root, *last_hash, - Some(alloy_rlp::encode(last_account.into_trie_account(EMPTY_ROOT_HASH))), + Some(alloy_rlp::encode(last_account.clone().into_trie_account(EMPTY_ROOT_HASH))), &proof, ); } @@ -303,7 +257,7 @@ async fn account_range_bounded_by_response_bytes_excludes_trailing_account() { let provider_rw = factory.provider_rw().unwrap(); provider_rw .insert_account_for_hashing( - accounts.iter().map(|(address, account)| (*address, Some(*account))), + accounts.iter().map(|(address, account)| (*address, Some(account.clone()))), ) .unwrap(); provider_rw.commit().unwrap(); @@ -315,7 +269,7 @@ async fn account_range_bounded_by_response_bytes_excludes_trailing_account() { let fetch = net.peers()[0].network().fetch_client().await.unwrap(); let mut expected: Vec<_> = - accounts.iter().map(|(address, account)| (keccak256(address), *account)).collect(); + accounts.iter().map(|(address, account)| (keccak256(address), account.clone())).collect(); expected.sort_by_key(|(hash, _)| *hash); // Each account costs a fixed 160 bytes. A budget below that admits only account A, since the @@ -349,7 +303,7 @@ async fn account_range_bounded_by_response_bytes_excludes_trailing_account() { assert_boundary_proof( state_root, expected[0].0, - Some(alloy_rlp::encode(expected[0].1.into_trie_account(EMPTY_ROOT_HASH))), + Some(alloy_rlp::encode(expected[0].1.clone().into_trie_account(EMPTY_ROOT_HASH))), &proof, ); } @@ -360,7 +314,7 @@ async fn storage_range_roundtrip_carries_rlp_values_and_proof() { let factory = genesis_provider_factory(); let address = Address::random(); - let account = Account { nonce: 1, balance: U256::from(1), ..Default::default() }; + let account = Account { nonce: 1, balance: U256::ONE, ..Default::default() }; let slots: Vec = (0..6u8) .map(|i| StorageEntry { key: B256::with_last_byte(i), value: U256::from(i as u64 + 1) }) .collect(); @@ -430,7 +384,7 @@ async fn storage_range_empty_window_returns_boundary_slot() { let factory = genesis_provider_factory(); let address = Address::random(); - let account = Account { nonce: 1, balance: U256::from(1), ..Default::default() }; + let account = Account { nonce: 1, balance: U256::ONE, ..Default::default() }; let slots: Vec = (0..4u8) .map(|i| StorageEntry { key: B256::with_last_byte(i), value: U256::from(i as u64 + 1) }) .collect(); @@ -457,7 +411,7 @@ async fn storage_range_empty_window_returns_boundary_slot() { // A window strictly between the 2nd- and 3rd-lowest hashes contains no slots; the response // must still return the first slot past it, proven with a boundary proof (non-zero origin // always requires one). - let origin = B256::from(U256::from_be_bytes(expected[1].0 .0) + U256::from(1)); + let origin = B256::from(U256::from_be_bytes(expected[1].0 .0) + U256::ONE); let limit = origin; let response = fetch .get_storage_ranges(GetStorageRangesMessage { @@ -497,7 +451,7 @@ async fn storage_ranges_multi_account_bounds_only_first_account() { let factory = genesis_provider_factory(); let (address_a, account_a) = - (Address::random(), Account { nonce: 1, balance: U256::from(1), ..Default::default() }); + (Address::random(), Account { nonce: 1, balance: U256::ONE, ..Default::default() }); let (address_b, account_b) = (Address::random(), Account { nonce: 2, balance: U256::from(2), ..Default::default() }); // 2 slots for A (fits fully in the byte budget below), 5 for B (doesn't). @@ -611,7 +565,7 @@ async fn retained_and_expired_account_range_requests_resolve_without_hanging() { { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .insert_account_for_hashing([(expired_account.0, Some(expired_account.1))]) + .insert_account_for_hashing([(expired_account.0, Some(expired_account.1.clone()))]) .unwrap(); provider_rw.commit().unwrap(); } @@ -623,7 +577,7 @@ async fn retained_and_expired_account_range_requests_resolve_without_hanging() { { let provider_rw = factory.provider_rw().unwrap(); provider_rw - .insert_account_for_hashing([(retained_account.0, Some(retained_account.1))]) + .insert_account_for_hashing([(retained_account.0, Some(retained_account.1.clone()))]) .unwrap(); provider_rw.commit().unwrap(); } @@ -696,7 +650,7 @@ async fn retained_and_expired_account_range_requests_resolve_without_hanging() { assert_boundary_proof( retained_root, *last_hash, - Some(alloy_rlp::encode(last_account.into_trie_account(EMPTY_ROOT_HASH))), + Some(alloy_rlp::encode(last_account.clone().into_trie_account(EMPTY_ROOT_HASH))), &proof, ); diff --git a/crates/net/network/tests/it/snap/protocol.rs b/crates/net/network/tests/it/snap/protocol.rs index 55697436544..d985c000cbc 100644 --- a/crates/net/network/tests/it/snap/protocol.rs +++ b/crates/net/network/tests/it/snap/protocol.rs @@ -16,7 +16,6 @@ use reth_network::{ use reth_network_api::{Direction, PeerId}; use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; use reth_provider::test_utils::MockEthProvider; -use reth_transaction_pool::test_utils::TestPool; use std::{ net::SocketAddr, pin::Pin, @@ -101,14 +100,10 @@ async fn eth_snap_and_third_satellite_protocol_fails_fast_on_snap_request() { let protocols = vec![EthVersion::Eth71.into(), Protocol::snap_2(), les_protocol.clone()]; let provider = Arc::new(MockEthProvider::default()); - let mut net: Testnet<_, TestPool> = Testnet::default(); - for _ in 0..2 { - let peer = PeerConfig::with_protocols(provider.clone(), protocols.clone()); - net.add_peer_with_config(peer).await.unwrap(); - } + let peer = || PeerConfig::new(provider.clone()).with_protocols(protocols.clone()); + let mut net = Testnet::from_configs([peer(), peer()]).await.with_request_handlers(); net.for_each_mut(|peer| { - peer.install_request_handler(); - peer.add_rlpx_sub_protocol(InertProtocolHandler::new(les_protocol.clone())); + peer.add_rlpx_sub_protocol(InertProtocolHandler::new(les_protocol.clone())) }); let net = net.spawn(); net.connect_peers().await; diff --git a/crates/net/network/tests/it/startup.rs b/crates/net/network/tests/it/startup.rs index 1574a2e03f0..675b7c851a4 100644 --- a/crates/net/network/tests/it/startup.rs +++ b/crates/net/network/tests/it/startup.rs @@ -6,14 +6,14 @@ use std::{ use reth_chainspec::MAINNET; use reth_discv4::{Discv4Config, NatResolver, DEFAULT_DISCOVERY_ADDR}; use reth_network::{ + config::rng_secret_key, error::{NetworkError, ServiceKind}, + test_utils::{unused_tcp_and_udp_port, unused_udp_port}, Discovery, NetworkConfigBuilder, NetworkManager, }; use reth_network_api::{NetworkInfo, PeersInfo}; use reth_storage_api::noop::NoopProvider; use reth_tasks::Runtime; -use secp256k1::SecretKey; -use tokio::net::TcpListener; fn is_addr_in_use_kind(err: &NetworkError, kind: ServiceKind) -> bool { match err { @@ -29,7 +29,7 @@ fn is_addr_in_use_kind(err: &NetworkError, kind: ServiceKind) -> bool { #[tokio::test(flavor = "multi_thread")] async fn test_is_default_syncing() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .disable_discovery() .listener_port(0) @@ -40,7 +40,7 @@ async fn test_is_default_syncing() { #[tokio::test(flavor = "multi_thread")] async fn test_listener_addr_in_use() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .disable_discovery() .listener_port(0) @@ -59,12 +59,9 @@ async fn test_listener_addr_in_use() { #[tokio::test(flavor = "multi_thread")] async fn test_discovery_addr_in_use() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let disc_config = Discv4Config::default(); - let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)); - let any_port_listener = TcpListener::bind(addr).await.unwrap(); - let port = any_port_listener.local_addr().unwrap().port(); - let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)); + let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, unused_udp_port())); let _discovery = Discovery::new(addr, addr, secret_key, Some(disc_config), None, None, None).await.unwrap(); let disc_config = Discv4Config::default(); @@ -75,14 +72,9 @@ async fn test_discovery_addr_in_use() { #[tokio::test(flavor = "multi_thread")] async fn test_discv5_and_discv4_same_socket_ok() { // Pick a free port for the shared UDP discovery socket and TCP RLPx listener. - let test_port: u16 = TcpListener::bind("127.0.0.1:0") - .await - .expect("Failed to bind to a port") - .local_addr() - .unwrap() - .port(); - - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let test_port = unused_tcp_and_udp_port(); + + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .listener_port(test_port) .discovery_port(test_port) @@ -102,14 +94,9 @@ async fn test_discv5_and_discv4_same_socket_ok() { #[tokio::test(flavor = "multi_thread")] async fn test_discv5_and_rlpx_same_socket_ok_without_discv4() { - let test_port: u16 = TcpListener::bind("127.0.0.1:0") // 0 means OS assigns a free port - .await - .expect("Failed to bind to a port") - .local_addr() - .unwrap() - .port(); - - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let test_port = unused_tcp_and_udp_port(); + + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .listener_port(test_port) .disable_discv4_discovery() @@ -130,7 +117,7 @@ async fn test_discv5_and_rlpx_same_socket_ok_without_discv4() { // #[tokio::test(flavor = "multi_thread")] async fn test_tcp_port_node_record_no_discovery() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .listener_port(0) .disable_discovery() @@ -148,7 +135,7 @@ async fn test_tcp_port_node_record_no_discovery() { // #[tokio::test(flavor = "multi_thread")] async fn test_tcp_port_node_record_discovery() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .listener_port(0) .discovery_port(0) @@ -167,7 +154,7 @@ async fn test_tcp_port_node_record_discovery() { #[tokio::test(flavor = "multi_thread")] async fn test_node_record_address_with_nat() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .add_nat(Some(NatResolver::ExternalIp("10.1.1.1".parse().unwrap()))) .disable_discv4_discovery() @@ -183,7 +170,7 @@ async fn test_node_record_address_with_nat() { #[tokio::test(flavor = "multi_thread")] async fn test_node_record_address_with_nat_disable_discovery() { - let secret_key = SecretKey::new(&mut rand_08::thread_rng()); + let secret_key = rng_secret_key(); let config = NetworkConfigBuilder::eth(secret_key, Runtime::test()) .add_nat(Some(NatResolver::ExternalIp("10.1.1.1".parse().unwrap()))) .disable_discovery() diff --git a/crates/net/network/tests/it/transaction_hash_fetching.rs b/crates/net/network/tests/it/transaction_hash_fetching.rs index 2fb6b7ddc92..66f5cee5f5b 100644 --- a/crates/net/network/tests/it/transaction_hash_fetching.rs +++ b/crates/net/network/tests/it/transaction_hash_fetching.rs @@ -1,57 +1,21 @@ //! Tests fetching transactions that peers announce by hash over real sessions. -use alloy_primitives::{TxHash, U256}; +use crate::utils::{funded_transactions, poll_until}; +use alloy_primitives::TxHash; use reth_network::{ test_utils::Testnet, transactions::{TransactionPropagationMode, TransactionsManagerConfig}, }; -use reth_primitives_traits::SignedTransaction; -use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; +use reth_provider::test_utils::MockEthProvider; use reth_tracing::init_test_tracing; -use reth_transaction_pool::{ - test_utils::TransactionGenerator, EthPooledTransaction, PoolTransaction, TransactionPool, -}; -use std::time::Duration; - -/// How long a peer may take to fetch and import the announced transactions. -const FETCH_TIMEOUT: Duration = Duration::from_secs(30); - -/// Generates pooled transactions from distinct senders that are funded in the provider. -fn funded_transactions(provider: &MockEthProvider, count: usize) -> Vec { - let mut generator = TransactionGenerator::with_num_signers(rand::rng(), count); - generator - .signer_keys - .clone() - .into_iter() - .map(|signer| { - let mut tx = generator.transaction(); - tx.signer = signer; - let tx = tx.into_eip1559().try_into_recovered().unwrap(); - let tx = EthPooledTransaction::try_from_consensus(tx).unwrap(); - provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::from(100_000_000))); - tx - }) - .collect() -} +use reth_transaction_pool::{PoolTransaction, TransactionPool}; /// Waits until the pool contains all the given transactions. async fn wait_for_transactions(pool: &impl TransactionPool, hashes: &[TxHash]) { - tokio::time::timeout(FETCH_TIMEOUT, async { - loop { - if pool.get_all(hashes.to_vec()).len() == hashes.len() { - return - } - tokio::time::sleep(Duration::from_millis(50)).await; - } + poll_until("transactions", async || { + (pool.get_all(hashes.to_vec()).len() == hashes.len()).then_some(()) }) .await - .unwrap_or_else(|_| { - panic!( - "timed out, pool has {} of {} transactions", - pool.get_all(hashes.to_vec()).len(), - hashes.len() - ) - }); } /// Hash-only propagation, so peers must request every transaction they learn about. @@ -67,21 +31,20 @@ async fn announced_transactions_are_fetched_from_announcing_peer() { init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(2, provider.clone()).await; - let net = net.with_eth_pool_config(hashes_only()); - let handle = net.spawn(); - handle.connect_peers().await; + let net = + Testnet::create_with(2, provider.clone()).await.with_eth_pool_config(hashes_only()).spawn(); + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); // more transactions than fit into a single request let txs = funded_transactions(&provider, 300); let hashes = txs.iter().map(|tx| *tx.hash()).collect::>(); - let peer0_pool = handle.peers()[0].pool().unwrap(); - for outcome in peer0_pool.add_external_transactions(txs).await { + for outcome in peer0.pool().unwrap().add_external_transactions(txs).await { outcome.unwrap(); } - wait_for_transactions(handle.peers()[1].pool().unwrap(), &hashes).await; + wait_for_transactions(peer1.pool().unwrap(), &hashes).await; } #[tokio::test(flavor = "multi_thread")] @@ -91,21 +54,17 @@ async fn transactions_reach_all_peers_by_broadcast_and_fetching() { // with the default propagation mode only some peers receive the transactions in full, the // others are announced the hashes and fetch them let provider = MockEthProvider::default().with_genesis_block(); - let num_peers = 5; - let net = Testnet::create_with(num_peers, provider.clone()).await; - let net = net.with_eth_pool(); - let handle = net.spawn(); - handle.connect_peers().await; + let net = Testnet::create_with(5, provider.clone()).await.with_eth_pool().spawn(); + net.connect_peers().await; let txs = funded_transactions(&provider, 100); let hashes = txs.iter().map(|tx| *tx.hash()).collect::>(); - let peer0_pool = handle.peers()[0].pool().unwrap(); - for outcome in peer0_pool.add_external_transactions(txs).await { + for outcome in net.peers()[0].pool().unwrap().add_external_transactions(txs).await { outcome.unwrap(); } - for peer in &handle.peers()[1..] { + for peer in &net.peers()[1..] { wait_for_transactions(peer.pool().unwrap(), &hashes).await; } } @@ -117,23 +76,21 @@ async fn transactions_announced_by_many_peers_are_fetched() { // every peer announces the same transactions to the last peer, which spreads its requests // over them let provider = MockEthProvider::default().with_genesis_block(); - let num_peers = 6; - let net = Testnet::create_with(num_peers, provider.clone()).await; - let net = net.with_eth_pool_config(hashes_only()); - let handle = net.spawn(); + let net = + Testnet::create_with(6, provider.clone()).await.with_eth_pool_config(hashes_only()).spawn(); + let (listening_peer, announcing_peers) = net.peers().split_last().unwrap(); let txs = funded_transactions(&provider, 400); let hashes = txs.iter().map(|tx| *tx.hash()).collect::>(); - for peer in &handle.peers()[..num_peers - 1] { + for peer in announcing_peers { for outcome in peer.pool().unwrap().add_external_transactions(txs.clone()).await { outcome.unwrap(); } } - handle.connect_peers().await; + net.connect_peers().await; - let listening_peer = &handle.peers()[num_peers - 1]; wait_for_transactions(listening_peer.pool().unwrap(), &hashes).await; } @@ -145,51 +102,19 @@ async fn transaction_hash_fetching() { config.transaction_fetcher_config.max_inflight_requests = 1; let provider = MockEthProvider::default().with_genesis_block(); - let num_peers = 10; - let net = Testnet::create_with(num_peers, provider.clone()).await; - - // install request handlers - let net = net.with_eth_pool_config(config); - let handle = net.spawn(); + let net = Testnet::create_with(10, provider.clone()).await.with_eth_pool_config(config).spawn(); // connect all the peers first - handle.connect_peers().await; - - let listening_peer = &handle.peers()[num_peers - 1]; - let mut listening_peer_tx_listener = - listening_peer.pool().unwrap().pending_transactions_listener(); - - let num_tx_per_peer = 10; - - // Generate transactions for peers - for i in 1..num_peers { - let peer = &handle.peers()[i]; - let peer_pool = peer.pool().unwrap(); - - for _ in 0..num_tx_per_peer { - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); - peer_pool.add_external_transaction(tx).await.unwrap(); + net.connect_peers().await; + let (listening_peer, announcing_peers) = net.peers().split_last().unwrap(); + + // every other peer inserts transactions of its own + let mut hashes = Vec::new(); + for peer in announcing_peers { + for tx in funded_transactions(&provider, 10) { + hashes.push(peer.pool().unwrap().add_external_transaction(tx).await.unwrap().hash); } } - // Total expected transactions - let total_expected_tx = num_tx_per_peer * (num_peers - 1); - let mut received_tx = 0; - - loop { - tokio::select! { - Some(_) = listening_peer_tx_listener.recv() => { - received_tx += 1; - if received_tx >= total_expected_tx { - break; - } - } - _ = tokio::time::sleep(Duration::from_secs(10)) => { - panic!("Timed out waiting for transactions. Received {received_tx}/{total_expected_tx}"); - } - } - } + wait_for_transactions(listening_peer.pool().unwrap(), &hashes).await; } diff --git a/crates/net/network/tests/it/txgossip.rs b/crates/net/network/tests/it/txgossip.rs index ab1338cc4ab..41732185a43 100644 --- a/crates/net/network/tests/it/txgossip.rs +++ b/crates/net/network/tests/it/txgossip.rs @@ -1,63 +1,40 @@ //! Testing gossiping of transactions. +use crate::utils::{funded_transaction, poll_until}; use alloy_consensus::TxLegacy; use alloy_primitives::{map::B256Set, Signature, TxHash, U256}; use reth_ethereum_primitives::TransactionSigned; use reth_network::{ - test_utils::{NetworkEventStream, PeerHandle, Testnet}, + test_utils::{PeerHandle, Testnet}, transactions::config::{ TransactionIngressPolicy, TransactionPropagationKind, TransactionsManagerConfig, }, - NetworkEventListenerProvider, Peers, + Peers, }; use reth_network_api::{PeerId, PeerKind, PeersInfo}; use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; -use reth_transaction_pool::{ - test_utils::TransactionGenerator, AddedTransactionOutcome, EthPooledTransaction, - PoolTransaction, TransactionPool, -}; +use reth_transaction_pool::{test_utils::TransactionGenerator, PoolTransaction, TransactionPool}; use std::{sync::Arc, time::Duration}; use tokio::join; -/// How long a peer may take to observe a session or an announcement. -const TIMEOUT: Duration = Duration::from_secs(30); - #[tokio::test(flavor = "multi_thread")] async fn test_tx_gossip() { reth_tracing::init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(2, provider.clone()).await; - - // install request handlers - let net = net.with_eth_pool(); - let handle = net.spawn(); - // connect all the peers - handle.connect_peers().await; - - let peer0 = &handle.peers()[0]; - let peer1 = &handle.peers()[1]; + let net = Testnet::create_with(2, provider.clone()).await.with_eth_pool().spawn(); + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); - let peer0_pool = peer0.pool().unwrap(); let mut peer0_tx_listener = peer0.pool().unwrap().pending_transactions_listener(); let mut peer1_tx_listener = peer1.pool().unwrap().pending_transactions_listener(); - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - - // ensure the sender has balance - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); - // insert pending tx in peer0's pool - let AddedTransactionOutcome { hash, .. } = - peer0_pool.add_external_transaction(tx).await.unwrap(); - - let inserted = peer0_tx_listener.recv().await.unwrap(); - assert_eq!(inserted, hash); + let tx = funded_transaction(&provider); + let hash = peer0.pool().unwrap().add_external_transaction(tx).await.unwrap().hash; + assert_eq!(peer0_tx_listener.recv().await.unwrap(), hash); // ensure tx is gossiped to peer1 - let received = peer1_tx_listener.recv().await.unwrap(); - assert_eq!(received, hash); + assert_eq!(peer1_tx_listener.recv().await.unwrap(), hash); } #[tokio::test(flavor = "multi_thread")] @@ -65,60 +42,39 @@ async fn test_tx_propagation_policy_trusted_only() { reth_tracing::init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); + let net = Testnet::create_with(2, provider.clone()) + .await + .with_eth_pool_config_and_policy(Default::default(), TransactionPropagationKind::Trusted) + .spawn(); + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); - let policy = TransactionPropagationKind::Trusted; - let net = Testnet::create_with(2, provider.clone()).await; - let net = net.with_eth_pool_config_and_policy(Default::default(), policy); - - let handle = net.spawn(); - - // connect all the peers - handle.connect_peers().await; - - let peer_0_handle = &handle.peers()[0]; - let peer_1_handle = &handle.peers()[1]; - - let mut peer0_tx_listener = peer_0_handle.pool().unwrap().pending_transactions_listener(); - let mut peer1_tx_listener = peer_1_handle.pool().unwrap().pending_transactions_listener(); - - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - - // ensure the sender has balance - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); + let mut peer0_tx_listener = peer0.pool().unwrap().pending_transactions_listener(); + let mut peer1_tx_listener = peer1.pool().unwrap().pending_transactions_listener(); // insert the tx in peer0's pool - let outcome_0 = peer_0_handle.pool().unwrap().add_external_transaction(tx).await.unwrap(); - let inserted = peer0_tx_listener.recv().await.unwrap(); - - assert_eq!(inserted, outcome_0.hash); + let tx = funded_transaction(&provider); + let outcome_0 = peer0.pool().unwrap().add_external_transaction(tx).await.unwrap(); + assert_eq!(peer0_tx_listener.recv().await.unwrap(), outcome_0.hash); // ensure tx is not gossiped to peer1 peer1_tx_listener.try_recv().expect_err("Empty"); - let mut event_stream_0 = NetworkEventStream::new(peer_0_handle.network().event_listener()); - let mut event_stream_1 = NetworkEventStream::new(peer_1_handle.network().event_listener()); + let mut event_stream_0 = peer0.event_stream(); + let mut event_stream_1 = peer1.event_stream(); // disconnect peer1 from peer0 - peer_0_handle.network().remove_peer(*peer_1_handle.peer_id(), PeerKind::Static); + peer0.network().remove_peer(*peer1.peer_id(), PeerKind::Static); join!(event_stream_0.next_session_closed(), event_stream_1.next_session_closed()); // re register peer1 as trusted - peer_0_handle.network().add_trusted_peer(*peer_1_handle.peer_id(), peer_1_handle.local_addr()); + peer0.add_trusted_peer(peer1); join!(event_stream_0.next_session_established(), event_stream_1.next_session_established()); - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - - // ensure the sender has balance - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); - // insert pending tx in peer0's pool - let outcome_1 = peer_0_handle.pool().unwrap().add_external_transaction(tx).await.unwrap(); - let inserted = peer0_tx_listener.recv().await.unwrap(); - assert_eq!(inserted, outcome_1.hash); + let tx = funded_transaction(&provider); + let outcome_1 = peer0.pool().unwrap().add_external_transaction(tx).await.unwrap(); + assert_eq!(peer0_tx_listener.recv().await.unwrap(), outcome_1.hash); // ensure peer1 now receives the pending txs from peer0 let mut buff = Vec::with_capacity(2); @@ -133,58 +89,40 @@ async fn test_tx_ingress_policy_trusted_only() { reth_tracing::init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); - let tx_manager_config = TransactionsManagerConfig { ingress_policy: TransactionIngressPolicy::Trusted, ..Default::default() }; + let net = Testnet::create_with(2, provider.clone()) + .await + .with_eth_pool_config(tx_manager_config) + .spawn(); + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); - let net = Testnet::create_with(2, provider.clone()).await; - let net = net.with_eth_pool_config(tx_manager_config); - - let handle = net.spawn(); - - // connect all the peers - handle.connect_peers().await; - - let peer_0_handle = &handle.peers()[0]; - let peer_1_handle = &handle.peers()[1]; - - let mut peer0_tx_listener = peer_0_handle.pool().unwrap().pending_transactions_listener(); - - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - - // ensure the sender has balance - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); + let mut peer0_tx_listener = peer0.pool().unwrap().pending_transactions_listener(); // insert the tx in peer1's pool - let outcome_0 = peer_1_handle.pool().unwrap().add_external_transaction(tx).await.unwrap(); + let tx = funded_transaction(&provider); + let outcome_0 = peer1.pool().unwrap().add_external_transaction(tx).await.unwrap(); // ensure tx is not accepted by peer0 peer0_tx_listener.try_recv().expect_err("Empty"); - let mut event_stream_0 = NetworkEventStream::new(peer_0_handle.network().event_listener()); - let mut event_stream_1 = NetworkEventStream::new(peer_1_handle.network().event_listener()); + let mut event_stream_0 = peer0.event_stream(); + let mut event_stream_1 = peer1.event_stream(); // disconnect peer1 from peer0 - peer_0_handle.network().remove_peer(*peer_1_handle.peer_id(), PeerKind::Static); + peer0.network().remove_peer(*peer1.peer_id(), PeerKind::Static); join!(event_stream_0.next_session_closed(), event_stream_1.next_session_closed()); // re register peer1 as trusted - peer_0_handle.network().add_trusted_peer(*peer_1_handle.peer_id(), peer_1_handle.local_addr()); + peer0.add_trusted_peer(peer1); join!(event_stream_0.next_session_established(), event_stream_1.next_session_established()); - let mut tx_gen = TransactionGenerator::new(rand::rng()); - let tx = tx_gen.gen_eip1559_pooled(); - - // ensure the sender has balance - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); - // insert pending tx in peer1's pool - let outcome_1 = peer_1_handle.pool().unwrap().add_external_transaction(tx).await.unwrap(); + let tx = funded_transaction(&provider); + let outcome_1 = peer1.pool().unwrap().add_external_transaction(tx).await.unwrap(); // ensure peer0 now receives both pending txs from peer1 (the blocked one and the new one) let mut buff = Vec::with_capacity(2); @@ -200,12 +138,11 @@ async fn test_tx_propagation_policy_trusted_only_on_connect() { reth_tracing::init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(3, provider.clone()).await; - let net = net - .with_eth_pool_config_and_policy(Default::default(), TransactionPropagationKind::Trusted); - let handle = net.spawn(); - - let [peer0, untrusted, trusted] = handle.peers() else { unreachable!() }; + let net = Testnet::create_with(3, provider.clone()) + .await + .with_eth_pool_config_and_policy(Default::default(), TransactionPropagationKind::Trusted) + .spawn(); + let [peer0, untrusted, trusted] = net.peers_array(); let pool0 = peer0.pool().unwrap(); // insert the tx before connecting, so it can only be learned from the announcement of the @@ -232,12 +169,11 @@ async fn test_tx_propagation_policy_trusted_only_get_pooled_transactions() { reth_tracing::init_test_tracing(); let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(3, provider.clone()).await; - let net = net - .with_eth_pool_config_and_policy(Default::default(), TransactionPropagationKind::Trusted); - let handle = net.spawn(); - - let [peer0, untrusted, trusted] = handle.peers() else { unreachable!() }; + let net = Testnet::create_with(3, provider.clone()) + .await + .with_eth_pool_config_and_policy(Default::default(), TransactionPropagationKind::Trusted) + .spawn(); + let [peer0, untrusted, trusted] = net.peers_array(); let pending = peer0 .pool() @@ -273,19 +209,11 @@ async fn test_tx_propagation_policy_trusted_only_get_pooled_transactions() { #[tokio::test(flavor = "multi_thread")] async fn test_4844_tx_gossip_penalization() { reth_tracing::init_test_tracing(); - let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(2, provider.clone()).await; - // install request handlers - let net = net.with_eth_pool(); - - let handle = net.spawn(); - - let peer0 = &handle.peers()[0]; - let peer1 = &handle.peers()[1]; - - // connect all the peers - handle.connect_peers().await; + let provider = MockEthProvider::default().with_genesis_block(); + let net = Testnet::create_with(2, provider.clone()).await.with_eth_pool().spawn(); + net.connect_peers().await; + let [peer0, peer1] = net.peers_array(); let mut peer1_tx_listener = peer1.pool().unwrap().pending_transactions_listener(); @@ -295,20 +223,17 @@ async fn test_4844_tx_gossip_penalization() { let txs = vec![tx_gen.gen_eip4844_pooled(), tx_gen.gen_eip1559_pooled()]; for tx in &txs { - let sender = tx.sender(); - provider.add_account(sender, ExtendedAccount::new(0, U256::from(100_000_000))); + provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::from(100_000_000))); } let signed_txs: Vec> = txs.iter().map(|tx| Arc::new(tx.transaction().clone().into_inner())).collect(); - let network_handle = peer0.network(); - let peer0_reputation_before = peer1.peer_handle().peer_by_id(*peer0.peer_id()).await.unwrap().reputation(); // sends txs directly to peer1 - network_handle.send_transactions(*peer1.peer_id(), signed_txs); + peer0.network().send_transactions(*peer1.peer_id(), signed_txs); let received = peer1_tx_listener.recv().await.unwrap(); @@ -324,20 +249,12 @@ async fn test_4844_tx_gossip_penalization() { #[tokio::test(flavor = "multi_thread")] async fn test_sending_invalid_transactions() { reth_tracing::init_test_tracing(); - let provider = MockEthProvider::default().with_genesis_block(); - let net = Testnet::create_with(2, provider.clone()).await; - // install request handlers - let net = net.with_eth_pool(); - - let handle = net.spawn(); - let peer0 = &handle.peers()[0]; - let peer1 = &handle.peers()[1]; - - let mut peer1_events = NetworkEventStream::new(peer1.network().event_listener()); - - // connect all the peers - handle.connect_peers().await; + let provider = MockEthProvider::default().with_genesis_block(); + let net = Testnet::create_with(2, provider).await.with_eth_pool().spawn(); + let [peer0, peer1] = net.peers_array(); + let mut peer1_events = peer1.event_stream(); + net.connect_peers().await; assert_eq!(peer0.network().num_connected_peers(), 1); let mut tx_listener = peer1.pool().unwrap().new_transactions_listener(); @@ -359,26 +276,17 @@ async fn test_sending_invalid_transactions() { // The listener also receives connection events, and PeerAdded can still be queued after // connect_peers returns. Wait specifically for the disconnect after bad transaction spam. - let (peer_id, _) = tokio::time::timeout( - std::time::Duration::from_secs(10), - peer1_events.next_session_closed(), - ) - .await - .expect("peer did not disconnect after invalid transaction spam") - .expect("network event stream ended before disconnect"); + let (peer_id, _) = + tokio::time::timeout(Duration::from_secs(10), peer1_events.next_session_closed()) + .await + .expect("peer did not disconnect after invalid transaction spam") + .expect("network event stream ended before disconnect"); assert_eq!(peer_id, *peer0.peer_id()); // ensure txs never made it to the pool assert!(tx_listener.try_recv().is_err()); } -/// Returns a transaction from a new sender that is funded in the provider. -fn funded_transaction(provider: &MockEthProvider) -> EthPooledTransaction { - let tx = TransactionGenerator::new(rand::rng()).gen_eip1559_pooled(); - provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::from(100_000_000))); - tx -} - /// Connects `peer` to `untrusted` as a basic peer and to `trusted` as a trusted peer, and waits /// until the transactions managers of all peers handled the new sessions. async fn connect_untrusted_and_trusted( @@ -386,8 +294,8 @@ async fn connect_untrusted_and_trusted( untrusted: &PeerHandle, trusted: &PeerHandle, ) { - peer.network().add_peer(*untrusted.peer_id(), untrusted.local_addr()); - peer.network().add_trusted_peer(*trusted.peer_id(), trusted.local_addr()); + peer.add_peer(untrusted); + peer.add_trusted_peer(trusted); wait_for_active_peers(peer, &[*untrusted.peer_id(), *trusted.peer_id()]).await; wait_for_active_peers(untrusted, &[*peer.peer_id()]).await; @@ -397,17 +305,11 @@ async fn connect_untrusted_and_trusted( /// Waits until the transactions manager of `peer` has active sessions with all `peers`. async fn wait_for_active_peers(peer: &PeerHandle, peers: &[PeerId]) { let transactions = peer.transactions().unwrap(); - tokio::time::timeout(TIMEOUT, async { - loop { - let active = transactions.get_active_peers().await.unwrap(); - if peers.iter().all(|peer| active.contains(peer)) { - return - } - tokio::time::sleep(Duration::from_millis(10)).await; - } + poll_until("sessions", async || { + let active = transactions.get_active_peers().await.unwrap(); + peers.iter().all(|peer| active.contains(peer)).then_some(()) }) .await - .expect("timed out waiting for sessions"); } /// Waits until `peer` learned from `announcer` that it has `hash`, and returns all hashes `peer` @@ -418,16 +320,9 @@ async fn wait_for_announcement( hash: TxHash, ) -> B256Set { let transactions = peer.transactions().unwrap(); - tokio::time::timeout(TIMEOUT, async { - loop { - let hashes = - transactions.get_peer_transaction_hashes(*announcer.peer_id()).await.unwrap(); - if hashes.contains(&hash) { - return hashes - } - tokio::time::sleep(Duration::from_millis(10)).await; - } + poll_until("announcement", async || { + let hashes = transactions.get_peer_transaction_hashes(*announcer.peer_id()).await.unwrap(); + hashes.contains(&hash).then_some(hashes) }) .await - .expect("timed out waiting for announcement") } diff --git a/crates/net/network/tests/it/utils.rs b/crates/net/network/tests/it/utils.rs new file mode 100644 index 00000000000..45484977eb6 --- /dev/null +++ b/crates/net/network/tests/it/utils.rs @@ -0,0 +1,40 @@ +//! Shared fixtures for the network integration tests. + +use alloy_primitives::U256; +use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; +use reth_transaction_pool::{ + test_utils::TransactionGenerator, EthPooledTransaction, PoolTransaction, +}; +use std::time::Duration; + +/// How long a test may wait for a condition, e.g. a session or an announcement. +const TIMEOUT: Duration = Duration::from_secs(30); + +/// Returns a transaction from a new sender that is funded in the provider. +pub(crate) fn funded_transaction(provider: &MockEthProvider) -> EthPooledTransaction { + let tx = TransactionGenerator::with_num_signers(rand::rng(), 1).gen_eip1559_pooled(); + provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::from(100_000_000))); + tx +} + +/// Returns transactions from distinct new senders that are funded in the provider. +pub(crate) fn funded_transactions( + provider: &MockEthProvider, + count: usize, +) -> Vec { + (0..count).map(|_| funded_transaction(provider)).collect() +} + +/// Polls `f` until it returns `Some`, panicking if `condition` is not met within [`TIMEOUT`]. +pub(crate) async fn poll_until(condition: &str, mut f: impl AsyncFnMut() -> Option) -> T { + tokio::time::timeout(TIMEOUT, async { + loop { + if let Some(value) = f().await { + return value + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {condition}")) +} diff --git a/crates/net/p2p/src/full_block.rs b/crates/net/p2p/src/full_block.rs index c6b260f96b3..cf27dc28efd 100644 --- a/crates/net/p2p/src/full_block.rs +++ b/crates/net/p2p/src/full_block.rs @@ -14,9 +14,7 @@ use alloy_primitives::{Bytes, Sealable, B256}; use core::marker::PhantomData; use futures::FutureExt; use reth_consensus::Consensus; -use reth_eth_wire_types::{ - BlockAccessLists, EthNetworkPrimitives, HeadersDirection, NetworkPrimitives, -}; +use reth_eth_wire_types::{BlockAccessLists, EthNetworkPrimitives, NetworkPrimitives}; use reth_network_peers::{PeerId, WithPeerId}; use reth_primitives_traits::{Block, SealedBlock, SealedBlockWith, SealedHeader}; use std::{ @@ -903,11 +901,9 @@ where if this.headers.is_none() { // did not receive a correct response yet, retry - this.request.headers = Some(this.client.get_headers(HeadersRequest { - start: this.start_hash.into(), - limit: this.count, - direction: HeadersDirection::Falling, - })); + this.request.headers = Some(this.client.get_headers( + HeadersRequest::falling(this.start_hash.into(), this.count), + )); } } // This branch handles block body responses from peers - it first inserts the diff --git a/crates/net/p2p/src/headers/client.rs b/crates/net/p2p/src/headers/client.rs index 23cfcdf9d7e..bc48b8c5ba5 100644 --- a/crates/net/p2p/src/headers/client.rs +++ b/crates/net/p2p/src/headers/client.rs @@ -28,7 +28,7 @@ impl HeadersRequest { /// # Arguments /// * `start` - The block hash or number to start from pub const fn one(start: BlockHashOrNumber) -> Self { - Self { direction: HeadersDirection::Rising, limit: 1, start } + Self::rising(start, 1) } /// Creates a request for headers in rising direction (ascending block numbers). diff --git a/crates/net/p2p/src/receipts/client.rs b/crates/net/p2p/src/receipts/client.rs index 54def51bf5f..dcafa7ee166 100644 --- a/crates/net/p2p/src/receipts/client.rs +++ b/crates/net/p2p/src/receipts/client.rs @@ -12,10 +12,14 @@ pub type ReceiptsFut = /// Response from a receipts request. /// -/// **Note for [`ReceiptsClient`] callers:** the network layer handles eth/70 -/// continuation rounds internally, so `last_block_incomplete` is always `false` -/// by the time this response reaches a [`ReceiptsClient`] consumer. The field -/// exists for internal use by the fetcher during multi-round assembly. +/// Eth/70 responses may contain an incomplete last block, indicated by +/// [`last_block_incomplete`](Self::last_block_incomplete). The network layer forwards this flag +/// without fetching the remaining receipts. +/// +/// [`ReceiptsClient`] does not expose a receipt offset. Resuming an incomplete block requires a +/// lower-level eth/70 [`GetReceipts70`](reth_eth_wire_types::GetReceipts70) request with that block +/// first in `block_hashes` and `first_block_receipt_index` set to the number of receipts already +/// received for it. Responses from eth/69 and older peers always set this flag to `false`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReceiptsResponse { /// Receipts grouped by block, in the same order as the requested hashes. @@ -23,9 +27,8 @@ pub struct ReceiptsResponse { /// When `true`, the **last** block in [`receipts`](Self::receipts) was /// truncated by the remote peer (eth/70 `Receipts70.last_block_incomplete`). /// - /// This is used internally by the fetcher to drive continuation rounds. - /// Responses surfaced through [`ReceiptsClient`] always have this set to - /// `false`. + /// The remaining receipts of that block have to be requested separately. Responses from + /// eth/69 and older peers always have this set to `false`. pub last_block_incomplete: bool, } diff --git a/crates/net/peers/src/lib.rs b/crates/net/peers/src/lib.rs index 959fae8da08..51bce7f7cef 100644 --- a/crates/net/peers/src/lib.rs +++ b/crates/net/peers/src/lib.rs @@ -222,9 +222,7 @@ impl core::fmt::Display for AnyNode { Self::NodeRecord(record) => write!(f, "{record}"), #[cfg(feature = "secp256k1")] Self::Enr(enr) => write!(f, "{enr}"), - Self::PeerId(peer_id) => { - write!(f, "enode://{}", alloy_primitives::hex::encode(peer_id.as_slice())) - } + Self::PeerId(peer_id) => write!(f, "enode://{peer_id:x}"), Self::TrustedPeer(peer) => write!(f, "{peer}"), } } @@ -307,6 +305,7 @@ impl WithPeerId> { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::b512; #[cfg(feature = "secp256k1")] #[test] @@ -317,7 +316,7 @@ mod tests { address: std::net::IpAddr::V4([10,3,58,6].into()), tcp_port: 30303, udp_port: 30301, - id: "6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0".parse().unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), })); assert_eq!(node.to_string(), url) } @@ -326,7 +325,7 @@ mod tests { fn test_peer_id_parse() { let url = "enode://6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"; let node: AnyNode = url.parse().unwrap(); - assert_eq!(node, AnyNode::PeerId("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0".parse().unwrap())); + assert_eq!(node, AnyNode::PeerId(b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"))); assert_eq!(node.to_string(), url); let url = "enode://"; @@ -342,9 +341,9 @@ mod tests { let node: AnyNode = url.parse().unwrap(); assert_eq!( node.peer_id(), - "0xca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd31387574077f301b421bc84df7266c44e9e6d569fc56be00812904767bf5ccd1fc7f" - .parse::() - .unwrap() + b512!( + "0xca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd31387574077f301b421bc84df7266c44e9e6d569fc56be00812904767bf5ccd1fc7f" + ) ); // The spec vector is discovery-only (no tcp key), so it has no RLPx endpoint. assert!(node.node_record().is_none()); @@ -358,7 +357,7 @@ mod tests { assert!(matches!(node, AnyNode::TrustedPeer(_))); assert_eq!( node.peer_id(), - "6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0".parse::().unwrap() + b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0") ); assert!(node.node_record().is_none()); assert!(node.trusted_peer().is_some()); diff --git a/crates/net/peers/src/node_record.rs b/crates/net/peers/src/node_record.rs index 891d253f811..ecd61b720d7 100644 --- a/crates/net/peers/src/node_record.rs +++ b/crates/net/peers/src/node_record.rs @@ -245,6 +245,7 @@ impl TryFrom<&Enr> for NodeRecord { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::b512; use alloy_rlp::Decodable; use rand::{rng, Rng, RngCore}; use std::net::Ipv6Addr; @@ -327,7 +328,7 @@ mod tests { address: IpAddr::V4([127,0,0, 1].into()), tcp_port: 35481, udp_port: 0, - id: "0xfc8a2ff614e848c0af4c99372a81b8655edb8e11b617cffd0aab1a0691bcca66ca533626a528ee567f05f70c8cb529bda2c0a864cc0aec638a367fd2bb8e49fb".parse().unwrap(), + id: b512!("0xfc8a2ff614e848c0af4c99372a81b8655edb8e11b617cffd0aab1a0691bcca66ca533626a528ee567f05f70c8cb529bda2c0a864cc0aec638a367fd2bb8e49fb"), }) } @@ -339,7 +340,7 @@ mod tests { address: IpAddr::V4([10,3,58,6].into()), tcp_port: 30303, udp_port: 30301, - id: "6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0".parse().unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), }) } @@ -366,7 +367,7 @@ mod tests { address: IpAddr::V4([10, 3, 58, 6].into()), tcp_port: 30303u16, udp_port: 30301u16, - id: PeerId::from_str("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0").unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), }, "\"enode://6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0@10.3.58.6:30303?discport=30301\"" ), @@ -376,7 +377,7 @@ mod tests { address: Ipv6Addr::new(0x2001, 0xdb8, 0x3c4d, 0x15, 0x0, 0x0, 0xabcd, 0xef12).into(), tcp_port: 52150u16, udp_port: 52151u16, - id: PeerId::from_str("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439").unwrap(), + id: b512!("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439"), }, "\"enode://1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439@[2001:db8:3c4d:15::abcd:ef12]:52150?discport=52151\"", ) @@ -398,7 +399,7 @@ mod tests { address: IpAddr::V4([10, 3, 58, 6].into()), tcp_port: 30303u16, udp_port: 30301u16, - id: PeerId::from_str("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0").unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), } ), // IPv6 @@ -408,7 +409,7 @@ mod tests { address: Ipv6Addr::new(0x2001, 0xdb8, 0x3c4d, 0x15, 0x0, 0x0, 0xabcd, 0xef12).into(), tcp_port: 52150u16, udp_port: 52151u16, - id: PeerId::from_str("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439").unwrap(), + id: b512!("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439"), } ), ]; diff --git a/crates/net/peers/src/trusted_peer.rs b/crates/net/peers/src/trusted_peer.rs index 6eef50e714c..ef73df90b38 100644 --- a/crates/net/peers/src/trusted_peer.rs +++ b/crates/net/peers/src/trusted_peer.rs @@ -8,7 +8,7 @@ use core::{ str::FromStr, }; use serde_with::{DeserializeFromStr, SerializeDisplay}; -use url::Host; +use url::{Host, Url}; /// Represents the node record of a trusted peer. The only difference between this and a /// [`NodeRecord`] is that this does not contain the IP address of the peer, but rather a domain @@ -128,18 +128,18 @@ impl FromStr for TrustedPeer { return Ok(record.into()) } - use url::Url; + let url = Url::parse(s).map_err(|e| NodeRecordParseError::InvalidUrl(e.to_string()))?; - // Parse the URL with enode prefix replaced with http. - // The enode prefix causes the parser to use parse_opaque() on - // the host str which only handles domains and ipv6, not ipv4. - let url = Url::parse(s.replace("enode://", "http://").as_str()) - .map_err(|e| NodeRecordParseError::InvalidUrl(e.to_string()))?; - - let host = url - .host() - .ok_or_else(|| NodeRecordParseError::InvalidUrl("no host specified".to_string()))? - .to_owned(); + // The enode scheme is not a special scheme, so the parser keeps the host as an opaque + // string, which covers domains and ipv6 but not ipv4. Parse it again as a regular host. + // Parsing the whole URL as http instead would drop port 80 as the default port. + let host = match url.host() { + Some(Host::Domain(domain)) => { + Host::parse(domain).map_err(|e| NodeRecordParseError::InvalidUrl(e.to_string()))? + } + Some(host) => host.to_owned(), + None => return Err(NodeRecordParseError::InvalidUrl("no host specified".to_string())), + }; let port = url .port() @@ -177,6 +177,7 @@ impl From for TrustedPeer { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::b512; use std::net::Ipv6Addr; #[test] @@ -187,7 +188,7 @@ mod tests { host: Host::Ipv4([10,3,58,6].into()), tcp_port: 30303, udp_port: 30301, - id: "6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0".parse().unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), }) } @@ -201,9 +202,9 @@ mod tests { host: Host::Ipv4([127, 0, 0, 1].into()), tcp_port: 30303, udp_port: 30303, - id: "ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd31387574077f301b421bc84df7266c44e9e6d569fc56be00812904767bf5ccd1fc7f" - .parse() - .unwrap(), + id: b512!( + "ca634cae0d49acb401d8a4c6b6fe8c55b70d115bf400769cc1400f3258cd31387574077f301b421bc84df7266c44e9e6d569fc56be00812904767bf5ccd1fc7f" + ), }); assert!("enr:garbage".parse::().is_err()); } @@ -231,7 +232,7 @@ mod tests { host: Host::Ipv4([10, 3, 58, 6].into()), tcp_port: 30303u16, udp_port: 30301u16, - id: PeerId::from_str("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0").unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), }, "\"enode://6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0@10.3.58.6:30303?discport=30301\"" ), @@ -241,7 +242,7 @@ mod tests { host: Host::Ipv6(Ipv6Addr::new(0x2001, 0xdb8, 0x3c4d, 0x15, 0x0, 0x0, 0xabcd, 0xef12)), tcp_port: 52150u16, udp_port: 52151u16, - id: PeerId::from_str("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439").unwrap(), + id: b512!("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439"), }, "\"enode://1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439@[2001:db8:3c4d:15::abcd:ef12]:52150?discport=52151\"" ), @@ -251,7 +252,7 @@ mod tests { host: Host::Domain("my-domain".to_string()), tcp_port: 52150u16, udp_port: 52151u16, - id: PeerId::from_str("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439").unwrap(), + id: b512!("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439"), }, "\"enode://1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439@my-domain:52150?discport=52151\"" ), @@ -268,12 +269,12 @@ mod tests { let cases = vec![ // IPv4 ( - "\"enode://6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0@10.3.58.6:30303?discport=30301\"", + "\"enode://6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0@10.3.58.6:80?discport=30301\"", TrustedPeer { host: Host::Ipv4([10, 3, 58, 6].into()), - tcp_port: 30303u16, + tcp_port: 80u16, udp_port: 30301u16, - id: PeerId::from_str("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0").unwrap(), + id: b512!("6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0"), } ), // IPv6 @@ -283,17 +284,17 @@ mod tests { host: Host::Ipv6(Ipv6Addr::new(0x2001, 0xdb8, 0x3c4d, 0x15, 0x0, 0x0, 0xabcd, 0xef12)), tcp_port: 52150u16, udp_port: 52151u16, - id: PeerId::from_str("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439").unwrap(), + id: b512!("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439"), } ), // URL ( - "\"enode://1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439@my-domain:52150?discport=52151\"", + "\"enode://1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439@my-domain:80?discport=52151\"", TrustedPeer { host: Host::Domain("my-domain".to_string()), - tcp_port: 52150u16, + tcp_port: 80u16, udp_port: 52151u16, - id: PeerId::from_str("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439").unwrap(), + id: b512!("1dd9d65c4552b5eb43d5ad55a2ee3f56c6cbc1c64a5c8d659f51fcd51bace24351232b8d7821617d2b29b54b81cdefb9b3e9c37d7fd5f63270bcc9e1a6f6a439"), } ), ]; diff --git a/crates/node/api/src/node.rs b/crates/node/api/src/node.rs index 1cff727ebda..a5aa0e275c4 100644 --- a/crates/node/api/src/node.rs +++ b/crates/node/api/src/node.rs @@ -63,7 +63,7 @@ impl PayloadBuilderFor for T where } /// Encapsulates all types and components of the node. -pub trait FullNodeComponents: FullNodeTypes + Clone + 'static { +pub trait FullNodeComponents: FullNodeTypes { /// The transaction pool of the node. type Pool: TransactionPool>> + Unpin; @@ -114,6 +114,8 @@ pub struct AddOnsContext<'a, N: FullNodeComponents> { /// Notification channel for engine API events pub engine_events: EventSender::Primitives>>, /// JWT secret for the node. + /// + /// Random when the auth server is disabled, since the secret is only used by the auth server. pub jwt_secret: JwtSecret, /// Cache of recovered transaction senders shared by node components, if enabled. pub sender_recovery_cache: Option, diff --git a/crates/node/builder/src/builder/mod.rs b/crates/node/builder/src/builder/mod.rs index a8b8ec8b8e4..45e26592bf5 100644 --- a/crates/node/builder/src/builder/mod.rs +++ b/crates/node/builder/src/builder/mod.rs @@ -8,6 +8,7 @@ use crate::{ components::NodeComponentsBuilder, node::FullNode, rpc::{RethRpcAddOns, RethRpcServerHandles, RpcContext}, + sync::PipelineBackfill, BlockReaderFor, DebugNode, DebugNodeLauncher, EngineNodeLauncher, LaunchNode, Node, }; use alloy_eips::eip4844::env_settings::EnvKzgSettings; @@ -744,16 +745,23 @@ where T::Types: DebugNode>, DebugNodeLauncher: LaunchNode>, { - let Self { builder, task_executor } = self; - - let engine_tree_config = builder.config.tree_config(); + self.launch_with_debug_capabilities_and_backfill(PipelineBackfill) + } - let launcher = DebugNodeLauncher::new(EngineNodeLauncher::new( - task_executor, - builder.config.datadir(), - engine_tree_config, - )); - builder.launch_with(launcher) + /// Launches the node with the [`DebugNodeLauncher`], with the engine's backfill built by + /// `backfill` instead of the staged pipeline. + pub fn launch_with_debug_capabilities_and_backfill( + self, + backfill: B, + ) -> > as LaunchNode< + NodeBuilderWithComponents, + >>::Future + where + T::Types: DebugNode>, + DebugNodeLauncher>: LaunchNode>, + { + let launcher = DebugNodeLauncher::new(self.engine_api_launcher().with_backfill(backfill)); + self.builder.launch_with(launcher) } /// Returns an [`EngineNodeLauncher`] that can be used to launch the node with engine API diff --git a/crates/node/builder/src/components/pool.rs b/crates/node/builder/src/components/pool.rs index 8d1afcdb59c..2712b4dd3c9 100644 --- a/crates/node/builder/src/components/pool.rs +++ b/crates/node/builder/src/components/pool.rs @@ -234,7 +234,7 @@ pub fn create_blob_store_with_cache( fn spawn_local_backup_task(ctx: &BuilderContext, pool: Pool) -> eyre::Result<()> where Node: FullNodeTypes, - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, { if !ctx.config().txpool.disable_transactions_backup { let data_dir = ctx.config().datadir(); @@ -272,7 +272,7 @@ fn spawn_pool_maintenance_task( ) -> eyre::Result<()> where Node: FullNodeTypes>, - Pool: reth_transaction_pool::TransactionPoolExt> + Clone + 'static, + Pool: reth_transaction_pool::TransactionPoolExt> + 'static, Pool::Transaction: PoolTransaction>, { let chain_events = ctx.provider().canonical_state_stream(); @@ -304,7 +304,7 @@ pub fn spawn_maintenance_tasks( ) -> eyre::Result<()> where Node: FullNodeTypes>, - Pool: reth_transaction_pool::TransactionPoolExt> + Clone + 'static, + Pool: reth_transaction_pool::TransactionPoolExt> + 'static, Pool::Transaction: PoolTransaction>, { spawn_local_backup_task(ctx, pool.clone())?; diff --git a/crates/node/builder/src/exex.rs b/crates/node/builder/src/exex.rs index e7f935ac06e..9d3ae5785dd 100644 --- a/crates/node/builder/src/exex.rs +++ b/crates/node/builder/src/exex.rs @@ -33,7 +33,7 @@ pub trait BoxedLaunchExEx: Send { /// Returns a [`BoxFuture`] that resolves to a [`BoxExEx`]. impl BoxedLaunchExEx for E where - E: LaunchExEx + Send + 'static, + E: LaunchExEx + 'static, Node: FullNodeComponents, { fn launch( diff --git a/crates/node/builder/src/launch/common.rs b/crates/node/builder/src/launch/common.rs index 1a7146c96c5..4f56bb1f1fd 100644 --- a/crates/node/builder/src/launch/common.rs +++ b/crates/node/builder/src/launch/common.rs @@ -89,7 +89,7 @@ use reth_storage_overlay::OverlayManager; use reth_tasks::TaskExecutor; use reth_tracing::tracing::{debug, error, info, warn}; use reth_transaction_pool::TransactionPool; -use std::{num::NonZeroUsize, sync::Arc, thread::available_parallelism, time::Duration}; +use std::{sync::Arc, time::Duration}; use tokio::sync::{ mpsc::{unbounded_channel, UnboundedSender}, oneshot, watch, @@ -236,7 +236,7 @@ impl LaunchContext { // TODO: reserved_cpu_cores is currently ignored because subtracting from thread pool // sizes doesn't actually reserve CPU cores for other processes. let _ = reserved_cpu_cores; - let num_threads = available_parallelism().map_or(1, NonZeroUsize::get); + let num_threads = self.task_executor.cpu_pool().current_num_threads(); if let Err(err) = ThreadPoolBuilder::new() .num_threads(num_threads) .thread_name(|i| format!("rayon-{i:02}")) @@ -480,6 +480,28 @@ where rocksdb_provider: Option, disabled_stages: &[StageId], ) -> eyre::Result> + where + N: ProviderNodeTypes, + Evm: ConfigureEvm + 'static, + { + self.create_provider_factory_with_recovery::( + overlay_manager, + rocksdb_provider, + disabled_stages, + |_| Ok(()), + ) + .await + } + + /// Like [`Self::create_provider_factory`], but first runs `recover` to finish writes an earlier + /// run left half-committed, before the consistency check. + pub async fn create_provider_factory_with_recovery( + &self, + overlay_manager: OverlayManager, + rocksdb_provider: Option, + disabled_stages: &[StageId], + recover: impl FnOnce(&ProviderFactory) -> eyre::Result<()>, + ) -> eyre::Result> where N: ProviderNodeTypes, Evm: ConfigureEvm + 'static, @@ -543,6 +565,8 @@ where .with_overlay_manager(overlay_manager) .with_bal_store(bal_store); + recover(&factory)?; + // Check consistency between the database and static files, returning // the unwind targets for each storage layer if inconsistencies are // found. @@ -675,12 +699,39 @@ where rocksdb_provider: Option, disabled_stages: &[StageId], ) -> eyre::Result, ProviderFactory>>> + where + N: ProviderNodeTypes, + Evm: ConfigureEvm + 'static, + { + self.with_provider_factory_and_recovery::( + overlay_manager, + rocksdb_provider, + disabled_stages, + |_| Ok(()), + ) + .await + } + + /// Like [`Self::with_provider_factory`], but first runs `recover` to finish writes an earlier + /// run left half-committed, before the consistency check. + pub async fn with_provider_factory_and_recovery( + self, + overlay_manager: OverlayManager, + rocksdb_provider: Option, + disabled_stages: &[StageId], + recover: impl FnOnce(&ProviderFactory) -> eyre::Result<()>, + ) -> eyre::Result, ProviderFactory>>> where N: ProviderNodeTypes, Evm: ConfigureEvm + 'static, { let factory = self - .create_provider_factory::(overlay_manager, rocksdb_provider, disabled_stages) + .create_provider_factory_with_recovery::( + overlay_manager, + rocksdb_provider, + disabled_stages, + recover, + ) .await?; let ctx = LaunchContextWith { inner: self.inner, diff --git a/crates/node/builder/src/launch/debug.rs b/crates/node/builder/src/launch/debug.rs index 28da0806ba4..0cfc5a38033 100644 --- a/crates/node/builder/src/launch/debug.rs +++ b/crates/node/builder/src/launch/debug.rs @@ -357,7 +357,7 @@ where N: FullNodeComponents>, AddOns: RethRpcAddOns + 'static, L: LaunchNode> + 'static, - B: PayloadProvider> + Clone + 'static, + B: PayloadProvider> + Clone, { type Output = eyre::Result>; type IntoFuture = Pin>> + Send>>; @@ -373,7 +373,6 @@ where N: FullNodeComponents>, AddOns: RethRpcAddOns + 'static, L: LaunchNode> + 'static, - DefaultDebugBlockProvider: PayloadProvider> + Clone, { type Node = NodeHandle; type Future = DebugNodeLauncherFuture; diff --git a/crates/node/builder/src/launch/engine.rs b/crates/node/builder/src/launch/engine.rs index 4380c35622f..c5504312c13 100644 --- a/crates/node/builder/src/launch/engine.rs +++ b/crates/node/builder/src/launch/engine.rs @@ -5,6 +5,7 @@ use crate::{ hooks::NodeHooks, rpc::{EngineShutdown, EngineValidatorAddOn, EngineValidatorBuilder, RethRpcAddOns, RpcHandle}, setup::build_networked_pipeline, + sync::{BackfillClientFor, BackfillContext, BackfillSyncBuilder, PipelineBackfill}, AddOns, AddOnsContext, FullNode, LaunchContext, LaunchNode, Node, NodeAdapter, NodeBuilderWithComponents, NodeComponents, NodeComponentsBuilder, NodeHandle, NodeTypesAdapter, RethFullAdapter, @@ -37,6 +38,7 @@ use reth_provider::{ providers::{BlockchainProvider, NodeTypesForProvider}, BlockNumReader, StorageSettingsCache, }; +use reth_rpc_layer::JwtSecret; use reth_storage_overlay::OverlayManager; use reth_tasks::TaskExecutor; use reth_tokio_util::EventSender; @@ -46,14 +48,20 @@ use tokio::sync::{mpsc::unbounded_channel, oneshot}; use tokio_stream::wrappers::UnboundedReceiverStream; /// The engine node launcher. +/// +/// `B` builds the engine's backfill, the staged pipeline unless set with +/// [`Self::with_backfill`]. #[derive(Debug)] -pub struct EngineNodeLauncher { +pub struct EngineNodeLauncher { /// The task executor for the node. pub ctx: LaunchContext, /// Temporary configuration for engine tree. /// After engine is stabilized, this should be configured through node builder. pub engine_tree_config: TreeConfig, + + // Builds the backfill the engine runs while far behind the chain. + backfill: B, } impl EngineNodeLauncher { @@ -63,9 +71,15 @@ impl EngineNodeLauncher { data_dir: ChainPath, engine_tree_config: TreeConfig, ) -> Self { - Self { ctx: LaunchContext::new(task_executor, data_dir), engine_tree_config } + Self { + ctx: LaunchContext::new(task_executor, data_dir), + engine_tree_config, + backfill: PipelineBackfill, + } } +} +impl EngineNodeLauncher { async fn launch_node( self, target: NodeBuilderWithComponents, @@ -81,8 +95,9 @@ impl EngineNodeLauncher { CB: NodeComponentsBuilder, AO: RethRpcAddOns> + EngineValidatorAddOn>, + B: BackfillSyncBuilder, BackfillClientFor>, { - let Self { ctx, engine_tree_config } = self; + let Self { ctx, engine_tree_config, mut backfill } = self; let NodeBuilderWithComponents { adapter: NodeTypesAdapter { database }, rocksdb_provider, @@ -109,11 +124,13 @@ impl EngineNodeLauncher { .attach(database.clone()) // ensure certain settings take effect .with_adjusted_configs() - // Create the provider factory with the shared overlay manager - .with_provider_factory::<_, >::Evm>( + // Create the provider factory with the shared overlay manager, letting the backfill + // finish writes an earlier run left half-committed before the consistency check + .with_provider_factory_and_recovery::<_, >::Evm>( overlay_manager.clone(), rocksdb_provider, disabled_stages, + |factory| backfill.recover(factory), ) .await? .inspect(|_| { @@ -194,8 +211,13 @@ impl EngineNodeLauncher { let beacon_engine_handle = ConsensusEngineHandle::new(consensus_engine_tx.clone()); - // extract the jwt secret from the args if possible - let jwt_secret = ctx.auth_jwt_secret()?; + // The JWT secret is only used by the auth server, so don't read or create it when the auth + // server is disabled. + let jwt_secret = if ctx.node_config().rpc.disable_auth_server { + JwtSecret::random() + } else { + ctx.auth_jwt_secret()? + }; let add_ons_ctx = AddOnsContext { node: ctx.node_adapter().clone(), @@ -245,6 +267,8 @@ impl EngineNodeLauncher { EngineApiKind::Ethereum }; + let (backfill_client, backfill_factory) = + (network_client.clone(), ctx.provider_factory().clone()); let mut orchestrator = EngineOrchestratorBuilder { engine_kind, consensus, @@ -263,7 +287,14 @@ impl EngineNodeLauncher { evm_config: ctx.components().evm_config().clone(), runtime: ctx.task_executor().clone(), } - .build(); + .build_with_backfill(|pipeline, runtime| { + backfill.build(BackfillContext::new( + pipeline, + backfill_client, + backfill_factory, + runtime, + )) + })?; info!(target: "reth::cli", "Consensus engine initialized"); @@ -443,9 +474,15 @@ impl EngineNodeLauncher { Ok(handle) } + + /// Returns this launcher with the engine's backfill built by `backfill`. + pub fn with_backfill(self, backfill: B2) -> EngineNodeLauncher { + let Self { ctx, engine_tree_config, .. } = self; + EngineNodeLauncher { ctx, engine_tree_config, backfill } + } } -impl LaunchNode> for EngineNodeLauncher +impl LaunchNode> for EngineNodeLauncher where T: FullNodeTypes< Types = N, @@ -458,6 +495,8 @@ where AO: RethRpcAddOns> + EngineValidatorAddOn> + 'static, + B: BackfillSyncBuilder, BackfillClientFor> + + 'static, { type Node = NodeHandle, AO>; type Future = Pin> + Send>>; diff --git a/crates/node/builder/src/launch/exex.rs b/crates/node/builder/src/launch/exex.rs index 064ed034cee..cd3c18aebf6 100644 --- a/crates/node/builder/src/launch/exex.rs +++ b/crates/node/builder/src/launch/exex.rs @@ -28,7 +28,7 @@ pub struct ExExLauncher { capacity: usize, } -impl ExExLauncher { +impl ExExLauncher { /// Create a new `ExExLauncher` with the given extensions. pub const fn new( head: Head, diff --git a/crates/node/builder/src/launch/invalid_block_hook.rs b/crates/node/builder/src/launch/invalid_block_hook.rs index 7965634c55b..ea2ae4b1fb7 100644 --- a/crates/node/builder/src/launch/invalid_block_hook.rs +++ b/crates/node/builder/src/launch/invalid_block_hook.rs @@ -72,13 +72,8 @@ pub async fn create_invalid_block_hook( ) -> eyre::Result>> where N: NodePrimitives, - P: reth_provider::StateProviderFactory - + reth_provider::ChainSpecProvider - + Clone - + Send - + Sync - + 'static, - E: reth_evm::ConfigureEvm + Clone + 'static, + P: reth_provider::StateProviderFactory + reth_provider::ChainSpecProvider + Clone + 'static, + E: reth_evm::ConfigureEvm + 'static, { use reth_engine_primitives::{InvalidBlockHooks, NoopInvalidBlockHook}; use reth_invalid_block_hooks::InvalidBlockWitnessHook; diff --git a/crates/node/builder/src/lib.rs b/crates/node/builder/src/lib.rs index a8722004437..71d13e16cf5 100644 --- a/crates/node/builder/src/lib.rs +++ b/crates/node/builder/src/lib.rs @@ -45,6 +45,8 @@ pub mod rpc; pub mod setup; +pub mod sync; + /// Type aliases for traits that are often used together pub mod aliases; pub use aliases::*; diff --git a/crates/node/builder/src/node.rs b/crates/node/builder/src/node.rs index 4c3d62137f4..8bd151c29b3 100644 --- a/crates/node/builder/src/node.rs +++ b/crates/node/builder/src/node.rs @@ -30,7 +30,7 @@ pub type ComponentsFor = <<::Types as Node>::Component /// A [`crate::Node`] is a [`NodeTypes`] that comes with preconfigured components. /// /// This can be used to configure the builder with a preset of components. -pub trait Node: NodeTypes + Clone { +pub trait Node: NodeTypes { /// The type that builds the node's components. type ComponentsBuilder: NodeComponentsBuilder; @@ -89,7 +89,7 @@ where impl Node for AnyNode where - N: FullNodeTypes + Clone, + N: FullNodeTypes, C: NodeComponentsBuilder + Clone + Debug + Sync + Unpin + 'static, AO: NodeAddOns> + Clone + Debug + Sync + Unpin + 'static, { diff --git a/crates/node/builder/src/rpc.rs b/crates/node/builder/src/rpc.rs index e9f62448fe1..644e694233b 100644 --- a/crates/node/builder/src/rpc.rs +++ b/crates/node/builder/src/rpc.rs @@ -17,7 +17,7 @@ use crate::{ ConsensusEngineEvent, ConsensusEngineHandle, }; use alloy_consensus::BlockHeader; -use alloy_eips::BlockNumberOrTag; +use alloy_eips::BlockId; use alloy_rpc_types::engine::ClientVersionV1; use alloy_rpc_types_engine::ExecutionData; use futures::{Stream, StreamExt}; @@ -1696,7 +1696,7 @@ async fn prewarm_new_block_bals_task> + Unpin, startup_blocks: usize, ) { - let mut startup = match eth_api.recovered_block(BlockNumberOrTag::Latest.into()).await { + let mut startup = match eth_api.recovered_block(BlockId::latest()).await { Ok(Some(head)) => { if head.block_access_list_hash().is_some() { debug!(target: "reth::cli", block_hash = ?head.hash(), "Stopping BAL prewarming: native BALs available"); diff --git a/crates/node/builder/src/sync.rs b/crates/node/builder/src/sync.rs new file mode 100644 index 00000000000..8eb0cd00067 --- /dev/null +++ b/crates/node/builder/src/sync.rs @@ -0,0 +1,87 @@ +//! Backfill the engine runs while the node is far behind the chain. + +use crate::components::NodeComponents; +use reth_engine_tree::backfill::{BackfillSync, PipelineSync}; +use reth_network_api::BlockDownloaderProvider; +use reth_provider::{providers::ProviderNodeTypes, ProviderFactory}; +use reth_stages::Pipeline; +use reth_tasks::Runtime; + +/// Builds the [`BackfillSync`] the engine hands long-range sync to. +/// +/// Nodes pick one with [`EngineNodeLauncher::with_backfill`](crate::EngineNodeLauncher); the +/// default is [`PipelineBackfill`]. +pub trait BackfillSyncBuilder: Send { + /// The backfill this builder produces. + type Backfill: BackfillSync + Unpin + 'static; + + /// Builds the backfill from the node's staged pipeline, block client and database. + fn build(self, ctx: BackfillContext) -> eyre::Result; + + /// Recovers from an earlier run that stopped mid-write, before the node checks the database + /// for consistency. A backfill that commits static files and the database separately uses it + /// so a crash between the two isn't treated as corruption. Does nothing by default. + fn recover(&mut self, _provider_factory: &ProviderFactory) -> eyre::Result<()> { + Ok(()) + } +} + +/// What the launcher hands a [`BackfillSyncBuilder`]. +#[derive(Debug)] +pub struct BackfillContext { + // Staged pipeline the backfill runs or hands over to. + pipeline: Pipeline, + // Client the pipeline downloads blocks with. + client: C, + provider_factory: ProviderFactory, + runtime: Runtime, +} + +impl BackfillContext { + /// Creates the context for one backfill build. + pub const fn new( + pipeline: Pipeline, + client: C, + provider_factory: ProviderFactory, + runtime: Runtime, + ) -> Self { + Self { pipeline, client, provider_factory, runtime } + } + + /// Returns the client the pipeline downloads blocks with. + pub const fn client(&self) -> &C { + &self.client + } + + /// Returns the node's database. + pub const fn provider_factory(&self) -> &ProviderFactory { + &self.provider_factory + } + + /// Returns the runtime backfill tasks are spawned on. + pub const fn runtime(&self) -> &Runtime { + &self.runtime + } + + /// Consumes the context, returning the staged pipeline. + pub fn into_pipeline(self) -> Pipeline { + self.pipeline + } +} + +/// Backfills with the staged pipeline, as every node does unless it picks another backfill. +#[derive(Debug, Clone, Copy, Default)] +pub struct PipelineBackfill; + +impl BackfillSyncBuilder for PipelineBackfill { + type Backfill = PipelineSync; + + fn build(self, ctx: BackfillContext) -> eyre::Result { + let runtime = ctx.runtime().clone(); + Ok(PipelineSync::new(ctx.into_pipeline(), runtime)) + } +} + +/// Client the network of `Components` hands the engine's backfill. +pub type BackfillClientFor = + <>::Network as BlockDownloaderProvider>::Client; diff --git a/crates/node/builder/src/txpool_prewarm.rs b/crates/node/builder/src/txpool_prewarm.rs index c895ffa1578..a76856cbdb4 100644 --- a/crates/node/builder/src/txpool_prewarm.rs +++ b/crates/node/builder/src/txpool_prewarm.rs @@ -25,12 +25,7 @@ impl

Source

{ impl PrewarmSource for Source

where N: NodePrimitives, - P: TransactionPool>> - + Clone - + Send - + Sync - + Debug - + 'static, + P: TransactionPool>> + 'static, { fn best_transactions(&self, parent_hash: B256) -> Option> { let block_info = self.0.block_info(); diff --git a/crates/node/core/src/args/database.rs b/crates/node/core/src/args/database.rs index 6554f2acabc..b9f8e9dd801 100644 --- a/crates/node/core/src/args/database.rs +++ b/crates/node/core/src/args/database.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for database configuration +//! clap [`Args`] for database configuration. use std::{fmt, str::FromStr, time::Duration}; @@ -57,6 +57,7 @@ pub struct DatabaseArgs { /// Controls how aggressively the database synchronizes data to disk. #[arg( long = "db.sync-mode", + env = "RETH_DB_SYNC_MODE", value_parser = value_parser!(SyncMode), )] pub sync_mode: Option, diff --git a/crates/node/core/src/args/datadir_args.rs b/crates/node/core/src/args/datadir_args.rs index ad24ac5ab97..fcf7bed5a86 100644 --- a/crates/node/core/src/args/datadir_args.rs +++ b/crates/node/core/src/args/datadir_args.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for datadir config +//! clap [`Args`] for datadir config. use crate::dirs::{ChainPath, DataDirPath, MaybePlatformPath}; use clap::Args; diff --git a/crates/node/core/src/args/debug.rs b/crates/node/core/src/args/debug.rs index 83074c0f66e..c8a49b3bbfb 100644 --- a/crates/node/core/src/args/debug.rs +++ b/crates/node/core/src/args/debug.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for debugging purposes +//! clap [`Args`] for debugging purposes. use alloy_primitives::B256; use clap::{ @@ -124,6 +124,11 @@ pub struct DebugArgs { /// the backfill, but did not yet receive any new blocks. #[arg(long = "debug.startup-sync-state-idle", help_heading = "Debug")] pub startup_sync_state_idle: bool, + + /// Load the KZG trusted setup when it is first needed instead of in the background on + /// startup. + #[arg(long = "debug.lazy-kzg-settings", help_heading = "Debug", hide = true)] + pub lazy_kzg_settings: bool, } impl Default for DebugArgs { @@ -145,6 +150,7 @@ impl Default for DebugArgs { healthy_node_rpc_url: None, ethstats: None, startup_sync_state_idle: false, + lazy_kzg_settings: false, } } } diff --git a/crates/node/core/src/args/dev.rs b/crates/node/core/src/args/dev.rs index 18af3c81fa1..9fb85200dfa 100644 --- a/crates/node/core/src/args/dev.rs +++ b/crates/node/core/src/args/dev.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for Dev testnet configuration +//! clap [`Args`] for Dev testnet configuration. use std::{num::NonZeroUsize, sync::OnceLock, time::Duration}; @@ -281,17 +281,6 @@ mod tests { assert_eq!(args.finality_depth, NonZeroUsize::new(1).unwrap()); } - #[test] - fn test_rejects_zero_finality_depth() { - assert!(CommandParser::::try_parse_from([ - "reth", - "--dev", - "--dev.finality-depth", - "0", - ]) - .is_err()); - } - #[test] fn test_parse_dev_args_conflicts() { let args = CommandParser::::try_parse_from([ diff --git a/crates/node/core/src/args/engine.rs b/crates/node/core/src/args/engine.rs index 0441a5e850c..f863ed5a929 100644 --- a/crates/node/core/src/args/engine.rs +++ b/crates/node/core/src/args/engine.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for engine purposes +//! clap [`Args`] for engine purposes. use clap::{ builder::{RangedU64ValueParser, Resettable}, @@ -11,7 +11,7 @@ use reth_engine_primitives::{ DEFAULT_MULTIPROOF_TASK_CHUNK_SIZE, DEFAULT_NUM_STATE_MASKING_BLOCKS, MIN_PERSISTENCE_BACKPRESSURE_THRESHOLD, }; -use std::{sync::OnceLock, time::Duration}; +use std::{num::NonZeroUsize, sync::OnceLock, time::Duration}; use crate::node_config::{ DEFAULT_CROSS_BLOCK_CACHE_SIZE_MB, DEFAULT_MEMORY_BLOCK_BUFFER_TARGET, @@ -454,6 +454,11 @@ pub struct EngineArgs { #[arg(long = "engine.reserved-cpu-cores", default_value_t = DefaultEngineValues::get_global().reserved_cpu_cores)] pub reserved_cpu_cores: usize, + /// Override the available CPU count for node thread pools and parallel execution decisions. + /// Does not change CPU affinity. Explicit thread counts take precedence. + #[arg(long = "cpu-cores", value_name = "COUNT")] + pub cpu_cores: Option, + /// CAUTION: This CLI flag has no effect anymore, use --engine.disable-precompile-cache /// if you want to disable precompile cache #[arg(long = "engine.precompile-cache", default_value = "true", hide = true)] @@ -484,13 +489,15 @@ pub struct EngineArgs { #[arg(long = "engine.allow-unwind-canonical-header", default_value_t = DefaultEngineValues::get_global().allow_unwind_canonical_header)] pub allow_unwind_canonical_header: bool, - /// Configure the number of storage proof workers in the Tokio blocking pool. - /// If not specified, defaults to 2x available parallelism. + /// Configure the number of storage proof workers spawned for each block. + /// If not specified or zero, the count is derived from the block: 2x available parallelism, + /// halved for blocks with few transactions and doubled for blocks at or above 100M gas. #[arg(long = "engine.storage-worker-count", default_value = Resettable::from(DefaultEngineValues::get_global().storage_worker_count.map(|v| v.to_string().into())))] pub storage_worker_count: Option, - /// Configure the number of account proof workers in the Tokio blocking pool. - /// If not specified, defaults to the same count as storage workers. + /// Configure the number of account proof workers spawned for each block. + /// If not specified or zero, the count is derived from the block the same way as for storage + /// workers. #[arg(long = "engine.account-worker-count", default_value = Resettable::from(DefaultEngineValues::get_global().account_worker_count.map(|v| v.to_string().into())))] pub account_worker_count: Option, @@ -531,7 +538,7 @@ pub struct EngineArgs { #[arg( long = "engine.state-root-task-timeout", value_parser = humantime::parse_duration, - default_value = DefaultEngineValues::get_global().state_root_task_timeout.as_deref().unwrap_or("4s"), + default_value = Resettable::from(DefaultEngineValues::get_global().state_root_task_timeout.as_ref().map(|v| v.as_str().into())), )] pub state_root_task_timeout: Option, @@ -590,6 +597,15 @@ pub struct EngineArgs { #[arg(long = "engine.disable-bal-batch-io", default_value_t = false)] pub disable_bal_batch_io: bool, + /// Reuse transaction prewarming results during block execution instead of executing + /// transactions again when their prewarm reads are still valid. + #[arg( + long = "engine.prewarm-reuse", + env = "RETH_ENGINE_PREWARM_REUSE", + default_value_t = false + )] + pub prewarm_reuse: bool, + /// Add random jitter before each proof computation (trie-debug only). /// Each proof worker sleeps for a random duration up to this value before /// starting work. Useful for stress-testing timing-sensitive proof logic. @@ -662,6 +678,7 @@ impl Default for EngineArgs { accept_execution_requests_hash, multiproof_chunk_size, reserved_cpu_cores, + cpu_cores: None, precompile_cache_enabled: true, precompile_cache_disabled, state_root_fallback, @@ -682,6 +699,7 @@ impl Default for EngineArgs { bal_parallel_execution_disabled, bal_parallel_state_root_disabled, disable_bal_batch_io: false, + prewarm_reuse: false, #[cfg(feature = "trie-debug")] proof_jitter: None, } @@ -794,10 +812,15 @@ impl EngineArgs { .with_suppress_persistence_during_build(self.suppress_persistence_during_build) .without_bal_parallel_execution(self.bal_parallel_execution_disabled) .without_bal_parallel_state_root(self.bal_parallel_state_root_disabled) - .without_bal_batch_io(self.disable_bal_batch_io); + .without_bal_batch_io(self.disable_bal_batch_io) + .with_prewarm_reuse(self.prewarm_reuse); #[cfg(feature = "trie-debug")] let config = config.with_proof_jitter(self.proof_jitter); - config + if let Some(cpu_cores) = self.cpu_cores { + config.with_has_enough_parallelism(cpu_cores.get() >= 5) + } else { + config + } } } @@ -813,6 +836,32 @@ mod tests { args: T, } + #[test] + fn cpu_cores_override_controls_parallel_state_root() { + for (cores, enabled) in [(1, false), (4, false), (5, true), (8, true)] { + let args = CommandParser::::parse_from([ + "reth", + "--cpu-cores", + &cores.to_string(), + ]) + .args; + assert_eq!(args.cpu_cores.unwrap().get(), cores); + assert_eq!(args.tree_config().use_state_root_task(), enabled); + } + } + + #[test] + fn cpu_cores_override_preserves_state_root_fallback() { + let args = CommandParser::::parse_from([ + "reth", + "--cpu-cores", + "8", + "--engine.state-root-fallback", + ]) + .args; + assert!(!args.tree_config().use_state_root_task()); + } + #[test] fn test_parse_engine_args() { let default_args = EngineArgs::default(); @@ -964,6 +1013,7 @@ mod tests { accept_execution_requests_hash: true, multiproof_chunk_size: 512, reserved_cpu_cores: 4, + cpu_cores: Some(NonZeroUsize::new(8).unwrap()), precompile_cache_enabled: true, precompile_cache_disabled: true, state_root_fallback: true, @@ -982,6 +1032,7 @@ mod tests { bal_parallel_execution_disabled: true, bal_parallel_state_root_disabled: true, disable_bal_batch_io: true, + prewarm_reuse: true, #[cfg(feature = "trie-debug")] proof_jitter: None, }; @@ -1011,6 +1062,8 @@ mod tests { "512", "--engine.reserved-cpu-cores", "4", + "--cpu-cores", + "8", "--engine.disable-precompile-cache", "--engine.state-root-fallback", "--engine.always-process-payload-attributes-on-canonical-head", @@ -1028,6 +1081,7 @@ mod tests { "--engine.disable-bal-parallel-execution", "--engine.disable-bal-parallel-state-root", "--engine.disable-bal-batch-io", + "--engine.prewarm-reuse", ]) .args; @@ -1063,20 +1117,6 @@ mod tests { assert!(err.contains("engine.persistence-threshold")); } - #[test] - fn test_parse_num_state_masking_blocks() { - let args = CommandParser::::parse_from([ - "reth", - "--engine.persistence-threshold", - "13", - "--engine.num-state-masking-blocks", - "7", - ]) - .args; - - assert_eq!(args.tree_config().num_state_masking_blocks(), 7); - } - #[test] fn test_parse_backfill_threshold() { let args = CommandParser::::parse_from(["reth"]).args; @@ -1155,17 +1195,6 @@ mod tests { assert_eq!(config.persistence_backpressure_threshold(), 1); } - #[test] - fn zero_persistence_threshold_disables_explicit_state_masking() { - let args = EngineArgs { - persistence_threshold: 0, - num_state_masking_blocks: u64::MAX, - ..EngineArgs::default() - }; - args.validate().unwrap(); - assert_eq!(args.tree_config().num_state_masking_blocks(), 0); - } - #[test] fn parse_rejects_zero_multiproof_chunk_size() { let result = CommandParser::::try_parse_from([ diff --git a/crates/node/core/src/args/gas_price_oracle.rs b/crates/node/core/src/args/gas_price_oracle.rs index 5d675d4dc1a..7085e478799 100644 --- a/crates/node/core/src/args/gas_price_oracle.rs +++ b/crates/node/core/src/args/gas_price_oracle.rs @@ -1,5 +1,6 @@ +use super::DefaultRpcServerArgs; use alloy_primitives::U256; -use clap::Args; +use clap::{builder::Resettable, Args}; use reth_rpc_eth_types::GasPriceOracleConfig; use reth_rpc_server_types::constants::gas_oracle::{ DEFAULT_GAS_PRICE_BLOCKS, DEFAULT_GAS_PRICE_PERCENTILE, DEFAULT_IGNORE_GAS_PRICE, @@ -11,23 +12,23 @@ use reth_rpc_server_types::constants::gas_oracle::{ #[command(next_help_heading = "Gas Price Oracle")] pub struct GasPriceOracleArgs { /// Number of recent blocks to check for gas price - #[arg(long = "gpo.blocks", default_value_t = DEFAULT_GAS_PRICE_BLOCKS)] + #[arg(long = "gpo.blocks", default_value_t = DefaultRpcServerArgs::get_global().gas_price_oracle.blocks)] pub blocks: u32, /// Gas Price below which gpo will ignore transactions - #[arg(long = "gpo.ignoreprice", default_value_t = DEFAULT_IGNORE_GAS_PRICE.to())] + #[arg(long = "gpo.ignoreprice", default_value_t = DefaultRpcServerArgs::get_global().gas_price_oracle.ignore_price)] pub ignore_price: u64, /// Maximum transaction priority fee(or gasprice before London Fork) to be recommended by gpo - #[arg(long = "gpo.maxprice", default_value_t = DEFAULT_MAX_GAS_PRICE.to())] + #[arg(long = "gpo.maxprice", default_value_t = DefaultRpcServerArgs::get_global().gas_price_oracle.max_price)] pub max_price: u64, /// The percentile of gas prices to use for the estimate - #[arg(long = "gpo.percentile", default_value_t = DEFAULT_GAS_PRICE_PERCENTILE)] + #[arg(long = "gpo.percentile", default_value_t = DefaultRpcServerArgs::get_global().gas_price_oracle.percentile)] pub percentile: u32, /// The default gas price to use if there are no blocks to use - #[arg(long = "gpo.default-suggested-fee")] + #[arg(long = "gpo.default-suggested-fee", default_value = Resettable::from(DefaultRpcServerArgs::get_global().gas_price_oracle.default_suggested_fee.map(|v| v.to_string().into())))] pub default_suggested_fee: Option, } diff --git a/crates/node/core/src/args/jit.rs b/crates/node/core/src/args/jit.rs index 9a81a40394e..251d4bcbf7e 100644 --- a/crates/node/core/src/args/jit.rs +++ b/crates/node/core/src/args/jit.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for JIT configuration. +//! clap [`Args`] for JIT configuration. use clap::Args; use humantime::parse_duration; diff --git a/crates/node/core/src/args/log.rs b/crates/node/core/src/args/log.rs index 931a6407571..561ee59ea0e 100644 --- a/crates/node/core/src/args/log.rs +++ b/crates/node/core/src/args/log.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for logging configuration. +//! clap [`Args`] for logging configuration. use crate::dirs::{LogsDir, PlatformPath}; use clap::{ArgAction, Args, ValueEnum}; diff --git a/crates/node/core/src/args/network.rs b/crates/node/core/src/args/network.rs index d381fd7576a..9a1377057ab 100644 --- a/crates/node/core/src/args/network.rs +++ b/crates/node/core/src/args/network.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for network related arguments. +//! clap [`Args`] for network related arguments. use alloy_eips::BlockNumHash; use alloy_primitives::B256; @@ -241,6 +241,11 @@ pub struct NetworkArgs { #[command(flatten)] pub discovery: DiscoveryArgs, + /// Advertise and serve experimental snap/2. `reth node` also bootstraps a fresh database with + /// it, and uses the staged pipeline while the chain head predates block access lists. + #[arg(long = "snap.v2")] + pub snap_v2: bool, + #[expect(clippy::doc_markdown)] /// Comma separated enode URLs or ENRs of trusted peers for P2P connections. /// @@ -591,6 +596,7 @@ impl NetworkArgs { // Configure basic network stack NetworkConfigBuilder::::new(secret_key, executor) + .with_snap(self.snap_v2) .external_ip_resolver(self.nat.clone()) .sessions_config( config.sessions.clone().with_upscaled_event_buffer(peers_config.max_peers()), @@ -726,6 +732,7 @@ impl Default for NetworkArgs { } = DefaultNetworkArgs::get_global().clone(); Self { discovery: DiscoveryArgs::default(), + snap_v2: false, trusted_peers: vec![], trusted_only: false, bootnodes: None, @@ -1169,6 +1176,7 @@ mod tests { use clap::Parser; use reth_chainspec::MAINNET; use reth_config::Config; + use reth_network::EthNetworkPrimitives; use reth_network_peers::NodeRecord; use secp256k1::SecretKey; use std::{ @@ -1231,15 +1239,6 @@ mod tests { ); } - #[test] - fn parse_enr_bootnode_args() { - let enr = "enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOonrkTfj499SZuOh8R33Ls8RRcy5wBgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQPKY0yuDUmstAHYpMa2_oxVtw0RW_QAdpzBQA8yWM0xOIN1ZHCCdl8"; - let args = CommandParser::::parse_from(["reth", "--bootnodes", enr]).args; - let trusted = - CommandParser::::parse_from(["reth", "--trusted-peers", enr]).args; - assert_eq!(args.bootnodes, Some(trusted.trusted_peers)); - } - #[test] fn parse_retry_strategy_args() { let tests = vec![0, 10]; @@ -1601,6 +1600,31 @@ mod tests { assert!(args.ip_filter().is_err()); } + #[test] + fn snap_v2_flag_controls_advertisement() { + for (cli, enabled) in [(vec!["reth"], false), (vec!["reth", "--snap.v2"], true)] { + let args = CommandParser::::parse_from(cli).args; + assert_eq!(args.snap_v2, enabled); + let config = args + .network_config::( + &Config::default(), + MAINNET.clone(), + SecretKey::from_byte_array(&[1u8; 32]).unwrap(), + PathBuf::new(), + Runtime::test(), + ) + .build_with_noop_provider(MAINNET.clone()); + let versions = config + .hello_message + .protocols + .iter() + .filter(|protocol| protocol.cap.name == "snap") + .map(|protocol| protocol.cap.version) + .collect::>(); + assert_eq!(versions, if enabled { vec![2] } else { vec![] }); + } + } + #[test] fn network_config_preserves_basic_nodes_from_peers_file() { let enode = "enode://6f8a80d14311c39f35f516fa664deaaaa13e85b2f7493f37f6144d86991ec012937307647bd3b9a82abe2974e1407241d54947bbb39763a4cac9f77166ad92a0@10.3.58.6:30303?discport=30301"; @@ -1618,7 +1642,7 @@ mod tests { // Build the network config using a deterministic secret key let secret_key = SecretKey::from_byte_array(&[1u8; 32]).unwrap(); - let builder = args.network_config::( + let builder = args.network_config::( &Config::default(), MAINNET.clone(), secret_key, @@ -1644,7 +1668,7 @@ mod tests { let secret_key = SecretKey::from_byte_array(&[1u8; 32]).unwrap(); let boot_nodes = |args: &NetworkArgs| { - args.network_config::( + args.network_config::( &config, MAINNET.clone(), secret_key, diff --git a/crates/node/core/src/args/pruning.rs b/crates/node/core/src/args/pruning.rs index 557d9b3d4a2..c8e40630a1d 100644 --- a/crates/node/core/src/args/pruning.rs +++ b/crates/node/core/src/args/pruning.rs @@ -454,8 +454,8 @@ pub(crate) fn parse_receipts_log_filter( #[cfg(test)] mod tests { + use super::*; - use alloy_primitives::address; use clap::Parser; use reth_chainspec::MAINNET; @@ -475,10 +475,7 @@ mod tests { ]) .args; let mut config = ReceiptsLogPruneConfig::default(); - config.0.insert( - address!("0x0000000000000000000000000000000000000003"), - PruneMode::Before(5000000), - ); + config.0.insert(Address::with_last_byte(3), PruneMode::Before(5000000)); assert_eq!(args.receipts_log_filter, Some(config)); } @@ -531,9 +528,9 @@ mod tests { assert_eq!(config.0.len(), 3); // Check that the args were parsed correctly. - let addr1: Address = "0x0000000000000000000000000000000000000001".parse().unwrap(); - let addr2: Address = "0x0000000000000000000000000000000000000002".parse().unwrap(); - let addr3: Address = "0x0000000000000000000000000000000000000003".parse().unwrap(); + let addr1 = Address::with_last_byte(1); + let addr2 = Address::with_last_byte(2); + let addr3 = Address::with_last_byte(3); assert_eq!(config.0.get(&addr1), Some(&PruneMode::Full)); assert_eq!(config.0.get(&addr2), Some(&PruneMode::Distance(1000))); @@ -550,8 +547,8 @@ mod tests { let config = result.unwrap(); assert_eq!(config.0.len(), 2); - let addr1: Address = "0x0000000000000000000000000000000000000001".parse().unwrap(); - let addr2: Address = "0x0000000000000000000000000000000000000002".parse().unwrap(); + let addr1 = Address::with_last_byte(1); + let addr2 = Address::with_last_byte(2); assert_eq!(config.0.get(&addr1), Some(&PruneMode::Full)); assert_eq!(config.0.get(&addr2), Some(&PruneMode::Distance(1000))); diff --git a/crates/node/core/src/args/rpc_server.rs b/crates/node/core/src/args/rpc_server.rs index 86d01bf434c..6e4634f8c14 100644 --- a/crates/node/core/src/args/rpc_server.rs +++ b/crates/node/core/src/args/rpc_server.rs @@ -1,17 +1,20 @@ -//! clap [Args](clap::Args) for RPC related arguments. +//! clap [`Args`] for RPC related arguments. use crate::args::{ types::{MaxU32, ZeroAsNoneU64}, GasPriceOracleArgs, RpcStateCacheArgs, }; -use alloy_primitives::map::AddressSet; +use alloy_primitives::{map::AddressSet, utils::format_ether}; use alloy_rpc_types_engine::JwtSecret; use clap::{ builder::{PossibleValue, RangedU64ValueParser, Resettable, TypedValueParser}, + parser::ValueSource, Arg, Args, Command, }; use rand::Rng; -use reth_cli_util::{parse_duration_from_secs_or_ms, parse_ether_value}; +use reth_cli_util::{ + parse_duration_from_secs_or_ms, parse_ether_value, parsers::format_duration_as_secs_or_ms, +}; use reth_rpc_eth_types::builder::config::PendingBlockKind; use reth_rpc_server_types::{constants, RethRpcModule, RpcModuleSelection}; use std::{ @@ -93,8 +96,8 @@ pub struct DefaultRpcServerArgs { rpc_pending_block: PendingBlockKind, rpc_forwarder: Option, builder_disallow: Option, - rpc_state_cache: RpcStateCacheArgs, - gas_price_oracle: GasPriceOracleArgs, + pub(super) rpc_state_cache: RpcStateCacheArgs, + pub(super) gas_price_oracle: GasPriceOracleArgs, rpc_send_raw_transaction_sync_timeout: Duration, } @@ -456,7 +459,9 @@ pub struct RpcServerArgs { #[arg(long = "http.port", default_value_t = DefaultRpcServerArgs::get_global().http_port)] pub http_port: u16, - /// Disable compression for HTTP responses + /// Disable compression for HTTP responses. + /// + /// Servers listening on a loopback address never compress responses. #[arg(long = "http.disable-compression", default_value_t = DefaultRpcServerArgs::get_global().http_disable_compression)] pub http_disable_compression: bool, @@ -581,7 +586,18 @@ pub struct RpcServerArgs { /// /// This is __not__ used for the authenticated engine-API RPC server, see /// `--authrpc.jwtsecret`. - #[arg(long = "rpc.jwtsecret", value_name = "HEX", global = true, required = false, default_value = Resettable::from(DefaultRpcServerArgs::get_global().rpc_jwtsecret.as_ref().map(|v| format!("{:?}", v).into())))] + #[arg( + long = "rpc.jwtsecret", + value_name = "HEX", + global = true, + required = false, + value_parser = DefaultValueParser { + inner: |value: &str| JwtSecret::from_hex(value), + default: &DefaultRpcServerArgs::get_global().rpc_jwtsecret, + }, + default_value = Resettable::from(DefaultRpcServerArgs::get_global().rpc_jwtsecret.as_ref().map(|_| "configured".into())), + hide_default_value = true, + )] pub rpc_jwtsecret: Option, /// Disable built-in RPC request metrics. @@ -662,7 +678,7 @@ pub struct RpcServerArgs { alias = "rpc-txfeecap", value_name = "TX_FEE_CAP", value_parser = parse_ether_value, - default_value = "1.0" + default_value = format_tx_fee_cap(DefaultRpcServerArgs::get_global().rpc_tx_fee_cap) )] pub rpc_tx_fee_cap: u128, @@ -700,16 +716,43 @@ pub struct RpcServerArgs { /// /// Options: full (include all transactions), empty (header only), none (disable pending /// blocks). - #[arg(long = "rpc.pending-block", default_value = "full", value_name = "KIND")] + #[arg( + long = "rpc.pending-block", + default_value = match DefaultRpcServerArgs::get_global().rpc_pending_block { + PendingBlockKind::Empty => "empty", + PendingBlockKind::None => "none", + PendingBlockKind::Full => "full", + }, + value_name = "KIND", + )] pub rpc_pending_block: PendingBlockKind, /// Endpoint to forward transactions to. - #[arg(long = "rpc.forwarder", alias = "rpc-forwarder", value_name = "FORWARDER")] + #[arg( + long = "rpc.forwarder", + alias = "rpc-forwarder", + value_name = "FORWARDER", + value_parser = DefaultValueParser { + inner: Url::parse, + default: &DefaultRpcServerArgs::get_global().rpc_forwarder, + }, + default_value = Resettable::from(DefaultRpcServerArgs::get_global().rpc_forwarder.as_ref().map(|_| "configured".into())), + hide_default_value = true, + )] pub rpc_forwarder: Option, /// Path to file containing disallowed addresses, json-encoded list of strings. Block /// validation API will reject blocks containing transactions from these addresses. - #[arg(long = "builder.disallow", value_name = "PATH", value_parser = reth_cli_util::parsers::read_json_from_file::, default_value = Resettable::from(DefaultRpcServerArgs::get_global().builder_disallow.as_ref().map(|v| format!("{:?}", v).into())))] + #[arg( + long = "builder.disallow", + value_name = "PATH", + value_parser = DefaultValueParser { + inner: reth_cli_util::parsers::read_json_from_file::, + default: &DefaultRpcServerArgs::get_global().builder_disallow, + }, + default_value = Resettable::from(DefaultRpcServerArgs::get_global().builder_disallow.as_ref().map(|_| "configured".into())), + hide_default_value = true, + )] pub builder_disallow: Option, /// State cache configuration. @@ -724,7 +767,7 @@ pub struct RpcServerArgs { #[arg( long = "rpc.send-raw-transaction-sync-timeout", value_name = "SECONDS", - default_value = "30s", + default_value = format_duration_as_secs_or_ms(DefaultRpcServerArgs::get_global().rpc_send_raw_transaction_sync_timeout), value_parser = parse_duration_from_secs_or_ms, )] pub rpc_send_raw_transaction_sync_timeout: Duration, @@ -997,6 +1040,11 @@ impl Default for RpcServerArgs { } } +/// Formats a wei amount as the ether string `--rpc.txfeecap` parses, without trailing zeros. +fn format_tx_fee_cap(wei: u128) -> String { + format_ether(wei).trim_end_matches('0').trim_end_matches('.').to_string() +} + /// clap value parser for [`RpcModuleSelection`] with configurable validation. #[derive(Clone, Debug, Default)] #[non_exhaustive] @@ -1026,6 +1074,48 @@ impl TypedValueParser for RpcModuleSelectionValueParser { } } +/// Uses a typed default without serializing secrets or treating address sets as file paths. +#[derive(Clone)] +struct DefaultValueParser +where + P::Value: 'static, +{ + inner: P, + default: &'static Option, +} + +impl TypedValueParser for DefaultValueParser

+where + P::Value: 'static, +{ + type Value = P::Value; + + fn parse_ref( + &self, + cmd: &Command, + arg: Option<&Arg>, + value: &OsStr, + ) -> Result { + self.inner.parse_ref(cmd, arg, value) + } + + fn parse_ref_( + &self, + cmd: &Command, + arg: Option<&Arg>, + value: &OsStr, + source: ValueSource, + ) -> Result { + if source == ValueSource::DefaultValue && + let Some(default) = self.default + { + Ok(default.clone()) + } else { + self.inner.parse_ref_(cmd, arg, value, source) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1386,18 +1476,6 @@ mod tests { assert_eq!(args.auth_jwtsecret, None); } - #[test] - fn parse_auth_jwtsecret_hex_with_0x_prefix() { - let hex = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; - let args = - CommandParser::::parse_from(["reth", "--authrpc.jwtsecret-hex", hex]) - .args; - - let expected = JwtSecret::from_hex(hex).unwrap(); - assert_eq!(args.auth_jwtsecret_hex, Some(expected)); - assert_eq!(args.auth_jwtsecret, None); - } - #[test] fn test_auth_jwtsecret_and_hex_are_mutually_exclusive() { let result = CommandParser::::try_parse_from([ diff --git a/crates/node/core/src/args/rpc_state_cache.rs b/crates/node/core/src/args/rpc_state_cache.rs index 1f61dcdd6a0..efcd40eb902 100644 --- a/crates/node/core/src/args/rpc_state_cache.rs +++ b/crates/node/core/src/args/rpc_state_cache.rs @@ -1,5 +1,5 @@ -use super::database::parse_byte_size; -use clap::Args; +use super::{database::parse_byte_size, DefaultRpcServerArgs}; +use clap::{builder::Resettable, Args}; use reth_rpc_server_types::constants::cache::{ DEFAULT_BAL_CACHE_MAX_BYTES, DEFAULT_BAL_CACHE_MAX_LEN, DEFAULT_BLOCK_CACHE_MAX_BYTES, DEFAULT_BLOCK_CACHE_MAX_LEN, DEFAULT_CACHE_IDLE_TIMEOUT, DEFAULT_CONCURRENT_DB_REQUESTS, @@ -15,14 +15,14 @@ pub struct RpcStateCacheArgs { /// Max number of blocks in cache. #[arg( long = "rpc-cache.max-blocks", - default_value_t = DEFAULT_BLOCK_CACHE_MAX_LEN, + default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_blocks, )] pub max_blocks: u32, /// Max number receipts in cache. #[arg( long = "rpc-cache.max-receipts", - default_value_t = DEFAULT_RECEIPT_CACHE_MAX_LEN, + default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_receipts, )] pub max_receipts: u32, @@ -30,7 +30,7 @@ pub struct RpcStateCacheArgs { #[arg( long = "rpc-cache.max-headers", alias = "rpc-cache.max-envs", - default_value_t = DEFAULT_HEADER_CACHE_MAX_LEN, + default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_headers, hide = true, )] pub max_headers: u32, @@ -38,33 +38,33 @@ pub struct RpcStateCacheArgs { /// Max number of block access lists in cache. #[arg( long = "rpc-cache.max-bals", - default_value_t = DEFAULT_BAL_CACHE_MAX_LEN, + default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_bals, )] pub max_bals: u32, /// Maximum estimated block cache memory in bytes or with a unit (e.g. 1GB, 500MB). /// Units use powers of 1024. Zero disables caching. The entry count limit also applies. - #[arg(long = "rpc-cache.max-blocks-bytes", value_name = "BYTES", value_parser = parse_byte_size, default_value_t = DEFAULT_BLOCK_CACHE_MAX_BYTES)] + #[arg(long = "rpc-cache.max-blocks-bytes", value_name = "BYTES", value_parser = parse_byte_size, default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_blocks_bytes)] pub max_blocks_bytes: usize, /// Maximum estimated receipts cache memory in bytes or with a unit (e.g. 1GB, 500MB). /// Units use powers of 1024. Zero disables caching. The entry count limit also applies. - #[arg(long = "rpc-cache.max-receipts-bytes", value_name = "BYTES", value_parser = parse_byte_size, default_value_t = DEFAULT_RECEIPT_CACHE_MAX_BYTES)] + #[arg(long = "rpc-cache.max-receipts-bytes", value_name = "BYTES", value_parser = parse_byte_size, default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_receipts_bytes)] pub max_receipts_bytes: usize, /// Maximum estimated block access list cache memory in bytes or with a unit (e.g. 1GB, 500MB). /// Units use powers of 1024. Zero disables caching. The entry count limit also applies. - #[arg(long = "rpc-cache.max-bals-bytes", value_name = "BYTES", value_parser = parse_byte_size, default_value_t = DEFAULT_BAL_CACHE_MAX_BYTES)] + #[arg(long = "rpc-cache.max-bals-bytes", value_name = "BYTES", value_parser = parse_byte_size, default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_bals_bytes)] pub max_bals_bytes: usize, /// Evict blocks, receipts, and block access lists after this duration without a cache hit /// (e.g. 5m, 30s). Zero disables expiration. Cleanup starts every 1 to 60 seconds and removes /// at most five entries per cache per poll. - #[arg(long = "rpc-cache.idle-timeout", value_name = "DURATION", value_parser = humantime::parse_duration, default_value = "1h")] + #[arg(long = "rpc-cache.idle-timeout", value_name = "DURATION", value_parser = humantime::parse_duration, default_value = humantime::format_duration(DefaultRpcServerArgs::get_global().rpc_state_cache.idle_timeout).to_string())] pub idle_timeout: Duration, /// Cache block access lists computed by RPC requests for transaction tracing. - #[arg(long = "rpc-cache.cache-computed-bals")] + #[arg(long = "rpc-cache.cache-computed-bals", default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.cache_computed_bals)] pub cache_computed_bals: bool, /// Replay new canonical blocks to cache block access lists until native BAL support. @@ -77,6 +77,7 @@ pub struct RpcStateCacheArgs { value_name = "BLOCKS", num_args = 0..=1, default_missing_value = "0", + default_value = Resettable::from(DefaultRpcServerArgs::get_global().rpc_state_cache.prewarm_bals.map(|v| v.to_string().into())), require_equals = true, )] pub prewarm_bals: Option, @@ -84,14 +85,14 @@ pub struct RpcStateCacheArgs { /// Max number of concurrent database requests. #[arg( long = "rpc-cache.max-concurrent-db-requests", - default_value_t = DEFAULT_CONCURRENT_DB_REQUESTS, + default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_concurrent_db_requests, )] pub max_concurrent_db_requests: usize, /// Maximum number of transaction hashes to cache for transaction lookups. #[arg( long = "rpc-cache.max-cached-tx-hashes", - default_value_t = DEFAULT_MAX_CACHED_TX_HASHES, + default_value_t = DefaultRpcServerArgs::get_global().rpc_state_cache.max_cached_tx_hashes, )] pub max_cached_tx_hashes: u32, } @@ -154,26 +155,6 @@ mod tests { } } - #[test] - fn rpc_cache_byte_limits_accept_zero_and_maximum() { - for limit in [0, 1024, usize::MAX] { - let value = limit.to_string(); - let args = CommandParser::parse_from([ - "reth", - "--rpc-cache.max-blocks-bytes", - &value, - "--rpc-cache.max-receipts-bytes", - &value, - "--rpc-cache.max-bals-bytes", - &value, - ]) - .args; - assert_eq!(args.max_blocks_bytes, limit); - assert_eq!(args.max_receipts_bytes, limit); - assert_eq!(args.max_bals_bytes, limit); - } - } - #[test] fn rpc_cache_byte_limits_reject_invalid_values() { let overflow = format!("{}0", usize::MAX); @@ -220,26 +201,6 @@ mod tests { } } - #[test] - fn rpc_cache_idle_timeout_parses_durations() { - for (value, expected) in [ - ("0s", Duration::ZERO), - ("500ms", Duration::from_millis(500)), - ("30s", Duration::from_secs(30)), - ("5m", Duration::from_secs(300)), - ] { - let args = CommandParser::parse_from(["reth", "--rpc-cache.idle-timeout", value]).args; - assert_eq!(args.idle_timeout, expected); - } - for value in ["-1s", "invalid", "18446744073709551616s"] { - assert!(CommandParser::try_parse_from([ - "reth", - &format!("--rpc-cache.idle-timeout={value}") - ]) - .is_err()); - } - } - #[test] fn disabling_rpc_cache_overrides_positive_byte_limits() { let mut config = NodeConfig::default(); diff --git a/crates/node/core/src/args/static_files.rs b/crates/node/core/src/args/static_files.rs index ce0247c42fa..3ce338c9e82 100644 --- a/crates/node/core/src/args/static_files.rs +++ b/crates/node/core/src/args/static_files.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for static files configuration +//! clap [`Args`] for static files configuration. use clap::Args; use reth_config::config::{BlocksPerFileConfig, StaticFilesConfig}; diff --git a/crates/node/core/src/args/storage.rs b/crates/node/core/src/args/storage.rs index 1237c7dd6de..a5b1c6823e2 100644 --- a/crates/node/core/src/args/storage.rs +++ b/crates/node/core/src/args/storage.rs @@ -1,4 +1,4 @@ -//! clap [Args](clap::Args) for storage configuration +//! clap [`Args`] for storage configuration. use clap::Args; use std::sync::OnceLock; diff --git a/crates/node/core/src/args/txpool.rs b/crates/node/core/src/args/txpool.rs index 9b7fea97baf..cf67114f007 100644 --- a/crates/node/core/src/args/txpool.rs +++ b/crates/node/core/src/args/txpool.rs @@ -342,7 +342,8 @@ pub struct TxPoolArgs { pub minimal_protocol_basefee: u64, /// Minimum priority fee required for transaction acceptance into the pool. - /// Transactions with priority fee below this value will be rejected. + /// Transactions with priority fee below this value will be rejected. For legacy and EIP-2930 + /// transactions the gas price is used as the priority fee. #[arg(long = "txpool.minimum-priority-fee", default_value = Resettable::from(DefaultTxPoolValues::get_global().minimum_priority_fee.map(|v| v.to_string().into())))] pub minimum_priority_fee: Option, @@ -555,8 +556,8 @@ impl RethTransactionPoolConfig for TxPoolArgs { #[cfg(test)] mod tests { + use super::*; - use alloy_primitives::address; use clap::Parser; /// A helper type to parse Args more easily @@ -627,10 +628,7 @@ mod tests { max_tx_input_bytes: 131072, max_cached_entries: 200, no_locals: true, - locals: vec![ - address!("0x0000000000000000000000000000000000000001"), - address!("0x0000000000000000000000000000000000000002"), - ], + locals: vec![Address::with_last_byte(1), Address::with_last_byte(2)], no_local_transactions_propagation: true, additional_validation_tasks: 4, pending_tx_listener_buffer_size: 512, diff --git a/crates/node/core/src/args/types.rs b/crates/node/core/src/args/types.rs index 5f034ca1ec1..eaedd84c83b 100644 --- a/crates/node/core/src/args/types.rs +++ b/crates/node/core/src/args/types.rs @@ -126,7 +126,7 @@ where arg: Option<&clap::Arg>, value: &std::ffi::OsStr, ) -> Result { - if value.to_str().map(|s| s.eq_ignore_ascii_case("max")).unwrap_or(false) { + if value.to_str().is_some_and(|s| s.eq_ignore_ascii_case("max")) { Ok(u64::MAX) } else { self.inner.parse_ref(cmd, arg, value).map(Into::into) diff --git a/crates/node/core/src/utils.rs b/crates/node/core/src/utils.rs index e63337c2735..04c58786389 100644 --- a/crates/node/core/src/utils.rs +++ b/crates/node/core/src/utils.rs @@ -1,7 +1,6 @@ //! Utility functions for node startup and shutdown, for example path parsing and retrieving single //! blocks from the network. -use alloy_consensus::BlockHeader; use alloy_eips::BlockHashOrNumber; use alloy_rpc_types_engine::{JwtError, JwtSecret}; use eyre::Result; @@ -46,12 +45,7 @@ where let header = SealedHeader::seal_slow(header); - let valid = match id { - BlockHashOrNumber::Hash(hash) => header.hash() == hash, - BlockHashOrNumber::Number(number) => header.number() == number, - }; - - if !valid { + if !header.num_hash().matches_block_or_num(&id) { client.report_bad_message(peer_id); eyre::bail!( "Received invalid header. Received: {:?}. Expected: {:?}", diff --git a/crates/node/core/tests/default_engine_args.rs b/crates/node/core/tests/default_engine_args.rs new file mode 100644 index 00000000000..0ed3b9a8ea2 --- /dev/null +++ b/crates/node/core/tests/default_engine_args.rs @@ -0,0 +1,24 @@ +//! Regression coverage for configurable engine CLI defaults. + +use clap::Parser; +use reth_node_core::args::{DefaultEngineValues, EngineArgs}; +use std::time::Duration; + +#[derive(Parser)] +struct CommandParser { + #[command(flatten)] + args: EngineArgs, +} + +#[test] +fn disabled_state_root_timeout_default_applies_to_cli() { + DefaultEngineValues::default().with_state_root_task_timeout(None).try_init().unwrap(); + + assert_eq!(CommandParser::parse_from(["reth"]).args, EngineArgs::default()); + assert_eq!( + CommandParser::parse_from(["reth", "--engine.state-root-task-timeout", "500ms"]) + .args + .state_root_task_timeout, + Some(Duration::from_millis(500)) + ); +} diff --git a/crates/node/core/tests/default_rpc_args.rs b/crates/node/core/tests/default_rpc_args.rs new file mode 100644 index 00000000000..714eb231a23 --- /dev/null +++ b/crates/node/core/tests/default_rpc_args.rs @@ -0,0 +1,91 @@ +//! Regression coverage for configurable RPC CLI defaults. + +use alloy_primitives::{map::AddressSet, Address}; +use alloy_rpc_types_engine::JwtSecret; +use clap::{CommandFactory, Parser}; +use reth_node_core::args::{DefaultRpcServerArgs, RpcServerArgs, RpcStateCacheArgs}; +use reth_rpc_eth_types::builder::config::PendingBlockKind; +use std::time::Duration; + +#[derive(Parser)] +struct CommandParser { + #[command(flatten)] + args: RpcServerArgs, +} + +#[test] +fn custom_rpc_defaults_apply_to_cli() { + DefaultRpcServerArgs::default() + .with_rpc_pending_block(PendingBlockKind::Empty) + .with_rpc_forwarder(Some( + "https://user:pass%3Aword@localhost:1234/path-token?key=query-token".parse().unwrap(), + )) + .with_rpc_send_raw_transaction_sync_timeout(Duration::from_millis(250)) + .with_rpc_jwtsecret(Some(JwtSecret::from_hex("11".repeat(32)).unwrap())) + .with_builder_disallow(Some(AddressSet::from_iter([Address::repeat_byte(1)]))) + .with_rpc_state_cache(RpcStateCacheArgs { + max_blocks: 1, + max_receipts: 2, + max_headers: 3, + max_bals: 4, + max_blocks_bytes: 1024, + max_receipts_bytes: 2048, + max_bals_bytes: 4096, + idle_timeout: Duration::from_millis(500), + cache_computed_bals: true, + prewarm_bals: Some(5), + max_concurrent_db_requests: 6, + max_cached_tx_hashes: 7, + }) + .try_init() + .unwrap(); + + assert_eq!(CommandParser::parse_from(["reth"]).args, RpcServerArgs::default()); + + let mut command = CommandParser::command(); + let mut without_forwarder_default = + CommandParser::command().mut_arg("rpc_forwarder", |arg| arg.default_value(None::<&str>)); + assert_eq!( + command.render_help().to_string(), + without_forwarder_default.render_help().to_string() + ); + assert_eq!( + command.render_long_help().to_string(), + without_forwarder_default.render_long_help().to_string() + ); + + for flag in ["--rpc.forwarder", "--rpc-forwarder"] { + let error = CommandParser::try_parse_from(["reth", flag, "configured"]).err().unwrap(); + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + + let jwt = "22".repeat(32); + let args = CommandParser::parse_from([ + "reth", + "--rpc.pending-block", + "none", + "--rpc.forwarder", + "http://localhost:5678", + "--rpc.send-raw-transaction-sync-timeout", + "2s", + "--rpc.jwtsecret", + &jwt, + "--builder.disallow", + concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/disallowed-addresses.json"), + "--rpc-cache.max-blocks", + "8", + "--rpc-cache.prewarm-bals", + ]) + .args; + let mut expected = RpcServerArgs { + rpc_pending_block: PendingBlockKind::None, + rpc_forwarder: Some("http://localhost:5678".parse().unwrap()), + rpc_send_raw_transaction_sync_timeout: Duration::from_secs(2), + rpc_jwtsecret: Some(JwtSecret::from_hex(jwt).unwrap()), + builder_disallow: Some(AddressSet::default()), + ..Default::default() + }; + expected.rpc_state_cache.max_blocks = 8; + expected.rpc_state_cache.prewarm_bals = Some(0); + assert_eq!(args, expected); +} diff --git a/crates/node/core/tests/default_rpc_server_args.rs b/crates/node/core/tests/default_rpc_server_args.rs new file mode 100644 index 00000000000..2d23eba1e39 --- /dev/null +++ b/crates/node/core/tests/default_rpc_server_args.rs @@ -0,0 +1,18 @@ +//! Integration tests for customizable RPC server CLI defaults. + +use clap::{Args, Parser}; +use reth_node_core::args::{DefaultRpcServerArgs, RpcServerArgs}; + +#[derive(Parser)] +struct CommandParser { + #[command(flatten)] + args: T, +} + +#[test] +fn custom_rpc_tx_fee_cap_default_applies_to_cli() { + let fee_cap = 1000 * 10u128.pow(18); + DefaultRpcServerArgs::default().with_rpc_tx_fee_cap(fee_cap).try_init().unwrap(); + + assert_eq!(CommandParser::::parse_from(["reth"]).args.rpc_tx_fee_cap, fee_cap); +} diff --git a/crates/node/core/tests/fixtures/disallowed-addresses.json b/crates/node/core/tests/fixtures/disallowed-addresses.json new file mode 100644 index 00000000000..fe51488c706 --- /dev/null +++ b/crates/node/core/tests/fixtures/disallowed-addresses.json @@ -0,0 +1 @@ +[] diff --git a/crates/node/ethstats/Cargo.toml b/crates/node/ethstats/Cargo.toml index 6ffad317702..1c012e63016 100644 --- a/crates/node/ethstats/Cargo.toml +++ b/crates/node/ethstats/Cargo.toml @@ -14,7 +14,7 @@ workspace = true reth-network-api.workspace = true reth-transaction-pool.workspace = true reth-primitives-traits.workspace = true -reth-storage-api.workspace = true +reth-storage-api = { workspace = true, features = ["chain-state"] } reth-chain-state.workspace = true alloy-primitives.workspace = true diff --git a/crates/node/ethstats/src/ethstats.rs b/crates/node/ethstats/src/ethstats.rs index e77badda816..ffde51f1200 100644 --- a/crates/node/ethstats/src/ethstats.rs +++ b/crates/node/ethstats/src/ethstats.rs @@ -759,7 +759,7 @@ mod tests { use reth_storage_api::noop::NoopProvider; use reth_transaction_pool::noop::NoopTransactionPool; use serde_json::json; - use tokio::{net::TcpListener, sync::Notify}; + use tokio::net::TcpListener; use tokio_tungstenite::tungstenite::protocol::{frame::Utf8Bytes, Message}; const TEST_HOST: &str = "127.0.0.1"; @@ -892,31 +892,18 @@ mod tests { let mut last_ping_guard = service.last_ping.lock().await; *last_ping_guard = Some(Instant::now()); - let started = Arc::new(Notify::new()); - let started_clone = started.clone(); - let service_clone = service.clone(); - let handle = tokio::spawn(async move { - started_clone.notify_one(); - let _ = service_clone.report_latency().await; - }); + let mut report_latency = Box::pin(service.report_latency()); + assert!(futures_util::poll!(report_latency.as_mut()).is_pending()); - // Let the pong handler start and (in the unfixed version) hold conn.read(). - started.notified().await; - tokio::task::yield_now().await; - - // This represents the timeout task trying to take conn.write() to close after a timeout. - // In the unfixed lock order, it would block because report_latency holds conn.read(). - let write_guard = - tokio::time::timeout(std::time::Duration::from_millis(100), service.conn.write()) - .await - .expect( - "conn write lock should not be held while report_latency waits on last_ping", - ); + let write_guard = service + .conn + .try_write() + .expect("conn write lock should not be held while report_latency waits on last_ping"); drop(write_guard); drop(last_ping_guard); - let _ = handle.await; + let _ = report_latency.await; server_handle.abort(); } } diff --git a/crates/node/metrics/Cargo.toml b/crates/node/metrics/Cargo.toml index edfca935fb5..18a03b67a9a 100644 --- a/crates/node/metrics/Cargo.toml +++ b/crates/node/metrics/Cargo.toml @@ -23,6 +23,7 @@ http.workspace = true http-body-util.workspace = true bytes.workspace = true tower.workspace = true +tower-http = { workspace = true, features = ["compression-gzip"] } reqwest.workspace = true tracing.workspace = true @@ -42,6 +43,7 @@ procfs = "0.18.0" [dev-dependencies] socket2.workspace = true +flate2.workspace = true [lints] workspace = true diff --git a/crates/node/metrics/src/server.rs b/crates/node/metrics/src/server.rs index be13514a034..f3702ffaf40 100644 --- a/crates/node/metrics/src/server.rs +++ b/crates/node/metrics/src/server.rs @@ -9,13 +9,19 @@ use crate::{ use bytes::Bytes; use eyre::WrapErr; use http::{header::CONTENT_TYPE, HeaderValue, Request, Response, StatusCode}; -use http_body_util::Full; +use http_body_util::{BodyExt, Full}; use metrics::describe_gauge; use metrics_process::Collector; use reqwest::Client; use reth_metrics::metrics::Unit; use reth_tasks::TaskExecutor; use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use tokio::sync::Semaphore; +use tower::Service; +use tower_http::compression::Compression; + +/// Bound rendering and compression work across all metrics connections. +const MAX_CONCURRENT_METRICS_REQUESTS: usize = 4; /// Configuration for the [`MetricServer`] #[derive(Debug)] @@ -136,7 +142,7 @@ impl MetricServer { Ok(()) } - async fn start_endpoint( + async fn start_endpoint( &self, listen_addr: SocketAddr, hook: Arc, @@ -150,6 +156,7 @@ impl MetricServer { tracing::info!(target: "reth::cli", "Starting metrics endpoint at {}", listener.local_addr().unwrap()); let executor = task_executor.clone(); + let requests = Arc::new(Semaphore::new(MAX_CONCURRENT_METRICS_REQUESTS)); task_executor.spawn_with_graceful_shutdown_signal(async move |mut signal| loop { let io = tokio::select! { _ = &mut signal => break, @@ -168,6 +175,7 @@ impl MetricServer { let hook = hook.clone(); let pprof_dump_dir = pprof_dump_dir.clone(); let executor = executor.clone(); + let compression_executor = executor.clone(); let service = tower::service_fn(move |req: Request<_>| { let hook = hook.clone(); let pprof_dump_dir = pprof_dump_dir.clone(); @@ -180,6 +188,50 @@ impl MetricServer { } }); + let service = Compression::new(service).gzip(true).no_br().no_deflate().no_zstd(); + let requests = requests.clone(); + let service = tower::service_fn(move |req| { + let mut service = service.clone(); + let executor = compression_executor.clone(); + let permit = requests.clone().try_acquire_owned(); + async move { + let Ok(permit) = permit else { + return Ok::<_, Infallible>(metrics_error_response( + StatusCode::SERVICE_UNAVAILABLE, + "Metrics collection is busy. Try again later.", + )) + }; + let response = service.call(req).await?; + // CompressionBody performs CPU work when polled. Fully collect it on a + // blocking worker so Hyper only polls the completed bytes on Tokio workers. + let span = tracing::Span::current(); + let result = executor + .spawn_blocking(move || { + let _entered = span.enter(); + // Keep the concurrency slot even if the client disconnects. + let _permit = permit; + tokio::runtime::Handle::current().block_on(async move { + let (parts, body) = response.into_parts(); + body.collect().await.map(|body| { + Response::from_parts(parts, Full::new(body.to_bytes())) + }) + }) + }) + .await; + let response = match result { + Ok(Ok(response)) => response, + error => { + tracing::error!(target: "reth::cli", ?error, "Failed to encode metrics response"); + metrics_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to encode metrics response.", + ) + } + }; + Ok(response) + } + }); + let mut shutdown = signal.clone().ignore_guard(); tokio::task::spawn(async move { let _ = jsonrpsee_server::serve_with_graceful_shutdown(io, service, &mut shutdown) @@ -247,6 +299,12 @@ impl MetricServer { } } +fn metrics_error_response(status: StatusCode, message: &'static str) -> Response> { + let mut response = Response::new(Full::new(Bytes::from_static(message.as_bytes()))); + *response.status_mut() = status; + response +} + fn describe_db_metrics() { describe_gauge!("db.table_size", Unit::Bytes, "The size of a database table (in bytes)"); describe_gauge!("db.table_pages", "The number of database pages for a table"); @@ -489,10 +547,13 @@ async fn handle_tokio_dump() -> Response> { #[cfg(test)] mod tests { use super::*; + use flate2::read::GzDecoder; + use http::header::{ACCEPT_ENCODING, CONTENT_ENCODING, VARY}; use reqwest::Client; use reth_tasks::Runtime; use socket2::{Domain, Socket, Type}; use std::{ + io::Read, net::{SocketAddr, TcpListener}, sync::{ atomic::{AtomicUsize, Ordering}, @@ -546,12 +607,22 @@ mod tests { ) .with_storage_settings_info(storage_settings_info); - MetricServer::new(config).serve().await.unwrap(); + let server = MetricServer::new(config); + server.serve().await.unwrap(); // Send request to the metrics endpoint let url = format!("http://{listen_addr}"); - let response = Client::new().get(&url).send().await.unwrap(); + let client = Client::builder() + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let response = client.get(&url).send().await.unwrap(); assert!(response.status().is_success()); + assert_eq!(response.headers().get(CONTENT_ENCODING), None); // Check the response body let body = response.text().await.unwrap(); @@ -563,6 +634,98 @@ mod tests { assert!(body.contains("pruning_mode=\"archive\""), "expected pruning mode label"); assert!(body.contains("prune_config="), "expected prune config label"); + let storage_metric = + body.lines().find(|line| line.starts_with("reth_storage_settings{")).unwrap(); + for (accept_encoding, gzip) in [ + ("gzip", true), + ("br, gzip;q=0.5", true), + ("gzip;q=0", false), + ("identity", false), + ("br", false), + ] { + let response = + client.get(&url).header(ACCEPT_ENCODING, accept_encoding).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/plain"); + assert_eq!(response.headers()[VARY], "accept-encoding"); + assert_eq!( + response.headers().get(CONTENT_ENCODING), + gzip.then_some(&HeaderValue::from_static("gzip")), + "Accept-Encoding: {accept_encoding}" + ); + let bytes = response.bytes().await.unwrap(); + let decoded = if gzip { + let mut decoded = String::new(); + GzDecoder::new(bytes.as_ref()).read_to_string(&mut decoded).unwrap(); + decoded + } else { + String::from_utf8(bytes.to_vec()).unwrap() + }; + assert_eq!( + decoded.lines().find(|line| line.starts_with("reth_storage_settings{")), + Some(storage_metric) + ); + } + + // Fill all request slots with blocked collectors on separate connections. Excess + // scrapes must fail promptly without starting more blocking work. + let busy_addr = get_random_available_addr(); + let started = Arc::new(Semaphore::new(0)); + let (release, wait_for_release) = mpsc::channel::<()>(); + let wait_for_release = Mutex::new(wait_for_release); + let hook = { + let started = started.clone(); + Arc::new(move || { + started.add_permits(1); + assert_eq!( + wait_for_release.lock().unwrap().recv_timeout(Duration::from_secs(10)), + Err(mpsc::RecvTimeoutError::Disconnected) + ); + }) + }; + server + .start_endpoint(busy_addr, hook, runtime.clone(), std::env::temp_dir()) + .await + .unwrap(); + let busy_url = format!("http://{busy_addr}"); + let mut scrapes = tokio::task::JoinSet::new(); + for _ in 0..MAX_CONCURRENT_METRICS_REQUESTS { + let client = client.clone(); + let url = busy_url.clone(); + scrapes.spawn(async move { + let response = + client.get(url).header(ACCEPT_ENCODING, "gzip").send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_ENCODING], "gzip"); + response.bytes().await.unwrap(); + }); + } + tokio::time::timeout( + Duration::from_secs(10), + started.acquire_many(MAX_CONCURRENT_METRICS_REQUESTS as u32), + ) + .await + .unwrap() + .unwrap() + .forget(); + let response = tokio::time::timeout( + Duration::from_secs(10), + client.get(&busy_url).header(ACCEPT_ENCODING, "gzip").send(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(started.available_permits(), 0); + // Closing the channel releases every collector, including during panic unwinding. + drop(release); + while let Some(result) = + tokio::time::timeout(Duration::from_secs(10), scrapes.join_next()).await.unwrap() + { + result.unwrap(); + } + assert_eq!(client.get(&busy_url).send().await.unwrap().status(), StatusCode::OK); + // Make sure the runtime is dropped after the test runs. drop(runtime); } diff --git a/crates/node/types/src/lib.rs b/crates/node/types/src/lib.rs index b5b38f48c7d..795dc302ce7 100644 --- a/crates/node/types/src/lib.rs +++ b/crates/node/types/src/lib.rs @@ -118,7 +118,7 @@ where P: NodePrimitives + Send + Sync + Unpin + 'static, C: EthChainSpec

+ Clone + 'static, S: Default + Clone + Send + Sync + Unpin + Debug + 'static, - PL: PayloadTypes> + Send + Sync + Unpin + 'static, + PL: PayloadTypes>, { type Primitives = P; type ChainSpec = C; @@ -170,10 +170,10 @@ impl AnyNodeTypesWithEngine { impl NodeTypes for AnyNodeTypesWithEngine where P: NodePrimitives + Send + Sync + Unpin + 'static, - E: EngineTypes + Send + Sync + Unpin, + E: EngineTypes, C: EthChainSpec
+ Clone + 'static, S: Default + Clone + Send + Sync + Unpin + Debug + 'static, - PL: PayloadTypes> + Send + Sync + Unpin + 'static, + PL: PayloadTypes>, { type Primitives = P; type ChainSpec = C; diff --git a/crates/payload/basic/src/lib.rs b/crates/payload/basic/src/lib.rs index ddb602d4685..689d10c3961 100644 --- a/crates/payload/basic/src/lib.rs +++ b/crates/payload/basic/src/lib.rs @@ -21,7 +21,7 @@ use reth_payload_builder::{ }; use reth_payload_builder_primitives::PayloadBuilderError; use reth_payload_primitives::{BuiltPayload, PayloadAttributes, PayloadKind}; -use reth_primitives_traits::{HeaderTy, NodePrimitives, SealedHeader}; +use reth_primitives_traits::{AlloyBlockHeader, HeaderTy, NodePrimitives, SealedHeader}; use reth_revm::cached::CachedReads; use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; use reth_tasks::{CancelOnDrop, Runtime}; @@ -39,7 +39,7 @@ use tokio::{ sync::{oneshot, Semaphore}, time::{Interval, Sleep}, }; -use tracing::{debug, trace, warn}; +use tracing::{debug, debug_span, trace, warn, Span}; mod better_payload_emitter; mod metrics; @@ -423,14 +423,20 @@ where self.metrics.inc_initiated_payload_builds(); let cached_reads = self.cached_reads.take().unwrap_or_default(); let execution_cache = self.execution_cache.clone(); - let state_root_handle = self.state_root_handle.take(); + let mut state_root_handle = self.state_root_handle.take(); + let on_payload_built = + state_root_handle.as_mut().and_then(PayloadStateRootHandle::take_on_payload_built); let leases = self.leases.clone(); let builder = self.builder.clone(); let executor = self.executor.clone(); + let span = Span::current(); self.executor.spawn_task(async move { // acquire the permit for executing the task let permit = guard.acquire_owned().await; executor.spawn_blocking_named_or_tokio(PAYLOAD_BUILDER_THREAD_NAME, move || { + // Restore the parent even when the worker span is filtered out. + let _parent = span.enter(); + let _span = debug_span!(target: "payload_builder", "build_payload").entered(); let _permit = permit; let args = BuildArguments { cached_reads, @@ -441,6 +447,12 @@ where best_payload, }; let result = builder.try_build(args); + if let Some(on_payload_built) = on_payload_built && + let Ok(outcome) = &result && + let Some(payload) = outcome.payload() + { + on_payload_built(payload.block().hash(), payload.block().state_root()); + } drop(leases); let _ = tx.send(result); }); @@ -595,9 +607,14 @@ where let (tx, rx) = oneshot::channel(); let config = self.config.clone(); let builder = self.builder.clone(); + let span = Span::current(); self.executor.spawn_blocking_named_or_tokio( PAYLOAD_BUILDER_THREAD_NAME, move || { + let _parent = span.enter(); + let _span = + debug_span!(target: "payload_builder", "build_empty_payload") + .entered(); let res = builder.build_empty_payload(config); let _ = tx.send(res); }, @@ -609,9 +626,14 @@ where debug!(target: "payload_builder", id=%self.config.payload_id(), "racing fallback payload"); // race the in progress job with this job let (tx, rx) = oneshot::channel(); + let span = Span::current(); self.executor.spawn_blocking_named_or_tokio( PAYLOAD_BUILDER_THREAD_NAME, move || { + let _parent = span.enter(); + let _span = + debug_span!(target: "payload_builder", "build_fallback_payload") + .entered(); let _ = tx.send(job()); }, ); @@ -932,10 +954,8 @@ pub struct BuildArguments { pub execution_cache: Option, /// Optional state-root task handle, shared with the engine. /// - /// The preserved trie is shared with the engine, so a concurrent `newPayload` will - /// block until this task completes. The trie is anchored at the built block's state - /// root, so if the next `newPayload` is not on top of that block, the trie cache is - /// invalidated and cleared. + /// A successful build returns its retained trie through the handle's completion callback, + /// associated with the built block's hash and state root. pub state_root_handle: Option, /// How to configure the payload. pub config: PayloadConfig>, diff --git a/crates/payload/builder/src/service.rs b/crates/payload/builder/src/service.rs index b9e2abac366..b7669c8734f 100644 --- a/crates/payload/builder/src/service.rs +++ b/crates/payload/builder/src/service.rs @@ -538,6 +538,10 @@ pub enum PayloadServiceCommand { /// /// Carries the caller's [`Span`] so the service can parent payload-building work under the /// originating Engine API trace. + /// + /// The sender receives the id of the started payload job. Answering with + /// [`PayloadBuilderError::InvalidAttributes`] rejects the payload attributes; any other error, + /// or dropping the sender, is reported to the CL as an internal error. BuildNewPayload( Box>, Span, diff --git a/crates/payload/builder/src/test_utils.rs b/crates/payload/builder/src/test_utils.rs index eb48312752c..202ede50d57 100644 --- a/crates/payload/builder/src/test_utils.rs +++ b/crates/payload/builder/src/test_utils.rs @@ -30,8 +30,7 @@ pub fn test_payload_service() -> ( PayloadBuilderHandle, ) where - T: PayloadTypes - + 'static, + T: PayloadTypes, { PayloadBuilderService::new(Default::default(), futures_util::stream::empty()) } @@ -39,8 +38,7 @@ where /// Creates a new [`PayloadBuilderService`] for testing purposes and spawns it in the background. pub fn spawn_test_payload_service() -> PayloadBuilderHandle where - T: PayloadTypes - + 'static, + T: PayloadTypes, { let (service, handle) = test_payload_service(); tokio::spawn(service); diff --git a/crates/payload/builder/src/traits.rs b/crates/payload/builder/src/traits.rs index 89ae6ecbfa5..dcfe151e2b4 100644 --- a/crates/payload/builder/src/traits.rs +++ b/crates/payload/builder/src/traits.rs @@ -22,13 +22,13 @@ use crate::service::BuildNewPayload; /// Note: A `PayloadJob` need to be cancel safe because it might be dropped after the CL has requested the payload via `engine_getPayloadV1` (see also [engine API docs](https://github.com/ethereum/execution-apis/blob/6709c2a795b707202e93c4f2867fa0bf2640a84f/src/engine/paris.md#engine_getpayloadv1)) pub trait PayloadJob: Future> { /// Represents the payload attributes type that is used to spawn this payload job. - type PayloadAttributes: PayloadAttributes + std::fmt::Debug; + type PayloadAttributes: PayloadAttributes; /// Represents the future that resolves the block that's returned to the CL. type ResolvePayloadFuture: Future> + Send + 'static; /// Represents the built payload type that is returned to the CL. - type BuiltPayload: BuiltPayload + Clone + std::fmt::Debug; + type BuiltPayload: BuiltPayload + Clone; /// Returns the best payload that has been built so far. /// @@ -112,6 +112,9 @@ pub trait PayloadJobGenerator { /// /// This is called when the CL requests a new payload job via a fork choice update. /// + /// Return [`PayloadBuilderError::InvalidAttributes`] to reject the payload attributes; any + /// other error is reported to the CL as an internal error. + /// /// # Note /// /// This is expected to initially build a new (empty) payload without transactions, so it can be diff --git a/crates/payload/primitives/src/error.rs b/crates/payload/primitives/src/error.rs index 9976bdea97d..a4ec2b78100 100644 --- a/crates/payload/primitives/src/error.rs +++ b/crates/payload/primitives/src/error.rs @@ -31,6 +31,15 @@ pub enum PayloadBuilderError { /// Any other payload building errors. #[error(transparent)] Other(Box), + /// The payload attributes are invalid on top of the parent block, e.g. a transaction they + /// include is invalid. + /// + /// Returned from `PayloadJobGenerator::new_payload_job`, this is the only variant that rejects + /// the attributes; returned later from a running job, it is an internal error like any other. + /// Failures of this node, such as a failed state read during execution, must use another + /// variant. + #[error("invalid payload attributes: {0}")] + InvalidAttributes(Box), } impl PayloadBuilderError { @@ -49,6 +58,14 @@ impl PayloadBuilderError { { Self::Other(Box::new(error)) } + + /// Create a new error that rejects the payload attributes. + pub fn invalid_attributes(error: E) -> Self + where + E: core::error::Error + Send + Sync + 'static, + { + Self::InvalidAttributes(Box::new(error)) + } } impl From for PayloadBuilderError { diff --git a/crates/payload/primitives/src/traits.rs b/crates/payload/primitives/src/traits.rs index 981c0547a2f..2ad71729a76 100644 --- a/crates/payload/primitives/src/traits.rs +++ b/crates/payload/primitives/src/traits.rs @@ -4,7 +4,6 @@ use crate::PayloadBuilderError; use alloc::{boxed::Box, sync::Arc, vec::Vec}; use alloy_eips::{eip4895::Withdrawal, eip7685::Requests}; use alloy_primitives::{Bytes, B256, U256}; -use alloy_rlp::Encodable; use alloy_rpc_types_engine::{PayloadAttributes as EthPayloadAttributes, PayloadId}; use core::fmt; use either::Either; @@ -203,9 +202,7 @@ pub fn payload_id( hasher.update(attributes.prev_randao.as_slice()); hasher.update(attributes.suggested_fee_recipient.as_slice()); if let Some(withdrawals) = &attributes.withdrawals { - let mut buf = Vec::new(); - withdrawals.encode(&mut buf); - hasher.update(buf); + hasher.update(alloy_rlp::encode(withdrawals)); } if let Some(parent_beacon_block) = attributes.parent_beacon_block_root { @@ -230,8 +227,7 @@ pub fn payload_id( mod tests { use super::*; use alloy_eips::eip4895::Withdrawal; - use alloy_primitives::{Address, B64}; - use core::str::FromStr; + use alloy_primitives::{address, b256, b64, Address}; #[test] fn attributes_serde() { @@ -242,19 +238,11 @@ mod tests { #[test] fn test_payload_id_basic() { // Create a parent block and payload attributes - let parent = - B256::from_str("0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a") - .unwrap(); + let parent = b256!("0x3b8fb240d288781d4aac94d3fd16809ee413bc99294a085798a589dae51ddd4a"); let attributes = EthPayloadAttributes { timestamp: 0x5, - prev_randao: B256::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000000", - ) - .unwrap(), - suggested_fee_recipient: Address::from_str( - "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", - ) - .unwrap(), + prev_randao: B256::ZERO, + suggested_fee_recipient: address!("0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b"), withdrawals: None, parent_beacon_block_root: None, slot_number: None, @@ -262,36 +250,28 @@ mod tests { }; // Verify that the generated payload ID matches the expected value - assert_eq!( - payload_id(&parent, &attributes), - PayloadId(B64::from_str("0xa247243752eb10b4").unwrap()) - ); + assert_eq!(payload_id(&parent, &attributes), PayloadId(b64!("0xa247243752eb10b4"))); } #[test] fn test_payload_id_with_withdrawals() { // Set up the parent and attributes with withdrawals - let parent = - B256::from_str("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef") - .unwrap(); + let parent = b256!("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef"); let attributes = EthPayloadAttributes { timestamp: 1622553200, prev_randao: B256::from_slice(&[1; 32]), - suggested_fee_recipient: Address::from_str( - "0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b", - ) - .unwrap(), + suggested_fee_recipient: address!("0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b"), withdrawals: Some(vec![ Withdrawal { index: 1, validator_index: 123, - address: Address::from([0xAA; 20]), + address: Address::repeat_byte(0xAA), amount: 10, }, Withdrawal { index: 2, validator_index: 456, - address: Address::from([0xBB; 20]), + address: Address::repeat_byte(0xBB), amount: 20, }, ]), @@ -301,58 +281,36 @@ mod tests { }; // Verify that the generated payload ID matches the expected value - assert_eq!( - payload_id(&parent, &attributes), - PayloadId(B64::from_str("0xedddc2f84ba59865").unwrap()) - ); + assert_eq!(payload_id(&parent, &attributes), PayloadId(b64!("0xedddc2f84ba59865"))); } #[test] fn test_payload_id_with_parent_beacon_block_root() { // Set up the parent and attributes with a parent beacon block root - let parent = - B256::from_str("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef") - .unwrap(); + let parent = b256!("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef"); let attributes = EthPayloadAttributes { timestamp: 1622553200, - prev_randao: B256::from_str( - "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234", - ) - .unwrap(), - suggested_fee_recipient: Address::from_str( - "0xc94f5374fce5edbc8e2a8697c15331677e6ebf0b", - ) - .unwrap(), - withdrawals: None, - parent_beacon_block_root: Some( - B256::from_str( - "0x2222222222222222222222222222222222222222222222222222222222222222", - ) - .unwrap(), + prev_randao: b256!( + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234" ), + suggested_fee_recipient: address!("0xc94f5374fce5edbc8e2a8697c15331677e6ebf0b"), + withdrawals: None, + parent_beacon_block_root: Some(B256::repeat_byte(0x22)), slot_number: None, ..Default::default() }; // Verify that the generated payload ID matches the expected value - assert_eq!( - payload_id(&parent, &attributes), - PayloadId(B64::from_str("0x0fc49cd532094cce").unwrap()) - ); + assert_eq!(payload_id(&parent, &attributes), PayloadId(b64!("0x0fc49cd532094cce"))); } #[test] fn test_payload_id_with_slot_number() { - let parent = - B256::from_str("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef") - .unwrap(); + let parent = b256!("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef"); let mut attributes = EthPayloadAttributes { timestamp: 1622553200, prev_randao: B256::from_slice(&[1; 32]), - suggested_fee_recipient: Address::from_str( - "0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b", - ) - .unwrap(), + suggested_fee_recipient: address!("0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b"), withdrawals: Some(vec![]), parent_beacon_block_root: Some(B256::from_slice(&[2; 32])), slot_number: Some(1), @@ -367,17 +325,12 @@ mod tests { #[test] fn test_payload_id_with_target_gas_limit() { - let parent = - B256::from_str("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef") - .unwrap(); + let parent = b256!("0x9876543210abcdef9876543210abcdef9876543210abcdef9876543210abcdef"); #[allow(clippy::needless_update)] let mut attributes = EthPayloadAttributes { timestamp: 1622553200, prev_randao: B256::from_slice(&[1; 32]), - suggested_fee_recipient: Address::from_str( - "0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b", - ) - .unwrap(), + suggested_fee_recipient: address!("0xb94f5374fce5edbc8e2a8697c15331677e6ebf0b"), withdrawals: Some(vec![]), parent_beacon_block_root: Some(B256::from_slice(&[2; 32])), slot_number: Some(1), diff --git a/crates/prune/prune/src/db_ext.rs b/crates/prune/prune/src/db_ext.rs index 736bc48a477..33a687e437d 100644 --- a/crates/prune/prune/src/db_ext.rs +++ b/crates/prune/prune/src/db_ext.rs @@ -1,6 +1,6 @@ use crate::PruneLimiter; use reth_db_api::{ - cursor::{DbCursorRO, DbCursorRW, DbDupCursorRO, RangeWalker}, + cursor::{DbCursorRO, DbCursorRW, RangeWalker}, table::{DupSort, Table, TableRow}, transaction::{DbTx, DbTxMut}, DatabaseError, @@ -193,44 +193,6 @@ pub(crate) trait DbTxPruneExt: DbTxMut + DbTx { Ok((starting_entries - ending_entries, done)) } - - /// Prune duplicate entries for a single DUPSORT key. - /// - /// Returns the number of rows pruned and whether all duplicate entries for the key were - /// deleted. - #[allow(dead_code)] - fn prune_dupsort_key_entries( - &self, - key: T::Key, - limiter: &mut PruneLimiter, - ) -> Result<(usize, bool), DatabaseError> { - let mut cursor = self.cursor_dup_write::()?; - let mut entry = cursor.seek_exact(key)?; - - let mut deleted_entries = 0; - - while entry.is_some() && !limiter.is_limit_reached() { - cursor.delete_current()?; - limiter.increment_deleted_entries_count(); - deleted_entries += 1; - entry = cursor.next_dup()?; - } - - // an entry remaining means the loop stopped because a limit was reached - let done = entry.is_none(); - if !done { - debug!( - target: "providers::db", - ?limiter, - deleted_entries_limit = %limiter.is_deleted_entries_limit_reached(), - time_limit = %limiter.is_time_limit_reached(), - table = %T::NAME, - "Pruning limit reached" - ); - } - - Ok((deleted_entries, done)) - } } impl DbTxPruneExt for Tx where Tx: DbTxMut + DbTx {} @@ -239,18 +201,14 @@ impl DbTxPruneExt for Tx where Tx: DbTxMut + DbTx {} mod tests { use super::DbTxPruneExt; use crate::PruneLimiter; - use alloy_primitives::{B256, U256}; - use reth_db_api::{tables, transaction::DbTxMut}; - use reth_primitives_traits::{SignerRecoverable, StorageEntry}; + use reth_db_api::tables; + use reth_primitives_traits::SignerRecoverable; use reth_provider::{DBProvider, DatabaseProviderFactory}; use reth_stages::test_utils::{StorageKind, TestStageDB}; use reth_testing_utils::generators::{self, random_block_range, BlockRangeParams}; - use std::{ - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, - }, - time::Duration, + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, }; struct CountingIter { @@ -286,51 +244,6 @@ mod tests { } } - fn storage_entry(slot_byte: u8) -> StorageEntry { - StorageEntry { key: B256::with_last_byte(slot_byte), value: U256::from(slot_byte) } - } - - fn insert_hashed_storages(db: &TestStageDB, rows: impl IntoIterator) { - let provider = db.factory.database_provider_rw().unwrap(); - for (address_byte, slot_byte) in rows { - provider - .tx_ref() - .put::( - B256::with_last_byte(address_byte), - storage_entry(slot_byte), - ) - .expect("insert hashed storage"); - } - provider.commit().expect("commit"); - } - - fn hashed_storage_slots(db: &TestStageDB, address_byte: u8) -> Vec { - db.table::() - .unwrap() - .into_iter() - .filter_map(|(key, entry)| { - (key == B256::with_last_byte(address_byte)).then_some(entry.key) - }) - .collect() - } - - fn prune_hashed_storage_key( - db: &TestStageDB, - address_byte: u8, - limiter: &mut PruneLimiter, - ) -> (usize, bool) { - let provider = db.factory.database_provider_rw().unwrap(); - let result = provider - .tx_ref() - .prune_dupsort_key_entries::( - B256::with_last_byte(address_byte), - limiter, - ) - .expect("prune hashed storages"); - provider.commit().expect("commit"); - result - } - #[test] fn prune_table_with_iterator_early_exit_does_not_overconsume() { let db = TestStageDB::default(); @@ -437,97 +350,4 @@ mod tests { provider.commit().expect("commit"); assert_eq!(db.table::().unwrap().len(), 0); } - - #[test] - fn prune_dupsort_key_entries_resumes_with_deleted_entries_budget() { - let db = TestStageDB::default(); - insert_hashed_storages(&db, (0..5).map(|slot| (1, slot))); - insert_hashed_storages(&db, (10..12).map(|slot| (2, slot))); - - let mut total_deleted = 0; - - let mut limiter = PruneLimiter::default().set_deleted_entries_limit(2); - let (deleted, done) = prune_hashed_storage_key(&db, 1, &mut limiter); - total_deleted += deleted; - assert_eq!((deleted, done), (2, false)); - assert_eq!( - hashed_storage_slots(&db, 1), - vec![B256::with_last_byte(2), B256::with_last_byte(3), B256::with_last_byte(4),] - ); - assert_eq!( - hashed_storage_slots(&db, 2), - vec![B256::with_last_byte(10), B256::with_last_byte(11)] - ); - - let mut limiter = PruneLimiter::default().set_deleted_entries_limit(2); - let (deleted, done) = prune_hashed_storage_key(&db, 1, &mut limiter); - total_deleted += deleted; - assert_eq!((deleted, done), (2, false)); - assert_eq!(hashed_storage_slots(&db, 1), vec![B256::with_last_byte(4)]); - assert_eq!( - hashed_storage_slots(&db, 2), - vec![B256::with_last_byte(10), B256::with_last_byte(11)] - ); - - let mut limiter = PruneLimiter::default().set_deleted_entries_limit(2); - let (deleted, done) = prune_hashed_storage_key(&db, 1, &mut limiter); - total_deleted += deleted; - assert_eq!((deleted, done), (1, true)); - assert!(hashed_storage_slots(&db, 1).is_empty()); - assert_eq!( - hashed_storage_slots(&db, 2), - vec![B256::with_last_byte(10), B256::with_last_byte(11)] - ); - assert_eq!(total_deleted, 5); - } - - #[test] - fn prune_dupsort_key_entries_stops_on_time_limit() { - let db = TestStageDB::default(); - insert_hashed_storages(&db, (0..3).map(|slot| (1, slot))); - - let mut limiter = PruneLimiter::default().set_time_limit(Duration::from_nanos(1)); - std::thread::sleep(Duration::from_millis(1)); - - let (deleted, done) = prune_hashed_storage_key(&db, 1, &mut limiter); - - assert_eq!((deleted, done), (0, false)); - assert!(limiter.is_time_limit_reached()); - assert_eq!( - hashed_storage_slots(&db, 1), - vec![B256::with_last_byte(0), B256::with_last_byte(1), B256::with_last_byte(2)] - ); - } - - #[test] - fn prune_dupsort_key_entries_missing_key_is_done() { - let db = TestStageDB::default(); - insert_hashed_storages(&db, (0..3).map(|slot| (1, slot))); - - let mut limiter = PruneLimiter::default().set_deleted_entries_limit(2); - let result = prune_hashed_storage_key(&db, 2, &mut limiter); - - assert_eq!(result, (0, true)); - assert_eq!( - hashed_storage_slots(&db, 1), - vec![B256::with_last_byte(0), B256::with_last_byte(1), B256::with_last_byte(2)] - ); - } - - #[test] - fn prune_dupsort_key_entries_leaves_other_keys_untouched() { - let db = TestStageDB::default(); - insert_hashed_storages(&db, (0..3).map(|slot| (1, slot))); - insert_hashed_storages(&db, (10..13).map(|slot| (2, slot))); - - let mut limiter = PruneLimiter::default(); - let result = prune_hashed_storage_key(&db, 1, &mut limiter); - - assert_eq!(result, (3, true)); - assert!(hashed_storage_slots(&db, 1).is_empty()); - assert_eq!( - hashed_storage_slots(&db, 2), - vec![B256::with_last_byte(10), B256::with_last_byte(11), B256::with_last_byte(12),] - ); - } } diff --git a/crates/prune/prune/src/segments/mod.rs b/crates/prune/prune/src/segments/mod.rs index 429ee60f24e..a81a3f0d382 100644 --- a/crates/prune/prune/src/segments/mod.rs +++ b/crates/prune/prune/src/segments/mod.rs @@ -15,7 +15,7 @@ use reth_stages_types::StageId; use reth_static_file_types::StaticFileSegment; pub use set::SegmentSet; use std::{fmt::Debug, ops::RangeInclusive}; -use tracing::error; +use tracing::{debug, error}; pub use user::{ AccountHistory, Bodies, Receipts as UserReceipts, ReceiptsByLogs, SenderRecovery, StorageHistory, TransactionLookup, @@ -170,12 +170,17 @@ impl PruneInput { ) -> ProviderResult>> { let from_tx_number = self.previous_checkpoint // Checkpoint exists, prune from the next transaction after the highest pruned one - .and_then(|checkpoint| match checkpoint.tx_number { - Some(tx_number) => Some(tx_number + 1), - _ => { + .and_then(|checkpoint| match (checkpoint.tx_number, checkpoint.block_number) { + (Some(tx_number), _) => Some(tx_number + 1), + // Pruned through a block without pruning any transactions, e.g. a snap sync pivot. + (None, Some(_)) => { + debug!(target: "pruner", ?checkpoint, "Prune checkpoint has no transaction number, starting from transaction 0"); + None + } + (None, None) => { error!(target: "pruner", ?checkpoint, "Expected transaction number in prune checkpoint, found None"); None - }, + } }) // No checkpoint exists, prune from genesis .unwrap_or_default(); diff --git a/crates/prune/prune/src/segments/receipts.rs b/crates/prune/prune/src/segments/receipts.rs index 30915f89b2f..eb81c031417 100644 --- a/crates/prune/prune/src/segments/receipts.rs +++ b/crates/prune/prune/src/segments/receipts.rs @@ -13,7 +13,6 @@ use reth_primitives_traits::NodePrimitives; use reth_provider::{ errors::provider::ProviderResult, BlockReader, DBProvider, EitherWriter, NodePrimitivesProvider, PruneCheckpointWriter, StaticFileProviderFactory, StorageSettingsCache, - TransactionsProvider, }; use reth_prune_types::{PruneCheckpoint, PruneSegment, SegmentOutput, SegmentOutputCheckpoint}; use reth_static_file_types::StaticFileSegment; @@ -25,7 +24,6 @@ pub(crate) fn prune( ) -> Result where Provider: DBProvider - + TransactionsProvider + BlockReader + StorageSettingsCache + StaticFileProviderFactory diff --git a/crates/prune/prune/src/segments/user/receipts.rs b/crates/prune/prune/src/segments/user/receipts.rs index 54f16e8b1ff..a00a966ecd1 100644 --- a/crates/prune/prune/src/segments/user/receipts.rs +++ b/crates/prune/prune/src/segments/user/receipts.rs @@ -6,7 +6,7 @@ use reth_db_api::{table::Value, transaction::DbTxMut}; use reth_primitives_traits::NodePrimitives; use reth_provider::{ errors::provider::ProviderResult, BlockReader, DBProvider, NodePrimitivesProvider, - PruneCheckpointWriter, StaticFileProviderFactory, StorageSettingsCache, TransactionsProvider, + PruneCheckpointWriter, StaticFileProviderFactory, StorageSettingsCache, }; use reth_prune_types::{PruneCheckpoint, PruneMode, PrunePurpose, PruneSegment, SegmentOutput}; use tracing::instrument; @@ -26,7 +26,6 @@ impl Segment for Receipts where Provider: DBProvider + PruneCheckpointWriter - + TransactionsProvider + BlockReader + StorageSettingsCache + StaticFileProviderFactory diff --git a/crates/prune/prune/src/segments/user/receipts_by_logs.rs b/crates/prune/prune/src/segments/user/receipts_by_logs.rs index 299f5804673..a44e0cf63d8 100644 --- a/crates/prune/prune/src/segments/user/receipts_by_logs.rs +++ b/crates/prune/prune/src/segments/user/receipts_by_logs.rs @@ -6,9 +6,7 @@ use crate::{ use alloy_consensus::TxReceipt; use reth_db_api::{table::Value, tables, transaction::DbTxMut}; use reth_primitives_traits::NodePrimitives; -use reth_provider::{ - BlockReader, DBProvider, NodePrimitivesProvider, PruneCheckpointWriter, TransactionsProvider, -}; +use reth_provider::{BlockReader, DBProvider, NodePrimitivesProvider, PruneCheckpointWriter}; use reth_prune_types::{ PruneCheckpoint, PruneMode, PrunePurpose, PruneSegment, ReceiptsLogPruneConfig, SegmentOutput, MINIMUM_UNWIND_SAFE_DISTANCE, @@ -29,7 +27,6 @@ impl Segment for ReceiptsByLogs where Provider: DBProvider + PruneCheckpointWriter - + TransactionsProvider + BlockReader + NodePrimitivesProvider>, { diff --git a/crates/prune/prune/src/segments/user/sender_recovery.rs b/crates/prune/prune/src/segments/user/sender_recovery.rs index 78d92665fd4..e6b6db315fe 100644 --- a/crates/prune/prune/src/segments/user/sender_recovery.rs +++ b/crates/prune/prune/src/segments/user/sender_recovery.rs @@ -6,7 +6,7 @@ use crate::{ use reth_db_api::{tables, transaction::DbTxMut}; use reth_provider::{ BlockReader, DBProvider, EitherWriterDestination, StaticFileProviderFactory, - StorageSettingsCache, TransactionsProvider, + StorageSettingsCache, }; use reth_prune_types::{ PruneMode, PruneProgress, PrunePurpose, PruneSegment, SegmentOutput, SegmentOutputCheckpoint, @@ -27,11 +27,8 @@ impl SenderRecovery { impl Segment for SenderRecovery where - Provider: DBProvider - + TransactionsProvider - + BlockReader - + StorageSettingsCache - + StaticFileProviderFactory, + Provider: + DBProvider + BlockReader + StorageSettingsCache + StaticFileProviderFactory, { fn segment(&self) -> PruneSegment { PruneSegment::SenderRecovery diff --git a/crates/prune/prune/src/segments/user/storage_history.rs b/crates/prune/prune/src/segments/user/storage_history.rs index 79eb40b4ef6..d2aa4052f3a 100644 --- a/crates/prune/prune/src/segments/user/storage_history.rs +++ b/crates/prune/prune/src/segments/user/storage_history.rs @@ -825,9 +825,7 @@ mod tests { vec![( address, account.clone(), - keys.iter() - .map(|key| StorageEntry { key: *key, value: U256::from(1) }) - .collect(), + keys.iter().map(|key| StorageEntry { key: *key, value: U256::ONE }).collect(), )] }) .collect::>(); diff --git a/crates/prune/types/Cargo.toml b/crates/prune/types/Cargo.toml index 64f5fa7cf02..1b305c00d31 100644 --- a/crates/prune/types/Cargo.toml +++ b/crates/prune/types/Cargo.toml @@ -35,6 +35,7 @@ proptest.workspace = true proptest-arbitrary-interop.workspace = true serde_json.workspace = true toml.workspace = true +modular-bitfield.workspace = true [features] default = ["std"] diff --git a/crates/prune/types/src/checkpoint.rs b/crates/prune/types/src/checkpoint.rs index 1a3a92d8d1a..338fd4570bd 100644 --- a/crates/prune/types/src/checkpoint.rs +++ b/crates/prune/types/src/checkpoint.rs @@ -18,5 +18,16 @@ pub struct PruneCheckpoint { pub prune_mode: PruneMode, } +impl PruneCheckpoint { + /// Returns a checkpoint for a segment pruned up to and including `block_number`. + pub const fn pruned_through(block_number: BlockNumber) -> Self { + Self { + block_number: Some(block_number), + tx_number: None, + prune_mode: PruneMode::before_inclusive(block_number), + } + } +} + #[cfg(any(test, feature = "reth-codec"))] reth_codecs::impl_compression_for_compact!(PruneCheckpoint); diff --git a/crates/prune/types/src/lib.rs b/crates/prune/types/src/lib.rs index 88d2d6490e6..c863fb98a72 100644 --- a/crates/prune/types/src/lib.rs +++ b/crates/prune/types/src/lib.rs @@ -135,7 +135,7 @@ mod tests { #[test] fn test_group_by_block_single_entry() { let mut config_map = BTreeMap::new(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let prune_mode = PruneMode::Before(500); config_map.insert(address, prune_mode); @@ -164,8 +164,8 @@ mod tests { #[test] fn test_group_by_block_multiple_entries() { let mut config_map = BTreeMap::new(); - let address1 = Address::new([1; 20]); - let address2 = Address::new([2; 20]); + let address1 = Address::repeat_byte(1); + let address2 = Address::repeat_byte(2); let prune_mode1 = PruneMode::Before(600); let prune_mode2 = PruneMode::Before(800); config_map.insert(address1, prune_mode1); @@ -186,7 +186,7 @@ mod tests { #[test] fn test_group_by_block_with_distance_prune_mode() { let mut config_map = BTreeMap::new(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let prune_mode = PruneMode::Distance(100000); config_map.insert(address, prune_mode); @@ -225,7 +225,7 @@ mod tests { #[test] fn test_lowest_block_with_distance_no_distance_mode() { let mut config_map = BTreeMap::new(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let prune_mode = PruneMode::Before(500); config_map.insert(address, prune_mode); @@ -240,7 +240,7 @@ mod tests { #[test] fn test_lowest_block_with_distance_single_entry() { let mut config_map = BTreeMap::new(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let prune_mode = PruneMode::Distance(100000); config_map.insert(address, prune_mode); @@ -270,8 +270,8 @@ mod tests { #[test] fn test_lowest_block_with_distance_multiple_entries_last() { let mut config_map = BTreeMap::new(); - let address1 = Address::new([1; 20]); - let address2 = Address::new([2; 20]); + let address1 = Address::repeat_byte(1); + let address2 = Address::repeat_byte(2); let prune_mode1 = PruneMode::Distance(100100); let prune_mode2 = PruneMode::Distance(100300); config_map.insert(address1, prune_mode1); @@ -291,8 +291,8 @@ mod tests { #[test] fn test_lowest_block_with_distance_multiple_entries_first() { let mut config_map = BTreeMap::new(); - let address1 = Address::new([1; 20]); - let address2 = Address::new([2; 20]); + let address1 = Address::repeat_byte(1); + let address2 = Address::repeat_byte(2); let prune_mode1 = PruneMode::Distance(100400); let prune_mode2 = PruneMode::Distance(100300); config_map.insert(address1, prune_mode1); @@ -312,8 +312,8 @@ mod tests { #[test] fn test_lowest_block_with_distance_multiple_entries_pruned_block() { let mut config_map = BTreeMap::new(); - let address1 = Address::new([1; 20]); - let address2 = Address::new([2; 20]); + let address1 = Address::repeat_byte(1); + let address2 = Address::repeat_byte(2); let prune_mode1 = PruneMode::Distance(100400); let prune_mode2 = PruneMode::Distance(100300); config_map.insert(address1, prune_mode1); diff --git a/crates/prune/types/src/mode.rs b/crates/prune/types/src/mode.rs index ed4852247ff..058e8570470 100644 --- a/crates/prune/types/src/mode.rs +++ b/crates/prune/types/src/mode.rs @@ -62,8 +62,8 @@ impl PruneMode { Self::Distance(distance) if *distance > tip => None, // Nothing to prune yet Self::Distance(distance) if *distance >= min_blocks => Some((tip - distance, *self)), Self::Before(n) if *n == tip + 1 && purpose.is_static_file() => Some((tip, *self)), - Self::Before(n) if *n > tip => None, // Nothing to prune yet - Self::Before(n) => (tip - n >= min_blocks).then(|| ((*n).saturating_sub(1), *self)), + Self::Before(n) if *n == 0 || *n > tip => None, + Self::Before(n) => (tip - n >= min_blocks).then(|| (*n - 1, *self)), _ => return Err(PruneSegmentError::Configuration(segment)), }; Ok(result) @@ -158,6 +158,9 @@ mod tests { ), // Nothing to prune (PruneMode::Before(tip - 1), Ok(None)), + // Nothing is before genesis + (PruneMode::Before(0), Ok(None)), + (PruneMode::Before(1), Ok(Some(0))), ]; for (index, (mode, expected_result)) in tests.into_iter().enumerate() { diff --git a/crates/prune/types/src/pruner.rs b/crates/prune/types/src/pruner.rs index c50c3b93c64..19abfee39dd 100644 --- a/crates/prune/types/src/pruner.rs +++ b/crates/prune/types/src/pruner.rs @@ -198,7 +198,7 @@ mod tests { )); // Finished adopts new progress - assert!(matches!(Finished.combine(Finished), Finished)); + assert!(Finished.combine(Finished).is_finished()); assert!(matches!( Finished.combine(HasMoreData(DeletedEntriesLimitReached)), HasMoreData(DeletedEntriesLimitReached) diff --git a/crates/prune/types/src/target.rs b/crates/prune/types/src/target.rs index 59610957304..9c310a48b57 100644 --- a/crates/prune/types/src/target.rs +++ b/crates/prune/types/src/target.rs @@ -31,6 +31,16 @@ pub enum UnwindTargetPrunedError { /// The limit of the history limit: u64, }, + /// The target block is below history that was already pruned + #[error("Cannot unwind to block {target_block} as {history_type} is pruned through block {pruned_block}")] + TargetBelowPrunedHistory { + /// The target block number + target_block: BlockNumber, + /// The type of history that was pruned + history_type: HistoryType, + /// The highest pruned block + pruned_block: BlockNumber, + }, } #[derive(Debug, Display, Clone, PartialEq, Eq)] @@ -165,6 +175,20 @@ impl PruneModes { } } } + + // History pruned through a block can't be reverted below it, whatever the mode. + // Unwinding to the checkpoint itself only needs the changesets above it. The database + // pruner can stop inside a block and then records the block before it, so the block + // right above the checkpoint may be partly pruned. + if let Some(pruned_block) = checkpoint.and_then(|checkpoint| checkpoint.1.block_number) && + target_block < pruned_block + { + return Err(UnwindTargetPrunedError::TargetBelowPrunedHistory { + target_block, + history_type: history_type.clone(), + pruned_block, + }) + } } Ok(()) } @@ -246,6 +270,24 @@ mod tests { ); } + #[test] + fn unwind_below_recorded_history_checkpoint() { + let checkpoints = [( + PruneSegment::AccountHistory, + PruneCheckpoint { + block_number: Some(499), + tx_number: None, + prune_mode: PruneMode::Before(500), + }, + )]; + let before = + PruneModes { account_history: Some(PruneMode::Before(500)), ..Default::default() }; + for prune_modes in [before, PruneModes::default()] { + assert!(prune_modes.ensure_unwind_target_unpruned(1000, 100, &checkpoints).is_err()); + assert!(prune_modes.ensure_unwind_target_unpruned(1000, 499, &checkpoints).is_ok()); + } + } + #[test] fn test_unwind_target_unpruned() { // Test case 1: No pruning configured - should always succeed diff --git a/crates/revm/Cargo.toml b/crates/revm/Cargo.toml index 0280dfca248..fff895a1ca9 100644 --- a/crates/revm/Cargo.toml +++ b/crates/revm/Cargo.toml @@ -33,6 +33,11 @@ alloy-consensus.workspace = true [features] default = ["std"] +account-ext = [ + "revm/account-ext", + "reth-primitives-traits/account-ext", + "reth-trie?/account-ext", +] std = [ "reth-primitives-traits/std", "alloy-primitives/std", diff --git a/crates/revm/src/database.rs b/crates/revm/src/database.rs index d05f37620a7..bf21e27aa9f 100644 --- a/crates/revm/src/database.rs +++ b/crates/revm/src/database.rs @@ -104,7 +104,7 @@ impl DatabaseRef for StateProviderDatabase { /// /// Returns `Ok` with the storage value, or the default value if not found. fn storage_ref(&self, address: Address, index: U256) -> Result { - Ok(self.0.storage(address, B256::new(index.to_be_bytes()))?.unwrap_or_default()) + Ok(self.0.storage(address, index.into())?.unwrap_or_default()) } /// Retrieves the block hash for a given block number. @@ -277,13 +277,7 @@ mod tests { let address = Address::repeat_byte(0x01); let code_hash = B256::repeat_byte(0x42); let bytecode = Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00])); - let account = AccountInfo { - nonce: 7, - balance: U256::from(42), - code_hash, - code: Some(bytecode.clone()), - ..Default::default() - }; + let account = AccountInfo::new(U256::from(42), 7, code_hash, bytecode.clone()); let db = CountingDatabaseRef::new(address, Some(account), bytecode.clone()); let provider = DatabaseStateProvider::new(db); @@ -342,13 +336,7 @@ mod tests { let address = Address::repeat_byte(0x01); let code_hash = B256::repeat_byte(0x42); let bytecode = Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00])); - let account = AccountInfo { - nonce: 7, - balance: U256::from(42), - code_hash, - code: Some(bytecode.clone()), - ..Default::default() - }; + let account = AccountInfo::new(U256::from(42), 7, code_hash, bytecode.clone()); let db = CountingDatabaseRef::new(address, Some(account), bytecode.clone()); let account_reads = db.account_reads.clone(); let bytecode_reads = db.bytecode_reads.clone(); diff --git a/crates/revm/src/test_utils.rs b/crates/revm/src/test_utils.rs index 5c9fcedb414..920152a2760 100644 --- a/crates/revm/src/test_utils.rs +++ b/crates/revm/src/test_utils.rs @@ -144,8 +144,8 @@ impl StateProofProvider for StateProviderTest { fn multiproof_v2( &self, - _input: TrieInput, _targets: reth_trie::MultiProofTargetsV2, + _requires_masks: bool, ) -> ProviderResult { unimplemented!("proof generation is not supported") } diff --git a/crates/revm/src/witness.rs b/crates/revm/src/witness.rs index 21a6980a333..15c14baecee 100644 --- a/crates/revm/src/witness.rs +++ b/crates/revm/src/witness.rs @@ -227,7 +227,7 @@ mod tests { fn additional_state_is_merged_with_executed_state() { let address = Address::with_last_byte(1); let hashed_address = keccak256(address); - let slot = U256::from(1); + let slot = U256::ONE; let additional_slot = B256::with_last_byte(2); let mut state = State::builder().with_database(EmptyDB::default()).build(); @@ -240,7 +240,7 @@ mod tests { let additional_state = HashedPostState::default().with_storages([( hashed_address, HashedStorage::from_iter([ - (keccak256(B256::from(slot)), U256::from(1)), + (keccak256(B256::from(slot)), U256::ONE), (additional_slot, U256::from(3)), ]), )]); diff --git a/crates/rpc/ipc/src/server/mod.rs b/crates/rpc/ipc/src/server/mod.rs index 4f392432f81..c102ddc94dc 100644 --- a/crates/rpc/ipc/src/server/mod.rs +++ b/crates/rpc/ipc/src/server/mod.rs @@ -393,7 +393,7 @@ where } fn call(&mut self, request: String) -> Self::Future { - trace!("{:?}", request); + trace!(request_bytes = request.len(), "Received IPC request"); let cfg = RpcServiceCfg { bounded_subscriptions: self.inner.bounded_subscriptions.clone(), @@ -485,10 +485,9 @@ fn process_connection( local_socket_stream, } = params; - let ipc = IpcConn(tokio_util::codec::Decoder::framed( - StreamCodec::stream_incoming(), - local_socket_stream, - )); + let codec = + StreamCodec::stream_incoming().with_max_length(server_cfg.max_request_body_size as usize); + let ipc = IpcConn(tokio_util::codec::Decoder::framed(codec, local_socket_stream)); let (tx, rx) = mpsc::channel::>(server_cfg.message_buffer_capacity as usize); let method_sink = MethodSink::new_with_limit(tx, server_cfg.max_response_body_size); diff --git a/crates/rpc/ipc/src/stream_codec.rs b/crates/rpc/ipc/src/stream_codec.rs index 8db8fc19ce0..5ff83934a69 100644 --- a/crates/rpc/ipc/src/stream_codec.rs +++ b/crates/rpc/ipc/src/stream_codec.rs @@ -48,10 +48,13 @@ impl Default for Separator { } /// Stream codec for streaming protocols (ipc, tcp) -#[derive(Debug, Default)] +#[derive(Debug)] pub struct StreamCodec { incoming_separator: Separator, outgoing_separator: Separator, + max_length: usize, + /// The first `max_length + 1` bytes of the message being skipped. + truncated: Option, scan: ScanState, } @@ -63,7 +66,32 @@ impl StreamCodec { /// New custom stream codec pub const fn new(incoming_separator: Separator, outgoing_separator: Separator) -> Self { - Self { incoming_separator, outgoing_separator, scan: ScanState::new() } + Self { + incoming_separator, + outgoing_separator, + max_length: usize::MAX, + truncated: None, + scan: ScanState::new(), + } + } + + /// Truncates messages longer than `max_length` bytes to `max_length + 1` bytes, so the + /// reader can reject them without buffering the whole message. + pub const fn with_max_length(mut self, max_length: usize) -> Self { + self.max_length = max_length; + self + } + + /// Starts skipping the message at the start of `buf`, keeping its first bytes. + fn truncate(&mut self, buf: &mut BytesMut) { + self.truncated = Some(buf.split_to(self.max_length + 1)); + buf.clear(); + } +} + +impl Default for StreamCodec { + fn default() -> Self { + Self::new(Default::default(), Default::default()) } } @@ -99,6 +127,18 @@ const fn is_whitespace(byte: u8) -> bool { matches!(byte, 0x0D | 0x0A | 0x20 | 0x09) } +/// Converts the start of a truncated message, which may end inside a UTF-8 sequence. +/// +/// The reader rejects the message by its length alone, so non-ASCII bytes are replaced with `?` +/// in place, which keeps the length. +fn lossy(truncated: BytesMut) -> String { + String::from_utf8(truncated.into()).unwrap_or_else(|err| { + let mut bytes = err.into_bytes(); + bytes.iter_mut().filter(|b| !b.is_ascii()).for_each(|b| *b = b'?'); + String::from_utf8(bytes).expect("ASCII is valid UTF-8") + }) +} + impl tokio_util::codec::Decoder for StreamCodec { type Item = String; type Error = io::Error; @@ -107,13 +147,21 @@ impl tokio_util::codec::Decoder for StreamCodec { if let Separator::Byte(separator) = self.incoming_separator { if let Some(i) = buf.as_ref().iter().position(|&b| b == separator) { let line = buf.split_to(i); - let _ = buf.split_to(1); + buf.advance(1); + if let Some(truncated) = self.truncated.take() { + return Ok(Some(lossy(truncated))) + } match str::from_utf8(line.as_ref()) { Ok(s) => Ok(Some(s.to_string())), Err(_) => Err(io::Error::other("invalid UTF-8")), } } else { + if self.truncated.is_some() { + buf.clear(); + } else if buf.len() > self.max_length { + self.truncate(buf); + } Ok(None) } } else { @@ -136,6 +184,13 @@ impl tokio_util::codec::Decoder for StreamCodec { } self.scan.is_escaped = byte == b'\\' && !self.scan.is_escaped && self.scan.in_str; + if self.scan.depth == 0 && + let Some(truncated) = self.truncated.take() + { + self.scan = ScanState::new(); + buf.advance(idx + 1); + return Ok(Some(lossy(truncated))) + } if self.scan.depth == 0 && idx != self.scan.start_idx && idx - self.scan.start_idx + 1 > self.scan.whitespaces @@ -148,11 +203,34 @@ impl tokio_util::codec::Decoder for StreamCodec { buf.advance(start); } let bts = buf.split_to(end - start); - return Ok(String::from_utf8(bts.into()).ok()) + // Skip invalid UTF-8 and keep scanning, the rest of the buffer may hold more + // messages. + match String::from_utf8(bts.into()) { + Ok(msg) => return Ok(Some(msg)), + Err(_) => continue, + } } self.scan.cursor += 1; } + + // Keep at most `max_length` bytes buffered between calls. + if self.truncated.is_some() { + buf.clear(); + self.scan.cursor = 0; + } else if buf.len() > self.max_length { + if self.scan.depth <= 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")) + } + // The bytes before an open value are never part of a message. + buf.advance(self.scan.start_idx); + self.scan.cursor -= self.scan.start_idx; + self.scan.start_idx = 0; + if buf.len() > self.max_length { + self.truncate(buf); + self.scan.cursor = 0; + } + } Ok(None) } } @@ -369,61 +447,10 @@ mod tests { assert!(buf.is_empty()); } - /// Escape sequence split across two `decode` calls, `is_escaped` must carry over. - #[test] - fn escape_split_across_chunk_boundary() { - let mut codec = StreamCodec::stream_incoming(); - let mut buf = BytesMut::with_capacity(64); - - buf.put_slice(br#"{"a":"\"#); - assert!(codec.decode(&mut buf).unwrap().is_none()); - - buf.put_slice(br#"\"}"#); - let msg = codec.decode(&mut buf).unwrap().unwrap(); - assert_eq!(msg, r#"{"a":"\\"}"#); - } - - /// Opening and closing bracket arriving in separate `decode` calls. - #[test] - fn depth_split_across_chunk_boundary() { - let mut codec = StreamCodec::stream_incoming(); - let mut buf = BytesMut::with_capacity(64); - - buf.put_u8(b'{'); - assert!(codec.decode(&mut buf).unwrap().is_none()); - buf.put_u8(b'}'); - assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), "{}"); - } - - /// Input that drives the depth negative must not wedge the codec. - #[test] - fn leading_close_bracket_does_not_poison() { - let mut codec = StreamCodec::stream_incoming(); - let mut buf = BytesMut::with_capacity(8); - buf.put_slice(b"]{"); - assert_eq!(codec.decode(&mut buf).unwrap(), Some("]{".to_string())); - } - - /// `Separator::Byte` decoding fed one byte at a time. - #[test] - fn byte_separator_split_at_every_byte() { - let payload = b"first line\nsecond line\nthird line\n"; - let mut codec = StreamCodec::default(); - let mut buf = BytesMut::with_capacity(64); - let mut got = Vec::new(); - - for byte in payload { - buf.put_u8(*byte); - while let Some(m) = codec.decode(&mut buf).unwrap() { - got.push(m); - } - } - - assert_eq!(got, vec!["first line", "second line", "third line"]); - } - /// The stateless `Separator::Empty` decode loop that `ScanState` replaced, kept as the /// reference implementation for the differential tests below. + /// + /// Messages with invalid UTF-8 are skipped. struct ReferenceDecoder; impl Decoder for ReferenceDecoder { @@ -459,7 +486,10 @@ mod tests { buf.advance(start_idx); } let bts = buf.split_to(idx + 1 - start_idx); - return Ok(String::from_utf8(bts.into()).ok()) + return match String::from_utf8(bts.into()) { + Ok(msg) => Ok(Some(msg)), + Err(_) => self.decode(buf), + } } } Ok(None) @@ -639,4 +669,79 @@ mod tests { ); } } + + #[test] + fn max_length() { + let mut codec = StreamCodec::stream_incoming().with_max_length(8); + let mut buf = BytesMut::from(&br#"{"a":1} {"b":"#[..]); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), r#"{"a":1}"#); + assert!(codec.decode(&mut buf).unwrap().is_none()); + buf.put_slice(br#""long","c":["#); + assert!(codec.decode(&mut buf).unwrap().is_none()); + buf.put_slice(br#"]}{"d":2}"#); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), r#"{"b":"lon"#); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), r#"{"d":2}"#); + assert!(buf.is_empty()); + + let mut buf = BytesMut::from(&br#" {"a":"#[..]); + assert!(codec.decode(&mut buf).unwrap().is_none()); + assert_eq!(buf, br#"{"a":"#[..]); + buf.put_slice(b"1}"); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), r#"{"a":1}"#); + + let mut buf = BytesMut::from(&b" "[..]); + assert_eq!(codec.decode(&mut buf).unwrap_err().kind(), io::ErrorKind::InvalidData); + + let mut codec = StreamCodec::stream_incoming().with_max_length(8); + let mut buf = BytesMut::from(&b"}aaaaaaaa"[..]); + assert_eq!(codec.decode(&mut buf).unwrap_err().kind(), io::ErrorKind::InvalidData); + + let mut codec = StreamCodec::default().with_max_length(4); + let mut buf = BytesMut::from(&b"abcd\nabcdef"[..]); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), "abcd"); + assert!(codec.decode(&mut buf).unwrap().is_none()); + buf.put_slice(b"gh\nij\n"); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), "abcde"); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), "ij"); + + let mut buf = BytesMut::from(&b"\xff\xffcd\xe2\x82\xacgh"[..]); + assert!(codec.decode(&mut buf).unwrap().is_none()); + buf.put_slice(b"\n"); + assert_eq!(codec.decode(&mut buf).unwrap().unwrap(), "??cd?"); + } + + /// Random input under random chunk schedules never leaves more than `max_length` bytes + /// buffered between calls, in either framing mode. + #[test] + fn max_length_bounds_buffer() { + const MAX_CHUNK: usize = 16; + + let mut rng = StdRng::seed_from_u64(0x00B0_0B5E_C0DE_C0DE); + let alphabet: &[u8] = b"{}[]\":,\\ \n\t a1\xff"; + + for _ in 0..200 { + let max_length = 1 + rng.next_u32() as usize % 16; + let mut codec = if rng.next_u32() % 2 == 0 { + StreamCodec::stream_incoming() + } else { + StreamCodec::default() + } + .with_max_length(max_length); + let mut buf = BytesMut::new(); + + 'input: for _ in 0..rng.next_u32() % 64 { + for _ in 0..1 + rng.next_u32() as usize % MAX_CHUNK { + buf.put_u8(alphabet[rng.next_u32() as usize % alphabet.len()]); + } + loop { + match codec.decode(&mut buf) { + Ok(Some(msg)) => assert!(msg.len() <= max_length + MAX_CHUNK), + Ok(None) => break, + Err(_) => break 'input, + } + } + assert!(buf.len() <= max_length, "{} bytes buffered", buf.len()); + } + } + } } diff --git a/crates/rpc/rpc-api/src/lib.rs b/crates/rpc/rpc-api/src/lib.rs index a6c82b185dc..63fdc337e22 100644 --- a/crates/rpc/rpc-api/src/lib.rs +++ b/crates/rpc/rpc-api/src/lib.rs @@ -33,7 +33,10 @@ mod validation; mod web3; pub use reth::RethJitAction; -pub use testing::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1, TESTING_COMMIT_BLOCK_V1}; +pub use testing::{ + TestingBuildBlockRequestV1, TestingBuildBlockResponseV1, TESTING_BUILD_BLOCK_V1, + TESTING_COMMIT_BLOCK_V1, +}; /// re-export of all server traits pub use servers::*; diff --git a/crates/rpc/rpc-api/src/testing.rs b/crates/rpc/rpc-api/src/testing.rs index 5172e6c8ee1..c83b4802995 100644 --- a/crates/rpc/rpc-api/src/testing.rs +++ b/crates/rpc/rpc-api/src/testing.rs @@ -6,14 +6,30 @@ //! explicit operator flag. use alloy_primitives::{Bytes, B256}; -use alloy_rpc_types_engine::{ExecutionPayloadEnvelopeV5, PayloadAttributes}; +use alloy_rpc_types_engine::{ + ExecutionPayloadEnvelopeV5, ExecutionPayloadEnvelopeV6, PayloadAttributes, +}; use jsonrpsee::proc_macros::rpc; +use serde::{Deserialize, Serialize}; pub use alloy_rpc_types_engine::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1}; /// Capability string for `testing_commitBlockV1`. pub const TESTING_COMMIT_BLOCK_V1: &str = "testing_commitBlockV1"; +/// Response of `testing_buildBlockV1`. +/// +/// Blocks after Amsterdam carry the block access list and the slot number in the payload. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TestingBuildBlockResponseV1 { + // Try V6 first: V5 accepts unknown fields and would discard the Amsterdam payload fields. + /// Payload envelope of a block after Amsterdam. + V6(ExecutionPayloadEnvelopeV6), + /// Payload envelope of a block before Amsterdam. + V5(ExecutionPayloadEnvelopeV5), +} + /// Testing RPC interface for building a block in a single call. /// /// # Enabling @@ -40,7 +56,7 @@ pub trait TestingApi { payload_attributes: PayloadAttributes, transactions: Option>, extra_data: Option, - ) -> jsonrpsee::core::RpcResult; + ) -> jsonrpsee::core::RpcResult; /// Builds a block on top of the current canonical head, inserts it, and makes it canonical. /// diff --git a/crates/rpc/rpc-builder/src/cors.rs b/crates/rpc/rpc-builder/src/cors.rs index ee0ceb4266d..b0b7abea204 100644 --- a/crates/rpc/rpc-builder/src/cors.rs +++ b/crates/rpc/rpc-builder/src/cors.rs @@ -1,5 +1,9 @@ -use http::{HeaderValue, Method}; -use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use http::{header::ORIGIN, HeaderValue, Method, Request, Response, StatusCode}; +use jsonrpsee::server::{ws, HttpBody}; +use tower_http::{ + cors::{AllowOrigin, Any, CorsLayer}, + validate_request::ValidateRequest, +}; /// Error thrown when parsing cors domains went wrong #[derive(Debug, thiserror::Error)] @@ -21,27 +25,12 @@ pub enum CorsDomainError { /// Creates a [`CorsLayer`] from the given domains pub(crate) fn create_cors_layer(http_cors_domains: &str) -> Result { - let cors = match http_cors_domains.trim() { - "*" => CorsLayer::new() + let cors = match parse_origins(http_cors_domains)? { + None => CorsLayer::new() .allow_methods([Method::GET, Method::POST]) .allow_origin(Any) .allow_headers(Any), - _ => { - let iter = http_cors_domains.split(',').map(str::trim); - if iter.clone().any(|o| o == "*") { - return Err(CorsDomainError::WildCardNotAllowed { - input: http_cors_domains.to_string(), - }) - } - - let origins = iter - .map(|domain| { - domain - .parse::() - .map_err(|_| CorsDomainError::InvalidHeader { domain: domain.to_string() }) - }) - .collect::, _>>()?; - + Some(origins) => { let origin = AllowOrigin::list(origins); CorsLayer::new() .allow_methods([Method::GET, Method::POST]) @@ -52,6 +41,54 @@ pub(crate) fn create_cors_layer(http_cors_domains: &str) -> Result>, +} + +impl WsOriginValidator { + pub(crate) fn new(domains: Option<&str>) -> Result { + Ok(Self { origins: domains.map(parse_origins).transpose()?.flatten() }) + } +} + +impl ValidateRequest for WsOriginValidator { + type ResponseBody = HttpBody; + + fn validate(&mut self, request: &mut Request) -> Result<(), Response> { + if let Some(allowed) = &self.origins && + ws::is_upgrade_request(request) + { + let mut origins = request.headers().get_all(ORIGIN).iter(); + // Originless clients are allowed; an explicit Origin must match exactly once. + if let Some(origin) = origins.next() && + (origins.next().is_some() || !allowed.contains(origin)) + { + let mut response = Response::new(HttpBody::empty()); + *response.status_mut() = StatusCode::FORBIDDEN; + return Err(response) + } + } + Ok(()) + } +} + +fn parse_origins(domains: &str) -> Result>, CorsDomainError> { + if domains.trim() == "*" { + return Ok(None) + } + let iter = domains.split(',').map(str::trim); + if iter.clone().any(|origin| origin == "*") { + return Err(CorsDomainError::WildCardNotAllowed { input: domains.to_string() }) + } + iter.map(|domain| { + domain.parse().map_err(|_| CorsDomainError::InvalidHeader { domain: domain.to_string() }) + }) + .collect::, _>>() + .map(Some) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/rpc/rpc-builder/src/lib.rs b/crates/rpc/rpc-builder/src/lib.rs index d903d60ea26..7275b77e683 100644 --- a/crates/rpc/rpc-builder/src/lib.rs +++ b/crates/rpc/rpc-builder/src/lib.rs @@ -44,10 +44,7 @@ use reth_rpc::{ use reth_rpc_api::servers::*; use reth_rpc_engine_api::RethEngineApi; use reth_rpc_eth_api::{ - helpers::{ - pending_block::PendingEnvBuilder, Call, EthApiSpec, EthTransactions, LoadPendingBlock, - TraceExt, - }, + helpers::{pending_block::PendingEnvBuilder, Call, EthApiSpec, EthTransactions, TraceExt}, node::RpcNodeCoreAdapter, EthApiServer, EthApiTypes, FullEthApiServer, FullEthApiTypes, RpcBlock, RpcConvert, RpcConverter, RpcHeader, RpcNodeCore, RpcReceipt, RpcTransaction, RpcTxReq, @@ -70,7 +67,7 @@ use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tower_http::cors::CorsLayer; +use tower_http::{cors::CorsLayer, validate_request::ValidateRequestHeaderLayer}; pub use cors::CorsDomainError; @@ -299,7 +296,7 @@ impl Provider: Clone, Pool: Clone, Network: Clone, - EvmConfig: ConfigureEvm + Clone, + EvmConfig: ConfigureEvm, RpcNodeCoreAdapter: RpcNodeCore, Evm = EvmConfig>, RpcConverter>: RpcConvert, @@ -319,7 +316,7 @@ where + PersistedBlockSubscriptions + ChangeSetReader + NodePrimitivesProvider, - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, Network: NetworkInfo + Peers + Clone + 'static, EvmConfig: ConfigureEvm + 'static, Consensus: FullConsensus + Clone + 'static, @@ -523,7 +520,7 @@ where + 'static, Pool: Send + Sync + Clone + 'static, Network: Clone + 'static, - EthApi: FullEthApiTypes + 'static, + EthApi: FullEthApiTypes, EvmConfig: ConfigureEvm, { /// Creates a new, empty instance. @@ -626,7 +623,7 @@ where pub fn admin_api(&self) -> AdminApi where Network: Peers, - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, { AdminApi::new(self.network.clone(), self.provider.chain_spec(), self.pool.clone()) } @@ -640,7 +637,7 @@ where pub fn register_admin(&mut self) -> &mut Self where Network: Peers, - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, { let adminapi = self.admin_api(); self.modules.insert(RethRpcModule::Admin, adminapi.into_rpc().into()); @@ -741,7 +738,7 @@ where /// If called outside of the tokio runtime. pub fn register_net(&mut self) -> &mut Self where - EthApi: EthApiSpec + 'static, + EthApi: EthApiSpec, { let netapi = self.net_api(); self.modules.insert(RethRpcModule::Net, netapi.into_rpc().into()); @@ -806,7 +803,7 @@ where /// If called outside of the tokio runtime. See also [`Self::eth_api`] pub fn bundle_api(&self) -> EthBundle where - EthApi: EthTransactions + LoadPendingBlock + Call, + EthApi: EthTransactions + Call, { let eth_api = self.eth_api().clone(); EthBundle::new(eth_api, self.blocking_pool_guard.clone()) @@ -836,7 +833,7 @@ where /// If called outside of the tokio runtime. See also [`Self::eth_api`] pub fn net_api(&self) -> NetApi where - EthApi: EthApiSpec + 'static, + EthApi: EthApiSpec, { let eth_api = self.eth_api().clone(); NetApi::new(self.network.clone(), eth_api) @@ -862,7 +859,7 @@ where + ForkChoiceSubscriptions
+ PersistedBlockSubscriptions + ChangeSetReader, - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, Network: NetworkInfo + Peers + Clone + 'static, EthApi: FullEthApiServer, EvmConfig: ConfigureEvm + 'static, @@ -1367,11 +1364,15 @@ impl RpcServerConfig { /// Returns a [`CompressionLayer`] that adds compression support (gzip, deflate, brotli, zstd) /// based on the client's `Accept-Encoding` header. + /// + /// Servers listening on a loopback address only serve local clients, which gain nothing from + /// compression, so they get no compression layer. fn maybe_compression_layer( + addr: SocketAddr, disable_compression: bool, algos: Option<&[String]>, ) -> Option { - if disable_compression { + if disable_compression || addr.ip().to_canonical().is_loopback() { None } else { match algos { @@ -1467,15 +1468,19 @@ impl RpcServerConfig { let server = ServerBuilder::new() .set_http_middleware( tower::ServiceBuilder::new() - .option_layer(Self::maybe_cors_layer(cors)?) + .option_layer(Self::maybe_cors_layer(cors.clone())?) .option_layer(Self::maybe_jwt_layer(self.jwt_secret)) .option_layer(Self::maybe_decompression_layer( self.http_max_request_body_size, self.http_decompression_algorithms.as_deref(), )) .option_layer(Self::maybe_compression_layer( + http_socket_addr, self.http_disable_compression, self.http_compression_algorithms.as_deref(), + )) + .layer(ValidateRequestHeaderLayer::custom( + cors::WsOriginValidator::new(cors.as_deref())?, )), ) .set_rpc_middleware( @@ -1530,7 +1535,10 @@ impl RpcServerConfig { .set_http_middleware( tower::ServiceBuilder::new() .option_layer(Self::maybe_cors_layer(self.ws_cors_domains.clone())?) - .option_layer(Self::maybe_jwt_layer(self.jwt_secret)), + .option_layer(Self::maybe_jwt_layer(self.jwt_secret)) + .layer(ValidateRequestHeaderLayer::custom(cors::WsOriginValidator::new( + self.ws_cors_domains.as_deref(), + )?)), ) .set_rpc_middleware( RpcServiceBuilder::default() @@ -1565,6 +1573,7 @@ impl RpcServerConfig { self.http_decompression_algorithms.as_deref(), )) .option_layer(Self::maybe_compression_layer( + http_socket_addr, self.http_disable_compression, self.http_compression_algorithms.as_deref(), )), diff --git a/crates/rpc/rpc-builder/tests/it/http.rs b/crates/rpc/rpc-builder/tests/it/http.rs index 3025b61c3f8..5bcedef37d6 100644 --- a/crates/rpc/rpc-builder/tests/it/http.rs +++ b/crates/rpc/rpc-builder/tests/it/http.rs @@ -3,7 +3,7 @@ use crate::utils::{launch_http, launch_http_ws, launch_ws}; use alloy_eips::{eip1898::LenientBlockNumberOrTag, BlockId, BlockNumberOrTag}; -use alloy_primitives::{hex_literal::hex, Address, Bytes, TxHash, B256, B64, U256, U64}; +use alloy_primitives::{bytes, Address, Bytes, TxHash, B256, B64, U256, U64}; use alloy_rpc_types_eth::{ transaction::TransactionRequest, Block, FeeHistory, Filter, Header, Index, Log, PendingTransactionFilterKind, SyncStatus, Transaction, TransactionReceipt, @@ -182,9 +182,7 @@ where let call_request = TransactionRequest::default(); let transaction_request = TransactionRequest::default(); let bytes = Bytes::default(); - let tx = Bytes::from(hex!( - "02f871018303579880850555633d1b82520894eee27662c2b8eba3cd936a23f039f3189633e4c887ad591c62bdaeb180c080a07ea72c68abfb8fca1bd964f0f99132ed9280261bdca3e549546c0205e800f7d0a05b4ef3039e9c9b9babc179a1878fb825b5aaf5aed2fa8744854150157b08d6f3" - )); + let tx = bytes!("02f871018303579880850555633d1b82520894eee27662c2b8eba3cd936a23f039f3189633e4c887ad591c62bdaeb180c080a07ea72c68abfb8fca1bd964f0f99132ed9280261bdca3e549546c0205e800f7d0a05b4ef3039e9c9b9babc179a1878fb825b5aaf5aed2fa8744854150157b08d6f3"); let typed_data = serde_json::from_str( r#"{ "types": { @@ -474,7 +472,7 @@ async fn test_basic_debug_calls(client: &C) where C: ClientT + SubscriptionClientT + Sync, { - let block_id = BlockId::Number(BlockNumberOrTag::default()); + let block_id = BlockId::latest(); DebugApiClient::::raw_header(client, block_id).await.unwrap_err(); DebugApiClient::::raw_block(client, block_id).await.unwrap_err(); @@ -535,16 +533,8 @@ async fn test_basic_trace_calls(client: &C) where C: ClientT + SubscriptionClientT + Sync, { - let block_id = BlockId::Number(BlockNumberOrTag::default()); - let trace_filter = TraceFilter { - from_block: Default::default(), - to_block: Default::default(), - from_address: Default::default(), - to_address: Default::default(), - mode: Default::default(), - after: None, - count: None, - }; + let block_id = BlockId::latest(); + let trace_filter = TraceFilter::default(); TraceApiClient::::trace_raw_transaction( client, @@ -554,13 +544,9 @@ where ) .await .unwrap_err(); - TraceApiClient::::trace_call_many( - client, - vec![], - Some(BlockNumberOrTag::Latest.into()), - ) - .await - .unwrap_err(); + TraceApiClient::::trace_call_many(client, vec![], Some(BlockId::latest())) + .await + .unwrap_err(); assert!(TraceApiClient::::replay_transaction( client, B256::default(), diff --git a/crates/rpc/rpc-builder/tests/it/startup.rs b/crates/rpc/rpc-builder/tests/it/startup.rs index eb84fb648ac..ccbb2c14cf3 100644 --- a/crates/rpc/rpc-builder/tests/it/startup.rs +++ b/crates/rpc/rpc-builder/tests/it/startup.rs @@ -1,6 +1,11 @@ //! Startup tests -use std::io; +use alloy_rpc_types_engine::Claims; +use reth_rpc_layer::JwtSecret; +use std::{ + io, + time::{SystemTime, UNIX_EPOCH}, +}; use reth_rpc_builder::{ error::{RpcError, ServerKind, WsHttpSamePortError}, @@ -134,3 +139,99 @@ async fn test_launch_same_port_different_cors() { RpcError::WsHttpSamePortError(WsHttpSamePortError::ConflictingCorsDomains { .. }) )); } + +#[tokio::test(flavor = "multi_thread")] +async fn test_ws_upgrade_origin_allowlist() { + for with_jwt in [false, true] { + for same_port in [false, true] { + for domains in [None, Some("*"), Some("https://allowed.example")] { + let secret = with_jwt.then(JwtSecret::random); + let token = secret.map(|secret| { + secret + .encode(&Claims { + iat: SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(), + exp: None, + }) + .unwrap() + }); + let builder = test_rpc_builder(); + let eth_api = builder.bootstrap_eth_api(); + let modules = builder.build( + TransportRpcModuleConfig::set_ws(vec![RethRpcModule::Web3]) + .with_http(vec![RethRpcModule::Web3]), + eth_api, + EventSender::new(1), + ); + let addr = test_address(); + let mut config = RpcServerConfig::ws(Default::default()) + .with_ws_address(addr) + .with_cors(domains.map(str::to_string)) + .with_jwt_secret(secret); + if same_port { + config = config.with_http(Default::default()).with_http_address(addr); + } + let handle = config.start(&modules).await.unwrap(); + let url = format!("http://{}", handle.ws_local_addr().unwrap()); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + if with_jwt { + assert_eq!( + client.get(&url).send().await.unwrap().status(), + http::StatusCode::UNAUTHORIZED + ); + } + for origins in [ + vec![], + vec!["https://allowed.example"], + vec!["https://denied.example"], + vec!["null"], + vec!["https://allowed.example", "https://denied.example"], + ] { + let mut request = client + .get(&url) + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="); + if let Some(token) = &token { + request = request.bearer_auth(token); + } + for origin in &origins { + request = request.header("origin", *origin); + } + let response = request.send().await.unwrap(); + let allowed = domains != Some("https://allowed.example") || + origins.is_empty() || + origins == ["https://allowed.example"]; + assert_eq!( + response.status(), + if allowed { + http::StatusCode::SWITCHING_PROTOCOLS + } else { + http::StatusCode::FORBIDDEN + }, + "same_port={same_port}, domains={domains:?}, origins={origins:?}" + ); + } + if same_port { + // Ordinary HTTP still relies on response CORS headers, not request rejection. + let mut request = client + .post(&url) + .header("origin", "https://denied.example") + .header("content-type", "application/json") + .body( + r#"{"jsonrpc":"2.0","id":1,"method":"web3_clientVersion","params":[]}"#, + ); + if let Some(token) = &token { + request = request.bearer_auth(token); + } + let response = request.send().await.unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + } + handle.stop().unwrap(); + } + } + } +} diff --git a/crates/rpc/rpc-convert/src/transaction.rs b/crates/rpc/rpc-convert/src/transaction.rs index ec3efeb3c58..16ae3b98433 100644 --- a/crates/rpc/rpc-convert/src/transaction.rs +++ b/crates/rpc/rpc-convert/src/transaction.rs @@ -707,8 +707,7 @@ where > + Send + Sync + Unpin - + Clone - + Debug, + + Clone, Header: HeaderConverter, RpcHeader>, Map: TxInfoMapper> + Clone + Debug + Unpin + Send + Sync + 'static, SimTx: SimTxConverter, TxTy>, diff --git a/crates/rpc/rpc-e2e-tests/Cargo.toml b/crates/rpc/rpc-e2e-tests/Cargo.toml index 78c04740497..6ddd76cd04c 100644 --- a/crates/rpc/rpc-e2e-tests/Cargo.toml +++ b/crates/rpc/rpc-e2e-tests/Cargo.toml @@ -16,11 +16,7 @@ workspace = true reth-e2e-test-utils.workspace = true reth-rpc-api = { workspace = true, features = ["client"] } -# ethereum -alloy-rpc-types-engine.workspace = true - # async -tokio.workspace = true futures-util.workspace = true # misc @@ -37,6 +33,7 @@ reth-tracing.workspace = true reth-chainspec.workspace = true reth-node-ethereum.workspace = true alloy-genesis.workspace = true +tokio.workspace = true [[test]] name = "e2e_testsuite" diff --git a/crates/rpc/rpc-e2e-tests/README.md b/crates/rpc/rpc-e2e-tests/README.md index 03d6081cb2d..36ca9771bc1 100644 --- a/crates/rpc/rpc-e2e-tests/README.md +++ b/crates/rpc/rpc-e2e-tests/README.md @@ -15,7 +15,7 @@ The RPC compatibility testing framework enables: ### Key Components 1. **`RunRpcCompatTests` Action**: Executes RPC test cases from .io files -2. **`InitializeFromExecutionApis` Action**: Applies forkchoice state from JSON files with automatic retry for syncing nodes +2. **`InitializeFromExecutionApis` Action**: Applies forkchoice state from JSON files and requires it to be VALID on every node. The import setup waits until the nodes accepted the imported head, so they are no longer syncing at this point 3. **Test Data Format**: Uses execution-apis .io file format for test cases ### Test Data Structure diff --git a/crates/rpc/rpc-e2e-tests/src/rpc_compat.rs b/crates/rpc/rpc-e2e-tests/src/rpc_compat.rs index d6b48e3f4fb..c39987cba5d 100644 --- a/crates/rpc/rpc-e2e-tests/src/rpc_compat.rs +++ b/crates/rpc/rpc-e2e-tests/src/rpc_compat.rs @@ -3,7 +3,10 @@ use eyre::{eyre, Result}; use futures_util::future::BoxFuture; use jsonrpsee::core::client::ClientT; -use reth_e2e_test_utils::testsuite::{actions::Action, BlockInfo, Environment}; +use reth_e2e_test_utils::testsuite::{ + actions::{expect_fcu_valid, Action}, + BlockInfo, Environment, +}; use reth_node_api::EngineTypes; use serde_json::Value; use std::path::Path; @@ -377,52 +380,20 @@ where fcu_state.finalized_block_hash ); - // Apply forkchoice update to each node + // Apply forkchoice update to each node. The import setup only returns once the nodes + // accepted the imported head, so the update does not need to wait for syncing. for (idx, client) in env.node_clients.iter().enumerate() { debug!("Applying forkchoice update to node {}", idx); - // Wait for the node to finish syncing imported blocks - let mut retries = 0; - const MAX_RETRIES: u32 = 10; - const RETRY_DELAY_MS: u64 = 500; - - loop { - let response = - reth_rpc_api::clients::EngineApiClient::::fork_choice_updated_v3( - &client.engine.http_client(), - fcu_state, - None, - ) - .await - .map_err(|e| eyre!("Failed to update forkchoice on node {}: {}", idx, e))?; - - match response.payload_status.status { - alloy_rpc_types_engine::PayloadStatusEnum::Valid => { - debug!("Forkchoice update successful on node {}", idx); - break; - } - alloy_rpc_types_engine::PayloadStatusEnum::Syncing => { - if retries >= MAX_RETRIES { - return Err(eyre!( - "Node {} still syncing after {} retries", - idx, - MAX_RETRIES - )); - } - debug!("Node {} is syncing, retrying in {}ms...", idx, RETRY_DELAY_MS); - tokio::time::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS)) - .await; - retries += 1; - } - _ => { - return Err(eyre!( - "Invalid forkchoice state on node {}: {:?}", - idx, - response.payload_status - )); - } - } - } + let response = + reth_rpc_api::clients::EngineApiClient::::fork_choice_updated_v3( + &client.engine.http_client(), + fcu_state, + None, + ) + .await + .map_err(|e| eyre!("Failed to update forkchoice on node {}: {}", idx, e))?; + expect_fcu_valid(&response, &format!("Forkchoice update on node {idx}"))?; } // Update environment state diff --git a/crates/rpc/rpc-engine-api/src/engine_api.rs b/crates/rpc/rpc-engine-api/src/engine_api.rs index 161bd58f155..42b6f8953ec 100644 --- a/crates/rpc/rpc-engine-api/src/engine_api.rs +++ b/crates/rpc/rpc-engine-api/src/engine_api.rs @@ -29,7 +29,7 @@ use reth_payload_primitives::{ }; use reth_primitives_traits::{AlloyBlockHeader, Block, BlockBody}; use reth_rpc_api::{EngineApiServer, IntoEngineApiRpcModule}; -use reth_storage_api::{BalProvider, BlockReader, HeaderProvider, StateProviderFactory}; +use reth_storage_api::{BalProvider, BlockReader, StateProviderFactory}; use reth_tasks::Runtime; use reth_transaction_pool::{BestTransactions, TransactionPool}; use std::{ @@ -84,7 +84,7 @@ impl impl EngineApi where - Provider: HeaderProvider + BlockReader + StateProviderFactory + BalProvider + 'static, + Provider: BlockReader + StateProviderFactory + BalProvider + 'static, PayloadT: PayloadTypes, Pool: TransactionPool + 'static, Validator: EngineApiValidator, @@ -333,7 +333,7 @@ where impl EngineApi where - Provider: HeaderProvider + BlockReader + StateProviderFactory + BalProvider + 'static, + Provider: BlockReader + StateProviderFactory + BalProvider + 'static, EngineT: EngineTypes, Pool: TransactionPool + 'static, Validator: EngineApiValidator, @@ -1335,7 +1335,7 @@ where impl EngineApiServer for EngineApi where - Provider: HeaderProvider + BlockReader + StateProviderFactory + BalProvider + 'static, + Provider: BlockReader + StateProviderFactory + BalProvider + 'static, EngineT: EngineTypes, Pool: TransactionPool + 'static, Validator: EngineApiValidator, @@ -1953,14 +1953,6 @@ mod tests { assert_eq!(res.unwrap(), vec![client]); } - #[tokio::test] - async fn get_inclusion_list_v1_returns_empty_list() { - let (_, api) = setup_engine_api(); - - let res = EngineApiServer::get_inclusion_list_v1(&api).await.unwrap(); - assert!(res.is_empty()); - } - #[tokio::test] async fn get_inclusion_list_v1_stops_at_size_limit() { let pool = eth_test_pool(); @@ -2430,15 +2422,6 @@ mod tests { assert_matches!(res, Ok(None)); } - #[test] - fn engine_bitvector_uses_little_endian_cell_indices() { - for index in [0, 7, 8, 63, 64, 127] { - let wire_mask = B128::from((1u128 << index).to_le_bytes()); - let mask = BlobCellMask::from_bits(u128::from_le_bytes(wire_mask.into())); - assert_eq!(mask.selected_indices().collect::>(), vec![index]); - } - } - #[tokio::test] async fn fcu_v4_updates_shared_cell_custody_before_forkchoice_result() { let chain_spec: Arc = @@ -2469,7 +2452,7 @@ mod tests { ); let state = ForkchoiceState { - head_block_hash: B256::from([0x33; 32]), + head_block_hash: B256::repeat_byte(0x33), safe_block_hash: B256::ZERO, finalized_block_hash: B256::ZERO, }; @@ -2536,7 +2519,7 @@ mod tests { ); let state = ForkchoiceState { - head_block_hash: B256::from([0x44; 32]), + head_block_hash: B256::repeat_byte(0x44), safe_block_hash: B256::ZERO, finalized_block_hash: B256::ZERO, }; @@ -2592,7 +2575,7 @@ mod tests { let (mut handle, api) = setup_engine_api(); let state = ForkchoiceState { - head_block_hash: B256::from([0x11; 32]), + head_block_hash: B256::repeat_byte(0x11), safe_block_hash: B256::ZERO, finalized_block_hash: B256::ZERO, }; @@ -2642,7 +2625,7 @@ mod tests { let (mut handle, api) = setup_engine_api(); let state = ForkchoiceState { - head_block_hash: B256::from([0x22; 32]), + head_block_hash: B256::repeat_byte(0x22), safe_block_hash: B256::ZERO, finalized_block_hash: B256::ZERO, }; diff --git a/crates/rpc/rpc-engine-api/src/error.rs b/crates/rpc/rpc-engine-api/src/error.rs index 7c7dbb3b1ea..de243ba1455 100644 --- a/crates/rpc/rpc-engine-api/src/error.rs +++ b/crates/rpc/rpc-engine-api/src/error.rs @@ -214,7 +214,18 @@ impl From for jsonrpsee_types::error::ErrorObject<'static> { ); } }, + // Only attributes that the payload builder rejects are invalid payload attributes. + // Any other failure to start the payload job is a failure of this node and must + // not be reported as invalid attributes, which the consensus client would discard. + BeaconForkChoiceUpdateError::PayloadJob( + PayloadBuilderError::InvalidAttributes(_), + ) => jsonrpsee_types::error::ErrorObject::owned( + INVALID_PAYLOAD_ATTRIBUTES_ERROR, + INVALID_PAYLOAD_ATTRIBUTES_ERROR_MSG, + Some(ErrorData::new(error)), + ), BeaconForkChoiceUpdateError::EngineUnavailable | + BeaconForkChoiceUpdateError::PayloadJob(_) | BeaconForkChoiceUpdateError::Internal(_) => { jsonrpsee_types::error::ErrorObject::owned( INTERNAL_ERROR_CODE, @@ -241,6 +252,7 @@ impl From for jsonrpsee_types::error::ErrorObject<'static> { mod tests { use super::*; use alloy_rpc_types_engine::ForkchoiceUpdateError; + use reth_payload_primitives::InvalidPayloadAttributesError; #[track_caller] fn ensure_engine_rpc_error( code: i32, @@ -363,4 +375,42 @@ mod tests { ), ); } + + fn payload_job_error(err: PayloadBuilderError) -> jsonrpsee_types::error::ErrorObject<'static> { + EngineApiError::ForkChoiceUpdate(BeaconForkChoiceUpdateError::PayloadJob(err)).into() + } + + #[test] + fn payload_job_rejecting_the_attributes_is_invalid_payload_attributes() { + let err = payload_job_error(PayloadBuilderError::invalid_attributes( + InvalidPayloadAttributesError::InvalidTimestamp, + )); + + assert_eq!(err.code(), INVALID_PAYLOAD_ATTRIBUTES_ERROR); + assert_eq!(err.message(), INVALID_PAYLOAD_ATTRIBUTES_ERROR_MSG); + assert_eq!( + err.data().unwrap().get(), + r#"{"err":"failed to start payload job: invalid payload attributes: invalid timestamp"}"# + ); + } + + #[test] + fn payload_job_failing_on_this_node_is_an_internal_error() { + let node_local = [ + PayloadBuilderError::MissingParentHeader(B256::ZERO), + PayloadBuilderError::ChannelClosed, + PayloadBuilderError::other(std::io::Error::other("other")), + ]; + + for payload_job in node_local { + let message = payload_job.to_string(); + let err = payload_job_error(payload_job); + assert_eq!(err.code(), INTERNAL_ERROR_CODE, "{message}"); + assert_eq!(err.message(), SERVER_ERROR_MSG, "{message}"); + assert_eq!( + err.data().unwrap().get(), + format!(r#"{{"err":"failed to start payload job: {message}"}}"#) + ); + } + } } diff --git a/crates/rpc/rpc-eth-api/Cargo.toml b/crates/rpc/rpc-eth-api/Cargo.toml index 1a10bd76071..e1b0384e73b 100644 --- a/crates/rpc/rpc-eth-api/Cargo.toml +++ b/crates/rpc/rpc-eth-api/Cargo.toml @@ -65,3 +65,9 @@ tracing.workspace = true [features] js-tracer = ["revm-inspectors/js-tracer", "reth-rpc-eth-types/js-tracer"] client = ["jsonrpsee/client", "jsonrpsee/async-client"] +account-ext = [ + "alloy-rpc-types-eth/account-ext", + "reth-primitives-traits/account-ext", + "reth-revm/account-ext", + "revm/account-ext", +] diff --git a/crates/rpc/rpc-eth-api/src/core.rs b/crates/rpc/rpc-eth-api/src/core.rs index 78a6c90fcb7..97dd8676aaa 100644 --- a/crates/rpc/rpc-eth-api/src/core.rs +++ b/crates/rpc/rpc-eth-api/src/core.rs @@ -35,7 +35,6 @@ pub trait FullEthApiServer: RpcHeader, TxTy, > + FullEthApi - + Clone { } @@ -48,7 +47,6 @@ impl FullEthApiServer for T where RpcHeader, TxTy, > + FullEthApi - + Clone { } diff --git a/crates/rpc/rpc-eth-api/src/helpers/bal.rs b/crates/rpc/rpc-eth-api/src/helpers/bal.rs index ef768f77424..1ec35985d51 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/bal.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/bal.rs @@ -11,12 +11,12 @@ use reth_storage_api::{StateProvider, StateProviderFactory}; use std::sync::Arc; use crate::{ - helpers::{Call, LoadBlock, Trace}, - RpcNodeCore, RpcNodeCoreExt, + helpers::{LoadBlock, Trace}, + RpcNodeCore, }; /// Helper trait for `eth_blockAccessList` RPC method. -pub trait GetBlockAccessList: Trace + Call + LoadBlock + RpcNodeCoreExt { +pub trait GetBlockAccessList: Trace + LoadBlock { /// Retrieves the block access list for a block identified by its hash. fn get_block_access_list( &self, diff --git a/crates/rpc/rpc-eth-api/src/helpers/blocking_task.rs b/crates/rpc/rpc-eth-api/src/helpers/blocking_task.rs index f6918948cf0..10b46760549 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/blocking_task.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/blocking_task.rs @@ -5,7 +5,7 @@ use futures::Future; use reth_rpc_eth_types::EthApiError; use reth_tasks::{ pool::{BlockingTaskGuard, BlockingTaskPool}, - Runtime, + CancelOnDrop, Runtime, }; use std::sync::Arc; use tokio::sync::{oneshot, AcquireError, OwnedSemaphorePermit, Semaphore}; @@ -23,7 +23,7 @@ use crate::EthApiTypes; /// This provides access to semaphores that permit how many of those are permitted concurrently. /// It's expected that tracing related tasks are configured with a lower threshold, because not only /// are they CPU heavy but they can also accumulate more memory for the traces. -pub trait SpawnBlocking: EthApiTypes + Clone + Send + Sync + 'static { +pub trait SpawnBlocking: EthApiTypes + 'static { /// Returns a handle for spawning IO heavy blocking tasks. /// /// Runtime access in default trait method implementations. @@ -156,6 +156,9 @@ pub trait SpawnBlocking: EthApiTypes + Clone + Send + Sync + 'static { /// /// Note: This is expected for futures that are dominated by blocking IO operations, for tracing /// or CPU bound operations in general use [`spawn_tracing`](Self::spawn_tracing). + /// + /// The task keeps running when the returned future is dropped, long running work in `f` + /// should check [`is_cancelled`](reth_tasks::cancel::is_cancelled) to stop early. fn spawn_blocking_io(&self, f: F) -> impl Future> + Send where F: FnOnce(Self) -> Result + Send + 'static, @@ -163,15 +166,21 @@ pub trait SpawnBlocking: EthApiTypes + Clone + Send + Sync + 'static { { let (tx, rx) = oneshot::channel(); let this = self.clone(); + let cancel = CancelOnDrop::default(); + let request = cancel.clone(); self.io_task_spawner().spawn_blocking_task(async move { if tx.is_closed() { return } - let res = f(this); + let res = cancel.scope(|| f(this)); let _ = tx.send(res); }); - async move { rx.await.map_err(|_| EthApiError::InternalEthError)? } + async move { + // cancels the blocking task once the request is dropped + let _request = request; + rx.await.map_err(|_| EthApiError::InternalEthError)? + } } /// Executes the future on a new blocking task. diff --git a/crates/rpc/rpc-eth-api/src/helpers/call.rs b/crates/rpc/rpc-eth-api/src/helpers/call.rs index ce47b596fd0..fb925499f56 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/call.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/call.rs @@ -41,7 +41,7 @@ use reth_rpc_eth_types::{ EthApiError, StateCacheDb, }; use reth_storage_api::{BlockIdReader, ProviderTx, StateProvider}; -use reth_tasks::CancelOnDrop; +use reth_tasks::{cancel::is_cancelled, CancelOnDrop}; use revm::{ context::Block, context_interface::{result::ResultAndState, Cfg, Transaction}, @@ -56,7 +56,7 @@ pub type SimulatedBlocksResult = Result>>, /// Execution related functions for the [`EthApiServer`](crate::EthApiServer) trait in /// the `eth_` namespace. -pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthApiTypes { +pub trait EthCall: EstimateCall + LoadBlock + FullEthApiTypes { /// Estimate gas needed for execution of the `request` at the [`BlockId`]. fn estimate_gas_at( &self, @@ -105,8 +105,11 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA .ok_or_else(|| EthApiError::other(EthSimulateError::BlockNotFound { block }))?; let parent = base_block.sealed_header().clone(); let max_simulate_blocks = self.max_simulate_blocks(); + // Load the state of the block resolved above: resolving a tag such as `latest` again + // could return a newer block than `parent`. + let state_at = if block.is_pending() { block } else { parent.hash().into() }; - self.spawn_with_state_at_block(block, move |this, db| { + self.spawn_with_state_at_block(state_at, move |this, db| { let _permit = permit; let state_provider = db.database.into_inner(); let mut db = State::builder() @@ -152,8 +155,9 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA evm_env.cfg_env.disable_eip3607 = true; // EIP-7825's transaction gas cap is only active with Amsterdam's - // regular/state-gas accounting. - if !evm_env.cfg_env.is_amsterdam_eip8037_enabled() { + // regular/state-gas accounting, and only in validation mode. Without + // validation the whole gas limit is execution gas. + if !validation || !evm_env.cfg_env.is_amsterdam_eip8037_enabled() { evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); } @@ -398,6 +402,10 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA // transact all transactions in the bundle for (tx_index, tx) in transactions.into_iter().enumerate() { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } + // Apply overrides, state overrides are only applied for the first tx in the // request let overrides = @@ -760,6 +768,9 @@ pub trait Call: if block_tx.tx_hash() == tx.tx_hash() { break; } + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } executor.execute_transaction(block_tx).map_err(Self::Error::from_eth_err)?; } @@ -799,6 +810,9 @@ pub trait Call: // reached the target transaction break } + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } let tx_env = self.evm_config().tx_env(tx); evm.transact_commit(tx_env).map_err(Self::Error::from_evm_err)?; @@ -881,8 +895,12 @@ pub trait Call: // evm_env.cfg_env.disable_base_fee = true; - // Disable EIP-7825 transaction gas limit to support larger transactions - evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); + // Disable EIP-7825 transaction gas limit to support larger transactions. Under Amsterdam's + // EIP-8037 it only caps execution gas, the rest going to the state gas reservoir, so keep + // it to execute the call like a transaction. + if !evm_env.cfg_env.is_amsterdam_eip8037_enabled() { + evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); + } // Disable additional fee charges, e.g. opstack operator fee charge // See: @@ -914,7 +932,11 @@ pub trait Call: if tx_env.gas_price() > 0 { // If gas price is specified, cap transaction gas limit with caller allowance trace!(target: "rpc::eth::call", ?tx_env, "Applying gas limit cap with caller allowance"); - let cap = self.caller_gas_allowance(db, &evm_env, &tx_env)?; + let mut cap = self.caller_gas_allowance(db, &evm_env, &tx_env)?; + // The allowance must not raise the already applied RPC gas cap. + if self.call_gas_limit() != 0 { + cap = cap.min(tx_env.gas_limit()); + } // ensure we cap gas_limit to the block's tx_env.set_gas_limit(cap.min(evm_env.block_env.gas_limit())); } diff --git a/crates/rpc/rpc-eth-api/src/helpers/config.rs b/crates/rpc/rpc-eth-api/src/helpers/config.rs index 18c5c65f315..0f14518faa8 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/config.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/config.rs @@ -6,7 +6,7 @@ use alloy_eips::{ eip7910::{EthConfig, EthForkConfig, SystemContract}, }; use alloy_evm::precompiles::Precompile; -use alloy_primitives::{address, Address}; +use alloy_primitives::Address; use jsonrpsee::{core::RpcResult, proc_macros::rpc}; use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks, Hardforks, Head}; use reth_errors::{ProviderError, RethError}; @@ -68,7 +68,7 @@ where } if chain_spec.is_amsterdam_active_at_timestamp(timestamp) { - system_contracts.extend(amsterdam_system_contracts()); + system_contracts.extend(SystemContract::amsterdam()); } // Fork config only exists for timestamp-based hardforks. @@ -173,31 +173,6 @@ where } } -/// Address of the builder deposit system contract introduced in Amsterdam -/// ([EIP-8282](https://eips.ethereum.org/EIPS/eip-8282)). -const BUILDER_DEPOSIT_CONTRACT_ADDRESS: Address = - address!("0x0000BFF46984E3725691FA540A8C7589300D8282"); - -/// Address of the builder exit system contract introduced in Amsterdam -/// ([EIP-8282](https://eips.ethereum.org/EIPS/eip-8282)). -const BUILDER_EXIT_CONTRACT_ADDRESS: Address = - address!("0x000064D678505AD48F8CCB093BC65613800E8282"); - -/// Returns the system contracts introduced in the Amsterdam hardfork -/// ([EIP-8282](https://eips.ethereum.org/EIPS/eip-8282) builder execution requests). -fn amsterdam_system_contracts() -> [(SystemContract, Address); 2] { - [ - ( - SystemContract::Other("BUILDER_DEPOSIT_CONTRACT_ADDRESS".to_string()), - BUILDER_DEPOSIT_CONTRACT_ADDRESS, - ), - ( - SystemContract::Other("BUILDER_EXIT_CONTRACT_ADDRESS".to_string()), - BUILDER_EXIT_CONTRACT_ADDRESS, - ), - ] -} - fn evm_to_precompiles_map( evm: impl Evm, ) -> BTreeMap { diff --git a/crates/rpc/rpc-eth-api/src/helpers/estimate.rs b/crates/rpc/rpc-eth-api/src/helpers/estimate.rs index 7f4fe4f5b71..21113e85094 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/estimate.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/estimate.rs @@ -27,6 +27,7 @@ use reth_rpc_eth_types::{ }; use reth_rpc_server_types::constants::gas_oracle::{CALL_STIPEND_GAS, ESTIMATE_GAS_ERROR_RATIO}; use reth_storage_api::{EvmStateProvider, StateProvider}; +use reth_tasks::cancel::is_cancelled; use revm::{ context::Block, context_interface::{result::ExecutionResult, Cfg, Transaction}, @@ -266,6 +267,10 @@ pub trait EstimateCall: Call { break }; + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } + let mut mid_tx_env = tx_env.clone(); mid_tx_env.set_gas_limit(mid_gas_limit); diff --git a/crates/rpc/rpc-eth-api/src/helpers/fee.rs b/crates/rpc/rpc-eth-api/src/helpers/fee.rs index e000f7abfa3..ecbb13fab09 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/fee.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/fee.rs @@ -3,7 +3,7 @@ use super::LoadBlock; use crate::FromEthApiError; use alloy_consensus::BlockHeader; -use alloy_eips::eip7840::BlobParams; +use alloy_eips::{eip7840::BlobParams, BlockId}; use alloy_primitives::U256; use alloy_rpc_types_eth::{BlockNumberOrTag, FeeHistory}; use futures::{Future, StreamExt}; @@ -346,7 +346,7 @@ where .provider() .latest_header() .map_err(Self::Error::from_eth_err)? - .ok_or(EthApiError::HeaderNotFound(BlockNumberOrTag::Latest.into()))?; + .ok_or(EthApiError::HeaderNotFound(BlockId::latest()))?; let pending_base_fee = self .provider() .chain_spec() @@ -414,7 +414,7 @@ where .provider() .latest_header() .map_err(Self::Error::from_eth_err)? - .ok_or(EthApiError::HeaderNotFound(BlockNumberOrTag::Latest.into()))?; + .ok_or(EthApiError::HeaderNotFound(BlockId::latest()))?; Ok(self .provider() .chain_spec() diff --git a/crates/rpc/rpc-eth-api/src/helpers/mod.rs b/crates/rpc/rpc-eth-api/src/helpers/mod.rs index 7641715535e..8ccc51d46e3 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/mod.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/mod.rs @@ -44,44 +44,35 @@ pub use subscriptions::EthSubscriptions; pub use trace::Trace; pub use transaction::{EthTransactions, LoadTransaction}; -use crate::FullEthApiTypes; - /// Extension trait that bundles traits needed for tracing transactions. -pub trait TraceExt: - LoadTransaction + LoadBlock + SpawnBlocking + Trace + Call + GetBlockAccessList -{ -} +pub trait TraceExt: LoadTransaction + GetBlockAccessList {} -impl TraceExt for T where T: LoadTransaction + LoadBlock + Trace + Call + GetBlockAccessList {} +impl TraceExt for T where T: LoadTransaction + GetBlockAccessList {} /// Helper trait to unify all `eth` rpc server building block traits, for simplicity. /// /// This trait is automatically implemented for any type that implements all the `Eth` traits. pub trait FullEthApi: - FullEthApiTypes - + EthApiSpec + EthApiSpec + EthTransactions + EthBlocks + EthState + EthCall + EthFees + EthSubscriptions - + Trace + LoadReceipt + GetBlockAccessList { } impl FullEthApi for T where - T: FullEthApiTypes - + EthApiSpec + T: EthApiSpec + EthTransactions + EthBlocks + EthState + EthCall + EthFees + EthSubscriptions - + Trace + LoadReceipt + GetBlockAccessList { diff --git a/crates/rpc/rpc-eth-api/src/helpers/pending_block.rs b/crates/rpc/rpc-eth-api/src/helpers/pending_block.rs index 7b85eecb9ce..0efe5829cba 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/pending_block.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/pending_block.rs @@ -4,9 +4,9 @@ use super::SpawnBlocking; use crate::{EthApiTypes, FromEthApiError, FromEvmError, RpcNodeCore}; use alloy_consensus::{BlockHeader, Transaction}; -use alloy_eips::eip7840::BlobParams; +use alloy_eips::{eip7840::BlobParams, BlockId}; use alloy_primitives::{B256, U256}; -use alloy_rpc_types_eth::{BlockNumberOrTag, BlockOverrides}; +use alloy_rpc_types_eth::BlockOverrides; use futures::Future; use reth_chain_state::ExecutedBlock; use reth_chainspec::{ChainSpecProvider, EthChainSpec, EthereumHardforks}; @@ -33,7 +33,7 @@ use reth_transaction_pool::{ error::InvalidPoolTransactionError, BestTransactions, BestTransactionsAttributes, PoolTransaction, TransactionPool, }; -use reth_trie_common::ComputedTrieData; +use reth_trie_common::SortedTrieData; use revm::context_interface::{Block, Cfg as _}; use std::{ sync::Arc, @@ -95,7 +95,7 @@ pub trait LoadPendingBlock: .provider() .latest_header() .map_err(Self::Error::from_eth_err)? - .ok_or(EthApiError::HeaderNotFound(BlockNumberOrTag::Latest.into()))?; + .ok_or(EthApiError::HeaderNotFound(BlockId::latest()))?; let evm_env = self .evm_config() @@ -469,7 +469,7 @@ pub trait LoadPendingBlock: Ok(ExecutedBlock::new( block.into(), Arc::new(execution_outcome), - ComputedTrieData::new( + SortedTrieData::new( Arc::new(hashed_state.into_sorted()), Arc::new(trie_updates.into_sorted()), ), diff --git a/crates/rpc/rpc-eth-api/src/helpers/receipt.rs b/crates/rpc/rpc-eth-api/src/helpers/receipt.rs index 2e9d6f3c896..7937d17f741 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/receipt.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/receipt.rs @@ -17,7 +17,7 @@ use reth_storage_api::{ProviderBlock, ProviderReceipt, ProviderTx}; /// /// Behaviour shared by several `eth_` RPC methods, not exclusive to `eth_` receipts RPC methods. pub trait LoadReceipt: - EthApiTypes> + RpcNodeCoreExt + Send + Sync + EthApiTypes> + RpcNodeCoreExt { /// Helper method for `eth_getBlockReceipts` and `eth_getTransactionReceipt`. /// diff --git a/crates/rpc/rpc-eth-api/src/helpers/state.rs b/crates/rpc/rpc-eth-api/src/helpers/state.rs index ecf933eacac..72c048a0b8c 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/state.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/state.rs @@ -97,12 +97,11 @@ pub trait EthState: LoadState + SpawnBlocking { block_id: Option, ) -> impl Future> + Send { self.spawn_blocking_io_with_state(block_id.unwrap_or_default(), move |_, state| { - Ok(B256::new( + Ok(B256::from( state .storage(address, index.as_b256()) .map_err(Self::Error::from_eth_err)? - .unwrap_or_default() - .to_be_bytes(), + .unwrap_or_default(), )) }) } @@ -140,7 +139,7 @@ pub trait EthState: LoadState + SpawnBlocking { .storage(address, slot.as_b256()) .map_err(Self::Error::from_eth_err)? .unwrap_or_default(); - values.push(B256::new(value.to_be_bytes())); + values.push(B256::from(value)); } result.insert(address, values); } @@ -216,16 +215,17 @@ pub trait EthState: LoadState + SpawnBlocking { for (address, slots) in &targets { let hashed_address = keccak256(address); proof_targets.account_targets.push(ProofV2Target::new(hashed_address)); - proof_targets - .storage_targets - .entry(hashed_address) - .or_default() - .extend(slots.iter().map(|slot| ProofV2Target::new(keccak256(slot)))); + if !slots.is_empty() { + proof_targets + .storage_targets + .entry(hashed_address) + .or_default() + .extend(slots.iter().map(|slot| ProofV2Target::new(keccak256(slot)))); + } } - let multiproof = state - .multiproof_v2(Default::default(), proof_targets) - .map_err(Self::Error::from_eth_err)?; + let multiproof = + state.multiproof_v2(proof_targets, false).map_err(Self::Error::from_eth_err)?; targets .into_iter() @@ -260,23 +260,20 @@ pub trait EthState: LoadState + SpawnBlocking { let account = state.basic_account(&address).map_err(Self::Error::from_eth_err)?; let Some(account) = account else { return Ok(None) }; - let balance = account.balance; - let nonce = account.nonce; - let code_hash = account.bytecode_hash.unwrap_or(KECCAK_EMPTY); - // Provide a default `HashedStorage` value in order to // get the storage root hash of the current state. let storage_root = state .storage_root(address, Default::default()) .map_err(Self::Error::from_eth_err)?; - Ok(Some(Account { balance, nonce, code_hash, storage_root })) + Ok(Some(account.into_trie_account(storage_root))) }) .await } } /// Retrieves the account's balance, nonce, and code for a given address. + #[allow(clippy::needless_update)] fn get_account_info( &self, address: Address, @@ -300,7 +297,14 @@ pub trait EthState: LoadState + SpawnBlocking { .original_bytes() }; - Ok(AccountInfo { balance, nonce, code }) + Ok(AccountInfo { + balance, + nonce, + code, + #[cfg(feature = "account-ext")] + extension: account.extension, + ..Default::default() + }) }) } } @@ -474,7 +478,7 @@ pub trait LoadState: >, > + Send where - Self: SpawnBlocking + LoadBlock, + Self: LoadBlock, { async move { if at.is_pending() { diff --git a/crates/rpc/rpc-eth-api/src/helpers/trace.rs b/crates/rpc/rpc-eth-api/src/helpers/trace.rs index d51921713e5..69cd0b7f891 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/trace.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/trace.rs @@ -13,8 +13,12 @@ use reth_evm::{ EvmFor, HaltReasonFor, InspectorFor, IntoTxEnv, TxEnvFor, }; use reth_primitives_traits::{BlockBody, BlockTy, Recovered, RecoveredBlock}; -use reth_rpc_eth_types::cache::db::{attach_bal_before_tx, StateCacheDb}; +use reth_rpc_eth_types::{ + cache::db::{attach_bal_before_tx, StateCacheDb}, + EthApiError, +}; use reth_storage_api::{ProviderBlock, ProviderTx}; +use reth_tasks::cancel::is_cancelled; use revm::{context::Block, context_interface::result::ResultAndState, state::bal::Bal as RevmBal}; use revm_inspectors::tracing::{TracingInspector, TracingInspectorConfig}; use std::sync::Arc; @@ -318,6 +322,9 @@ pub trait Trace: LoadState> + Call { .evm_factory() .create_tracer(&mut db, evm_env, inspector_setup()) .try_trace_many(block.transactions_recovered().take(max_transactions), |ctx| { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } let tx_info = TransactionInfo { hash: Some(*ctx.tx.tx_hash()), index: Some(idx), diff --git a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs index 2a2ed08ee89..ab5eafa12f7 100644 --- a/crates/rpc/rpc-eth-api/src/helpers/transaction.rs +++ b/crates/rpc/rpc-eth-api/src/helpers/transaction.rs @@ -8,7 +8,7 @@ use crate::{ RpcTransaction, }; use alloy_consensus::{ - transaction::{SignerRecoverable, TransactionMeta, TxHashRef}, + transaction::{TransactionMeta, TxHashRef}, BlockHeader, Transaction, }; use alloy_dyn_abi::TypedData; @@ -139,7 +139,7 @@ pub trait EthTransactions: LoadTransaction { timeout_ms: Option, ) -> impl Future, Self::Error>> + Send where - Self: LoadReceipt + 'static, + Self: LoadReceipt, { let this = self.clone(); let configured_timeout = self.send_raw_transaction_sync_timeout(); @@ -282,7 +282,7 @@ pub trait EthTransactions: LoadTransaction { hash: B256, ) -> impl Future>, Self::Error>> + Send where - Self: LoadReceipt + 'static, + Self: LoadReceipt, { async move { match self.load_transaction_and_receipt(hash).await? { @@ -354,7 +354,7 @@ pub trait EthTransactions: LoadTransaction { return Ok(None); }; - let tx = tx.try_into_recovered_unchecked().map_err(Self::Error::from_eth_err)?; + let tx = this.recover_mined_transaction(tx).map_err(Self::Error::from_eth_err)?; let receipt = provider.receipt_by_hash(hash).map_err(Self::Error::from_eth_err)?; @@ -504,7 +504,7 @@ pub trait EthTransactions: LoadTransaction { mut request: RpcTxReq, ) -> impl Future> + Send where - Self: EthApiSpec + LoadBlock + EstimateCall + LoadFee, + Self: EthApiSpec + EstimateCall + LoadFee, { async move { let from = match request.as_ref().from() { @@ -588,7 +588,7 @@ pub trait EthTransactions: LoadTransaction { mut request: RpcTxReq, ) -> impl Future>, Self::Error>> + Send where - Self: EthApiSpec + LoadBlock + EstimateCall + LoadFee, + Self: EthApiSpec + EstimateCall + LoadFee, { async move { if request.as_ref().value().is_none() { @@ -768,6 +768,26 @@ pub trait LoadTransaction: SpawnBlocking + FullEthApiTypes + RpcNodeCoreExt { txs.into_iter().map(|tx| self.recover_raw_transaction(tx.as_ref())).collect() } + /// Recovers the sender of a mined transaction, reusing a cached sender when configured. + /// + /// Mined transactions can predate EIP-2, so recovery does not reject high-s signatures. A + /// sender recovered on a cache miss is not cached: lookups of mined transactions rarely + /// repeat, and caching them would evict senders that ingress and payload execution still need. + fn recover_mined_transaction(&self, tx: T) -> EthResult> { + let cached = self + .eth_api_settings() + .sender_recovery_cache + .as_ref() + .and_then(|cache| cache.get(tx.tx_hash())); + let signer = match cached { + Some(signer) => signer, + None => tx + .recover_signer_unchecked() + .map_err(|_| EthApiError::InvalidTransactionSignature)?, + }; + Ok(Recovered::new_unchecked(tx, signer)) + } + /// Returns the transaction by hash. /// /// Checks the pool and state. @@ -800,9 +820,8 @@ pub trait LoadTransaction: SpawnBlocking + FullEthApiTypes + RpcNodeCoreExt { // Note: we assume this transaction is valid, because it's mined (or // part of pending block) and already. We don't need to // check for pre EIP-2 because this transaction could be pre-EIP-2. - let transaction = tx - .try_into_recovered_unchecked() - .map_err(|_| EthApiError::InvalidTransactionSignature)?; + let transaction = + self.recover_mined_transaction(tx).map_err(Self::Error::from_eth_err)?; return Ok(Some(TransactionSource::Block { transaction, @@ -840,9 +859,7 @@ pub trait LoadTransaction: SpawnBlocking + FullEthApiTypes + RpcNodeCoreExt { async move { Ok(self.transaction_by_hash(transaction_hash).await?.map(|tx| match tx { tx @ TransactionSource::Pool(_) => (tx, BlockId::pending()), - tx @ TransactionSource::Block { block_hash, .. } => { - (tx, BlockId::Hash(block_hash.into())) - } + tx @ TransactionSource::Block { block_hash, .. } => (tx, BlockId::hash(block_hash)), })) } } diff --git a/crates/rpc/rpc-eth-api/src/node.rs b/crates/rpc/rpc-eth-api/src/node.rs index 1ddb901f09d..bdbd6fb411b 100644 --- a/crates/rpc/rpc-eth-api/src/node.rs +++ b/crates/rpc/rpc-eth-api/src/node.rs @@ -41,15 +41,13 @@ pub trait RpcNodeCore: Clone + Send + Sync + Unpin + 'static { + StageCheckpointReader + PruneCheckpointReader + BalProvider - + Send - + Sync + Clone + Unpin + 'static; /// The transaction pool of the node. type Pool: TransactionPool>>; /// The node's EVM configuration, defining settings for the Ethereum Virtual Machine. - type Evm: ConfigureEvm + Send + Sync + 'static; + type Evm: ConfigureEvm + 'static; /// Network API. type Network: NetworkInfo + Clone; @@ -137,12 +135,10 @@ where + StageCheckpointReader + PruneCheckpointReader + BalProvider - + Send - + Sync + Unpin + Clone + 'static, - Evm: ConfigureEvm + Clone + 'static, + Evm: ConfigureEvm + 'static, Pool: TransactionPool>> + Unpin + 'static, diff --git a/crates/rpc/rpc-eth-types/src/cache/config.rs b/crates/rpc/rpc-eth-types/src/cache/config.rs index 19a1868f6a4..ea74b7e4907 100644 --- a/crates/rpc/rpc-eth-types/src/cache/config.rs +++ b/crates/rpc/rpc-eth-types/src/cache/config.rs @@ -102,34 +102,4 @@ mod tests { assert_eq!(config.max_bals_bytes, DEFAULT_BAL_CACHE_MAX_BYTES); assert_eq!(config.idle_timeout, DEFAULT_CACHE_IDLE_TIMEOUT); } - - #[test] - fn cache_memory_limits_and_idle_timeout_round_trip() { - for config in [ - EthStateCacheConfig::default(), - EthStateCacheConfig { - max_blocks_bytes: 1024, - max_receipts_bytes: 2048, - max_bals_bytes: 4096, - idle_timeout: Duration::from_millis(1500), - ..Default::default() - }, - EthStateCacheConfig { - max_blocks_bytes: 0, - max_receipts_bytes: 0, - max_bals_bytes: 0, - idle_timeout: Duration::ZERO, - ..Default::default() - }, - ] { - let serialized = serde_json::to_value(config).unwrap(); - assert_eq!(serialized["maxBlocksBytes"], serde_json::json!(config.max_blocks_bytes)); - assert_eq!( - serialized["maxReceiptsBytes"], - serde_json::json!(config.max_receipts_bytes) - ); - assert_eq!(serialized["maxBalsBytes"], serde_json::json!(config.max_bals_bytes)); - assert_eq!(serde_json::from_value::(serialized).unwrap(), config); - } - } } diff --git a/crates/rpc/rpc-eth-types/src/cache/db.rs b/crates/rpc/rpc-eth-types/src/cache/db.rs index 81a4e6b40d1..884f4774897 100644 --- a/crates/rpc/rpc-eth-types/src/cache/db.rs +++ b/crates/rpc/rpc-eth-types/src/cache/db.rs @@ -31,10 +31,11 @@ pub fn attach_bal_before_tx( #[cfg(test)] mod tests { + use super::*; - use alloy_primitives::{address, Bytes, U256}; + use alloy_primitives::{Address, Bytes, U256}; use revm::{ - database::{CacheDB, EmptyDB}, + database::InMemoryDB, state::{ bal::{AccountBal, Bal, BalWrites, BlockAccessIndex}, AccountInfo, @@ -43,13 +44,13 @@ mod tests { #[test] fn attach_bal_before_tx_serves_positioned_reads() { - let covered = address!("0x0000000000000000000000000000000000000001"); - let uncovered = address!("0x0000000000000000000000000000000000000002"); - let written_slot = U256::from(1); + let covered = Address::with_last_byte(1); + let uncovered = Address::with_last_byte(2); + let written_slot = U256::ONE; let read_slot = U256::from(2); // pre-block state - let mut db = CacheDB::new(EmptyDB::default()); + let mut db = InMemoryDB::default(); db.insert_account_info( covered, AccountInfo { balance: U256::from(7), nonce: 5, ..Default::default() }, diff --git a/crates/rpc/rpc-eth-types/src/cache/mod.rs b/crates/rpc/rpc-eth-types/src/cache/mod.rs index 9c4ce896245..ece915da664 100644 --- a/crates/rpc/rpc-eth-types/src/cache/mod.rs +++ b/crates/rpc/rpc-eth-types/src/cache/mod.rs @@ -1375,17 +1375,13 @@ mod tests { fn cached_revm_bal_size_accounts_for_nested_allocations() { let mut account = RevmAccountBal::default(); account.account_info.nonce.writes.push((BlockAccessIndex::new(1), 1)); - account - .account_info - .balance - .writes - .push((BlockAccessIndex::new(2), StorageValue::from(1u64))); + account.account_info.balance.writes.push((BlockAccessIndex::new(2), StorageValue::ONE)); account.account_info.code.writes.push(( BlockAccessIndex::new(3), (B256::repeat_byte(0xaa), Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00]))), )); account.storage.storage.insert( - StorageKey::from(1u64), + StorageKey::ONE, RevmBalWrites::new(vec![(BlockAccessIndex::new(4), StorageValue::from(2u64))]), ); diff --git a/crates/rpc/rpc-eth-types/src/error/mod.rs b/crates/rpc/rpc-eth-types/src/error/mod.rs index 2049db7cb68..c67469461b3 100644 --- a/crates/rpc/rpc-eth-types/src/error/mod.rs +++ b/crates/rpc/rpc-eth-types/src/error/mod.rs @@ -5,7 +5,7 @@ use alloy_eips::BlockId; use alloy_evm::{call::CallError, overrides::StateOverrideError}; use alloy_primitives::{Address, Bytes, B256, U256}; use alloy_rpc_types_eth::{error::EthRpcErrorCode, request::TransactionInputError, BlockError}; -use alloy_sol_types::{ContractError, RevertReason}; +use alloy_sol_types::RevertReason; use alloy_transport::{RpcError, TransportErrorKind}; pub use api::{AsEthApiError, FromEthApiError, FromEvmError, IntoEthApiError}; use core::time::Duration; @@ -360,7 +360,7 @@ impl From for jsonrpsee_types::error::ErrorObject<'static> { } err @ EthApiError::TransactionInputError(_) => invalid_params_rpc_err(err.to_string()), EthApiError::PrunedHistoryUnavailable { .. } => { - rpc_error_with_code(4444, error.to_string()) + rpc_error_with_code(EthRpcErrorCode::PrunedHistory.code(), error.to_string()) } EthApiError::Other(err) => err.to_rpc_error(), EthApiError::MuxTracerError(msg) => internal_rpc_err(msg.to_string()), @@ -989,7 +989,7 @@ impl std::fmt::Display for RevertError { if let Some(reason) = self.output.as_ref().and_then(|out| RevertReason::decode(out)) { let error = reason.to_string(); let mut error = error.as_str(); - if matches!(reason, RevertReason::ContractError(ContractError::Revert(_))) { + if reason.is_revert() { // we strip redundant `revert: ` prefix from the revert reason error = error.trim_start_matches("revert: "); } @@ -1193,9 +1193,7 @@ mod tests { let cases = [ (EthApiError::TracingTransactionNotFound, "transaction not found"), ( - EthApiError::TracingBlockNotFound(BlockId::hash(b256!( - "0x0000000000000000000000000000000000000000000000000000000000000001" - ))), + EthApiError::TracingBlockNotFound(BlockId::hash(B256::with_last_byte(1))), "block 0x0000000000000000000000000000000000000000000000000000000000000001 not found", ), (EthApiError::GenesisNotTraceable, "genesis is not traceable"), diff --git a/crates/rpc/rpc-eth-types/src/pending_block.rs b/crates/rpc/rpc-eth-types/src/pending_block.rs index 7e8f52bdc6f..f831552657c 100644 --- a/crates/rpc/rpc-eth-types/src/pending_block.rs +++ b/crates/rpc/rpc-eth-types/src/pending_block.rs @@ -6,7 +6,7 @@ use std::{sync::Arc, time::Instant}; use crate::block::{BlockAndReceipts, SharedReceipts}; use alloy_consensus::BlockHeader; -use alloy_eips::{BlockId, BlockNumberOrTag}; +use alloy_eips::BlockId; use alloy_primitives::{BlockHash, TxHash, B256}; use derive_more::Constructor; use reth_chain_state::{BlockState, ExecutedBlock}; @@ -60,8 +60,8 @@ impl PendingBlockEnvOrigin { /// identify the block by its hash (latest block). pub fn state_block_id(&self) -> BlockId { match self { - Self::ActualPending(_, _) => BlockNumberOrTag::Pending.into(), - Self::DerivedFromLatest(latest) => BlockId::Hash(latest.hash().into()), + Self::ActualPending(_, _) => BlockId::pending(), + Self::DerivedFromLatest(latest) => BlockId::hash(latest.hash()), } } diff --git a/crates/rpc/rpc-eth-types/src/simulate.rs b/crates/rpc/rpc-eth-types/src/simulate.rs index 6f44f39d089..d6c3684f5a4 100644 --- a/crates/rpc/rpc-eth-types/src/simulate.rs +++ b/crates/rpc/rpc-eth-types/src/simulate.rs @@ -26,9 +26,9 @@ use reth_rpc_convert::{RpcBlock, RpcConvert, RpcTxReq}; use reth_rpc_server_types::result::rpc_err; use reth_storage_api::{noop::NoopProvider, StateProvider}; use revm::{ - context::Block, + context::{Block, Cfg as _}, context_interface::result::ExecutionResult, - primitives::{Address, Bytes, TxKind, U256}, + primitives::{hardfork::SpecId, Address, Bytes, TxKind, U256}, Database, }; @@ -326,7 +326,11 @@ pub fn execute_transactions( EthApiError, > where - S: BlockBuilder>>>>, + S: BlockBuilder< + Executor: BlockExecutor< + Evm: Evm>, Spec: Into>, + >, + >, T: RpcConvert, { builder.apply_pre_execution_changes()?; @@ -337,31 +341,26 @@ where let mut block_state_gas_used: u64 = 0; let block_gas_limit = builder.evm().block().gas_limit(); let is_amsterdam = builder.evm().cfg_env().enable_amsterdam_eip8037; - let tx_gas_limit_cap = builder.evm().cfg_env().tx_gas_limit_cap.unwrap_or(u64::MAX); + let tx_gas_limit_cap = builder.evm().cfg_env().tx_gas_limit_cap(); for mut call in calls { let block_gas_remaining = if is_amsterdam { - block_gas_limit - .saturating_sub(block_regular_gas_used) - .min(block_gas_limit.saturating_sub(block_state_gas_used)) + let regular_available_gas = block_gas_limit.saturating_sub(block_regular_gas_used); + let state_available_gas = block_gas_limit.saturating_sub(block_state_gas_used); + // Gas above the tx gas cap goes to the state gas reservoir and only needs state gas. + if regular_available_gas >= tx_gas_limit_cap { + state_available_gas + } else { + regular_available_gas.min(state_available_gas) + } } else { block_gas_limit.saturating_sub(cumulative_tx_gas_used) }; let mut default_gas_limit = block_gas_remaining; - if let Some(gas_limit) = call.as_ref().gas_limit() { - let exceeds_gas_limit = if is_amsterdam { - let regular_available_gas = block_gas_limit.saturating_sub(block_regular_gas_used); - let state_available_gas = block_gas_limit.saturating_sub(block_state_gas_used); - let regular_tx_gas_limit = gas_limit.min(tx_gas_limit_cap); - - regular_tx_gas_limit > regular_available_gas || gas_limit > state_available_gas - } else { - gas_limit > block_gas_remaining - }; - - if exceeds_gas_limit { - return Err(EthApiError::other(EthSimulateError::BlockGasLimitExceeded)) - } + if let Some(gas_limit) = call.as_ref().gas_limit() && + gas_limit > block_gas_remaining + { + return Err(EthApiError::other(EthSimulateError::BlockGasLimitExceeded)) } if let Some(remaining_call_gas_limit) = *remaining_call_gas_limit { @@ -447,12 +446,16 @@ where Address::ZERO }; + let state_nonce = db.basic(from).map_err(Into::into)?.map(|acc| acc.nonce).unwrap_or_default(); if tx.as_ref().nonce().is_none() { - tx.as_mut().set_nonce( - db.basic(from).map_err(Into::into)?.map(|acc| acc.nonce).unwrap_or_default(), - ); + tx.as_mut().set_nonce(state_nonce); + } + // EIP-2681 applies in both modes. Check it here because revm checks the fee cap first. + if state_nonce == u64::MAX && (disable_nonce_check || tx.as_ref().nonce() == Some(u64::MAX)) { + return Err(EthApiError::other(EthSimulateError::NonceMaxValue)) } - // eth_simulateV1 validation-off mode behaves like eth_call; avoid revm's max-nonce guard. + // revm rejects a tx nonce of u64::MAX even with the nonce check disabled. The state nonce is + // below the limit here, so only the tx object changes. if disable_nonce_check && tx.as_ref().nonce() == Some(u64::MAX) { tx.as_mut().set_nonce(0); } @@ -592,7 +595,7 @@ mod tests { use alloy_chains::Chain; use alloy_consensus::Header; use alloy_evm::precompiles::PrecompilesMap; - use alloy_primitives::{address, U256}; + use alloy_primitives::{address, Address, U256}; use alloy_rpc_types_eth::{ simulate::SimBlock, state::{AccountOverride, StateOverride}, @@ -648,7 +651,7 @@ mod tests { #[test] fn precompile_self_move_errors_for_existing_precompile() { - let address = address!("0000000000000000000000000000000000000001"); + let address = Address::with_last_byte(1); let mut state_overrides = StateOverride::default(); state_overrides.insert( address, @@ -663,7 +666,7 @@ mod tests { #[test] fn moved_precompile_is_callable() { - let source = address!("0000000000000000000000000000000000000001"); + let source = Address::with_last_byte(1); let dest = address!("0000000000000000000000000000000000123456"); let mut state_overrides = StateOverride::default(); state_overrides.insert( @@ -793,7 +796,7 @@ mod tests { let blocks = vec![ SimBlock { block_overrides: Some(BlockOverrides { - number: Some(U256::from(1)), + number: Some(U256::ONE), time: Some(u64::MAX), ..Default::default() }), diff --git a/crates/rpc/rpc-layer/Cargo.toml b/crates/rpc/rpc-layer/Cargo.toml index b9507fc2add..96c7940544d 100644 --- a/crates/rpc/rpc-layer/Cargo.toml +++ b/crates/rpc/rpc-layer/Cargo.toml @@ -23,8 +23,10 @@ tower-http = { workspace = true, features = ["full"] } tracing.workspace = true [dev-dependencies] +bytes.workspace = true +http-body = "1" reqwest.workspace = true -tokio = { workspace = true, features = ["macros"] } +tokio = { workspace = true, features = ["macros", "test-util"] } jsonrpsee = { workspace = true, features = ["server"] } flate2.workspace = true diff --git a/crates/rpc/rpc-layer/src/compression_layer.rs b/crates/rpc/rpc-layer/src/compression_layer.rs index b8129c29ae4..9f6858db238 100644 --- a/crates/rpc/rpc-layer/src/compression_layer.rs +++ b/crates/rpc/rpc-layer/src/compression_layer.rs @@ -5,7 +5,15 @@ use std::{ task::{Context, Poll}, }; use tower::{Layer, Service}; -use tower_http::compression::{Compression, CompressionLayer as TowerCompressionLayer}; +use tower_http::compression::{ + predicate::SizeAbove, Compression, CompressionLayer as TowerCompressionLayer, CompressionLevel, +}; + +/// Responses smaller than this many bytes are sent uncompressed. +/// +/// They fit in a single TCP segment anyway, and setting up a compressor costs far more than +/// sending them as they are. +const MIN_COMPRESSION_SIZE: u16 = 1024; /// This layer is a wrapper around [`tower_http::compression::CompressionLayer`] that integrates /// with jsonrpsee's HTTP types. It automatically compresses responses based on the client's @@ -13,15 +21,15 @@ use tower_http::compression::{Compression, CompressionLayer as TowerCompressionL #[expect(missing_debug_implementations)] #[derive(Clone)] pub struct CompressionLayer { - inner_layer: TowerCompressionLayer, + inner_layer: TowerCompressionLayer, } impl CompressionLayer { /// Creates a new compression layer with zstd, gzip, brotli and deflate enabled. + /// + /// Responses are compressed at the fastest level, and only if they are at least 1 KiB. pub fn new() -> Self { - Self { - inner_layer: TowerCompressionLayer::new().gzip(true).br(true).deflate(true).zstd(true), - } + Self::from_layer(TowerCompressionLayer::new().gzip(true).br(true).deflate(true).zstd(true)) } /// Creates a compression layer allowing only the listed algorithms. @@ -44,7 +52,15 @@ impl CompressionLayer { } } - Self { inner_layer: layer } + Self::from_layer(layer) + } + + /// Applies the compression level and the minimum response size to `layer`. + fn from_layer(layer: TowerCompressionLayer) -> Self { + let inner_layer = layer + .quality(CompressionLevel::Fastest) + .compress_when(SizeAbove::new(MIN_COMPRESSION_SIZE)); + Self { inner_layer } } } @@ -70,7 +86,7 @@ impl Layer for CompressionLayer { #[expect(missing_debug_implementations)] #[derive(Clone)] pub struct CompressionService { - compression: Compression, + compression: Compression, } impl Service for CompressionService @@ -111,7 +127,7 @@ mod tests { const REPEAT_COUNT: usize = 1000; #[derive(Clone)] - struct MockRequestService; + struct MockRequestService(String); impl Service for MockRequestService { type Response = HttpResponse; @@ -126,15 +142,16 @@ mod tests { } fn call(&mut self, _: HttpRequest) -> Self::Future { - let body = HttpBody::from(TEST_DATA.repeat(REPEAT_COUNT)); + let body = HttpBody::from(self.0.clone()); let response = HttpResponse::builder().body(body).unwrap(); ready(Ok(response)) } } fn setup_compression_service( + body: String, ) -> impl Service { - CompressionLayer::new().layer(MockRequestService) + CompressionLayer::new().layer(MockRequestService(body)) } async fn get_response_size(response: HttpResponse) -> usize { @@ -144,7 +161,7 @@ mod tests { #[tokio::test] async fn test_gzip_compression() { - let mut service = setup_compression_service(); + let mut service = setup_compression_service(TEST_DATA.repeat(REPEAT_COUNT)); let request = HttpRequest::builder().header(ACCEPT_ENCODING, "gzip").body(HttpBody::empty()).unwrap(); @@ -171,7 +188,7 @@ mod tests { #[tokio::test] async fn test_no_compression_when_not_requested() { // Create a service with compression - let mut service = setup_compression_service(); + let mut service = setup_compression_service(TEST_DATA.repeat(REPEAT_COUNT)); let request = HttpRequest::builder().body(HttpBody::empty()).unwrap(); let response = service.call(request).await.unwrap(); @@ -189,4 +206,16 @@ mod tests { "Response size ({response_size}) should equal original size ({uncompressed_len})" ); } + + #[tokio::test] + async fn test_no_compression_for_small_responses() { + let body = "a".repeat(MIN_COMPRESSION_SIZE as usize - 1); + let mut service = setup_compression_service(body.clone()); + let request = + HttpRequest::builder().header(ACCEPT_ENCODING, "br").body(HttpBody::empty()).unwrap(); + + let response = service.call(request).await.unwrap(); + assert!(response.headers().get(CONTENT_ENCODING).is_none()); + assert_eq!(get_response_size(response).await, body.len()); + } } diff --git a/crates/rpc/rpc-layer/src/decompression_layer.rs b/crates/rpc/rpc-layer/src/decompression_layer.rs index 077003c5e98..3ee2e939a3a 100644 --- a/crates/rpc/rpc-layer/src/decompression_layer.rs +++ b/crates/rpc/rpc-layer/src/decompression_layer.rs @@ -6,6 +6,7 @@ use std::{ pin::Pin, sync::Arc, task::{Context, Poll}, + time::Duration, }; use tokio::sync::Semaphore; use tower::{Layer, Service}; @@ -22,6 +23,9 @@ use tracing::debug; /// being rejected. const MAX_CONCURRENT_DECOMPRESSIONS: usize = 8; +/// Maximum time to read and decompress a compressed request body. +const DECOMPRESSION_BODY_READ_TIMEOUT: Duration = Duration::from_secs(30); + /// This layer is a wrapper around [`tower_http::decompression::RequestDecompressionLayer`] that /// integrates with jsonrpsee's HTTP types. #[expect(missing_debug_implementations)] @@ -140,15 +144,23 @@ where .await .expect("decompression semaphore is never closed"); - let body = match Limited::new(body, max_body_size).collect().await { - Ok(body) => body, - Err(err) if err.is::() => { + let body = match tokio::time::timeout( + DECOMPRESSION_BODY_READ_TIMEOUT, + Limited::new(body, max_body_size).collect(), + ) + .await + { + Ok(Ok(body)) => body, + Ok(Err(err)) if err.is::() => { return Ok(err_response(StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large")); } - Err(err) => { + Ok(Err(err)) => { debug!(target: "rpc::decompression", %err, "Failed to decompress request body"); return Ok(err_response(StatusCode::BAD_REQUEST, "Invalid compressed body")); } + Err(_) => { + return Ok(err_response(StatusCode::REQUEST_TIMEOUT, "Request body timed out")); + } }; // Decompression is done; release the permit before dispatching the request. @@ -221,10 +233,19 @@ fn err_response(status: StatusCode, msg: &'static str) -> HttpResponse { #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; use http::header::CONTENT_ENCODING; + use http_body::{Body, Frame}; use http_body_util::BodyExt; use jsonrpsee_http_client::{HttpRequest, HttpResponse}; - use std::{convert::Infallible, future::ready, io::Write}; + use std::{ + convert::Infallible, + future::ready, + io::Write, + pin::Pin, + task::{Context, Poll}, + }; + use tokio::sync::Notify; const TEST_DATA: &str = r#"{"method":"test","params":["test data"],"id":1}"#; const DEFAULT_MAX_SIZE: usize = 15 * 1024 * 1024; @@ -234,6 +255,23 @@ mod tests { #[derive(Clone)] struct MockEchoService; + struct PendingBody { + polled: Arc, + } + + impl Body for PendingBody { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.polled.notify_one(); + Poll::Pending + } + } + impl Service for MockEchoService { type Response = HttpResponse; type Error = Infallible; @@ -303,6 +341,15 @@ mod tests { .unwrap() } + fn build_pending_compressed_request(encoding: &str) -> (HttpRequest, Arc) { + let polled = Arc::new(Notify::new()); + let request = HttpRequest::builder() + .header(CONTENT_ENCODING, encoding) + .body(HttpBody::new(PendingBody { polled: polled.clone() })) + .unwrap(); + (request, polled) + } + #[tokio::test] async fn configured_algorithms_are_decompressed_without_content_length() { let cases: [(&str, Compressor); 4] = [ @@ -415,6 +462,31 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn slow_compressed_body_times_out_and_releases_permit() { + let mut layer = DecompressionLayer::new(&["gzip"], DEFAULT_MAX_SIZE); + layer.decompression_permits = Arc::new(Semaphore::new(1)); + let permits = layer.decompression_permits.clone(); + let service = layer.layer(MockEchoService); + + let (slow_request, slow_body_polled) = build_pending_compressed_request("gzip"); + let mut slow_service = service.clone(); + let slow = tokio::spawn(async move { slow_service.call(slow_request).await.unwrap() }); + slow_body_polled.notified().await; + assert_eq!(permits.available_permits(), 0); + + tokio::time::advance(DECOMPRESSION_BODY_READ_TIMEOUT).await; + assert_eq!(slow.await.unwrap().status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!(permits.available_permits(), 1); + + let mut fast_service = service; + let response = fast_service + .call(build_compressed_request("gzip", compress_gzip(TEST_DATA.as_bytes()))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + #[tokio::test] async fn identity_and_unencoded_bodies_are_not_eagerly_collected() { for encoding in [None, Some("identity"), Some("Identity")] { diff --git a/crates/rpc/rpc-server-types/src/constants.rs b/crates/rpc/rpc-server-types/src/constants.rs index 750b37f8cf2..7f0fc9e4c66 100644 --- a/crates/rpc/rpc-server-types/src/constants.rs +++ b/crates/rpc/rpc-server-types/src/constants.rs @@ -95,7 +95,7 @@ pub mod gas_oracle { pub const DEFAULT_MAX_GAS_PRICE: U256 = U256::from_limbs([500_000_000_000u64, 0, 0, 0]); /// The default minimum gas price, under which the sample will be ignored - pub const DEFAULT_IGNORE_GAS_PRICE: U256 = U256::ZERO; + pub const DEFAULT_IGNORE_GAS_PRICE: U256 = U256::from_limbs([2, 0, 0, 0]); /// The default gas limit for `eth_call` and adjacent calls. /// diff --git a/crates/rpc/rpc/Cargo.toml b/crates/rpc/rpc/Cargo.toml index 92f333270a0..f3507238659 100644 --- a/crates/rpc/rpc/Cargo.toml +++ b/crates/rpc/rpc/Cargo.toml @@ -93,6 +93,7 @@ sha2.workspace = true thiserror.workspace = true derive_more.workspace = true itertools.workspace = true +humantime = { workspace = true, optional = true } [dev-dependencies] reth-testing-utils.workspace = true @@ -109,7 +110,17 @@ jsonrpsee = { workspace = true, features = ["client"] } [features] js-tracer = [ + "dep:humantime", "revm-inspectors/js-tracer", "reth-rpc-eth-types/js-tracer", "reth-rpc-eth-api/js-tracer", ] +account-ext = [ + "reth-rpc-eth-api/account-ext", + "reth-primitives-traits/account-ext", + "alloy-genesis/account-ext", + "alloy-rpc-types-eth/account-ext", + "reth-provider/account-ext", + "reth-revm/account-ext", + "revm/account-ext", +] diff --git a/crates/rpc/rpc/src/admin.rs b/crates/rpc/rpc/src/admin.rs index 14fbd3cf287..f772496feb0 100644 --- a/crates/rpc/rpc/src/admin.rs +++ b/crates/rpc/rpc/src/admin.rs @@ -38,7 +38,7 @@ impl AdminApi { impl AdminApiServer for AdminApi where N: NetworkInfo + Peers + 'static, - ChainSpec: EthChainSpec + EthereumHardforks + Send + Sync + 'static, + ChainSpec: EthChainSpec + EthereumHardforks + 'static, Pool: TransactionPool + 'static, { /// Handler for `admin_addPeer` diff --git a/crates/rpc/rpc/src/chain_config.rs b/crates/rpc/rpc/src/chain_config.rs index 8fadb1a9275..822c5391dfd 100644 --- a/crates/rpc/rpc/src/chain_config.rs +++ b/crates/rpc/rpc/src/chain_config.rs @@ -128,7 +128,7 @@ mod tests { #[test] fn sepolia_config_reports_its_chain_id() { let config = chain_config(&*SEPOLIA); - assert_eq!(config.chain_id, SEPOLIA.chain().id()); + assert_eq!(config.chain_id, SEPOLIA.chain_id()); assert_eq!(config.shanghai_time, Some(1_677_557_088)); assert!(config.blob_schedule.contains_key("cancun")); } diff --git a/crates/rpc/rpc/src/debug.rs b/crates/rpc/rpc/src/debug.rs index 3c137519b83..b647591d809 100644 --- a/crates/rpc/rpc/src/debug.rs +++ b/crates/rpc/rpc/src/debug.rs @@ -1,4 +1,4 @@ -use alloy_consensus::{constants::KECCAK_EMPTY, transaction::TxHashRef, BlockHeader}; +use alloy_consensus::{transaction::TxHashRef, BlockHeader}; use alloy_eips::{eip2718::Encodable2718, BlockId, BlockNumberOrTag}; use alloy_evm::{env::BlockEnvironment, Evm}; use alloy_genesis::ChainConfig; @@ -38,7 +38,7 @@ use reth_storage_api::{ ReceiptProviderIdExt, StateProviderBox, StateProviderFactory, StateRootProvider, StorageRootProvider, TransactionVariant, }; -use reth_tasks::{pool::BlockingTaskGuard, Runtime}; +use reth_tasks::{cancel::is_cancelled, pool::BlockingTaskGuard, Runtime}; use reth_transaction_pool::TransactionPool; use reth_trie_common::{ root::storage_root_unsorted, updates::TrieUpdates, ExecutionWitnessMode, HashedPostState, @@ -50,6 +50,11 @@ use serde::{Deserialize, Serialize}; use std::{collections::VecDeque, sync::Arc}; use tokio::sync::{AcquireError, OwnedSemaphorePermit}; use tokio_stream::StreamExt; +#[cfg(feature = "js-tracer")] +use { + alloy_rpc_types_trace::geth::GethDebugTracerType, + revm_inspectors::tracing::js::JsInspectorInterrupt, std::time::Duration, +}; /// `debug` API implementation. /// @@ -133,6 +138,9 @@ where let mut evm = eth_api.evm_config().evm_with_env_and_inspector(&mut db, evm_env, inspector); while let Some((index, tx)) = transactions.next() { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } let tx_env = eth_api.evm_config().tx_env(tx); let res = evm.transact(tx_env.clone()).map_err(Eth::Error::from_evm_err)?; @@ -322,8 +330,11 @@ where Some(res) => res, }; - self.eth_api() - .spawn_with_state_at_block(block.parent_hash(), move |eth_api, mut db| { + let interrupt = JsTraceInterrupt::new(&opts)?; + let tracer_interrupt = interrupt.clone(); + let trace = self.eth_api().spawn_with_state_at_block( + block.parent_hash(), + move |eth_api, mut db| { // configure env for the target transaction let (tx, tx_info) = transaction.split(); @@ -333,7 +344,8 @@ where tx_info.index.expect("transaction_and_block only returns block transactions") as usize; - let mut inspector = DebugInspector::new(opts).map_err(Eth::Error::from_eth_err)?; + let mut inspector = tracer_interrupt + .attach(DebugInspector::new(opts).map_err(Eth::Error::from_eth_err)?); let tx_env = eth_api.evm_config().tx_env(&tx); let (res, evm_env) = eth_api.inspect_transaction_in_block( &block, @@ -359,8 +371,9 @@ where .map_err(Eth::Error::from_eth_err)?; Ok(trace) - }) - .await + }, + ); + interrupt.run(trace).await } /// The `debug_traceCall` method lets you run an `eth_call` within the context of the given @@ -394,11 +407,14 @@ where .await; } + let interrupt = JsTraceInterrupt::new(&tracing_options)?; + let tracer_interrupt = interrupt.clone(); let this = self.clone(); - self.eth_api() - .spawn_with_call_at(call, at, overrides, move |db, evm_env, tx_env| { - let mut inspector = - DebugInspector::new(tracing_options).map_err(Eth::Error::from_eth_err)?; + let trace = + self.eth_api().spawn_with_call_at(call, at, overrides, move |db, evm_env, tx_env| { + let mut inspector = tracer_interrupt.attach( + DebugInspector::new(tracing_options).map_err(Eth::Error::from_eth_err)?, + ); let res = this.eth_api().inspect( &mut *db, evm_env.clone(), @@ -409,15 +425,14 @@ where .get_result(None, &tx_env, &evm_env.block_env, &res, db) .map_err(Eth::Error::from_eth_err)?; Ok(trace) - }) - .await - .map_err(|err| match err.as_err() { - Some(EthApiError::HeaderNotFound(id)) if *id == at => { - // Unknown blocks use -32000: https://github.com/ethereum/execution-apis/pull/855 - EthApiError::TracingBlockNotFound(at).into() - } - _ => err, - }) + }); + interrupt.run(trace).await.map_err(|err| match err.as_err() { + Some(EthApiError::HeaderNotFound(id)) if *id == at => { + // Unknown blocks use -32000: https://github.com/ethereum/execution-apis/pull/855 + EthApiError::TracingBlockNotFound(at).into() + } + _ => err, + }) } /// Helper method to execute `debug_trace_call` at a specific transaction index within a block. @@ -454,8 +469,11 @@ where let evm_env = self.eth_api().evm_env_for_header(block.sealed_block().sealed_header())?; - self.eth_api() - .spawn_with_state_at_block(block.parent_hash(), move |eth_api, mut db| { + let interrupt = JsTraceInterrupt::new(&tracing_options)?; + let tracer_interrupt = interrupt.clone(); + let trace = self.eth_api().spawn_with_state_at_block( + block.parent_hash(), + move |eth_api, mut db| { // 1. position the state before the transaction at the index eth_api.replay_block_until(&mut db, &block, tx_index, bal.as_deref())?; @@ -463,8 +481,9 @@ where let (evm_env, tx_env) = eth_api.prepare_call_env(evm_env, call, &mut db, overrides)?; - let mut inspector = - DebugInspector::new(tracing_options).map_err(Eth::Error::from_eth_err)?; + let mut inspector = tracer_interrupt.attach( + DebugInspector::new(tracing_options).map_err(Eth::Error::from_eth_err)?, + ); let res = eth_api.inspect(&mut db, evm_env.clone(), tx_env.clone(), &mut inspector)?; let trace = inspector @@ -472,8 +491,9 @@ where .map_err(Eth::Error::from_eth_err)?; Ok(trace) - }) - .await + }, + ); + interrupt.run(trace).await } /// The `debug_traceCallMany` method lets you run an `eth_callMany` within the context of the @@ -546,6 +566,9 @@ where let mut transactions = transactions.into_iter().peekable(); while let Some(tx) = transactions.next() { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } // apply state overrides only once, before the first transaction let state_overrides = state_overrides.take(); let overrides = EvmOverrides::new(state_overrides, block_overrides.clone()); @@ -711,6 +734,9 @@ where executor.apply_pre_execution_changes().map_err(Eth::Error::from_eth_err)?; for tx in block.transactions_recovered().take(tx_index + 1) { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } executor.execute_transaction(tx).map_err(Eth::Error::from_eth_err)?; } drop(executor); @@ -726,9 +752,6 @@ where let account = db.basic(address).map_err(Eth::Error::from_eth_err)?; let Some(account) = account else { return Ok(None) }; - let balance = account.balance; - let nonce = account.nonce; - let code_hash = account.code_hash; let (hashed_storage, status) = db .cache .accounts @@ -749,17 +772,18 @@ where db.database.storage_root(address, hashed_storage).map_err(Eth::Error::from_eth_err)? }; - Ok(Some(Account { balance, nonce, code_hash, storage_root })) + Ok(Some(reth_primitives_traits::Account::from(account).into_trie_account(storage_root))) } /// Retrieves the account's balance, nonce, and code from the given state. + #[allow(clippy::needless_update)] fn account_info(db: &mut DB, address: Address) -> Result where DB: Database, EthApiError: From, { let account = db.basic(address).map_err(Eth::Error::from_eth_err)?.unwrap_or_default(); - let code = if account.code_hash == KECCAK_EMPTY { + let code = if account.is_empty_code_hash() { Default::default() } else if let Some(code) = account.code { code.original_bytes() @@ -767,7 +791,16 @@ where db.code_by_hash(account.code_hash).map_err(Eth::Error::from_eth_err)?.original_bytes() }; - Ok(AccountInfo { balance: account.balance, nonce: account.nonce, code }) + Ok(AccountInfo { + balance: account.balance, + nonce: account.nonce, + code, + #[cfg(feature = "account-ext")] + extension: reth_primitives_traits::AccountExtension::from_shared( + account.extension.into_shared(), + ), + ..Default::default() + }) } /// Returns the code associated with a given hash at the specified block ID. If no code is @@ -824,6 +857,9 @@ where let mut roots = Vec::with_capacity(block.body().transactions().len()); let mut evm = eth_api.evm_config().evm_with_env(&mut db, evm_env); for tx in block.transactions_recovered() { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } let tx_env = eth_api.evm_config().tx_env(tx); evm.transact_commit(tx_env).map_err(Eth::Error::from_evm_err)?; @@ -1038,7 +1074,7 @@ where break } - let block_id = BlockId::Number(number.into()); + let block_id = BlockId::number(number); let block = match this.eth_api().recovered_block(block_id).await { Ok(Some(block)) => block, Ok(None) => { @@ -1503,6 +1539,88 @@ impl Default for BadBlockStore { } } +/// Default timeout of a JS tracer, matching geth. +#[cfg(feature = "js-tracer")] +const DEFAULT_JS_TRACER_TIMEOUT: Duration = Duration::from_secs(5); + +/// Interrupts a JS tracer once its request is dropped or its timeout expires. +/// +/// Built-in tracers can't be interrupted, so for them this does nothing. +#[derive(Clone, Debug, Default)] +struct JsTraceInterrupt { + #[cfg(feature = "js-tracer")] + js: Option<(JsInspectorInterrupt, Duration)>, +} + +impl JsTraceInterrupt { + /// Creates an interrupt if `opts` selects a JS tracer, using its `timeout` or geth's default. + #[cfg_attr( + not(feature = "js-tracer"), + expect(unused_variables, reason = "only JS tracers can be interrupted") + )] + fn new(opts: &GethDebugTracingOptions) -> Result { + #[cfg(feature = "js-tracer")] + if let Some(GethDebugTracerType::JsTracer(_)) = &opts.tracer { + let timeout = match &opts.timeout { + Some(timeout) => humantime::parse_duration(timeout).map_err(|err| { + EthApiError::InvalidParams(format!("invalid tracer timeout {timeout:?}: {err}")) + })?, + None => DEFAULT_JS_TRACER_TIMEOUT, + }; + return Ok(Self { js: Some((JsInspectorInterrupt::new(), timeout)) }) + } + Ok(Self::default()) + } + + /// Lets this interrupt stop `inspector` if it's a JS tracer. + #[cfg_attr( + not(feature = "js-tracer"), + expect(clippy::missing_const_for_fn, reason = "only JS tracers can be interrupted") + )] + fn attach(&self, inspector: DebugInspector) -> DebugInspector { + #[cfg(feature = "js-tracer")] + if let Some((interrupt, _)) = &self.js { + return match inspector { + DebugInspector::Js(js) => { + DebugInspector::Js(Box::new(js.with_interrupt(interrupt.clone()))) + } + inspector => inspector, + } + } + inspector + } + + /// Awaits `trace`, interrupting the tracer if the returned future is dropped or the timeout + /// expires. + /// + /// The interrupt is armed before the future is first polled, so dropping it unpolled still + /// stops a trace that is already running on a blocking task. + #[cfg_attr( + not(feature = "js-tracer"), + expect( + clippy::manual_async_fn, + clippy::redundant_async_block, + reason = "only JS tracers can be interrupted" + ) + )] + fn run( + &self, + trace: impl Future>, + ) -> impl Future> { + #[cfg(feature = "js-tracer")] + let js = self.js.as_ref().map(|(interrupt, timeout)| (interrupt.drop_guard(), *timeout)); + async move { + #[cfg(feature = "js-tracer")] + if let Some((_interrupt_on_drop, timeout)) = js { + return tokio::time::timeout(timeout, trace) + .await + .map_err(|_| E::from_eth_err(EthApiError::ExecutionTimedOut(timeout)))? + } + trace.await + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1559,11 +1677,75 @@ mod tests { assert_eq!(err.code(), -32001); } + #[cfg(feature = "js-tracer")] + fn js_tracer_opts(timeout: Option<&str>) -> GethDebugTracingOptions { + GethDebugTracingOptions { + tracer: Some(GethDebugTracerType::JsTracer( + "{ fault: function() {}, result: function() { return null; } }".into(), + )), + timeout: timeout.map(Into::into), + ..Default::default() + } + } + + #[cfg(feature = "js-tracer")] + #[test] + fn js_trace_interrupt_stops_attached_js_tracer() { + use revm::{ + context::TxEnv, + context_interface::result::EVMError, + database::{CacheDB, EmptyDB}, + primitives::TxKind, + InspectEvm, MainBuilder, MainContext, + }; + + let opts = js_tracer_opts(None); + let interrupt = JsTraceInterrupt::new(&opts).unwrap(); + let inspector = interrupt.attach(DebugInspector::new(opts).unwrap()); + interrupt.js.as_ref().unwrap().0.interrupt(); + + let mut evm = revm::Context::mainnet() + .with_db(CacheDB::::default()) + .build_mainnet_with_inspector(inspector); + let result = evm.inspect_tx(TxEnv { + gas_limit: 1_000_000, + kind: TxKind::Call(Address::with_last_byte(1)), + ..Default::default() + }); + assert!( + matches!(&result, Err(EVMError::Custom(msg)) if msg == "JavaScript tracing interrupted"), + "{result:?}" + ); + } + + #[cfg(feature = "js-tracer")] + #[tokio::test] + async fn js_trace_interrupt_times_out_and_interrupts_tracer() { + let interrupt = JsTraceInterrupt::new(&js_tracer_opts(Some("10ms"))).unwrap(); + let err = + interrupt.run(std::future::pending::>()).await.unwrap_err(); + assert!( + matches!(err, EthApiError::ExecutionTimedOut(timeout) if timeout == Duration::from_millis(10)) + ); + assert!(interrupt.js.unwrap().0.is_interrupted()); + } + + #[cfg(feature = "js-tracer")] + #[test] + fn js_trace_interrupt_interrupts_tracer_when_dropped_unpolled() { + let interrupt = JsTraceInterrupt::new(&js_tracer_opts(None)).unwrap(); + let (tracer_interrupt, _) = interrupt.js.clone().unwrap(); + let trace = interrupt.run(std::future::pending::>()); + assert!(!tracer_interrupt.is_interrupted()); + drop(trace); + assert!(tracer_interrupt.is_interrupted()); + } + #[test] fn hashed_post_state_zeroes_destroyed_account_parent_storage() { let factory = create_test_provider_factory(); let address = Address::with_last_byte(1); - let old_slot = U256::from(1); + let old_slot = U256::ONE; let new_slot = U256::from(2); let old_value = U256::from(10); let new_value = U256::from(20); diff --git a/crates/rpc/rpc/src/eth/bundle.rs b/crates/rpc/rpc/src/eth/bundle.rs index 43b776afece..bc1503f7477 100644 --- a/crates/rpc/rpc/src/eth/bundle.rs +++ b/crates/rpc/rpc/src/eth/bundle.rs @@ -9,7 +9,7 @@ use jsonrpsee::core::RpcResult; use reth_chainspec::{ChainSpecProvider, EthChainSpec}; use reth_evm::{ConfigureEvm, Evm}; use reth_rpc_eth_api::{ - helpers::{Call, EthTransactions, LoadPendingBlock}, + helpers::{Call, EthTransactions}, EthCallBundleApiServer, FromEthApiError, FromEvmError, }; use reth_rpc_eth_types::{ @@ -45,7 +45,7 @@ impl EthBundle { impl EthBundle where - Eth: EthTransactions + LoadPendingBlock + Call + 'static, + Eth: EthTransactions + Call, { /// Simulates a bundle of transactions at the top of a given block number with the state of /// another (or the same) block. This can be used to simulate future blocks with the current @@ -126,7 +126,7 @@ where } if let Some(difficulty) = difficulty { - evm_env.block_env.inner_mut().difficulty = U256::from(difficulty); + evm_env.block_env.inner_mut().difficulty = difficulty; } // Validate that the bundle does not contain more than MAX_BLOB_NUMBER_PER_BLOCK blob @@ -289,7 +289,7 @@ where #[async_trait::async_trait] impl EthCallBundleApiServer for EthBundle where - Eth: EthTransactions + LoadPendingBlock + Call + 'static, + Eth: EthTransactions + Call, { async fn call_bundle(&self, request: EthCallBundle) -> RpcResult { Self::call_bundle(self, request).await.map_err(Into::into) diff --git a/crates/rpc/rpc/src/eth/core.rs b/crates/rpc/rpc/src/eth/core.rs index 4fb2731cf14..4313f67a516 100644 --- a/crates/rpc/rpc/src/eth/core.rs +++ b/crates/rpc/rpc/src/eth/core.rs @@ -570,16 +570,18 @@ mod tests { PruneCheckpointReader, StageCheckpointReader, }; use reth_rpc_eth_api::{ - helpers::{EthBlocks, EthCall}, + helpers::{EthBlocks, EthCall, SpawnBlocking}, node::RpcNodeCoreAdapter, EthApiServer, }; use reth_rpc_eth_types::RpcInvalidTransactionError; use reth_storage_api::{ - BalProvider, BlockReader, BlockReaderIdExt, NodePrimitivesProvider, StateProviderFactory, + BalProvider, BlockReaderIdExt, NodePrimitivesProvider, StateProviderFactory, }; + use reth_tasks::cancel::is_cancelled; use reth_testing_utils::generators; use reth_transaction_pool::test_utils::{testing_pool, TestPool}; + use std::time::{Duration, Instant}; type FakeEthApi

= EthApi< RpcNodeCoreAdapter, @@ -592,8 +594,7 @@ mod tests { Receipt = reth_ethereum_primitives::Receipt, Header = alloy_consensus::Header, Transaction = reth_ethereum_primitives::TransactionSigned, - > + BlockReader - + ChainSpecProvider + > + ChainSpecProvider + StateProviderFactory + NodePrimitivesProvider + CanonStateSubscriptions @@ -626,6 +627,30 @@ mod tests { .build() } + #[tokio::test(flavor = "multi_thread")] + async fn blocking_task_observes_dropped_request() { + let api = build_test_eth_api(MockEthProvider::default()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel(); + + let request = api.spawn_blocking_io(move |_| { + let _ = started_tx.send(()); + let deadline = Instant::now() + Duration::from_secs(5); + while !is_cancelled() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(1)); + } + let _ = cancelled_tx.send(is_cancelled()); + Ok(()) + }); + // drop the request while its blocking task is running + tokio::select! { + _ = request => panic!("blocking task completed before the request was dropped"), + _ = started_rx => {} + } + + assert!(cancelled_rx.await.unwrap()); + } + #[tokio::test] async fn test_transaction_by_sender_and_nonce_without_sender_transaction_returns_none() { use reth_rpc_eth_api::helpers::EthTransactions; @@ -1110,6 +1135,91 @@ mod tests { ); } + #[tokio::test] + async fn call_allowance_respects_rpc_gas_cap() { + use alloy_rpc_types_trace::{parity::TraceType, tracerequest::TraceCallRequest}; + use reth_rpc_eth_types::EthConfig; + use reth_tasks::pool::BlockingTaskGuard; + + let sender = Address::repeat_byte(0x11); + let contract = Address::repeat_byte(0xaa); + // Return the gas remaining after intrinsic gas and the GAS opcode. + let code = Bytes::from_static(&[0x5a, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3]); + + // RPC cap, supplied gas, price, balance after value, block limit, expected budget. + for (gas_cap, gas, price, balance, block_limit, budget) in [ + (50_000, None, 1, 100_000, 100_000, 50_000), + (50_000, None, 0, 100_000, 100_000, 50_000), + (50_000, None, 1, 40_000, 100_000, 40_000), + (50_000, None, 1, 100_000, 40_000, 40_000), + (50_000, Some(70_000), 1, 100_000, 100_000, 50_000), + (50_000, Some(30_000), 1, 100_000, 100_000, 30_000), + (50_000, Some(70_000), 1, 100_000, 40_000, 50_000), + (0, None, 1, 100_000, 100_000, 100_000), + (0, Some(70_000), 1, 100_000, 40_000, 70_000), + ] { + let provider = MockEthProvider::default() + .with_chain_spec(ChainSpecBuilder::mainnet().cancun_activated().build()); + provider.add_block( + B256::repeat_byte(0x42), + Block { + header: Header { + number: 1, + gas_limit: block_limit, + excess_blob_gas: Some(0), + ..Default::default() + }, + body: BlockBody::default(), + }, + ); + let api = build_test_eth_api_with_gas_cap(provider, gas_cap); + let trace_api = + crate::TraceApi::new(api.clone(), BlockingTaskGuard::new(1), EthConfig::default()); + let state_override = StateOverride::from_iter([ + ( + sender, + AccountOverride { + balance: Some(U256::from(balance + 1_000)), + ..Default::default() + }, + ), + (contract, AccountOverride { code: Some(code.clone()), ..Default::default() }), + ]); + + for dynamic_fee in [false, true] { + let request = TransactionRequest { + gas, + gas_price: (!dynamic_fee).then_some(price), + max_fee_per_gas: dynamic_fee.then_some(price), + max_priority_fee_per_gas: dynamic_fee.then_some(price), + value: Some(U256::from(1_000)), + ..TransactionRequest::default().with_from(sender).with_to(contract) + }; + let output = EthCall::call( + &api, + request.clone(), + Some(BlockId::latest()), + EvmOverrides::state(Some(state_override.clone())), + ) + .await + .unwrap(); + let traces = trace_api + .trace_call(TraceCallRequest { + call: request, + trace_types: std::iter::once(TraceType::Trace).collect(), + block_id: Some(BlockId::latest()), + state_overrides: Some(state_override.clone()), + block_overrides: None, + }) + .await + .unwrap(); + + assert_eq!(U256::from_be_slice(&output), U256::from(budget - 21_002)); + assert_eq!(traces.output, output); + } + } + } + #[tokio::test] async fn estimate_gas_respects_rpc_gas_cap() { const LOW_GAS_CAP: u64 = 50_000; @@ -1125,7 +1235,7 @@ mod tests { let mut state_override = StateOverride::default(); state_override.insert(contract, AccountOverride { code: Some(code), ..Default::default() }); let overrides = EvmOverrides::state(Some(state_override)); - let at = BlockId::Number(BlockNumberOrTag::Latest); + let at = BlockId::latest(); for chain_spec in [ ChainSpecBuilder::mainnet().cancun_activated().build(), @@ -1188,7 +1298,7 @@ mod tests { }, ); let request = TransactionRequest::default().with_to(Address::repeat_byte(0xaa)); - let at = BlockId::Number(BlockNumberOrTag::Latest); + let at = BlockId::latest(); let capped = build_test_eth_api_with_gas_cap(provider.clone(), 20_999); let err = EthCall::estimate_gas_at(&capped, request.clone(), at, EvmOverrides::default()) @@ -1221,7 +1331,7 @@ mod tests { provider.add_block(hash, block); let api = build_test_eth_api(provider); - for block_id in [BlockId::Number(BlockNumberOrTag::Number(1)), BlockId::Hash(hash.into())] { + for block_id in [BlockId::number(1), BlockId::hash(hash)] { let header = EthBlocks::rpc_block_header(&api, block_id).await.unwrap().unwrap(); let response = serde_json::to_value(&header).unwrap(); assert!(response.get("size").is_none()); @@ -1229,10 +1339,7 @@ mod tests { for full in [false, true] { let block = - EthBlocks::rpc_block(&api, BlockId::Number(BlockNumberOrTag::Number(1)), full) - .await - .unwrap() - .unwrap(); + EthBlocks::rpc_block(&api, BlockId::number(1), full).await.unwrap().unwrap(); assert_eq!(block.header.size, Some(U256::from(block_size))); } } diff --git a/crates/rpc/rpc/src/eth/filter.rs b/crates/rpc/rpc/src/eth/filter.rs index 7a60f2ee743..73e583cc682 100644 --- a/crates/rpc/rpc/src/eth/filter.rs +++ b/crates/rpc/rpc/src/eth/filter.rs @@ -4,7 +4,8 @@ use alloy_consensus::BlockHeader; use alloy_eips::BlockNumberOrTag; use alloy_primitives::{Sealable, TxHash}; use alloy_rpc_types_eth::{ - Filter, FilterBlockOption, FilterChanges, FilterId, PendingTransactionFilterKind, + error::EthRpcErrorCode, Filter, FilterBlockOption, FilterChanges, FilterId, + PendingTransactionFilterKind, }; use async_trait::async_trait; use futures::{ @@ -49,11 +50,7 @@ use tracing::{debug, error, trace}; impl EngineEthFilter> for EthFilter where - Eth: FullEthApiTypes - + RpcNodeCoreExt - + LoadReceipt - + EthBlocks - + 'static, + Eth: FullEthApiTypes + RpcNodeCoreExt + LoadReceipt + EthBlocks, { /// Returns logs matching given filter object, no query limits fn logs( @@ -208,11 +205,7 @@ where impl EthFilter where - Eth: FullEthApiTypes - + RpcNodeCoreExt - + LoadReceipt - + EthBlocks - + 'static, + Eth: FullEthApiTypes + LoadReceipt + EthBlocks, { /// Access the underlying provider. fn provider(&self) -> &Eth::Provider { @@ -353,7 +346,7 @@ where impl EthFilterApiServer, RpcLog> for EthFilter where - Eth: FullEthApiTypes + RpcNodeCoreExt + LoadReceipt + EthBlocks + 'static, + Eth: FullEthApiTypes + LoadReceipt + EthBlocks, { /// Handler for `eth_newFilter` async fn new_filter(&self, filter: Filter) -> RpcResult { @@ -472,8 +465,7 @@ where Eth: RpcNodeCoreExt + EthApiTypes + LoadReceipt - + EthBlocks - + 'static, + + EthBlocks, { /// Access the underlying provider. fn provider(&self) -> &Eth::Provider { @@ -945,7 +937,7 @@ impl FullTransactionsFilter> for FullTransactionsReceiver where T: PoolTransaction + 'static, - TxCompat: RpcConvert> + 'static, + TxCompat: RpcConvert>, { async fn drain(&self) -> FilterChanges> { Self::drain(self).await @@ -1060,7 +1052,7 @@ impl From for jsonrpsee::types::error::ErrorObject<'static> { } EthFilterError::EthAPIError(err) => err.into(), err @ EthFilterError::ReceiptsUnavailable(_) => { - rpc_error_with_code(4444, err.to_string()) + rpc_error_with_code(EthRpcErrorCode::PrunedHistory.code(), err.to_string()) } err @ (EthFilterError::InvalidBlockRangeParams | EthFilterError::QueryExceedsMaxBlocks(_) | @@ -1093,7 +1085,7 @@ impl From for EthFilterError { /// a match for the filter. struct ReceiptBlockResult

where - P: ReceiptProvider + BlockReader, + P: BlockReader, { /// We always need the entire receipts for the matching block. receipts: Arc>>, @@ -1105,11 +1097,7 @@ where /// Represents different modes for processing block ranges when filtering logs enum RangeMode< - Eth: RpcNodeCoreExt - + EthApiTypes - + LoadReceipt - + EthBlocks - + 'static, + Eth: RpcNodeCoreExt + LoadReceipt + EthBlocks, > { /// Use cache-based processing for recent blocks Cached(CachedMode), @@ -1118,11 +1106,7 @@ enum RangeMode< } impl< - Eth: RpcNodeCoreExt - + EthApiTypes - + LoadReceipt - + EthBlocks - + 'static, + Eth: RpcNodeCoreExt + LoadReceipt + EthBlocks, > RangeMode { /// Creates a new `RangeMode`. @@ -1199,22 +1183,14 @@ impl< /// Mode for processing blocks using cache optimization for recent blocks struct CachedMode< - Eth: RpcNodeCoreExt - + EthApiTypes - + LoadReceipt - + EthBlocks - + 'static, + Eth: RpcNodeCoreExt + LoadReceipt + EthBlocks, > { filter_inner: Arc>, headers_iter: std::vec::IntoIter::Header>>, } impl< - Eth: RpcNodeCoreExt - + EthApiTypes - + LoadReceipt - + EthBlocks - + 'static, + Eth: RpcNodeCoreExt + LoadReceipt + EthBlocks, > CachedMode { async fn next(&mut self) -> Result>, EthFilterError> { @@ -1237,11 +1213,7 @@ type ReceiptFetchFuture

= /// Mode for processing blocks using range queries for older blocks struct RangeBlockMode< - Eth: RpcNodeCoreExt - + EthApiTypes - + LoadReceipt - + EthBlocks - + 'static, + Eth: RpcNodeCoreExt + LoadReceipt + EthBlocks, > { filter_inner: Arc>, iter: Peekable::Header>>>, @@ -1255,11 +1227,7 @@ struct RangeBlockMode< } impl< - Eth: RpcNodeCoreExt - + EthApiTypes - + LoadReceipt - + EthBlocks - + 'static, + Eth: RpcNodeCoreExt + LoadReceipt + EthBlocks, > RangeBlockMode { async fn next(&mut self) -> Result>, EthFilterError> { @@ -1440,7 +1408,7 @@ mod tests { use super::*; use crate::{eth::EthApi, EthApiBuilder}; use alloy_network::Ethereum; - use alloy_primitives::FixedBytes; + use alloy_primitives::{Bloom, FixedBytes}; use rand::Rng; use reth_chainspec::{ChainSpec, ChainSpecProvider}; use reth_ethereum_primitives::TxType; @@ -1988,7 +1956,7 @@ mod tests { let header = alloy_consensus::Header { number: block_number, parent_hash: prev_hash, - logs_bloom: alloy_primitives::Bloom::from([1u8; 256]), + logs_bloom: Bloom::repeat_byte(1u8), ..Default::default() }; let hash = header.hash_slow(); @@ -2062,7 +2030,7 @@ mod tests { parent_hash: prev_hash, // Set bloom to match filter only for blocks 100 and 102 logs_bloom: if i == 100 || i == 102 { - alloy_primitives::Bloom::from([1u8; 256]) + Bloom::repeat_byte(1u8) } else { alloy_primitives::Bloom::default() }, @@ -2206,7 +2174,7 @@ mod tests { let header = alloy_consensus::Header { number, parent_hash, - logs_bloom: if matches { Bloom::from([1u8; 256]) } else { Bloom::default() }, + logs_bloom: if matches { Bloom::repeat_byte(1u8) } else { Bloom::default() }, ..Default::default() }; parent_hash = header.hash_slow(); @@ -2304,7 +2272,7 @@ mod tests { let header = alloy_consensus::Header { number, parent_hash, - logs_bloom: alloy_primitives::Bloom::from([1u8; 256]), + logs_bloom: Bloom::repeat_byte(1u8), ..Default::default() }; parent_hash = header.hash_slow(); diff --git a/crates/rpc/rpc/src/eth/helpers/signer.rs b/crates/rpc/rpc/src/eth/helpers/signer.rs index 086d8334e28..3a6907c497f 100644 --- a/crates/rpc/rpc/src/eth/helpers/signer.rs +++ b/crates/rpc/rpc/src/eth/helpers/signer.rs @@ -248,8 +248,8 @@ mod tests { gas_price: Some(1000u128), value: Some(U256::from(1000)), input: TransactionInput { - data: Some(Bytes::from(message.to_vec())), - input: Some(Bytes::from(message.to_vec())), + data: Some(Bytes::from(message)), + input: Some(Bytes::from(message)), }, nonce: Some(0u64), ..Default::default() @@ -258,6 +258,6 @@ mod tests { signer.sign_transaction(request, &from).await; assert!(txn_signed.is_ok()); - assert_eq!(Bytes::from(message.to_vec()), txn_signed.unwrap().input().0); + assert_eq!(Bytes::from(message), txn_signed.unwrap().input().0); } } diff --git a/crates/rpc/rpc/src/eth/helpers/state.rs b/crates/rpc/rpc/src/eth/helpers/state.rs index ee30428095b..0e01c9d137a 100644 --- a/crates/rpc/rpc/src/eth/helpers/state.rs +++ b/crates/rpc/rpc/src/eth/helpers/state.rs @@ -116,6 +116,34 @@ mod tests { assert!(account.is_none()); } + #[cfg(feature = "account-ext")] + #[tokio::test] + async fn test_get_account_extension() { + let address = Address::random(); + let extension = reth_primitives_traits::AccountExtension::copy_from_slice(&[0x01]); + let eth_api = mock_eth_api(AddressMap::from_iter([( + address, + ExtendedAccount::new(0, U256::ZERO).with_extension(extension.clone()), + )])); + eth_api.provider().add_block(B256::ZERO, Block::default()); + + // An account whose only non-default field is its extension still exists. + let info = eth_api.get_account_info(address, Default::default()).await.unwrap(); + assert!(!info.is_empty()); + assert_eq!(info.extension, extension); + let json = serde_json::to_value(&info).unwrap(); + assert_eq!(json["extension"], "0x01"); + assert_eq!(serde_json::from_value::(json).unwrap(), info); + + let account = eth_api.get_account(address, Default::default()).await.unwrap().unwrap(); + assert_eq!(account.extension, extension); + + let missing = + eth_api.get_account_info(Address::random(), Default::default()).await.unwrap(); + assert!(missing.is_empty()); + assert!(serde_json::to_value(missing).unwrap().get("extension").is_none()); + } + #[test] fn pending_state_and_access_list_do_not_deadlock() { let runtime = tokio::runtime::Builder::new_multi_thread() diff --git a/crates/rpc/rpc/src/eth/helpers/transaction.rs b/crates/rpc/rpc/src/eth/helpers/transaction.rs index 15546b68442..c174a0bbd3c 100644 --- a/crates/rpc/rpc/src/eth/helpers/transaction.rs +++ b/crates/rpc/rpc/src/eth/helpers/transaction.rs @@ -128,11 +128,12 @@ mod tests { use super::*; use crate::eth::helpers::{signer::DevSigner, types::EthRpcConverter}; - use alloy_consensus::{ - BlobTransactionSidecar, Block, Header, SidecarBuilder, SimpleCoder, Transaction, + use alloy_consensus::{BlobTransactionSidecar, Block, Header, Transaction}; + use alloy_eips::{ + eip2718::Encodable2718, + eip4844::{Blob, Bytes48}, }; - use alloy_eips::eip2718::Encodable2718; - use alloy_primitives::{map::AddressMap, Address, Bytes, Signature, U256}; + use alloy_primitives::{bytes, map::AddressMap, Address, Bytes, Signature, U256}; use alloy_rpc_types_eth::request::TransactionRequest; use reth_chainspec::{ChainSpec, ChainSpecBuilder}; use reth_ethereum_primitives::TransactionSigned; @@ -147,9 +148,20 @@ mod tests { use reth_rpc_eth_api::node::RpcNodeCoreAdapter; use reth_transaction_pool::{ test_utils::{testing_pool, TestPool, TransactionGenerator}, - EthPooledTransaction, PoolPooledTx, TransactionOrigin, TransactionPool, + PoolPooledTx, TransactionOrigin, TransactionPool, }; + /// A sidecar with one zero blob. Its commitment and proof are the point at infinity, so it is + /// valid without loading the KZG trusted setup. + fn zero_blob_sidecar() -> BlobTransactionSidecar { + let mut point_at_infinity = [0; 48]; + point_at_infinity[0] = 0xc0; + BlobTransactionSidecar::new( + vec![Blob::default()], + vec![Bytes48::from(point_at_infinity)], + vec![Bytes48::from(point_at_infinity)], + ) + } fn mock_eth_api( accounts: AddressMap, ) -> EthApi< @@ -202,9 +214,7 @@ mod tests { fn raw_transfer_tx() -> Bytes { // https://etherscan.io/tx/0xa694b71e6c128a2ed8e2e0f6770bddbe52e3bb8f10e8472f9a79ab81497a8b5d - Bytes::from(hex!( - "02f871018303579880850555633d1b82520894eee27662c2b8eba3cd936a23f039f3189633e4c887ad591c62bdaeb180c080a07ea72c68abfb8fca1bd964f0f99132ed9280261bdca3e549546c0205e800f7d0a05b4ef3039e9c9b9babc179a1878fb825b5aaf5aed2fa8744854150157b08d6f3" - )) + bytes!("02f871018303579880850555633d1b82520894eee27662c2b8eba3cd936a23f039f3189633e4c887ad591c62bdaeb180c080a07ea72c68abfb8fca1bd964f0f99132ed9280261bdca3e549546c0205e800f7d0a05b4ef3039e9c9b9babc179a1878fb825b5aaf5aed2fa8744854150157b08d6f3") } #[tokio::test] @@ -303,10 +313,7 @@ mod tests { ) .sender_recovery_cache(Some(cache.clone())) .build(); - let mut builder = SidecarBuilder::::new(); - builder.ingest(b"cached blob transaction"); - let sidecar = - BlobTransactionSidecarVariant::from(builder.build::().unwrap()); + let sidecar = BlobTransactionSidecarVariant::from(zero_blob_sidecar()); let transaction = TransactionSigned::new_unhashed( alloy_consensus::TxEip4844 { blob_versioned_hashes: sidecar.versioned_hashes().collect(), @@ -330,22 +337,6 @@ mod tests { } } - #[tokio::test] - async fn send_raw_transaction_sync_populates_sender_cache() { - let cache = SenderRecoveryCache::new(16); - let eth_api = mock_eth_api_builder(Default::default()) - .sender_recovery_cache(Some(cache.clone())) - .build(); - let raw = raw_transfer_tx(); - let transaction = EthPooledTransaction::decode_raw_transaction(&raw).unwrap(); - let sender = transaction.try_recover().unwrap(); - - let err = eth_api.send_raw_transaction_sync(raw, Some(1)).await.unwrap_err(); - assert!(matches!(err, EthApiError::TransactionConfirmationTimeout { .. })); - assert_eq!(cache.get(transaction.tx_hash()), Some(sender)); - assert_eq!(eth_api.pool().len(), 1); - } - #[tokio::test] async fn send_raw_transaction() { let eth_api = mock_eth_api(Default::default()); @@ -362,9 +353,7 @@ mod tests { ); // https://etherscan.io/tx/0x48816c2f32c29d152b0d86ff706f39869e6c1f01dc2fe59a3c1f9ecf39384694 - let tx_2 = Bytes::from(hex!( - "02f9043c018202b7843b9aca00850c807d37a08304d21d94ef1c6e67703c7bd7107eed8303fbe6ec2554bf6b881bc16d674ec80000b903c43593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000063e2d99f00000000000000000000000000000000000000000000000000000000000000030b000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000065717fe021ea67801d1088cc80099004b05b64600000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009e95fd5965fd1f1a6f0d4600000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000428dca9537116148616a5a3e44035af17238fe9dc080a0c6ec1e41f5c0b9511c49b171ad4e04c6bb419c74d99fe9891d74126ec6e4e879a032069a753d7a2cfa158df95421724d24c0e9501593c09905abf3699b4a4405ce" - )); + let tx_2 = bytes!("02f9043c018202b7843b9aca00850c807d37a08304d21d94ef1c6e67703c7bd7107eed8303fbe6ec2554bf6b881bc16d674ec80000b903c43593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000063e2d99f00000000000000000000000000000000000000000000000000000000000000030b000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000065717fe021ea67801d1088cc80099004b05b64600000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002bc02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009e95fd5965fd1f1a6f0d4600000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000428dca9537116148616a5a3e44035af17238fe9dc080a0c6ec1e41f5c0b9511c49b171ad4e04c6bb419c74d99fe9891d74126ec6e4e879a032069a753d7a2cfa158df95421724d24c0e9501593c09905abf3699b4a4405ce"); let tx_2_result = eth_api.send_raw_transaction(tx_2).await.unwrap(); assert_eq!( @@ -480,7 +469,7 @@ mod tests { let tx_req = TransactionRequest { from: Some(address), to: Some(address.into()), - value: Some(U256::from(1)), + value: Some(U256::ONE), ..Default::default() }; @@ -664,16 +653,11 @@ mod tests { let eth_api = mock_eth_api(accounts); - let mut builder = SidecarBuilder::::new(); - builder.ingest(b"dummy blob"); - // EIP-4844 blob transaction with versioned hashes but no blob fee let tx_req = TransactionRequest { from: Some(address), to: Some(Address::random().into()), - sidecar: Some(BlobTransactionSidecarVariant::from( - builder.build::().unwrap(), - )), + sidecar: Some(BlobTransactionSidecarVariant::from(zero_blob_sidecar())), ..Default::default() }; @@ -703,17 +687,12 @@ mod tests { let provided_blob_fee = 5000000u128; - let mut builder = SidecarBuilder::::new(); - builder.ingest(b"dummy blob"); - // EIP-4844 blob transaction with blob fee already set let tx_req = TransactionRequest { from: Some(address), to: Some(Address::random().into()), transaction_type: Some(3), // EIP-4844 - sidecar: Some(BlobTransactionSidecarVariant::from( - builder.build::().unwrap(), - )), + sidecar: Some(BlobTransactionSidecarVariant::from(zero_blob_sidecar())), max_fee_per_blob_gas: Some(provided_blob_fee), // Already set ..Default::default() }; diff --git a/crates/rpc/rpc/src/eth/sim_bundle.rs b/crates/rpc/rpc/src/eth/sim_bundle.rs index 288230d4851..1ef6429106a 100644 --- a/crates/rpc/rpc/src/eth/sim_bundle.rs +++ b/crates/rpc/rpc/src/eth/sim_bundle.rs @@ -1,7 +1,6 @@ //! `Eth` Sim bundle implementation and helpers. use alloy_consensus::{transaction::TxHashRef, BlockHeader}; -use alloy_eips::BlockNumberOrTag; use alloy_evm::{env::BlockEnvironment, overrides::apply_block_overrides}; use alloy_primitives::U256; use alloy_rpc_types_eth::{BlockId, Log}; @@ -143,7 +142,7 @@ impl EthSimBundle { impl EthSimBundle where - Eth: EthTransactions + LoadBlock + Call + 'static, + Eth: EthTransactions + LoadBlock + Call, { /// Flattens a potentially nested bundle into a list of individual transactions in a /// `FlattenedBundleItem` with their associated metadata. This handles recursive bundle @@ -292,7 +291,7 @@ where // Also, flatten the bundle here so that its easier to process let flattened_bundle = self.parse_and_flatten_bundle(&request)?; - let block_id = parent_block.unwrap_or(BlockId::Number(BlockNumberOrTag::Latest)); + let block_id = parent_block.unwrap_or(BlockId::latest()); let (parent, _, parent_block_id) = self.eth_api().evm_env_and_recovered_block_at(block_id).await?; @@ -494,7 +493,7 @@ where #[async_trait::async_trait] impl MevSimApiServer for EthSimBundle where - Eth: EthTransactions + LoadBlock + Call + 'static, + Eth: EthTransactions + LoadBlock + Call, { async fn sim_bundle( &self, @@ -659,7 +658,7 @@ mod tests { (SimBundleOverrides::default(), 1, 112, 875_000_000, coinbase), ( SimBundleOverrides { - parent_block: Some(BlockNumberOrTag::Number(0).into()), + parent_block: Some(BlockId::number(0)), block_overrides: BlockOverrides { number: Some(U256::from(2)), time: Some(200), diff --git a/crates/rpc/rpc/src/net.rs b/crates/rpc/rpc/src/net.rs index 3df3b63d0fb..0647c15b125 100644 --- a/crates/rpc/rpc/src/net.rs +++ b/crates/rpc/rpc/src/net.rs @@ -27,7 +27,7 @@ impl NetApi { impl NetApiServer for NetApi where Net: PeersInfo + 'static, - Eth: EthApiSpec + 'static, + Eth: EthApiSpec, { /// Handler for `net_version` fn version(&self) -> Result { diff --git a/crates/rpc/rpc/src/otterscan.rs b/crates/rpc/rpc/src/otterscan.rs index 54355746a60..6fe69138517 100644 --- a/crates/rpc/rpc/src/otterscan.rs +++ b/crates/rpc/rpc/src/otterscan.rs @@ -19,7 +19,7 @@ use reth_rpc_api::{EthApiServer, OtterscanServer}; use reth_rpc_convert::RpcTxReq; use reth_rpc_eth_api::{ helpers::{EthTransactions, TraceExt}, - FullEthApiTypes, RpcBlock, RpcHeader, RpcReceipt, RpcTransaction, + RpcBlock, RpcHeader, RpcReceipt, RpcTransaction, }; use reth_rpc_eth_types::{utils::binary_search, EthApiError}; use reth_rpc_server_types::result::internal_rpc_err; @@ -31,6 +31,7 @@ use revm_inspectors::{ TracingInspectorConfig, }, }; +use tokio::sync::OwnedSemaphorePermit; const API_LEVEL: u64 = 8; @@ -49,7 +50,7 @@ impl OtterscanApi { impl OtterscanApi where - Eth: FullEthApiTypes + TraceExt, + Eth: TraceExt, { /// Constructs a `BlockDetails` from a block and its receipts. async fn block_details( @@ -95,6 +96,13 @@ where }; Ok(BlockDetails::new(block, issuance, total_fees)) } + + /// Acquires a permit to execute a tracing call. + /// + /// The permit should be moved into the spawned trace so it is held until the trace completes. + async fn acquire_trace_permit(&self) -> RpcResult { + Ok(self.eth.acquire_owned_tracing().await.map_err(|_| EthApiError::InternalEthError)?) + } } #[async_trait] @@ -109,8 +117,7 @@ where RpcHeader, TxTy, > + EthTransactions - + TraceExt - + 'static, + + TraceExt, { /// Handler for `ots_getHeaderByNumber` and `erigon_getHeaderByNumber` async fn get_header_by_number( @@ -132,11 +139,15 @@ where /// Handler for `ots_getInternalOperations` async fn get_internal_operations(&self, tx_hash: TxHash) -> RpcResult> { + let permit = self.acquire_trace_permit().await?; self.eth .spawn_trace_transaction_in_block_with_inspector( tx_hash, InternalOperationsInspector::default(), - |_tx_info, inspector, _, _| Ok(inspector.into_operations()), + move |_tx_info, inspector, _, _| { + let _permit = permit; + Ok(inspector.into_operations()) + }, ) .await .map_err(Into::into) @@ -145,19 +156,25 @@ where /// Handler for `ots_getTransactionError` async fn get_transaction_error(&self, tx_hash: TxHash) -> RpcResult> { + let permit = self.acquire_trace_permit().await?; self.eth - .spawn_replay_transaction(tx_hash, |_tx_info, res, _| Ok(transaction_error(res.result))) + .spawn_replay_transaction(tx_hash, move |_tx_info, res, _| { + let _permit = permit; + Ok(transaction_error(res.result)) + }) .await .map_err(Into::into) } /// Handler for `ots_traceTransaction` async fn trace_transaction(&self, tx_hash: TxHash) -> RpcResult>> { + let permit = self.acquire_trace_permit().await?; self.eth .spawn_trace_transaction_in_block( tx_hash, TracingInspectorConfig::default_parity(), - |_tx_info, inspector, _, _| { + move |_tx_info, inspector, _, _| { + let _permit = permit; Ok(otterscan_traces(inspector.into_traces().into_nodes())) }, ) @@ -314,6 +331,7 @@ where return Ok(None); } + let permit = self.acquire_trace_permit().await?; let num = binary_search::<_, _, ErrorObjectOwned>( 1, self.eth.block_number()?.saturating_to(), @@ -333,7 +351,8 @@ where num.into(), None, TracingInspectorConfig::default_parity(), - |tx_info, mut ctx| { + move |tx_info, mut ctx| { + let _permit = &permit; Ok(ctx .take_inspector() .into_parity_builder() @@ -456,9 +475,10 @@ fn otterscan_traces(nodes: Vec) -> Vec { #[cfg(test)] mod tests { + use super::*; use alloy_consensus::{constants::ETH_TO_WEI, Header}; - use alloy_primitives::{hex, TxKind}; + use alloy_primitives::{bytes, TxKind}; use alloy_rpc_types_trace::parity::TransactionTrace; use reth_chainspec::MAINNET; use reth_evm_ethereum::EthEvmConfig; @@ -686,6 +706,36 @@ mod tests { } } + #[tokio::test] + async fn trace_methods_wait_for_tracing_permit() { + use reth_rpc_eth_api::helpers::SpawnBlocking; + use std::time::Duration; + + let api = OtterscanApi::new( + crate::eth::EthApiBuilder::new( + MockEthProvider::default(), + testing_pool(), + NoopNetwork::default(), + EthEvmConfig::new(MAINNET.clone()), + ) + .proof_permits(1) + .build(), + ); + let permit = api.eth.acquire_owned_tracing().await.unwrap(); + + let timeout = Duration::from_millis(100); + assert!(tokio::time::timeout(timeout, api.trace_transaction(B256::ZERO)).await.is_err()); + assert!(tokio::time::timeout(timeout, api.get_transaction_error(B256::ZERO)) + .await + .is_err()); + assert!(tokio::time::timeout(timeout, api.get_internal_operations(B256::ZERO)) + .await + .is_err()); + + drop(permit); + assert_eq!(api.trace_transaction(B256::ZERO).await.unwrap(), None); + } + #[tokio::test] async fn execution_fees_use_full_u256_width() { let api = OtterscanApi::new( @@ -771,7 +821,7 @@ mod tests { #[test] fn selfdestruct_preserves_enclosing_call_and_beneficiary() { for spec in [SpecId::SHANGHAI, SpecId::CANCUN] { - let (result, traces) = execute(hex!("6022ff").into(), spec); + let (result, traces) = execute(bytes!("6022ff"), spec); assert!(result.is_success()); assert_eq!(traces.len(), 2); assert_eq!(traces[0].r#type, "CALL"); @@ -790,7 +840,7 @@ mod tests { // Call another account, then destroy the root contract. The destruction is a sibling // of that call, not its child. let (result, traces) = - execute(hex!("60006000600060006000603361fffff1506022ff").into(), SpecId::CANCUN); + execute(bytes!("60006000600060006000603361fffff1506022ff"), SpecId::CANCUN); assert!(result.is_success()); assert_eq!(traces.len(), 3); assert_eq!(traces[1].r#type, "CALL"); @@ -846,7 +896,7 @@ mod tests { #[test] fn precompile_calls_remain_in_transaction_traces() { let (result, traces) = - execute(hex!("60006000600060006000600461fffff15000").into(), SpecId::CANCUN); + execute(bytes!("60006000600060006000600461fffff15000"), SpecId::CANCUN); assert!(result.is_success()); assert_eq!(traces.len(), 2); assert_eq!(traces[1].to, Address::with_last_byte(4)); @@ -907,7 +957,7 @@ mod tests { fn static_and_delegate_calls_have_no_value() { // STATICCALL, DELEGATECALL and CALLCODE to 0x22 do not transfer ETH. let (result, traces) = execute( - hex!("6000600060006000602261fffffa506000600060006000602261fffff45060006000600060006001602261fffff25000").into(), + bytes!("6000600060006000602261fffffa506000600060006000602261fffff45060006000600060006001602261fffff25000"), SpecId::CANCUN, ); assert!(result.is_success()); @@ -917,6 +967,6 @@ mod tests { assert_eq!(traces[2].r#type, "DELEGATECALL"); assert_eq!(traces[2].value, None); assert_eq!(traces[3].r#type, "CALLCODE"); - assert_eq!(traces[3].value, Some(U256::from(1))); + assert_eq!(traces[3].value, Some(U256::ONE)); } } diff --git a/crates/rpc/rpc/src/testing.rs b/crates/rpc/rpc/src/testing.rs index 24b8381a4be..c6be2b05c5d 100644 --- a/crates/rpc/rpc/src/testing.rs +++ b/crates/rpc/rpc/src/testing.rs @@ -23,8 +23,9 @@ use alloy_primitives::{ }; use alloy_rlp::Encodable; use alloy_rpc_types_engine::{ - BlobsBundleV2, ExecutionData, ExecutionPayloadEnvelopeV5, ExecutionPayloadSidecar, - ExecutionPayloadV3, ForkchoiceState, PayloadAttributes, PraguePayloadFields, + BlobsBundleV2, ExecutionData, ExecutionPayloadEnvelopeV5, ExecutionPayloadEnvelopeV6, + ExecutionPayloadSidecar, ExecutionPayloadV3, ExecutionPayloadV4, ForkchoiceState, + PayloadAttributes, PraguePayloadFields, }; use async_trait::async_trait; use jsonrpsee::core::RpcResult; @@ -40,7 +41,7 @@ use reth_primitives_traits::{ AlloyBlockHeader as BlockTrait, Block as _, HeaderTy, TxTy, }; use reth_revm::{database::StateProviderDatabase, db::State}; -use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1}; +use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1, TestingBuildBlockResponseV1}; use reth_rpc_eth_api::{helpers::Call, FromEthApiError}; use reth_rpc_eth_types::EthApiError; use reth_storage_api::{BlockReader, BlockReaderIdExt, HeaderProvider}; @@ -288,23 +289,37 @@ where &self, request: TestingBuildBlockRequestV1, use_pool_transactions: bool, - ) -> Result { + ) -> Result { let payload = self .build_payload_v1(request, self.skip_invalid_transactions, use_pool_transactions) .await?; let fees = payload.fees(); let requests = payload.requests().unwrap_or_default(); + let block_access_list = payload.block_access_list().cloned(); let block = Arc::unwrap_or_clone(payload.into_block_arc()); let block_hash = block.hash(); let block = block.into_block().into_ethereum_block(); - Ok(ExecutionPayloadEnvelopeV5 { - execution_payload: ExecutionPayloadV3::from_block_unchecked(block_hash, &block), + let Some(block_access_list) = block_access_list else { + return Ok(TestingBuildBlockResponseV1::V5(ExecutionPayloadEnvelopeV5 { + execution_payload: ExecutionPayloadV3::from_block_unchecked(block_hash, &block), + block_value: fees, + blobs_bundle: BlobsBundleV2::empty(), + should_override_builder: false, + execution_requests: requests, + })) + }; + Ok(TestingBuildBlockResponseV1::V6(ExecutionPayloadEnvelopeV6 { + execution_payload: ExecutionPayloadV4::from_block_unchecked_with_bal( + block_hash, + &block, + block_access_list, + ), block_value: fees, blobs_bundle: BlobsBundleV2::empty(), should_override_builder: false, execution_requests: requests, - }) + })) } async fn commit_block_v1( @@ -420,7 +435,7 @@ where payload_attributes: PayloadAttributes, transactions: Option>, extra_data: Option, - ) -> RpcResult { + ) -> RpcResult { let use_pool_transactions = transactions.is_none(); let request = TestingBuildBlockRequestV1 { parent_block_hash, diff --git a/crates/rpc/rpc/src/trace.rs b/crates/rpc/rpc/src/trace.rs index 4aa98ac83fe..f694deffe61 100644 --- a/crates/rpc/rpc/src/trace.rs +++ b/crates/rpc/rpc/src/trace.rs @@ -1,6 +1,6 @@ use alloy_consensus::BlockHeader as _; -use alloy_eips::BlockId; -use alloy_evm::block::calc::{base_block_reward_pre_merge, block_reward, ommer_reward}; +use alloy_eips::{BlockId, BlockNumHash}; +use alloy_evm::block::calc::{base_block_reward, block_reward, ommer_reward}; use alloy_primitives::{ map::{HashMap, HashSet}, Address, BlockHash, Bytes, B256, U256, @@ -18,17 +18,17 @@ use alloy_rpc_types_trace::{ use async_trait::async_trait; use futures::{FutureExt, StreamExt}; use jsonrpsee::core::RpcResult; -use reth_chainspec::{ChainSpecProvider, EthereumHardforks}; +use reth_chainspec::ChainSpecProvider; use reth_primitives_traits::{BlockBody, BlockHeader}; use reth_rpc_api::TraceApiServer; use reth_rpc_convert::RpcTxReq; use reth_rpc_eth_api::{ - helpers::{Call, LoadPendingBlock, LoadTransaction, Trace, TraceExt}, + helpers::{LoadTransaction, Trace, TraceExt}, FromEthApiError, RpcNodeCore, }; use reth_rpc_eth_types::{error::EthApiError, EthConfig}; use reth_storage_api::{BlockNumReader, BlockReader}; -use reth_tasks::pool::BlockingTaskGuard; +use reth_tasks::{cancel::is_cancelled, pool::BlockingTaskGuard}; use reth_transaction_pool::{PoolPooledTx, PoolTransaction, TransactionPool}; use revm::DatabaseCommit; use revm_inspectors::{ @@ -91,7 +91,7 @@ impl TraceApi where // tracing methods do _not_ read from mempool, hence no `LoadBlock` trait // bound - Eth: Trace + Call + LoadPendingBlock + LoadTransaction + 'static, + Eth: Trace + LoadTransaction, { /// Executes the given call and returns a number of possible traces for it. pub async fn trace_call( @@ -164,6 +164,9 @@ where let mut calls = calls.into_iter().peekable(); while let Some((call, trace_types)) = calls.next() { + if is_cancelled() { + return Err(EthApiError::InternalEthError.into()) + } let (evm_env, tx_env) = eth_api.prepare_call_env( evm_env.clone(), call, @@ -305,17 +308,12 @@ where /// - the genesis block is not mined, so no block rewards are given /// - if Paris hardfork is activated, no block rewards are given /// - if Paris hardfork is not activated, calculate block rewards with block number only - fn calculate_base_block_reward( - &self, - header: &H, - ) -> Result, Eth::Error> { - let chain_spec = self.provider().chain_spec(); - - if header.number() == 0 || chain_spec.is_paris_active_at_block(header.number()) { - return Ok(None) + fn calculate_base_block_reward(&self, header: &H) -> Option { + if header.number() == 0 { + return None } - Ok(Some(base_block_reward_pre_merge(&chain_spec, header.number()))) + base_block_reward(self.provider().chain_spec(), header.number()) } /// Extracts the reward traces for the given block: @@ -331,30 +329,29 @@ where let ommers_cnt = ommers.map(|o| o.len()).unwrap_or_default(); let mut traces = Vec::with_capacity(ommers_cnt + 1); + let block = BlockNumHash::new(header.number(), block_hash); let block_reward = block_reward(base_block_reward, ommers_cnt); - traces.push(reward_trace( - block_hash, - header, + traces.push( RewardAction { author: header.beneficiary(), reward_type: RewardType::Block, value: U256::from(block_reward), - }, - )); + } + .into_localized_trace(block), + ); let Some(ommers) = ommers else { return traces }; for uncle in ommers { let uncle_reward = ommer_reward(base_block_reward, header.number(), uncle.number()); - traces.push(reward_trace( - block_hash, - header, + traces.push( RewardAction { author: uncle.beneficiary(), reward_type: RewardType::Uncle, value: U256::from(uncle_reward), - }, - )); + } + .into_localized_trace(block), + ); } traces } @@ -364,7 +361,7 @@ impl TraceApi where // tracing methods read from mempool, hence `LoadBlock` trait bound via // `TraceExt` - Eth: TraceExt + 'static, + Eth: TraceExt, { /// Returns all transaction traces that match the given filter. /// @@ -475,7 +472,7 @@ where let (block, traces) = block_replay?; let reward_traces = if include_reward_traces { if let Some(base_block_reward) = - self.calculate_base_block_reward(block.header())? + self.calculate_base_block_reward(block.header()) { self.extract_reward_traces( block.header(), @@ -548,7 +545,7 @@ where .map(|traces| traces.into_iter().flatten().collect::>()); if let Some(traces) = traces.as_mut() && - let Some(base_block_reward) = self.calculate_base_block_reward(block.header())? + let Some(base_block_reward) = self.calculate_base_block_reward(block.header()) { traces.extend(self.extract_reward_traces( block.header(), @@ -704,7 +701,7 @@ fn apply_trace_filter_pagination( #[async_trait] impl TraceApiServer> for TraceApi where - Eth: TraceExt + 'static, + Eth: TraceExt, { /// Executes the given call and returns a number of possible traces for it. /// @@ -872,34 +869,13 @@ pub struct BlockStorageAccess { pub transactions: Vec, } -/// Helper to construct a [`LocalizedTransactionTrace`] that describes a reward to the block -/// beneficiary. -fn reward_trace( - block_hash: BlockHash, - header: &H, - reward: RewardAction, -) -> LocalizedTransactionTrace { - LocalizedTransactionTrace { - block_hash: Some(block_hash), - block_number: Some(header.number()), - transaction_hash: None, - transaction_position: None, - trace: TransactionTrace { - trace_address: vec![], - subtraces: 0, - action: Action::Reward(reward), - error: None, - result: None, - }, - } -} - #[cfg(test)] mod tests { use super::*; use crate::EthApiBuilder; use alloy_consensus::Header; use alloy_genesis::Genesis; + use alloy_primitives::bytes; use alloy_rpc_types_eth::TransactionRequest; use reth_chainspec::ChainSpecBuilder; use reth_db_common::init::init_genesis; @@ -923,8 +899,7 @@ mod tests { // Return NUMBER as a 32-byte word. provider.add_account( target, - ExtendedAccount::new(0, U256::ZERO) - .with_bytecode("4360005260206000f3".parse().unwrap()), + ExtendedAccount::new(0, U256::ZERO).with_bytecode(bytes!("4360005260206000f3")), ); let header = Header { number: 1, gas_limit: 30_000_000, ..Default::default() }; provider.add_block(header.hash_slow(), Block { header, body: BlockBody::default() }); @@ -946,7 +921,7 @@ mod tests { let pending = api.trace_call_many(calls, Some(BlockId::pending())).await.unwrap(); assert_eq!(omitted, latest); assert_ne!(omitted, pending); - assert_eq!(U256::from_be_slice(&omitted[0].output), U256::from(1)); + assert_eq!(U256::from_be_slice(&omitted[0].output), U256::ONE); assert_eq!(U256::from_be_slice(&pending[0].output), U256::from(2)); let module = api.into_rpc(); @@ -1026,7 +1001,7 @@ mod tests { TxLegacy { gas_limit: 500_000, to: TxKind::Call(Address::with_last_byte(0x42)), - value: U256::from(1), + value: U256::ONE, ..Default::default() } .into(), @@ -1146,7 +1121,7 @@ mod tests { TxLegacy { gas_limit: 21_000, to: TxKind::Call(Address::with_last_byte(0x42)), - value: U256::from(1), + value: U256::ONE, ..Default::default() } .into(), @@ -1266,23 +1241,8 @@ mod tests { } fn localized_reward_trace(block_number: u64) -> LocalizedTransactionTrace { - LocalizedTransactionTrace { - block_hash: Some(B256::ZERO), - block_number: Some(block_number), - transaction_hash: None, - transaction_position: None, - trace: TransactionTrace { - trace_address: vec![], - subtraces: 0, - action: Action::Reward(RewardAction { - author: Address::ZERO, - reward_type: RewardType::Block, - value: U256::ZERO, - }), - error: None, - result: None, - }, - } + RewardAction { author: Address::ZERO, reward_type: RewardType::Block, value: U256::ZERO } + .into_localized_trace(BlockNumHash::new(block_number, B256::ZERO)) } fn trace_order(traces: &[LocalizedTransactionTrace]) -> Vec<(u64, Option, bool)> { @@ -1318,7 +1278,7 @@ mod tests { async fn replay_block_vmtrace_includes_root_and_callcode_bytecode() { use crate::EthApiBuilder; use alloy_consensus::{Header, TxLegacy}; - use alloy_primitives::{hex, Signature, TxKind}; + use alloy_primitives::{Signature, TxKind}; use reth_chain_state::CanonStateNotification; use reth_ethereum_primitives::{Block, BlockBody, TransactionSigned}; use reth_evm_ethereum::EthEvmConfig; @@ -1334,9 +1294,8 @@ mod tests { let child = Address::with_last_byte(0x43); // Increment slot zero, CALLCODE the child, then return the incremented value. let root_code: Bytes = - hex!("60005460010160005560006000600060006000604361fffff25060005460005260206000f3") - .into(); - let child_code: Bytes = hex!("60025000").into(); + bytes!("60005460010160005560006000600060006000604361fffff25060005460005260206000f3"); + let child_code: Bytes = bytes!("60025000"); provider.add_account( target, ExtendedAccount::new(0, U256::ZERO).with_bytecode(root_code.clone()), diff --git a/crates/rpc/rpc/src/txpool.rs b/crates/rpc/rpc/src/txpool.rs index 850b4797bcf..fbc6295d27a 100644 --- a/crates/rpc/rpc/src/txpool.rs +++ b/crates/rpc/rpc/src/txpool.rs @@ -90,7 +90,7 @@ where impl TxPoolApiServer> for TxPoolApi where Pool: TransactionPool> + 'static, - Eth: RpcConvert>> + 'static, + Eth: RpcConvert>>, { /// Returns the number of transactions currently pending for inclusion in the next block(s), as /// well as the ones that are being scheduled for future execution only. diff --git a/crates/rpc/rpc/src/validation.rs b/crates/rpc/rpc/src/validation.rs index 2cd5cbaf854..e2895ad6e81 100644 --- a/crates/rpc/rpc/src/validation.rs +++ b/crates/rpc/rpc/src/validation.rs @@ -330,7 +330,7 @@ where } let (mut balance_before, balance_after) = if let Some(acc) = - output.state.state.get(&message.proposer_fee_recipient) + output.state.account(&message.proposer_fee_recipient) { let balance_before = acc.original_info.as_ref().map(|i| i.balance).unwrap_or_default(); let balance_after = acc.info.as_ref().map(|i| i.balance).unwrap_or_default(); @@ -948,8 +948,8 @@ mod tests { #[test] fn test_hash_disallow_list_deterministic() { let mut addresses = AddressSet::default(); - addresses.insert(Address::from([1u8; 20])); - addresses.insert(Address::from([2u8; 20])); + addresses.insert(Address::repeat_byte(1u8)); + addresses.insert(Address::repeat_byte(2u8)); let hash1 = hash_disallow_list(&addresses); let hash2 = hash_disallow_list(&addresses); @@ -960,10 +960,10 @@ mod tests { #[test] fn test_hash_disallow_list_different_content() { let mut addresses1 = AddressSet::default(); - addresses1.insert(Address::from([1u8; 20])); + addresses1.insert(Address::repeat_byte(1u8)); let mut addresses2 = AddressSet::default(); - addresses2.insert(Address::from([2u8; 20])); + addresses2.insert(Address::repeat_byte(2u8)); let hash1 = hash_disallow_list(&addresses1); let hash2 = hash_disallow_list(&addresses2); @@ -974,12 +974,12 @@ mod tests { #[test] fn test_hash_disallow_list_order_independent() { let mut addresses1 = AddressSet::default(); - addresses1.insert(Address::from([1u8; 20])); - addresses1.insert(Address::from([2u8; 20])); + addresses1.insert(Address::repeat_byte(1u8)); + addresses1.insert(Address::repeat_byte(2u8)); let mut addresses2 = AddressSet::default(); - addresses2.insert(Address::from([2u8; 20])); // Different insertion order - addresses2.insert(Address::from([1u8; 20])); + addresses2.insert(Address::repeat_byte(2u8)); // Different insertion order + addresses2.insert(Address::repeat_byte(1u8)); let hash1 = hash_disallow_list(&addresses1); let hash2 = hash_disallow_list(&addresses2); diff --git a/crates/rpc/rpc/src/validation/blob_cache.rs b/crates/rpc/rpc/src/validation/blob_cache.rs index e71bf6bf32d..bcdecd4e08b 100644 --- a/crates/rpc/rpc/src/validation/blob_cache.rs +++ b/crates/rpc/rpc/src/validation/blob_cache.rs @@ -173,8 +173,10 @@ mod tests { #[test] fn rejects_incomplete_bundles_without_changing_cache() { let cache = BlobValidationCache::default(); - let mut v2 = valid_v2_bundle(b"blob"); - v2.proofs.pop(); + let mut v2 = BlobsBundleV2::empty(); + v2.blobs.push(Blob::default()); + v2.commitments.push(Bytes48::ZERO); + v2.proofs = vec![Bytes48::ZERO; CELLS_PER_EXT_BLOB - 1]; assert!(matches!(cache.validate(v2), Err(ValidationApiError::InvalidBlobsBundle))); assert!(cache.entries.lock().is_empty()); } diff --git a/crates/snap-sync/Cargo.toml b/crates/snap-sync/Cargo.toml index afd5eaf5a91..56b1cc5b28b 100644 --- a/crates/snap-sync/Cargo.toml +++ b/crates/snap-sync/Cargo.toml @@ -19,7 +19,8 @@ reth-eth-wire-types.workspace = true reth-network-p2p.workspace = true reth-network-peers.workspace = true reth-primitives-traits = { workspace = true, features = ["eip7928"] } -reth-storage-api = { workspace = true, features = ["db-api"] } +reth-prune-types.workspace = true +reth-storage-api = { workspace = true, features = ["db-api", "std"] } reth-stages-types.workspace = true reth-storage-errors.workspace = true reth-tasks.workspace = true @@ -27,13 +28,14 @@ reth-trie-common.workspace = true # alloy alloy-eip7928.workspace = true -alloy-eips.workspace = true +alloy-eips = { workspace = true, features = ["serde"] } alloy-primitives.workspace = true # revm revm.workspace = true # misc +futures.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true @@ -43,7 +45,6 @@ tracing.workspace = true [dev-dependencies] alloy-consensus.workspace = true alloy-rlp.workspace = true -futures.workspace = true reth-chainspec.workspace = true reth-ethereum-primitives.workspace = true reth-evm.workspace = true diff --git a/crates/snap-sync/README.md b/crates/snap-sync/README.md index 374be8529aa..c4718bd319a 100644 --- a/crates/snap-sync/README.md +++ b/crates/snap-sync/README.md @@ -24,9 +24,12 @@ This crate owns the synchronization logic and its progress. Requests and proof c by default the sync re-anchors to a newer block. BALs of the blocks in between carry the state already downloaded forward, applied strictly in block order, and the remaining ranges download at the new root. -4. **Hand off.** Once every account is downloaded and the BALs reach the pivot, the state goes to - the merkle stage, which rebuilds the trie. It is accepted only when the root matches the pivot's - header. +4. **Repair what BALs cannot.** BALs only overwrite the fields their blocks change, so entries left + stale for another reason are scheduled for repair. Once the BALs reach the pivot, each scheduled + account and slot is fetched again on its own, proved against the pivot's root. +5. **Hand off.** Once every account is downloaded, the BALs reach the pivot and no repairs remain, + the state goes to the merkle stage, which rebuilds the trie. It is accepted only when the root + matches the pivot's header. ```rust use reth_snap_sync::SnapPivotPolicy; @@ -57,10 +60,25 @@ assert_eq!(policy.pivot_block(4, None), None); - **The trie is rebuilt once, at the end.** The existing merkle stage builds it from the downloaded state, instead of maintaining one during the download. - **Starting over is the fallback.** An attempt restarts when catch-up falls behind the BALs peers - still serve. + still serve, or when a reorg across its pivot cannot be repaired. -Reorg recovery is not implemented yet: a reorg that orphans the pivot is detected and restarts the -attempt, instead of repairing the state from the orphaned blocks' BALs as EIP-8189 describes. +## Reorgs across the pivot + +A reorg that orphans the pivot leaves the downloaded state holding the abandoned branch's changes. +The attempt keeps the number, hash, parent and BAL hash of the last 64 blocks through its pivot, so +it can find the last block both branches share and fetch the orphaned blocks' BALs: + +- Every field and storage slot those BALs changed is scheduled for repair, catch-up rewinds to the + shared block, and the attempt re-anchors to a new canonical pivot. +- Applying the new branch's BALs drops whatever they overwrite, so only entries changed on the + orphaned branch alone stay scheduled. +- Once catch-up reaches the new pivot, those entries are fetched again on their own, proved against + its root, and the hand-off waits until none remain. + +Orphaned BALs no peer serves are waited for while the head is within the served-state window (128 +blocks) of the ancestor. A reorg reaching further back than the kept blocks, orphaned BALs still +unserved past that window, or a reorg after the hand-off to the merkle stage starts the attempt over +instead. snap/1 synchronization is not covered: this design keeps the state current with BALs, which only snap/2 serves, rather than with snap/1's trie-node healing. diff --git a/crates/snap-sync/src/account/download.rs b/crates/snap-sync/src/account/download.rs index 6c01563a341..8b2fc593f6f 100644 --- a/crates/snap-sync/src/account/download.rs +++ b/crates/snap-sync/src/account/download.rs @@ -4,14 +4,15 @@ use crate::{ common::DownloadContext, AccountCoverage, SnapAccountStore, SnapAttemptStore, SnapSyncError, SnapWrite, MAX_HASH, }; -use alloy_primitives::{map::B256Map, B256}; +use alloy_primitives::{map::B256Map, B256, U256}; use reth_db_api::transaction::DbTxMut; use reth_downloaders::snap::{AccountRangeDownloader, AccountRangeOutcome, VerifiedAccountRange}; use reth_eth_wire_types::snap::GetAccountRangeMessage; use reth_network_p2p::snap::client::SnapClient; use reth_network_peers::PeerId; use reth_storage_api::{ - DBProvider, DatabaseProviderFactory, MetadataProvider, MetadataWriter, StateWriter, + BlockHashReader, DBProvider, DatabaseProviderFactory, MetadataProvider, MetadataWriter, + StateWriter, }; use reth_tasks::Runtime; use reth_trie_common::HashedStorage; @@ -51,7 +52,8 @@ where C: SnapClient + Clone + Unpin, F: DatabaseProviderFactory + Clone + 'static, F::Provider: MetadataProvider, - F::ProviderRW: MetadataProvider + MetadataWriter + StateWriter + DBProvider, + F::ProviderRW: + BlockHashReader + MetadataProvider + MetadataWriter + StateWriter + DBProvider, { /// Fetches the range at the coverage cursor. /// @@ -61,29 +63,7 @@ where let (write, root_hash, coverage) = self.active_write()?; self.coverage = Some(coverage); let Some(origin) = coverage.next() else { return Ok(None) }; - - let request = GetAccountRangeMessage { - request_id: self.context.next_request_id(), - root_hash, - starting_hash: origin, - limit_hash: MAX_HASH, - response_bytes: self.context.response_bytes(), - }; - let downloader = AccountRangeDownloader::new( - self.context.client().clone(), - request, - self.context.runtime().clone(), - ) - .expect("origin never exceeds the maximum hash"); - - Ok(Some(match downloader.await? { - AccountRangeOutcome::Verified(range) => { - AccountRangeStep::Verified(VerifiedRange { write, range }) - } - AccountRangeOutcome::Unavailable { peer_id } => { - AccountRangeStep::Unavailable { origin, peer_id } - } - })) + self.request(write, root_hash, origin, MAX_HASH).await.map(Some) } /// Commits `verified` with the storage and code resolved for it, moving the cursor past it. @@ -107,6 +87,62 @@ where Ok(coverage) } + /// Fetches the first account scheduled for repair on its own, proved at the pivot. + /// + /// `Ok(None)` once nothing is scheduled. + pub async fn next_repair(&mut self) -> Result, SnapSyncError> { + let (write, root_hash, _) = self.active_write()?; + let repairs = self.context.factory().database_provider_ro()?.snap_repairs(write)?; + let Some(hashed_address) = repairs.first() else { return Ok(None) }; + self.request(write, root_hash, hashed_address, hashed_address).await.map(Some) + } + + /// Commits the account `verified` repairs with the pivot's values of `slots`, returning how + /// many accounts remain scheduled. + pub async fn commit_repair( + &mut self, + verified: VerifiedRange, + slots: Vec<(B256, U256)>, + ) -> Result { + self.context + .commit(move |provider| { + let VerifiedRange { write, range } = verified; + provider.commit_account_repair(write, &range, slots) + }) + .await + } + + // Requests the accounts from `origin` through `limit` under `root_hash`. + async fn request( + &mut self, + write: SnapWrite, + root_hash: B256, + origin: B256, + limit: B256, + ) -> Result { + let request = GetAccountRangeMessage { + request_id: self.context.next_request_id(), + root_hash, + starting_hash: origin, + limit_hash: limit, + response_bytes: self.context.response_bytes(), + }; + let downloader = AccountRangeDownloader::new( + self.context.client().clone(), + request, + self.context.runtime().clone(), + )?; + + Ok(match downloader.await? { + AccountRangeOutcome::Verified(range) => { + AccountRangeStep::Verified(VerifiedRange { write, range }) + } + AccountRangeOutcome::Unavailable { peer_id } => { + AccountRangeStep::Unavailable { origin, peer_id } + } + }) + } + // The write the attempt accepts right now, the root to request against, and the coverage the // store records for it. fn active_write(&self) -> Result<(SnapWrite, B256, AccountCoverage), SnapSyncError> { @@ -178,16 +214,16 @@ impl VerifiedRange { #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{ - account, account_range, generation, hashed_factory, key, state_root, verified_range, - ScriptedSnapClient, + use crate::{ + test_utils::{ + account, account_range, generation, hashed_factory, hashed_factory_with_roots, key, + state_root, verified_range, ScriptedSnapClient, + }, + StateRepairs, }; use reth_db_api::{cursor::DbCursorRO, tables, transaction::DbTx}; use reth_eth_wire_types::snap::AccountRangeMessage; - use reth_network_p2p::{ - error::{PeerRequestResult, RequestError}, - snap::client::SnapResponse, - }; + use reth_network_p2p::{error::PeerRequestResult, snap::client::SnapResponse}; use reth_network_peers::WithPeerId; use reth_provider::{test_utils::MockNodeTypesWithDB, ProviderFactory}; use reth_trie_common::TrieAccount; @@ -200,7 +236,7 @@ mod tests { } fn started(accounts: &[(B256, TrieAccount)]) -> ProviderFactory { - let factory = hashed_factory(); + let factory = hashed_factory_with_roots(&[state_root(accounts); 3]); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -278,7 +314,7 @@ mod tests { assert!(matches!( step, AccountRangeStep::Unavailable { origin, peer_id } - if origin == B256::ZERO && peer_id == peer + if origin.is_zero() && peer_id == peer )); assert_eq!(download.coverage(), Some(AccountCoverage::START)); @@ -288,19 +324,6 @@ mod tests { assert_eq!(*client.origins(), [B256::ZERO, B256::ZERO]); } - #[tokio::test] - async fn a_failed_request_leaves_the_cursor_in_place() { - let factory = started(&accounts()); - let (_, mut download) = - download([Err(RequestError::UnsupportedCapability)], factory.clone()); - - let error = download.next().await.unwrap_err(); - - assert!(matches!(error, SnapSyncError::Request(RequestError::UnsupportedCapability))); - assert_eq!(download.coverage(), Some(AccountCoverage::START)); - assert!(stored_accounts(&factory).is_empty()); - } - #[tokio::test] async fn the_download_continues_from_the_coverage_the_store_records() { let accounts = accounts(); @@ -341,21 +364,6 @@ mod tests { assert_eq!(download.coverage(), Some(AccountCoverage::START)); } - #[tokio::test] - async fn a_range_missing_its_dependencies_is_refused_at_commit() { - let mut accounts = accounts(); - accounts[1].1.code_hash = B256::repeat_byte(0x33); - let factory = started(&accounts); - let (_, mut download) = download([account_range(1, &accounts, 0..3, &[])], factory.clone()); - let range = verified(&mut download).await; - - let error = download.commit(range, Default::default(), Vec::new()).await.unwrap_err(); - - assert!(matches!(error, SnapSyncError::MissingCode { .. })); - assert!(stored_accounts(&factory).is_empty()); - assert_eq!(download.coverage(), Some(AccountCoverage::START)); - } - #[tokio::test] async fn nothing_is_requested_without_an_active_attempt() { let (client, mut download) = download([], hashed_factory()); @@ -367,7 +375,7 @@ mod tests { #[tokio::test] async fn nothing_is_requested_without_recorded_coverage() { - let factory = hashed_factory(); + let factory = hashed_factory_with_roots(&[state_root(&accounts())]); let provider = factory.database_provider_rw().unwrap(); provider.start_snap_attempt(generation(1, state_root(&accounts()))).unwrap(); provider.commit().unwrap(); @@ -378,18 +386,24 @@ mod tests { } #[tokio::test] - async fn a_complete_coverage_requests_nothing() { + async fn repairs_are_fetched_one_account_at_a_time() { let accounts = accounts(); let factory = started(&accounts); + let responses = [account_range(1, &accounts, 1..2, &[key(2)])]; + let (client, mut download) = download(responses, factory.clone()); + assert!(download.next_repair().await.unwrap().is_none()); + let provider = factory.database_provider_rw().unwrap(); let write = provider.active_snap_write().unwrap().unwrap(); - let whole = verified_range(&accounts, 0..3, B256::ZERO, &[]); - provider.commit_account_range(write, &whole, Default::default(), Vec::new()).unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_account(key(2)); + provider.schedule_snap_repairs(write, repairs).unwrap(); provider.commit().unwrap(); - let (client, mut download) = download([], factory); - assert!(download.next().await.unwrap().is_none()); - assert_eq!(download.coverage(), Some(AccountCoverage::COMPLETE)); - assert!(client.origins().is_empty()); + let Some(AccountRangeStep::Verified(range)) = download.next_repair().await.unwrap() else { + panic!("fixture serves the account") + }; + assert_eq!(*client.origins(), [key(2)]); + assert_eq!(range.range().accounts(), &accounts[1..2]); } } diff --git a/crates/snap-sync/src/account/store.rs b/crates/snap-sync/src/account/store.rs index b0029233cc3..fa79a728bd6 100644 --- a/crates/snap-sync/src/account/store.rs +++ b/crates/snap-sync/src/account/store.rs @@ -1,10 +1,11 @@ //! Persists account ranges, their storage and code, and the coverage cursor in one transaction. //! -//! Each range replaces its key interval, and ranges commit in key order. +//! Each range replaces its key interval, and ranges commit in key order. Accounts scheduled for +//! repair are fetched again on their own and replace what the state holds for them. use crate::{ - common::SnapRecord, storage::persisted_storage_root, SnapAttemptStore, SnapCatchUpStore, - SnapStorageStore, SnapSyncError, SnapWrite, StorageProgress, + common::SnapRecord, repair::StoredRepairs, storage::persisted_storage_root, SnapAttemptStore, + SnapCatchUpStore, SnapStorageStore, SnapSyncError, SnapWrite, StateRepairs, StorageProgress, }; use alloy_primitives::{ map::{B256Map, B256Set}, @@ -17,7 +18,9 @@ use reth_db_api::{ }; use reth_downloaders::snap::VerifiedAccountRange; use reth_primitives_traits::Account; -use reth_storage_api::{DBProvider, MetadataProvider, MetadataWriter, SnapAttemptId, StateWriter}; +use reth_storage_api::{ + BlockHashReader, DBProvider, MetadataProvider, MetadataWriter, SnapAttemptId, StateWriter, +}; use reth_trie_common::{ root::storage_root, HashedPostState, HashedPostStateSorted, HashedStorage, TrieAccount, EMPTY_ROOT_HASH, @@ -64,6 +67,29 @@ pub trait SnapAccountStore { ) -> Result<(), SnapSyncError> where Self: DBProvider; + + /// Returns the repairs scheduled for the attempt `write` belongs to. + fn snap_repairs(&self, write: SnapWrite) -> Result; + + /// Adds `repairs` to those scheduled for the attempt `write` belongs to. + fn schedule_snap_repairs( + &self, + write: SnapWrite, + repairs: StateRepairs, + ) -> Result<(), SnapSyncError> + where + Self: MetadataWriter; + + /// Writes the pivot's account at `range`'s origin and its `slots`, returning how many accounts + /// remain scheduled. Refused until catch-up reaches the pivot. + fn commit_account_repair( + &self, + write: SnapWrite, + range: &VerifiedAccountRange, + slots: Vec<(B256, U256)>, + ) -> Result + where + Self: BlockHashReader + MetadataWriter + StateWriter + DBProvider; } /// How far the account key space has been downloaded. @@ -135,7 +161,7 @@ impl StoredCoverage { } impl SnapAccountStore for T { - // Resumes the recorded coverage, so restarting an attempt keeps its progress. + // Resumes the recorded coverage, so resuming an attempt keeps its progress. fn start_account_coverage(&self, write: SnapWrite) -> Result where Self: MetadataWriter, @@ -177,14 +203,18 @@ impl SnapAccountStore for T { let advanced = coverage.advance(range)?; let progress = self.storage_progress(write, range.origin())?; // Slots persisted before the pivot moved hold its old values until the lists reach it. - if progress != StorageProgress::START && - let Some(catch_up) = self.catch_up_progress(write)? && - catch_up.applied().number < attempt.pivot().number - { - return Err(SnapSyncError::CatchUpBehindPivot { - applied: catch_up.applied().number, - pivot: attempt.pivot().number, - }) + if progress != StorageProgress::START { + let applied = self + .catch_up_progress(write)? + .ok_or(SnapSyncError::NoCatchUpProgress)? + .applied() + .number; + if applied < attempt.pivot().number { + return Err(SnapSyncError::CatchUpBehindPivot { + applied, + pivot: attempt.pivot().number, + }) + } } let dependencies = RangeDependencies::new(range.accounts(), storages, bytecodes); let persisted = dependencies.verify(range.accounts(), &progress, self.tx_ref())?; @@ -212,7 +242,7 @@ impl SnapAccountStore for T { for account in kept { self.remove::((start, Bound::Excluded(*account)))?; // Walks cannot start at an excluded key, so the next piece starts one key past it. - let Some(after) = U256::from_be_bytes(account.0).checked_add(U256::from(1)) else { + let Some(after) = U256::from_be_bytes(account.0).checked_add(U256::ONE) else { return Ok(()) }; start = Bound::Included(after.into()); @@ -220,6 +250,88 @@ impl SnapAccountStore for T { self.remove::((start, end))?; Ok(()) } + + // A record left by another attempt reads as nothing scheduled. + fn snap_repairs(&self, write: SnapWrite) -> Result { + self.authorize_snap_write(write)?; + let Some(stored) = StoredRepairs::read(self)? else { return Ok(StateRepairs::default()) }; + Ok(if stored.attempt == write.attempt() { stored.repairs } else { StateRepairs::default() }) + } + + fn schedule_snap_repairs( + &self, + write: SnapWrite, + repairs: StateRepairs, + ) -> Result<(), SnapSyncError> + where + Self: MetadataWriter, + { + let mut scheduled = self.snap_repairs(write)?; + scheduled.extend(repairs); + StoredRepairs::store(self, write.attempt(), scheduled) + } + + // Every check runs before the first write, so a refused repair changes nothing. + #[allow(clippy::clone_on_copy)] + fn commit_account_repair( + &self, + write: SnapWrite, + range: &VerifiedAccountRange, + slots: Vec<(B256, U256)>, + ) -> Result + where + Self: BlockHashReader + MetadataWriter + StateWriter + DBProvider, + { + // The repaired values belong to the pivot, so it must still be canonical. + let attempt = self.authorize_canonical_snap_write(write)?; + if range.state_root() != attempt.state_root() { + return Err(SnapSyncError::RootMismatch { + expected: attempt.state_root(), + got: range.state_root(), + }) + } + let applied = + self.catch_up_progress(write)?.ok_or(SnapSyncError::NoCatchUpProgress)?.applied(); + if applied != attempt.pivot() { + return Err(SnapSyncError::CatchUpBehindPivot { + applied: applied.number, + pivot: attempt.pivot().number, + }) + } + let hashed_address = range.origin(); + // The proof starts at the origin, so an account keyed past it means none is there. + let account = range + .accounts() + .first() + .filter(|(hash, _)| *hash == hashed_address) + .map(|(_, account)| account); + if let Some(hash) = account.map(|account| account.code_hash) && + hash != KECCAK256_EMPTY && + self.tx_ref().get::>(RawKey::new(hash))?.is_none() + { + return Err(SnapSyncError::MissingCode { hash }) + } + + let has_storage = account.is_some_and(|account| account.storage_root != EMPTY_ROOT_HASH); + let mut state = HashedPostState::default().with_accounts([( + hashed_address, + account.map(|account| Account::from(account.clone())), + )]); + if has_storage { + // Zero values remove their slots. + state = state + .with_storages([(hashed_address, HashedStorage::from_iter(slots.iter().copied()))]); + } else { + self.remove::(hashed_address..=hashed_address)?; + } + self.write_hashed_state(&state.into_sorted())?; + + let mut remaining = self.snap_repairs(write)?; + remaining.resolve(hashed_address, has_storage.then_some(slots.as_slice())); + let accounts = remaining.len(); + StoredRepairs::store(self, write.attempt(), remaining)?; + Ok(accounts) + } } // Accounts of a range with the storage and code supplied for them. @@ -344,7 +456,10 @@ impl RangeDependencies { mod tests { use super::*; use crate::{ - test_utils::{account, generation, hashed_factory, key, state_root, verified_range}, + test_utils::{ + account, generation, hashed_factory_with_roots, key, state_root, stored_slots, + verified_range, verified_repair, + }, SnapGeneration, StorageChunk, }; use alloy_primitives::{Bytes, U256}; @@ -356,6 +471,10 @@ mod tests { const FAR: B256 = B256::repeat_byte(0xaa); const SLOT: B256 = B256::repeat_byte(0x55); + // A slot the stale state holds and the pivot does not. + const OTHER: B256 = B256::repeat_byte(0x66); + // Sorts between the contract and the far account, holding nothing at the pivot. + const ABSENT: B256 = B256::repeat_byte(0x33); fn code() -> Bytecode { Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00])) @@ -387,7 +506,19 @@ mod tests { fn started( accounts: &[(B256, TrieAccount)], ) -> (ProviderFactory, SnapWrite, SnapGeneration) { - let factory = hashed_factory(); + started_moving_to(accounts, accounts) + } + + // `started`, with the later blocks committing to the state `moved`. + fn started_moving_to( + accounts: &[(B256, TrieAccount)], + moved: &[(B256, TrieAccount)], + ) -> (ProviderFactory, SnapWrite, SnapGeneration) { + let factory = hashed_factory_with_roots(&[ + state_root(accounts), + state_root(moved), + state_root(moved), + ]); let generation = generation(1, state_root(accounts)); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation).unwrap(); @@ -427,22 +558,6 @@ mod tests { assert_eq!(provider.account_coverage(write).unwrap(), Some(coverage)); } - #[test] - fn an_interrupted_commit_leaves_nothing_behind() { - let accounts = accounts(); - let (factory, write, _) = started(&accounts); - let range = verified_range(&accounts, 0..3, B256::ZERO, &[]); - let (storages, bytecodes) = dependencies(); - - let provider = factory.database_provider_rw().unwrap(); - provider.commit_account_range(write, &range, storages, bytecodes).unwrap(); - drop(provider); - - let provider = factory.database_provider_rw().unwrap(); - assert_eq!(stored(&provider), (Vec::new(), false, false)); - assert_eq!(provider.account_coverage(write).unwrap(), Some(AccountCoverage::START)); - } - #[test] fn a_partial_range_moves_the_coverage_to_its_next_key() { let accounts = accounts(); @@ -522,6 +637,35 @@ mod tests { assert_eq!(provider.account_coverage(write).unwrap(), Some(AccountCoverage::START)); } + #[test] + fn missing_code_is_named_after_contracts_whose_code_is_stored() { + let stored_code = code(); + let missing_code = Bytecode::new_raw(Bytes::from_static(&[0x60, 0x01])); + let mut first = account(1); + first.code_hash = stored_code.hash_slow(); + let mut second = account(2); + second.code_hash = missing_code.hash_slow(); + let accounts = vec![(key(1), first), (key(2), second), (key(3), account(3))]; + let (factory, write, _) = started(&accounts); + let range = verified_range(&accounts, 0..3, B256::ZERO, &[]); + let provider = factory.database_provider_rw().unwrap(); + provider + .write_state_changes(StateChangeset { + contracts: vec![(stored_code.hash_slow(), stored_code)], + ..Default::default() + }) + .unwrap(); + + let refused = provider.commit_account_range(write, &range, Default::default(), Vec::new()); + + assert!(matches!( + refused, + Err(SnapSyncError::MissingCode { hash }) if hash == missing_code.hash_slow() + )); + assert_eq!(stored_accounts(&provider), Vec::::new()); + assert_eq!(provider.account_coverage(write).unwrap(), Some(AccountCoverage::START)); + } + #[test] fn storage_and_code_are_checked_against_what_the_account_commits_to() { let accounts = accounts(); @@ -608,7 +752,8 @@ mod tests { #[test] fn a_new_attempt_replaces_what_an_earlier_one_left_in_the_interval() { let earlier = accounts(); - let (factory, write, _) = started(&earlier); + let current = vec![(key(1), account(9)), (FAR, account(3))]; + let (factory, write, _) = started_moving_to(&earlier, ¤t); let (storages, bytecodes) = dependencies(); let provider = factory.database_provider_rw().unwrap(); let range = verified_range(&earlier, 0..3, B256::ZERO, &[]); @@ -616,7 +761,6 @@ mod tests { provider.commit().unwrap(); // At the new root the contract is gone and the first account changed. - let current = vec![(key(1), account(9)), (FAR, account(3))]; let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(2, state_root(¤t))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -647,7 +791,7 @@ mod tests { #[test] fn a_range_without_recorded_coverage_is_refused() { let accounts = accounts(); - let factory = hashed_factory(); + let factory = hashed_factory_with_roots(&[state_root(&accounts)]); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(&accounts))).unwrap(); let range = verified_range(&accounts, 0..3, B256::ZERO, &[]); @@ -658,21 +802,6 @@ mod tests { )); } - #[test] - fn coverage_survives_reopening_the_database() { - let accounts = accounts(); - let (factory, write, _) = started(&accounts); - let range = verified_range(&accounts, 0..1, B256::ZERO, &[key(1)]); - let provider = factory.database_provider_rw().unwrap(); - let coverage = - provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); - provider.commit().unwrap(); - - let reopened = factory.database_provider_rw().unwrap(); - - assert_eq!(reopened.account_coverage(write).unwrap(), Some(coverage)); - } - #[test] fn storage_persisted_ahead_of_the_range_commits_with_it() { let accounts = accounts(); @@ -744,4 +873,115 @@ mod tests { )); } } + + // `started`, with headers so the pivot is canonical, and stale values for the contract and + // for `ABSENT`, both scheduled for repair. + fn repairing( + accounts: &[(B256, TrieAccount)], + ) -> (ProviderFactory, SnapWrite) { + let (factory, write, _) = started(accounts); + let provider = factory.database_provider_rw().unwrap(); + let stale = HashedPostState::default() + .with_accounts([ + (key(2), Some(Account::from(account(9)))), + (ABSENT, Some(Account::from(account(9)))), + ]) + .with_storages([ + (key(2), HashedStorage::from_iter([(SLOT, U256::ONE), (OTHER, U256::from(2))])), + (ABSENT, HashedStorage::from_iter([(SLOT, U256::ONE)])), + ]); + provider.write_hashed_state(&stale.into_sorted()).unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_slot(key(2), SLOT); + repairs.insert_slot(key(2), OTHER); + repairs.insert_account(ABSENT); + provider.schedule_snap_repairs(write, repairs).unwrap(); + provider.commit().unwrap(); + (factory, write) + } + + fn repair( + factory: &ProviderFactory, + write: SnapWrite, + range: &VerifiedAccountRange, + slots: Vec<(B256, U256)>, + ) -> Result { + let provider = factory.database_provider_rw().unwrap(); + let remaining = provider.commit_account_repair(write, range, slots)?; + provider.commit().unwrap(); + Ok(remaining) + } + + #[test] + #[allow(clippy::clone_on_copy)] + fn a_repair_takes_the_pivot_account_and_scheduled_slots() { + let accounts = accounts(); + let (factory, write) = repairing(&accounts); + let range = verified_repair(&accounts, 1..2, key(2), &[key(2)]); + // The pivot holds 7 at `SLOT` and nothing at `OTHER`. + let slots = vec![(SLOT, U256::from(7)), (OTHER, U256::ZERO)]; + + let refused = repair(&factory, write, &range, slots.clone()); + assert!(matches!(refused, Err(SnapSyncError::MissingCode { .. }))); + let provider = factory.database_provider_rw().unwrap(); + provider + .write_state_changes(StateChangeset { + contracts: vec![(code().hash_slow(), code())], + ..Default::default() + }) + .unwrap(); + provider.commit().unwrap(); + let remaining = repair(&factory, write, &range, slots).unwrap(); + + assert_eq!(remaining, 1); + let provider = factory.database_provider_ro().unwrap(); + let mut expected = StateRepairs::default(); + expected.insert_account(ABSENT); + assert_eq!(provider.snap_repairs(write).unwrap(), expected); + let stored = provider.tx_ref().get::(key(2)).unwrap(); + assert_eq!(stored, Some(Account::from(accounts[1].1.clone()))); + assert_eq!(stored_slots(&provider, key(2)), [(SLOT, U256::from(7))]); + } + + #[test] + fn an_account_the_pivot_lacks_is_removed_with_its_storage() { + let accounts = accounts(); + let (factory, write) = repairing(&accounts); + // The far account, the first past `ABSENT`, proves it absent. + let range = verified_repair(&accounts, 2..3, ABSENT, &[ABSENT, FAR]); + + repair(&factory, write, &range, Vec::new()).unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.tx_ref().get::(ABSENT).unwrap(), None); + assert!(stored_slots(&provider, ABSENT).is_empty()); + assert_eq!(provider.snap_repairs(write).unwrap().len(), 1); + } + + #[test] + fn a_repair_waits_for_catch_up_to_reach_the_pivot() { + let accounts = accounts(); + let (factory, write) = repairing(&accounts); + let provider = factory.database_provider_rw().unwrap(); + let write = + provider.advance_snap_pivot(write, generation(2, state_root(&accounts))).unwrap(); + provider.commit().unwrap(); + let range = verified_repair(&accounts, 2..3, ABSENT, &[ABSENT, FAR]); + + let refused = repair(&factory, write, &range, Vec::new()); + + assert!(matches!(refused, Err(SnapSyncError::CatchUpBehindPivot { applied: 1, pivot: 2 }))); + } + + #[test] + fn a_new_attempt_starts_without_repairs() { + let accounts = accounts(); + let (factory, _) = repairing(&accounts); + let provider = factory.database_provider_rw().unwrap(); + + let restarted = provider.start_snap_attempt(generation(1, state_root(&accounts))).unwrap(); + + assert!(provider.snap_repairs(restarted).unwrap().is_empty()); + assert!(StoredRepairs::read(&provider).unwrap().is_none()); + } } diff --git a/crates/snap-sync/src/attempt.rs b/crates/snap-sync/src/attempt.rs index 1d0cbfd38e0..5e346f6241b 100644 --- a/crates/snap-sync/src/attempt.rs +++ b/crates/snap-sync/src/attempt.rs @@ -5,11 +5,16 @@ //! refused. use crate::{ - account::StoredCoverage, common::SnapRecord, storage::StoredProgress, verify::StoredRebuild, - CatchUpProgress, SnapGeneration, SnapSyncError, + account::StoredCoverage, common::SnapRecord, reorg::StoredAncestry, repair::StoredRepairs, + storage::StoredProgress, verify::StoredRebuild, CatchUpProgress, DownloadedAccount, + SnapAccountStore, SnapCatchUpStore, SnapGeneration, SnapReorg, SnapSyncError, StateRepairs, }; +use alloy_eips::{eip7928::bal::DecodedBal, BlockNumHash}; +use alloy_primitives::keccak256; +use reth_primitives_traits::AlloyBlockHeader; use reth_storage_api::{ - BlockHashReader, MetadataProvider, MetadataWriter, SnapAttempt, SnapAttemptId, StorageSettings, + BlockHashReader, DBProvider, HeaderProvider, MetadataProvider, MetadataWriter, SnapAttempt, + SnapAttemptId, StorageSettings, }; use serde::{Deserialize, Serialize}; @@ -22,7 +27,7 @@ pub trait SnapAttemptStore { /// progress it kept, with its catch-up progress at that pivot. fn start_snap_attempt(&self, generation: SnapGeneration) -> Result where - Self: MetadataWriter; + Self: MetadataWriter + HeaderProvider; /// Returns the write an unfinished attempt accepts, if one owns the persisted state. fn active_snap_write(&self) -> Result, SnapSyncError>; @@ -51,7 +56,7 @@ pub trait SnapAttemptStore { generation: SnapGeneration, ) -> Result where - Self: MetadataWriter + BlockHashReader; + Self: MetadataWriter + HeaderProvider + BlockHashReader; /// Marks the attempt's downloaded state verified. fn verify_snap_attempt(&self, write: SnapWrite) -> Result<(), SnapSyncError> @@ -62,6 +67,28 @@ pub trait SnapAttemptStore { fn abandon_snap_attempt(&self) -> Result<(), SnapSyncError> where Self: MetadataWriter; + + /// Returns where the canonical chain diverges from the branch the pivot of `write`'s attempt + /// is on. + /// + /// `Ok(None)` when the kept blocks do not reach back to where the branches part. + fn snap_reorg(&self, write: SnapWrite) -> Result, SnapSyncError> + where + Self: BlockHashReader; + + /// Schedules what `lists`, those of the orphaned blocks, changed in the downloaded state for + /// repair, and moves the attempt to `generation` with catch-up back at `ancestor` at most. + /// + /// Storage persisted ahead of its range may hold the orphaned values, so it is dropped. + fn commit_reorg_recovery( + &self, + write: SnapWrite, + ancestor: BlockNumHash, + lists: &[DecodedBal], + generation: SnapGeneration, + ) -> Result + where + Self: MetadataWriter + HeaderProvider + BlockHashReader + DBProvider; } /// What a write presents to prove it belongs to the attempt owning the persisted state. @@ -93,12 +120,13 @@ impl SnapWrite { impl SnapAttemptStore for T { fn start_snap_attempt(&self, generation: SnapGeneration) -> Result where - Self: MetadataWriter, + Self: MetadataWriter + HeaderProvider, { // Absent settings mean the legacy layout. if !self.storage_settings()?.unwrap_or_else(StorageSettings::v1).use_hashed_state() { return Err(SnapSyncError::UnsupportedStorage) } + ensure_header_root(self, &generation)?; let attempt = SnapAttempt::start(self.snap_attempt()?, generation.target(), generation.state_root()); @@ -109,7 +137,9 @@ impl SnapAttemptStore for T { // would fail every read of it. StoredCoverage::clear(self)?; StoredProgress::clear(self)?; + StoredRepairs::clear(self)?; StoredRebuild::clear(self)?; + StoredAncestry::record(self, attempt.id(), attempt.pivot())?; Ok(SnapWrite::of(&attempt)) } @@ -146,7 +176,7 @@ impl SnapAttemptStore for T { generation: SnapGeneration, ) -> Result where - Self: MetadataWriter + BlockHashReader, + Self: MetadataWriter + HeaderProvider + BlockHashReader, { // Replacing an orphaned pivot would hide the fork that downloaded ranges belong to. let mut attempt = self.authorize_canonical_snap_write(write)?; @@ -161,8 +191,10 @@ impl SnapAttemptStore for T { if self.block_hash(target.number)? != Some(target.hash) { return Err(SnapSyncError::NonCanonicalBlock { block: target.number, hash: target.hash }) } + ensure_header_root(self, &generation)?; attempt.re_anchor(target, generation.state_root()); self.write_snap_attempt(&attempt)?; + StoredAncestry::record(self, attempt.id(), target)?; Ok(SnapWrite::of(&attempt)) } @@ -188,6 +220,89 @@ impl SnapAttemptStore for T { } Ok(()) } + + fn snap_reorg(&self, write: SnapWrite) -> Result, SnapSyncError> + where + Self: BlockHashReader, + { + StoredAncestry::reorg(self, &self.authorize_snap_write(write)?) + } + + fn commit_reorg_recovery( + &self, + write: SnapWrite, + ancestor: BlockNumHash, + lists: &[DecodedBal], + generation: SnapGeneration, + ) -> Result + where + Self: MetadataWriter + HeaderProvider + BlockHashReader + DBProvider, + { + let mut attempt = self.authorize_snap_write(write)?; + let target = generation.target(); + for block in [ancestor, target] { + if self.block_hash(block.number)? != Some(block.hash) { + return Err(SnapSyncError::NonCanonicalBlock { + block: block.number, + hash: block.hash, + }) + } + } + if target.number < ancestor.number { + return Err(SnapSyncError::PivotBelowAncestor { + ancestor: ancestor.number, + target: target.number, + }) + } + + ensure_header_root(self, &generation)?; + + let coverage = self.account_coverage(write)?.ok_or(SnapSyncError::NoCoverage)?; + let mut repairs = StateRepairs::default(); + for changes in lists.iter().flat_map(|list| list.as_bal().iter()) { + let hashed_address = keccak256(changes.address()); + // Accounts not downloaded yet are downloaded whole at a canonical pivot later. + if !changes.has_changes() || + self.downloaded_account(coverage, hashed_address)? == DownloadedAccount::Unknown + { + continue + } + repairs.insert_changes(hashed_address, changes); + } + self.schedule_snap_repairs(write, repairs)?; + + let applied = self + .catch_up_progress(write)? + .ok_or(SnapSyncError::NoCatchUpProgress)? + .resume_after(ancestor); + attempt.re_anchor(target, generation.state_root()); + self.write_snap_attempt(&attempt)?; + CatchUpProgress::at_pivot(applied).write(self, attempt.id())?; + StoredProgress::clear(self)?; + StoredAncestry::record(self, attempt.id(), target)?; + Ok(SnapWrite::of(&attempt)) + } +} + +// Rejects a generation whose root is not that of the canonical header at its target, which +// ranges are proved against and which the attempt can only be verified with. +fn ensure_header_root( + provider: &impl HeaderProvider, + generation: &SnapGeneration, +) -> Result<(), SnapSyncError> { + let target = generation.target(); + let header = provider + .sealed_header(target.number)? + .filter(|header| header.hash() == target.hash) + .ok_or(SnapSyncError::NonCanonicalBlock { block: target.number, hash: target.hash })?; + if header.state_root() != generation.state_root() { + return Err(SnapSyncError::HeaderRootMismatch { + block: target.number, + expected: header.state_root(), + got: generation.state_root(), + }) + } + Ok(()) } #[cfg(test)] @@ -215,7 +330,7 @@ mod tests { fn generation(block: u64) -> SnapGeneration { SnapGeneration::new( BlockNumHash::new(block, B256::repeat_byte(block as u8)), - B256::repeat_byte(0xaa), + B256::repeat_byte(block as u8), ) } @@ -226,7 +341,7 @@ mod tests { let provider = factory.database_provider_rw().unwrap(); provider.write_storage_settings(StorageSettings::v2()).unwrap(); provider.commit().unwrap(); - insert_generation_headers(&factory); + insert_generation_headers(&factory, &[]); factory } @@ -251,16 +366,6 @@ mod tests { ) } - #[test] - fn nothing_owns_the_state_before_an_attempt_starts() { - let factory = factory(); - let provider = factory.database_provider_rw().unwrap(); - - assert_eq!(provider.active_snap_write().unwrap(), None); - let write = SnapWrite { attempt: SnapAttemptId::FIRST, state_version: 0 }; - assert!(matches!(provider.authorize_snap_write(write), Err(SnapSyncError::NoAttempt))); - } - #[test] fn address_keyed_state_cannot_host_an_attempt() { let factory = create_test_provider_factory(); @@ -334,22 +439,6 @@ mod tests { )); } - #[test] - fn the_attempt_survives_reopening_the_database() { - let factory = factory(); - let provider = factory.database_provider_rw().unwrap(); - let write = provider.start_snap_attempt(generation(7)).unwrap(); - provider.commit().unwrap(); - - let reopened = factory.database_provider_rw().unwrap(); - - assert_eq!(reopened.active_snap_write().unwrap(), Some(write)); - let attempt = reopened.snap_attempt().unwrap().unwrap(); - assert_eq!(attempt.pivot(), BlockNumHash::new(7, B256::repeat_byte(7))); - assert_eq!(attempt.state_root(), B256::repeat_byte(0xaa)); - assert!(attempt.is_unfinished()); - } - #[test] fn committing_keeps_downloaded_state_and_progress_together() { let factory = factory(); @@ -377,23 +466,6 @@ mod tests { assert_eq!(downloaded(&provider), (false, false)); } - #[test] - fn advancing_the_pivot_rejects_writes_proved_against_the_old_root() { - let factory = factory(); - let provider = factory.database_provider_rw().unwrap(); - - let before = provider.start_snap_attempt(generation(1)).unwrap(); - let after = provider.advance_snap_pivot(before, generation(2)).unwrap(); - - assert_eq!(after.attempt(), before.attempt()); - assert_eq!(after.state_version(), before.state_version() + 1); - assert!(matches!( - provider.authorize_snap_write(before), - Err(SnapSyncError::StaleWrite { .. }) - )); - provider.authorize_snap_write(after).unwrap(); - } - #[test] fn the_pivot_advances_repeatedly_within_one_attempt() { let factory = factory(); @@ -445,20 +517,22 @@ mod tests { } #[test] - fn an_interrupted_advance_keeps_the_previous_pivot() { + fn a_generation_must_carry_the_root_of_its_canonical_header() { let factory = factory(); let provider = factory.database_provider_rw().unwrap(); - let write = provider.start_snap_attempt(generation(1)).unwrap(); - provider.commit().unwrap(); + let wrong = + |block: u64| SnapGeneration::new(generation(block).target(), B256::repeat_byte(0xcc)); - let provider = factory.database_provider_rw().unwrap(); - provider.advance_snap_pivot(write, generation(2)).unwrap(); - drop(provider); - - // Downloads still in flight against the old root keep committing. - let provider = factory.database_provider_rw().unwrap(); - assert_eq!(provider.active_snap_write().unwrap(), Some(write)); - assert_eq!(provider.authorize_snap_write(write).unwrap().pivot().number, 1); + assert!(matches!( + provider.start_snap_attempt(wrong(1)), + Err(SnapSyncError::HeaderRootMismatch { .. }) + )); + let write = provider.start_snap_attempt(generation(1)).unwrap(); + assert!(matches!( + provider.advance_snap_pivot(write, wrong(2)), + Err(SnapSyncError::HeaderRootMismatch { .. }) + )); + provider.authorize_snap_write(write).unwrap(); } #[test] diff --git a/crates/snap-sync/src/bootstrap.rs b/crates/snap-sync/src/bootstrap.rs index a549d05ff93..b6c04ef0727 100644 --- a/crates/snap-sync/src/bootstrap.rs +++ b/crates/snap-sync/src/bootstrap.rs @@ -11,12 +11,12 @@ use crate::{ }; use alloy_eips::BlockNumHash; use reth_db_api::transaction::DbTxMut; -use reth_network_p2p::snap::client::SnapClient; +use reth_network_p2p::{error::RequestError, snap::client::SnapClient}; +use reth_primitives_traits::AlloyBlockHeader; use reth_storage_api::{ BlockHashReader, DBProvider, DatabaseProviderFactory, HeaderProvider, MetadataProvider, MetadataWriter, StageCheckpointWriter, StateWriter, }; -use reth_storage_errors::provider::ProviderError; use reth_tasks::Runtime; use std::{fmt, future::Future}; use tokio_util::sync::CancellationToken; @@ -29,10 +29,9 @@ pub const DEFAULT_RANGES_PER_CHECK: usize = 64; /// Drives snap synchronization until its downloaded state is ready for the trie rebuild. /// -/// Each pass moves a lagging pivot forward, applies the block access lists that carry the -/// downloaded state to it, then downloads account ranges with their storage and code. An attempt -/// whose pivot leaves the canonical chain or outlives the served lists is abandoned and started -/// over. +/// Each pass moves a lagging pivot forward, applies the lists carrying the state to it, refetches +/// scheduled repairs there, then downloads account ranges with storage and code. A reorg across the +/// pivot is repaired from the orphaned lists, while handed-off or expired state starts over. pub struct SnapBootstrap { factory: F, // Blocking database work runs off the async worker. @@ -45,6 +44,8 @@ pub struct SnapBootstrap { ranges_per_check: usize, // Stops the run at the next step boundary, leaving committed progress in place. cancel: CancellationToken, + // Stops the all-or-nothing hand-off scan, which `cancel` otherwise stops. + shutdown: Option, accounts: AccountRangeDownload, storage: StorageRangeDownload, bytecode: BytecodeDownload, @@ -67,13 +68,14 @@ impl SnapBootstrap { session: SnapSyncSession::new(policy), ranges_per_check: DEFAULT_RANGES_PER_CHECK, cancel: CancellationToken::new(), + shutdown: None, } } } impl SnapBootstrap { /// Returns this run anchoring and re-anchoring pivots with `policy`. - pub fn with_policy(mut self, policy: SnapPivotPolicy) -> Self { + pub const fn with_policy(mut self, policy: SnapPivotPolicy) -> Self { self.policy = policy; self.session = SnapSyncSession::new(policy); self @@ -91,6 +93,14 @@ impl SnapBootstrap { self.cancel = cancel; self } + + /// Returns this run finishing the hand-off scan unless `shutdown` fires. + /// + /// The scan restarts from scratch once stopped, so [`Self::with_cancellation`] lets it finish. + pub fn with_shutdown(mut self, shutdown: CancellationToken) -> Self { + self.shutdown = Some(shutdown); + self + } } impl SnapBootstrap @@ -112,6 +122,9 @@ where /// Peers that do not serve the state, or headers not downloaded yet, wait for the context to /// report progress instead of failing the run. pub async fn run(&mut self) -> Result { + // Waits recur every few seconds, so only the first for a reason logs at info until a pass + // makes progress. + let mut announced = None; loop { if self.cancel.is_cancelled() { return Ok(SnapBootstrapOutcome::Stopped) @@ -119,22 +132,42 @@ where let head = self.context.head()?; let step = match self.resolve(head)? { Resolved::Verified(pivot) => return Ok(SnapBootstrapOutcome::Verified { pivot }), + Resolved::BeforeBlockAccessLists => { + return Ok(SnapBootstrapOutcome::BeforeBlockAccessLists) + } Resolved::Waiting => { - debug!(target: "sync::snap", head, "No eligible snap pivot"); + if announced.replace(Wait::Pivot) == Some(Wait::Pivot) { + debug!(target: "sync::snap", head, "No eligible snap pivot yet, waiting for headers"); + } else { + info!(target: "sync::snap", head, "No eligible snap pivot yet, waiting for headers"); + } Step::Wait } Resolved::Active(write) => match self.drive(write, head).await { Ok(step) => step, + // The network fails snap requests at once while no connected peer serves them. + Err(SnapSyncError::Request(RequestError::UnsupportedCapability)) => { + if announced.replace(Wait::SnapPeer) == Some(Wait::SnapPeer) { + debug!(target: "sync::snap", "No connected peer serves snap/2, waiting for one"); + } else { + info!(target: "sync::snap", "No connected peer serves snap/2, waiting for one"); + } + Step::Wait + } Err(error) if error.is_transient() => { debug!(target: "sync::snap", %error, "Waiting for peers or headers"); Step::Wait } + // The next pass finds the pivot orphaned and recovers or restarts it. Err(error) if error.is_reorg() => { - info!(target: "sync::snap", %error, "Snap pivot was reorged, restarting"); - Step::Restart + debug!(target: "sync::snap", %error, "Snap pivot was reorged"); + Step::Continue } // Progress this build cannot read is unusable, so the attempt starts over. - Err(error @ SnapSyncError::UnsupportedRecord { .. }) => { + Err( + error @ (SnapSyncError::UnsupportedRecord { .. } | + SnapSyncError::UnreadableRecord { .. }), + ) => { info!(target: "sync::snap", %error, "Snap progress is unreadable, restarting"); Step::Restart } @@ -148,7 +181,7 @@ where info!(target: "sync::snap", ?pivot, "Snap state handed to the trie rebuild"); return Ok(SnapBootstrapOutcome::TrieRebuild { write, pivot }) } - Step::Continue => {} + Step::Continue => announced = None, Step::Wait => { if !self.wait(head).await { return Ok(SnapBootstrapOutcome::Stopped) @@ -164,8 +197,9 @@ where } } - // Resumes the recorded attempt while its pivot is canonical, otherwise starts one at the - // pivot under `head`. These are single-record writes, cheap enough for the async worker. + // Resumes the recorded attempt, even one whose pivot a reorg orphaned, otherwise starts one at + // the pivot under `head`. Starting one reads the kept blocks and writes a few records, cheap + // enough for the async worker. fn resolve(&mut self, head: u64) -> Result { let provider = self.factory.database_provider_rw()?; let mut session = SnapSyncSession::new(self.policy); @@ -174,18 +208,27 @@ where return Ok(Resolved::Verified(attempt.pivot())) } if let Some(write) = provider.active_snap_write()? { - let generation = SnapGeneration::new(attempt.pivot(), attempt.state_root()); - if generation.is_canonical(&provider)? { - session.resume(generation); - self.session = session; - return Ok(Resolved::Active(write)) - } - info!(target: "sync::snap", pivot = ?attempt.pivot(), "Snap pivot was reorged, restarting"); + // An orphaned pivot is resumed too, so the pass can recover or restart it. + session.resume(SnapGeneration::new(attempt.pivot(), attempt.state_root())); + self.session = session; + return Ok(Resolved::Active(write)) } } - session.select(&provider, head, self.context.finalized())?; - let Some((generation, _)) = session.start() else { return Ok(Resolved::Waiting) }; + session.select(&provider, head, self.context.finalized()?)?; + let Some(generation) = session.start() else { + // A head without a block access list predates them, so no block under it can anchor. + // Once the staged pipeline executes past genesis, the node stays on it. The first + // header pass may stop at the finalized block, so if block access lists + // activated between it and the head, the node full syncs. That window is + // narrow and accepted. + return match provider.sealed_header(head)? { + Some(header) if header.block_access_list_hash().is_none() => { + Ok(Resolved::BeforeBlockAccessLists) + } + _ => Ok(Resolved::Waiting), + } + }; let write = provider.start_snap_attempt(generation)?; provider.start_account_coverage(write)?; provider.commit()?; @@ -199,23 +242,37 @@ where Ok(Resolved::Active(write)) } - // One pass over the attempt: pivot, catch-up, then up to `ranges_per_check` account ranges. + // One pass over the attempt: reorg recovery, pivot, catch-up, repairs, then up to + // `ranges_per_check` account ranges. async fn drive(&mut self, write: SnapWrite, head: u64) -> Result { - let (applied, covered) = { + let (applied, complete) = { let provider = self.factory.database_provider_ro()?; - if provider.is_trie_rebuild_started(write)? { + let generation = self.session.target().copied().ok_or(SnapSyncError::NoAttempt)?; + let handed_off = provider.is_trie_rebuild_started(write)?; + if !generation.is_canonical(&provider)? { + // Handed-off state may be partly rebuilt, so it cannot be repaired in place. + if handed_off { + info!(target: "sync::snap", pivot = ?generation.target(), "Handed-off snap pivot was reorged, restarting"); + return Ok(Step::Restart) + } + drop(provider); + return self.recover(write, head).await + } + if handed_off { return Ok(Step::HandedOff(write)) } let applied = provider .catch_up_progress(write)? .ok_or(SnapSyncError::NoCatchUpProgress)? .applied(); - let covered = provider.account_coverage(write)?.is_some_and(|c| c.is_complete()); - (applied, covered) + // Repairs are fetched at the pivot, so pending ones still need it served. + let complete = provider.account_coverage(write)?.is_some_and(|c| c.is_complete()) && + provider.snap_repairs(write)?.is_empty(); + (applied, complete) }; // Complete state carried to its pivot needs no further lists, only its trie rebuilt, so // neither their retention nor a newer pivot applies to it. - if covered && applied == self.pivot()? { + if complete && applied == self.pivot()? { return self.hand_off(write).await } // Catch-up continues from the last applied block, not the pivot, so once that block's @@ -230,16 +287,77 @@ where if let Some(step) = self.catch_up(write).await? { return Ok(step) } + // Repairs take the pivot's values, which the rest of the state holds once the lists reach + // it. + if let Some(step) = self.repair().await? { + return Ok(step) + } self.download_ranges(write).await } + // Repairs what a reorg across the pivot left in the downloaded state, as EIP-8189 describes: + // the orphaned blocks' lists schedule what they changed for repair, and catch-up continues from + // the last block both branches share. Unserved lists are waited for within the served state + // window. + async fn recover(&mut self, write: SnapWrite, head: u64) -> Result { + let pivot = self.pivot()?; + let provider = self.factory.database_provider_ro()?; + let Some(reorg) = provider.snap_reorg(write)? else { + info!(target: "sync::snap", ?pivot, "Snap pivot was reorged past its kept blocks, restarting"); + return Ok(Step::Restart) + }; + let ancestor = reorg.ancestor(); + // Lists activate by timestamp, which grows along a chain, so an ancestor committing to one + // means every block after it on either branch does too. + let committed = provider + .sealed_header(ancestor.number)? + .is_some_and(|header| header.block_access_list_hash().is_some()); + if !committed { + info!(target: "sync::snap", ?pivot, ?ancestor, "Snap pivot was reorged across block access list activation, restarting"); + return Ok(Step::Restart) + } + let resume = provider + .catch_up_progress(write)? + .ok_or(SnapSyncError::NoCatchUpProgress)? + .resume_after(ancestor) + .number; + if !self.policy.is_catchable_from(resume, head) { + info!(target: "sync::snap", ?pivot, ?ancestor, resume, head, "Orphaned block access lists expired, restarting"); + return Ok(Step::Restart) + } + let generation = self.policy.select(&provider, head, self.context.finalized()?)?; + // The new pivot must descend from the ancestor, or the new branch is still too short. + // Checked first, so waiting for it does not fetch the orphaned lists on every pass. + let Some(generation) = generation.filter(|g| g.target().number >= ancestor.number) else { + return Ok(Step::Wait) + }; + drop(provider); + // A peer lacking side-chain lists says nothing of the others, so another is asked later. + let Some(lists) = self.catch_up.orphaned_lists(reorg.orphaned()).await? else { + if !self.policy.awaits_orphaned_lists(ancestor.number, head) { + info!(target: "sync::snap", ?pivot, ?ancestor, head, "Orphaned block access lists stayed unavailable, restarting"); + return Ok(Step::Restart) + } + debug!(target: "sync::snap", ?pivot, "Orphaned block access lists are unavailable"); + return Ok(Step::Wait) + }; + + // Scheduling reads every orphaned list, so it runs on the blocking pool. + self.commit_blocking(move |provider| { + provider.commit_reorg_recovery(write, ancestor, &lists, generation).map(|_| ()) + }) + .await?; + info!(target: "sync::snap", ?pivot, ?ancestor, to = ?generation.target(), "Recovered snap state from a pivot reorg"); + Ok(Step::Continue) + } + // Moves a lagging pivot forward, since a few lists cost less than downloading state peers no // longer serve. Returns the write the attempt accepts afterwards. fn advance_pivot(&mut self, write: SnapWrite, head: u64) -> Result { let provider = self.factory.database_provider_rw()?; let from = self.pivot()?; let Some(advanced) = - self.session.advance(&provider, write, head, self.context.finalized())? + self.session.advance(&provider, write, head, self.context.finalized()?)? else { return Ok(write) }; @@ -258,12 +376,41 @@ where CatchUpStep::Applied { progress, .. } => { debug!(target: "sync::snap", applied = ?progress.applied(), pivot, "Applied block access lists"); } - CatchUpStep::Unavailable { .. } => return Ok(Some(Step::Wait)), + CatchUpStep::Unavailable { peer_id, .. } => { + debug!(target: "sync::snap", ?peer_id, pivot, "Peer does not serve the pivot's block access lists"); + return Ok(Some(Step::Wait)) + } + } + if self.cancel.is_cancelled() { + return Ok(Some(Step::Stop)) + } + } + } + + // Fetches scheduled repairs again at the pivot, with the slots and code they need, committing + // each batch. `Some` ends the pass, at the latest after `ranges_per_check` commits so the pivot + // is checked again. + async fn repair(&mut self) -> Result, SnapSyncError> { + for _ in 0..self.ranges_per_check { + let range = match self.accounts.next_repair().await? { + None => return Ok(None), + Some(AccountRangeStep::Verified(range)) => range, + Some(AccountRangeStep::Unavailable { .. }) => return Ok(Some(Step::Wait)), + }; + let Some(slots) = self.storage.repair_slots(&range).await? else { + return Ok(Some(Step::Wait)) + }; + if let Some(step) = self.download_code(&range).await? { + return Ok(Some(step)) } + let hashed_address = range.origin(); + let remaining = self.accounts.commit_repair(range, slots).await?; + debug!(target: "sync::snap", %hashed_address, remaining, "Committed snap repair batch"); if self.cancel.is_cancelled() { return Ok(Some(Step::Stop)) } } + Ok(Some(Step::Continue)) } // Commits up to `ranges_per_check` account ranges, handing the state off once none remain. @@ -274,7 +421,10 @@ where } let range = match self.accounts.next().await? { Some(AccountRangeStep::Verified(range)) => range, - Some(AccountRangeStep::Unavailable { .. }) => return Ok(Step::Wait), + Some(AccountRangeStep::Unavailable { origin, peer_id }) => { + debug!(target: "sync::snap", ?peer_id, %origin, "Peer does not serve the pivot state"); + return Ok(Step::Wait) + } None => return self.hand_off(write).await, }; if let Some(step) = self.download_storage_and_code(&range).await? { @@ -295,7 +445,10 @@ where match self.storage.next(range).await? { StorageRangeStep::Complete => break, StorageRangeStep::Committed(_) => {} - StorageRangeStep::Unavailable { .. } => return Ok(Some(Step::Wait)), + StorageRangeStep::Unavailable { peer_id, .. } => { + debug!(target: "sync::snap", ?peer_id, "Peer does not serve the pivot's storage"); + return Ok(Some(Step::Wait)) + } } // A large contract takes many responses, each committed, so any of them is a // resumable place to stop. @@ -303,11 +456,22 @@ where return Ok(Some(Step::Stop)) } } + self.download_code(range).await + } + + // Persists the code `range` references. `Some` ends the pass. + async fn download_code( + &mut self, + range: &VerifiedRange, + ) -> Result, SnapSyncError> { loop { match self.bytecode.next(range).await? { BytecodeStep::Complete => return Ok(None), BytecodeStep::Committed { .. } => {} - BytecodeStep::Unavailable { .. } => return Ok(Some(Step::Wait)), + BytecodeStep::Unavailable { peer_id, .. } => { + debug!(target: "sync::snap", ?peer_id, "Peer does not serve the pivot's code"); + return Ok(Some(Step::Wait)) + } } if self.cancel.is_cancelled() { return Ok(Some(Step::Stop)) @@ -318,18 +482,29 @@ where // Checks the downloaded state is complete and hands it to the merkle stage. The scan reads // every account, so it runs on the blocking pool. async fn hand_off(&self, write: SnapWrite) -> Result { + let cancel = self.shutdown.as_ref().unwrap_or(&self.cancel).clone(); + self.commit_blocking(move |provider| { + provider.start_trie_rebuild(write, DEFAULT_SCAN_CHUNK, &cancel) + }) + .await?; + Ok(Step::HandedOff(write)) + } + + // Runs `apply` in one transaction on the blocking pool, committing only if it succeeds. + async fn commit_blocking( + &self, + apply: impl FnOnce(&F::ProviderRW) -> Result<(), SnapSyncError> + Send + 'static, + ) -> Result<(), SnapSyncError> { let factory = self.factory.clone(); - let cancel = self.cancel.clone(); self.runtime .spawn_blocking(move || -> Result<(), SnapSyncError> { let provider = factory.database_provider_rw()?; - provider.start_trie_rebuild(write, DEFAULT_SCAN_CHUNK, &cancel)?; + apply(&provider)?; provider.commit()?; Ok(()) }) .await - .map_err(|error| SnapSyncError::Provider(ProviderError::other(error)))??; - Ok(Step::HandedOff(write)) + .map_err(SnapSyncError::blocking_task)? } // Pivot of the attempt being driven. @@ -373,6 +548,8 @@ pub enum SnapBootstrapOutcome { /// The run was cancelled or the context reported no further progress. Committed progress is /// kept for the next run. Stopped, + /// The head predates block access lists, so snap/2 can't sync this chain yet. + BeforeBlockAccessLists, } /// What a [`SnapBootstrap`] needs to know about the chain and peers it synchronizes from. @@ -380,13 +557,16 @@ pub trait SnapSyncContext: Send { /// Highest block whose canonical header is downloaded. fn head(&self) -> Result; - /// Latest finalized block, preferred as the pivot when recent enough. - fn finalized(&self) -> Option { - None + /// Latest finalized block, preferred as the pivot when recent enough. `None` if no finalized + /// block is known locally yet. + fn finalized(&self) -> Result, SnapSyncError> { + Ok(None) } - /// Waits until the head moves past `head` or new peers connect, returning `false` once no - /// further progress will come. + /// Waits for a reason to retry the stalled step and returns `true` if one came, such as new + /// peers connecting or a retry delay passing. Returns `false` if the run should stop instead, + /// so the caller can refresh its headers, as when a new target arrived or the source of + /// targets is gone. fn wait_for_progress(&mut self, head: u64) -> impl Future + Send; } @@ -398,6 +578,8 @@ enum Resolved { Verified(BlockNumHash), // No block is eligible as a pivot yet. Waiting, + // The head predates block access lists, so no block can be a pivot. + BeforeBlockAccessLists, } // What one pass over the attempt left to do. @@ -413,23 +595,37 @@ enum Step { Stop, } +// Why a run waits, so a repeated wait logs at debug. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Wait { + Pivot, + SnapPeer, +} + #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{ - account, account_range, hashed_factory, header, key, policy, state_root, storage_ranges, - storage_root_of, verified_range, ScriptedSnapClient, + use crate::{ + common::SnapRecord, + reorg::StoredAncestry, + test_utils::{ + account, account_range, hashed_factory, header, key, policy, state_root, + storage_ranges, storage_root_of, stored_slots, verified_range, ReorgFactoryExt, + ScriptedSnapClient, + }, + StateRepairs, VerifiedSnapState, }; - use alloy_eip7928::{compute_block_access_list_hash, AccountChanges}; - use alloy_eips::eip7928::bal::Bal; - use alloy_primitives::{Bytes, B256, U256}; - use reth_eth_wire_types::{snap::BlockAccessListsMessage, BlockAccessLists}; - use reth_network_p2p::{ - error::{PeerRequestResult, RequestError}, - snap::client::SnapResponse, + use alloy_eip7928::{ + compute_block_access_list_hash, AccountChanges, BalanceChange, BlockAccessIndex, }; + use alloy_primitives::{keccak256, Address, Bytes, B256, U256}; + use reth_eth_wire_types::{ + snap::{AccountRangeMessage, BlockAccessListsMessage}, + BlockAccessLists, + }; + use reth_network_p2p::{error::PeerRequestResult, snap::client::SnapResponse}; use reth_network_peers::{PeerId, WithPeerId}; - use reth_primitives_traits::SealedHeader; + use reth_primitives_traits::{Account, AlloyBlockHeader, Header, SealedHeader}; use reth_provider::{ test_utils::{insert_headers, MockNodeTypesWithDB}, ProviderFactory, @@ -438,13 +634,17 @@ mod tests { use reth_stages_api::{ExecInput, Stage}; use reth_stages_types::StageId; use reth_storage_api::{SnapAttemptId, StageCheckpointReader}; - use reth_trie_common::TrieAccount; + use reth_trie_common::{HashedPostState, TrieAccount}; use std::{cell::RefCell, collections::VecDeque, sync::Arc}; type Factory = ProviderFactory; type Bootstrap = SnapBootstrap, Factory, TestContext>; const FAR: B256 = B256::repeat_byte(0xaa); + // Changed on the orphaned branch, which credited it. + const STALE: Address = Address::repeat_byte(0x51); + // Untouched by the orphaned branch. + const KEPT: Address = Address::repeat_byte(0x52); fn accounts() -> Vec<(B256, TrieAccount)> { vec![(key(1), account(1)), (key(2), account(2)), (FAR, account(3))] @@ -453,37 +653,17 @@ mod tests { // Blocks `0..=tip`, each committing to an empty list and to `root`, so any of them anchors // the same state. fn insert_chain(factory: &Factory, tip: u64, root: B256) { - let commitment = compute_block_access_list_hash(&Vec::::new()); - let mut parent = B256::ZERO; - let headers: Vec<_> = (0..=tip) - .map(|number| { - let mut header = header(number, parent, Some(commitment)); - header.state_root = root; - let sealed = SealedHeader::seal_slow(header); - parent = sealed.hash(); - sealed - }) - .collect(); - insert_headers(factory, &headers); + insert_headers(factory, &chain(tip, root)); } // A peer serving `blocks` empty lists. fn empty_lists(request_id: u64, blocks: usize) -> PeerRequestResult { - let list: Bytes = alloy_rlp::encode(Bal::from(Vec::::new())).into(); - let message = BlockAccessListsMessage { - request_id, - block_access_lists: BlockAccessLists(vec![Some(list); blocks]), - }; - Ok(WithPeerId::new(PeerId::random(), SnapResponse::BlockAccessLists(message))) + lists(request_id, &vec![Vec::new(); blocks], true) } // A peer holding no list for the first block it is asked for. fn no_lists(request_id: u64) -> PeerRequestResult { - let message = BlockAccessListsMessage { - request_id, - block_access_lists: BlockAccessLists(vec![None]), - }; - Ok(WithPeerId::new(PeerId::random(), SnapResponse::BlockAccessLists(message))) + lists(request_id, &[Vec::new()], false) } fn scripted( @@ -499,10 +679,215 @@ mod tests { (client, bootstrap) } + // An attempt at block 2 that downloaded all of `accounts()`, not handed off yet. + fn downloaded(factory: &Factory) -> SnapWrite { + let accounts = accounts(); + insert_chain(factory, 3, state_root(&accounts)); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(2).unwrap().unwrap().num_hash(); + let write = + provider.start_snap_attempt(SnapGeneration::new(pivot, state_root(&accounts))).unwrap(); + provider.start_account_coverage(write).unwrap(); + let range = verified_range(&accounts, 0..accounts.len(), B256::ZERO, &[]); + provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); + provider.commit().unwrap(); + write + } + fn attempt_id(factory: &Factory) -> SnapAttemptId { factory.database_provider_ro().unwrap().snap_attempt().unwrap().unwrap().id() } + // Serves heads in order, repeating the last, and ends the run at the first wait. + struct TestContext { + heads: RefCell>, + waits: usize, + } + + impl SnapSyncContext for TestContext { + fn head(&self) -> Result { + let mut heads = self.heads.borrow_mut(); + let head = if heads.len() > 1 { heads.pop_front() } else { heads.front().copied() }; + Ok(head.expect("the context is given at least one head")) + } + + fn wait_for_progress(&mut self, _head: u64) -> impl Future + Send { + self.waits += 1; + std::future::ready(false) + } + } + + // Blocks `0..=tip`, each committing to an empty list and to `root`. + fn chain(tip: u64, root: B256) -> Vec { + let access_list_hash = compute_block_access_list_hash(&Vec::::new()); + let mut parent = B256::ZERO; + (0..=tip) + .map(|number| { + let mut header = header(number, parent, Some(access_list_hash)); + header.state_root = root; + let sealed = SealedHeader::seal_slow(header); + parent = sealed.hash(); + sealed + }) + .collect() + } + + // Blocks continuing `parent`, one per list, each committing to its list and to `root`. + fn branch( + parent: &SealedHeader, + lists: &[Vec], + root: B256, + ) -> Vec { + let mut parent = parent.clone(); + lists + .iter() + .map(|list| { + let access_list_hash = compute_block_access_list_hash(list); + let mut header = header(parent.number() + 1, parent.hash(), Some(access_list_hash)); + header.state_root = root; + parent = SealedHeader::seal_slow(header); + parent.clone() + }) + .collect() + } + + // A peer serving `lists` in order, or none of them. + fn lists( + request_id: u64, + lists: &[Vec], + served: bool, + ) -> PeerRequestResult { + let block_access_lists = + lists.iter().map(|list| served.then(|| alloy_rlp::encode(list).into())).collect(); + let message = BlockAccessListsMessage { + request_id, + block_access_lists: BlockAccessLists(block_access_lists), + }; + Ok(WithPeerId::new(PeerId::random(), SnapResponse::BlockAccessLists(message))) + } + + // Rebuilds the trie of `write`'s state up to `target` and checks it against that header. + fn rebuild_and_verify(factory: &Factory, write: SnapWrite, target: u64) -> VerifiedSnapState { + let provider = factory.database_provider_rw().unwrap(); + let mut stage = MerkleStage::default_execution(); + loop { + let checkpoint = provider.get_stage_checkpoint(StageId::MerkleExecute).unwrap(); + let output = + stage.execute(&provider, ExecInput { target: Some(target), checkpoint }).unwrap(); + provider.save_stage_checkpoint(StageId::MerkleExecute, output.checkpoint).unwrap(); + if output.done { + break + } + } + provider.verify_state_root(write).unwrap() + } + + // The accounts at every block of the new branch, which never credits `STALE`. + fn reorg_accounts() -> Vec<(B256, TrieAccount)> { + let mut accounts = vec![(keccak256(STALE), account(1)), (keccak256(KEPT), account(2))]; + accounts.sort_by_key(|(hashed_address, _)| *hashed_address); + accounts + } + + fn stale_changes() -> AccountChanges { + AccountChanges::new(STALE) + } + + // An attempt that downloaded every account at pivot 4 of a branch crediting `STALE` in blocks + // 3 and 4, then a reorg to a branch forking after block 2 whose first lists are `new_lists`. + // Returns the orphaned headers and the new branch's. + fn reorged( + factory: &Factory, + new_lists: [Vec; 2], + ) -> (SnapAttemptId, Vec, Vec) { + let accounts = reorg_accounts(); + let root = state_root(&accounts); + let shared = chain(2, root); + let orphaned = branch(&shared[2], &orphaned_lists(), root); + insert_headers(factory, &shared); + insert_headers(factory, &orphaned); + let provider = factory.database_provider_rw().unwrap(); + let write = + provider.start_snap_attempt(SnapGeneration::new(orphaned[1].num_hash(), root)).unwrap(); + provider.start_account_coverage(write).unwrap(); + let range = verified_range(&accounts, 0..2, B256::ZERO, &[]); + provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); + // The range was served at the orphaned pivot, holding its credit. + let mut credited = Account::from(account(1)); + credited.balance = U256::from(1_000); + let stale = HashedPostState::default().with_accounts([(keccak256(STALE), Some(credited))]); + provider.write_hashed_state(&stale.into_sorted()).unwrap(); + provider.commit().unwrap(); + + let [first, second] = new_lists; + let new = branch(&shared[2], &[first, second, Vec::new()], root); + factory.replace_headers_after(2, &new); + (attempt_id(factory), orphaned, new) + } + + // The orphaned branch's lists for blocks 3 and 4, crediting `STALE` in each. + fn orphaned_lists() -> Vec> { + [999, 1_000] + .map(|balance| { + vec![stale_changes().with_balance_change(BalanceChange::new( + BlockAccessIndex::new(1), + U256::from(balance), + ))] + }) + .to_vec() + } + + // A peer serving no account range. + fn unserved_range(request_id: u64) -> PeerRequestResult { + let message = AccountRangeMessage { request_id, accounts: Vec::new(), proof: Vec::new() }; + Ok(WithPeerId::new(PeerId::random(), SnapResponse::AccountRange(message))) + } + + // A peer serving the account at `hashed_address` alone. + fn served_account( + request_id: u64, + accounts: &[(B256, TrieAccount)], + hashed_address: B256, + ) -> PeerRequestResult { + let index = accounts.iter().position(|(key, _)| *key == hashed_address).unwrap(); + account_range(request_id, accounts, index..index + 1, &[hashed_address]) + } + + fn hashes(headers: &[SealedHeader]) -> Vec { + headers.iter().map(SealedHeader::hash).collect() + } + + // Runs a bootstrap through a reorg to a branch whose first lists are `new_lists`, returning + // the account ranges it fetched again. + async fn recover(new_lists: [Vec; 2], repaired: bool) -> Vec { + let accounts = reorg_accounts(); + let factory = hashed_factory(); + let (attempt, orphaned, new) = reorged(&factory, new_lists.clone()); + let mut responses = vec![ + lists(1, &orphaned_lists(), true), + // Blocks 3 and 4 of the new branch carry the state to its pivot. + lists(2, &new_lists, true), + ]; + if repaired { + responses.push(served_account(1, &accounts, keccak256(STALE))); + } + let (client, mut bootstrap) = scripted(&factory, responses, [5]); + + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { write, pivot } = outcome else { + panic!("the state is repaired: {outcome:?}") + }; + assert_eq!(pivot, new[1].num_hash()); + assert_eq!(attempt_id(&factory), attempt); + assert_eq!(*client.block_requests(), [hashes(&orphaned), hashes(&new[..2])]); + let origins = client.origins().clone(); + // The repaired state is the new pivot's, as its header commits to. + let verified = rebuild_and_verify(&factory, write, pivot.number); + assert_eq!(verified.state_root(), state_root(&accounts)); + origins + } + #[tokio::test] async fn downloads_the_state_and_hands_it_to_the_trie_rebuild() { let accounts = accounts(); @@ -522,6 +907,101 @@ mod tests { assert!(provider.is_trie_rebuild_started(write).unwrap()); } + #[tokio::test] + async fn pending_repairs_follow_the_pivot_once_the_accounts_are_complete() { + let accounts = accounts(); + let root = state_root(&accounts); + let factory = hashed_factory(); + insert_chain(&factory, 8, root); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(2).unwrap().unwrap().num_hash(); + let write = provider.start_snap_attempt(SnapGeneration::new(pivot, root)).unwrap(); + provider.start_account_coverage(write).unwrap(); + let range = verified_range(&accounts, 0..3, B256::ZERO, &[]); + provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_account(key(2)); + provider.schedule_snap_repairs(write, repairs).unwrap(); + provider.commit().unwrap(); + // No peer serves the repair at pivot 2 any more. + let (_, mut bootstrap) = scripted(&factory, [unserved_range(1)], [3]); + assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + + // Once the head moves on, the pivot advances and the repair is fetched there. + let responses = [empty_lists(1, 5), account_range(1, &accounts, 1..2, &[key(2)])]; + let (client, mut bootstrap) = scripted(&factory, responses, [8]); + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { + panic!("the state is complete: {outcome:?}") + }; + assert_eq!(pivot.number, 7); + assert_eq!(*client.origins(), [key(2)]); + } + + #[tokio::test] + async fn scheduled_repairs_are_fetched_at_the_pivot_before_ranges() { + let accounts = accounts(); + let factory = hashed_factory(); + insert_chain(&factory, 3, state_root(&accounts)); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(2).unwrap().unwrap().num_hash(); + let write = + provider.start_snap_attempt(SnapGeneration::new(pivot, state_root(&accounts))).unwrap(); + provider.start_account_coverage(write).unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_account(key(2)); + provider.schedule_snap_repairs(write, repairs).unwrap(); + provider.commit().unwrap(); + // One download fetches the repair and then the ranges, numbering both requests. + let responses = + [account_range(1, &accounts, 1..2, &[key(2)]), account_range(2, &accounts, 0..3, &[])]; + let (client, mut bootstrap) = scripted(&factory, responses, [3]); + + let outcome = bootstrap.run().await.unwrap(); + + assert!(matches!(outcome, SnapBootstrapOutcome::TrieRebuild { .. }), "{outcome:?}"); + assert_eq!(*client.origins(), [key(2), B256::ZERO]); + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.snap_repairs(write).unwrap().is_empty()); + } + + #[tokio::test] + async fn repairs_yield_to_pivot_checks_between_batches() { + let accounts = accounts(); + let root = state_root(&accounts); + let factory = hashed_factory(); + insert_chain(&factory, 8, root); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(2).unwrap().unwrap().num_hash(); + let write = provider.start_snap_attempt(SnapGeneration::new(pivot, root)).unwrap(); + provider.start_account_coverage(write).unwrap(); + let range = verified_range(&accounts, 0..3, B256::ZERO, &[]); + provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_account(key(1)); + repairs.insert_account(key(2)); + provider.schedule_snap_repairs(write, repairs).unwrap(); + provider.commit().unwrap(); + let responses = [ + account_range(1, &accounts, 0..1, &[key(1)]), + // The head moved past the advance window meanwhile, so the pivot moves to block 7. + empty_lists(1, 5), + account_range(2, &accounts, 1..2, &[key(2)]), + ]; + let (client, bootstrap) = scripted(&factory, responses, [3, 8]); + let mut bootstrap = bootstrap.with_ranges_per_check(1); + + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { + panic!("the state is repaired: {outcome:?}") + }; + assert_eq!(pivot.number, 7); + assert_eq!(*client.origins(), [key(1), key(2)]); + assert_eq!(client.block_requests().len(), 1); + } + #[tokio::test] async fn a_lagging_pivot_advances_before_more_ranges_download() { let accounts = accounts(); @@ -548,18 +1028,7 @@ mod tests { assert_eq!(client.block_requests().len(), 1); // The merkle stage rebuilds the trie, which the pivot's header then accepts. - let provider = factory.database_provider_rw().unwrap(); - let mut stage = MerkleStage::default_execution(); - loop { - let checkpoint = provider.get_stage_checkpoint(StageId::MerkleExecute).unwrap(); - let output = - stage.execute(&provider, ExecInput { target: Some(7), checkpoint }).unwrap(); - provider.save_stage_checkpoint(StageId::MerkleExecute, output.checkpoint).unwrap(); - if output.done { - break - } - } - let verified = provider.verify_state_root(write).unwrap(); + let verified = rebuild_and_verify(&factory, write, 7); assert_eq!((verified.target(), verified.state_root()), (pivot, root)); } @@ -601,18 +1070,41 @@ mod tests { } #[tokio::test] - async fn a_reorged_pivot_restarts_the_attempt() { + async fn a_head_before_block_access_lists_falls_back_without_waiting() { + let factory = hashed_factory(); + let genesis = header(0, B256::ZERO, None); + insert_headers(&factory, &[SealedHeader::seal_slow(genesis)]); + let (client, mut bootstrap) = scripted(&factory, [], [0]); + + assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::BeforeBlockAccessLists); + + assert_eq!(bootstrap.context.waits, 0); + assert!(client.origins().is_empty()); + assert!(factory.database_provider_ro().unwrap().snap_attempt().unwrap().is_none()); + } + + #[tokio::test] + async fn a_reorged_pivot_without_kept_blocks_restarts_the_attempt() { let accounts = accounts(); let factory = hashed_factory(); - insert_chain(&factory, 3, state_root(&accounts)); - // A previous run anchored to a block the canonical chain no longer holds. + let blocks = chain(3, state_root(&accounts)); + insert_headers(&factory, &blocks); + // A previous run anchored to a block the canonical chain then replaced. let provider = factory.database_provider_rw().unwrap(); - let orphaned = SnapGeneration::new( - BlockNumHash::new(2, B256::repeat_byte(0xee)), - state_root(&accounts), - ); + let orphaned = SnapGeneration::new(blocks[2].num_hash(), state_root(&accounts)); provider.start_snap_attempt(orphaned).unwrap(); + StoredAncestry::clear(&provider).unwrap(); provider.commit().unwrap(); + let mut replaced = chain(3, state_root(&accounts)); + replaced[2] = SealedHeader::seal_slow(Header { + extra_data: Bytes::from_static(&[1]), + ..replaced[2].header().clone() + }); + replaced[3] = SealedHeader::seal_slow(Header { + parent_hash: replaced[2].hash(), + ..replaced[3].header().clone() + }); + factory.replace_headers_after(1, &replaced[2..]); let orphaned = attempt_id(&factory); let (_, mut bootstrap) = scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [3]); @@ -621,7 +1113,7 @@ mod tests { let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { panic!("the state is complete: {outcome:?}") }; - assert_ne!(pivot.hash, B256::repeat_byte(0xee)); + assert_ne!(pivot.hash, blocks[2].hash()); assert_ne!(attempt_id(&factory), orphaned); } @@ -639,19 +1131,6 @@ mod tests { assert!(client.origins().is_empty()); } - #[tokio::test] - async fn a_cancelled_run_stops_before_any_work() { - let factory = hashed_factory(); - insert_chain(&factory, 3, state_root(&accounts())); - let cancel = CancellationToken::new(); - let (client, bootstrap) = scripted(&factory, [], [3]); - let mut bootstrap = bootstrap.with_cancellation(cancel.clone()); - cancel.cancel(); - - assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::Stopped); - assert!(client.origins().is_empty()); - } - #[tokio::test] async fn a_catch_up_stalled_past_the_served_lists_restarts() { let accounts = accounts(); @@ -706,29 +1185,34 @@ mod tests { #[tokio::test] async fn unreadable_progress_restarts_the_attempt() { - let accounts = accounts(); - let factory = hashed_factory(); - insert_chain(&factory, 3, state_root(&accounts)); - let (_, mut stopped) = scripted(&factory, [Err(RequestError::UnsupportedCapability)], [3]); - assert_eq!(stopped.run().await.unwrap(), SnapBootstrapOutcome::Stopped); - // Another build rewrote the coverage the attempt resumes from. - let provider = factory.database_provider_rw().unwrap(); - let stale = provider.active_snap_write().unwrap().unwrap(); - provider.write_metadata("snap_account_coverage", br#"{"version":999}"#.to_vec()).unwrap(); - provider.commit().unwrap(); - - let (_, mut restarted) = scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [3]); - let outcome = restarted.run().await.unwrap(); - - let SnapBootstrapOutcome::TrieRebuild { write, .. } = outcome else { - panic!("the state is complete: {outcome:?}") - }; - assert_ne!(write.attempt(), stale.attempt()); - let provider = factory.database_provider_ro().unwrap(); - assert!(matches!( - provider.authorize_snap_write(stale), - Err(SnapSyncError::StaleWrite { .. }) - )); + // A record another build wrote, and one that is not a record at all. + for record in [br#"{"version":999}"#.as_slice(), b"not json"] { + let accounts = accounts(); + let factory = hashed_factory(); + insert_chain(&factory, 3, state_root(&accounts)); + let (_, mut stopped) = + scripted(&factory, [Err(RequestError::UnsupportedCapability)], [3]); + assert_eq!(stopped.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + // Another build rewrote the coverage the attempt resumes from. + let provider = factory.database_provider_rw().unwrap(); + let stale = provider.active_snap_write().unwrap().unwrap(); + provider.write_metadata("snap_account_coverage", record.to_vec()).unwrap(); + provider.commit().unwrap(); + + let (_, mut restarted) = + scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [3]); + let outcome = restarted.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { write, .. } = outcome else { + panic!("the state is complete: {outcome:?}") + }; + assert_ne!(write.attempt(), stale.attempt()); + let provider = factory.database_provider_ro().unwrap(); + assert!(matches!( + provider.authorize_snap_write(stale), + Err(SnapSyncError::StaleWrite { .. }) + )); + } } #[tokio::test] @@ -759,22 +1243,323 @@ mod tests { assert_eq!(client.storage_requests().len(), 1); } - // Serves heads in order, repeating the last, and ends the run at the first wait. - struct TestContext { - heads: RefCell>, - waits: usize, + #[tokio::test] + async fn a_new_branch_too_short_for_a_pivot_waits_before_fetching_lists() { + let factory = hashed_factory(); + let (attempt, _, _) = reorged(&factory, [Vec::new(), Vec::new()]); + // Head 2 puts the pivot at block 1, below the ancestor at block 2. + let (client, mut bootstrap) = scripted(&factory, [], [2]); + + assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + + assert!(client.block_requests().is_empty()); + assert_eq!(attempt_id(&factory), attempt); } - impl SnapSyncContext for TestContext { - fn head(&self) -> Result { - let mut heads = self.heads.borrow_mut(); - let head = if heads.len() > 1 { heads.pop_front() } else { heads.front().copied() }; - Ok(head.expect("the context is given at least one head")) - } + #[tokio::test] + async fn a_handed_off_pivot_reorged_before_verification_restarts_the_attempt() { + let accounts = accounts(); + let root = state_root(&accounts); + let factory = hashed_factory(); + let shared = chain(3, root); + insert_headers(&factory, &shared); + let (_, mut bootstrap) = scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [3]); + let SnapBootstrapOutcome::TrieRebuild { pivot: orphaned, .. } = + bootstrap.run().await.unwrap() + else { + panic!("the state is complete") + }; + let attempt = attempt_id(&factory); + // Blocks 2 and 3 are replaced before the rebuild verifies the orphaned pivot. + let new = branch(&shared[1], &[vec![stale_changes()], Vec::new()], root); + factory.replace_headers_after(1, &new); + let (_, mut resumed) = scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [3]); - fn wait_for_progress(&mut self, _head: u64) -> impl Future + Send { - self.waits += 1; - std::future::ready(false) + let outcome = resumed.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { write, pivot } = outcome else { + panic!("the new branch's state is complete: {outcome:?}") + }; + assert_ne!(pivot, orphaned); + assert_eq!(pivot, new[0].num_hash()); + assert_ne!(attempt_id(&factory), attempt); + let verified = rebuild_and_verify(&factory, write, pivot.number); + assert_eq!(verified.state_root(), root); + } + + #[tokio::test] + async fn a_field_only_the_orphaned_branch_changed_is_fetched_again() { + let origins = recover([Vec::new(), Vec::new()], true).await; + + // The untouched account is kept, never fetched again. + assert_eq!(origins, [keccak256(STALE)]); + } + + #[tokio::test] + async fn a_field_the_new_branch_changes_too_needs_no_repair() { + let balance = stale_changes() + .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::ONE)); + + let origins = recover([vec![balance], Vec::new()], false).await; + + assert!(origins.is_empty()); + } + + #[tokio::test] + async fn a_second_reorg_repairs_what_the_first_new_branch_changed() { + let accounts = reorg_accounts(); + let factory = hashed_factory(); + let kept = AccountChanges::new(KEPT) + .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::from(7))); + let first_lists = [vec![kept], Vec::new()]; + let (attempt, _, first) = reorged(&factory, first_lists.clone()); + // Catch-up applies the first new branch, then no peer serves the repair at its pivot. + let responses = + [lists(1, &orphaned_lists(), true), lists(2, &first_lists, true), unserved_range(1)]; + let (_, mut bootstrap) = scripted(&factory, responses, [5]); + assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + + // After a restart, a second reorg orphans the re-anchored pivot too. + let shared = factory.database_provider_ro().unwrap().sealed_header(2).unwrap().unwrap(); + let second = branch(&shared, &[Vec::new(), Vec::new(), Vec::new()], state_root(&accounts)); + factory.replace_headers_after(2, &second); + let mut responses = + vec![lists(1, &first_lists, true), lists(2, &[Vec::new(), Vec::new()], true)]; + // Both the first orphaned branch's and the first new branch's changes are fetched again. + let mut repaired = [keccak256(STALE), keccak256(KEPT)]; + repaired.sort(); + for (id, hashed_address) in (1..).zip(repaired) { + responses.push(served_account(id, &accounts, hashed_address)); } + let (client, mut bootstrap) = scripted(&factory, responses, [5]); + + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { write, pivot } = outcome else { + panic!("the state is repaired: {outcome:?}") + }; + assert_eq!(pivot, second[1].num_hash()); + assert_eq!(attempt_id(&factory), attempt); + assert_eq!(client.block_requests()[0], hashes(&first[..2])); + assert_eq!(*client.origins(), repaired); + let verified = rebuild_and_verify(&factory, write, pivot.number); + assert_eq!(verified.state_root(), state_root(&accounts)); + } + + #[tokio::test] + async fn unserved_orphaned_lists_are_waited_for() { + let accounts = reorg_accounts(); + let factory = hashed_factory(); + let (attempt, orphaned, new) = reorged(&factory, [Vec::new(), Vec::new()]); + let (_, mut bootstrap) = scripted(&factory, [lists(1, &orphaned_lists(), false)], [5]); + assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + assert_eq!(attempt_id(&factory), attempt); + + // A restarted node resumes the orphaned attempt, and another peer serves them. + let responses = [ + lists(1, &orphaned_lists(), true), + lists(2, &[Vec::new(), Vec::new()], true), + served_account(1, &accounts, keccak256(STALE)), + ]; + let (client, mut bootstrap) = scripted(&factory, responses, [5]); + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { + panic!("the state is repaired: {outcome:?}") + }; + assert_eq!(pivot, new[1].num_hash()); + assert_eq!(attempt_id(&factory), attempt); + assert_eq!(client.block_requests()[0], hashes(&orphaned)); + } + + #[tokio::test] + async fn a_reorg_across_block_access_list_activation_restarts_the_attempt() { + let accounts = accounts(); + let root = state_root(&accounts); + let factory = hashed_factory(); + let mut shared = chain(1, root); + // The ancestor predates block access lists, so the branches may hold blocks without them. + let mut ancestor = header(2, shared[1].hash(), None); + ancestor.state_root = root; + shared.push(SealedHeader::seal_slow(ancestor)); + let orphaned = branch(&shared[2], &orphaned_lists(), root); + insert_headers(&factory, &shared); + insert_headers(&factory, &orphaned); + let provider = factory.database_provider_rw().unwrap(); + provider.start_snap_attempt(SnapGeneration::new(orphaned[1].num_hash(), root)).unwrap(); + provider.commit().unwrap(); + let attempt = attempt_id(&factory); + let new = branch(&shared[2], &[vec![stale_changes()], Vec::new(), Vec::new()], root); + factory.replace_headers_after(2, &new); + let (client, mut bootstrap) = + scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [5]); + + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { + panic!("the new branch's state is complete: {outcome:?}") + }; + assert_eq!(pivot, new[1].num_hash()); + assert_ne!(attempt_id(&factory), attempt); + assert!(client.block_requests().is_empty()); + } + + #[tokio::test] + async fn a_catch_up_stalled_below_the_ancestor_restarts_before_fetching_lists() { + let accounts = accounts(); + let root = state_root(&accounts); + let factory = hashed_factory(); + let shared = chain(2, root); + let orphaned = branch(&shared[2], &orphaned_lists(), root); + insert_headers(&factory, &shared); + insert_headers(&factory, &orphaned); + // The pivot advanced to block 4 while catch-up stayed at block 1, below the ancestor. + let provider = factory.database_provider_rw().unwrap(); + let write = + provider.start_snap_attempt(SnapGeneration::new(shared[1].num_hash(), root)).unwrap(); + provider + .advance_snap_pivot(write, SnapGeneration::new(orphaned[1].num_hash(), root)) + .unwrap(); + provider.commit().unwrap(); + let attempt = attempt_id(&factory); + let mut lists = vec![vec![stale_changes()]]; + lists.resize(8, Vec::new()); + let new = branch(&shared[2], &lists, root); + factory.replace_headers_after(2, &new); + // Head 10 still serves the list after the ancestor, but not the one after block 1. + let (client, mut bootstrap) = + scripted(&factory, [account_range(1, &accounts, 0..3, &[])], [10]); + + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { + panic!("the new branch's state is complete: {outcome:?}") + }; + assert_eq!(pivot, new[6].num_hash()); + assert_ne!(attempt_id(&factory), attempt); + assert!(client.block_requests().is_empty()); + } + + #[tokio::test] + async fn orphaned_lists_unserved_past_the_state_window_restart_the_attempt() { + let accounts = accounts(); + let root = state_root(&accounts); + let factory = hashed_factory(); + let shared = chain(2, root); + let orphaned = branch(&shared[2], &orphaned_lists(), root); + insert_headers(&factory, &shared); + insert_headers(&factory, &orphaned); + let provider = factory.database_provider_rw().unwrap(); + provider.start_snap_attempt(SnapGeneration::new(orphaned[1].num_hash(), root)).unwrap(); + provider.commit().unwrap(); + let attempt = attempt_id(&factory); + let mut new_lists = vec![vec![stale_changes()]]; + new_lists.resize(129, Vec::new()); + let new = branch(&shared[2], &new_lists, root); + factory.replace_headers_after(2, &new); + // Head 131 leaves the ancestor past the served state window, but its lists are still + // served. + let responses = + [lists(1, &orphaned_lists(), false), account_range(1, &accounts, 0..3, &[])]; + let (_, bootstrap) = scripted(&factory, responses, [131]); + let mut bootstrap = bootstrap.with_policy(policy().with_history(256)); + + let outcome = bootstrap.run().await.unwrap(); + + let SnapBootstrapOutcome::TrieRebuild { pivot, .. } = outcome else { + panic!("the new branch's state is complete: {outcome:?}") + }; + assert_eq!(pivot, new[127].num_hash()); + assert_ne!(attempt_id(&factory), attempt); + } + + #[tokio::test] + async fn a_cancelled_repair_resumes_at_its_pending_slots() { + let slots = vec![(key(1), U256::from(11)), (key(2), U256::from(12))]; + let mut contract = account(1); + contract.storage_root = storage_root_of(&slots); + let accounts = vec![(key(1), contract)]; + let factory = hashed_factory(); + insert_chain(&factory, 3, state_root(&accounts)); + let provider = factory.database_provider_rw().unwrap(); + let pivot = provider.sealed_header(2).unwrap().unwrap().num_hash(); + let write = + provider.start_snap_attempt(SnapGeneration::new(pivot, state_root(&accounts))).unwrap(); + provider.start_account_coverage(write).unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_slot(key(1), key(1)); + repairs.insert_slot(key(1), key(2)); + provider.schedule_snap_repairs(write, repairs).unwrap(); + provider.commit().unwrap(); + let cancel = CancellationToken::new(); + let on_request = cancel.clone(); + // Cancellation is checked between repair batches, so the batch ends first, with + // `key(2)` left pending by a peer that does not serve it. + let client = Arc::new( + ScriptedSnapClient::new([ + account_range(1, &accounts, 0..1, &[key(1)]), + storage_ranges(1, &[&slots[..1]], &slots, &[key(1)]), + storage_ranges(2, &[], &[], &[]), + ]) + .on_storage_request(move || on_request.cancel()), + ); + let context = TestContext { heads: RefCell::new(VecDeque::from([3])), waits: 0 }; + let mut bootstrap = + SnapBootstrap::new(Arc::clone(&client), factory.clone(), Runtime::test(), context) + .with_policy(policy()) + .with_cancellation(cancel); + assert_eq!(bootstrap.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + assert_eq!(client.storage_requests().len(), 2); + let provider = factory.database_provider_ro().unwrap(); + let pending = provider.snap_repairs(write).unwrap().slots(key(1)).collect::>(); + assert_eq!(pending, [key(2)]); + assert_eq!(stored_slots(&provider, key(1)), slots[..1]); + drop(provider); + + let (client, mut resumed) = scripted( + &factory, + [ + account_range(1, &accounts, 0..1, &[key(1)]), + storage_ranges(1, &[&slots[1..]], &slots, &[key(2)]), + Err(RequestError::UnsupportedCapability), + ], + [3], + ); + assert_eq!(resumed.run().await.unwrap(), SnapBootstrapOutcome::Stopped); + assert_eq!(*client.storage_requests(), [(vec![key(1)], key(2))]); + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.snap_repairs(write).unwrap().is_empty()); + assert_eq!(stored_slots(&provider, key(1)), slots); + } + + #[tokio::test] + async fn a_cancelled_run_still_finishes_the_hand_off_scan() { + let factory = hashed_factory(); + let write = downloaded(&factory); + let cancel = CancellationToken::new(); + cancel.cancel(); + let (_, bootstrap) = scripted(&factory, [], [3]); + let bootstrap = bootstrap.with_cancellation(cancel).with_shutdown(CancellationToken::new()); + + // The scan restarts from scratch once stopped, so a header refresh doesn't stop it. + assert!(matches!(bootstrap.hand_off(write).await, Ok(Step::HandedOff(_)))); + + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.is_trie_rebuild_started(write).unwrap()); + } + + #[tokio::test] + async fn shutdown_stops_the_hand_off_scan_without_committing() { + let factory = hashed_factory(); + let write = downloaded(&factory); + let shutdown = CancellationToken::new(); + shutdown.cancel(); + let (_, bootstrap) = scripted(&factory, [], [3]); + let bootstrap = bootstrap.with_shutdown(shutdown); + + assert!(matches!(bootstrap.hand_off(write).await, Err(SnapSyncError::Cancelled))); + + let provider = factory.database_provider_ro().unwrap(); + assert!(!provider.is_trie_rebuild_started(write).unwrap()); } } diff --git a/crates/snap-sync/src/bytecode/download.rs b/crates/snap-sync/src/bytecode/download.rs index cafe6c8b1ea..63468221ca9 100644 --- a/crates/snap-sync/src/bytecode/download.rs +++ b/crates/snap-sync/src/bytecode/download.rs @@ -128,8 +128,8 @@ mod tests { use super::*; use crate::{ test_utils::{ - account, byte_codes, generation, hashed_factory, insert_generation_headers, key, - state_root, verified_range, ScriptedSnapClient, + account, byte_codes, generation, hashed_factory_with_roots, key, state_root, + verified_range, verified_repair, ScriptedSnapClient, }, SnapAccountStore, SnapAttemptStore, }; @@ -167,8 +167,7 @@ mod tests { // An attempt that fetched all of `accounts` as one range, not yet committed. fn started(accounts: &[(B256, TrieAccount)]) -> (Factory, VerifiedRange) { - let factory = hashed_factory(); - insert_generation_headers(&factory); + let factory = hashed_factory_with_roots(&[state_root(accounts); 3]); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -220,17 +219,24 @@ mod tests { } #[tokio::test] - async fn code_already_stored_is_never_requested() { + async fn a_repair_requests_only_the_code_of_its_account() { let accounts = accounts(); let (factory, range) = started(&accounts); - let (_, mut first) = download([byte_codes(1, &[code(1), code(2)])], factory.clone()); - committed(&mut first, &range).await; - drop(first); - - let (client, mut resumed) = download([], factory); + let write = range.write(); + let (client, mut download) = download([byte_codes(1, &[code(2)])], factory); + + // Key 2 holds no code, and the contract at key 3 only proves where its range ends. + let plain = + VerifiedRange::new(write, verified_repair(&accounts, 1..3, key(2), &[key(2), key(3)])); + assert!(matches!(download.next(&plain).await.unwrap(), BytecodeStep::Complete)); + let contract = + VerifiedRange::new(write, verified_repair(&accounts, 3..4, key(4), &[key(4)])); + assert!(matches!( + download.next(&contract).await.unwrap(), + BytecodeStep::Committed { persisted: 1 } + )); - assert!(matches!(resumed.next(&range).await.unwrap(), BytecodeStep::Complete)); - assert!(client.code_requests().is_empty()); + assert_eq!(*client.code_requests(), [vec![keccak256(code(2))]]); } #[tokio::test] @@ -265,27 +271,6 @@ mod tests { ); } - #[tokio::test] - async fn unresolved_code_prevents_the_range_from_committing() { - let accounts = accounts(); - let (factory, range) = started(&accounts); - let (_, mut download) = download([byte_codes(1, &[code(1)])], factory.clone()); - committed(&mut download, &range).await; - - let provider = factory.database_provider_rw().unwrap(); - let refused = provider.commit_account_range( - range.write(), - range.range(), - Default::default(), - Vec::new(), - ); - - assert!(matches!( - refused, - Err(SnapSyncError::MissingCode { hash }) if hash == keccak256(code(2)) - )); - } - #[tokio::test] async fn requests_ask_for_at_most_the_configured_hashes() { let accounts = accounts(); @@ -300,28 +285,4 @@ mod tests { assert!(matches!(download.next(&range).await.unwrap(), BytecodeStep::Complete)); assert_eq!(*client.code_requests(), [vec![keccak256(code(1))], vec![keccak256(code(2))]]); } - - #[tokio::test] - async fn a_range_without_contracts_requests_no_code() { - let accounts = vec![(key(1), account(1)), (key(2), account(2))]; - let (factory, range) = started(&accounts); - let (client, mut download) = download([], factory); - - assert!(matches!(download.next(&range).await.unwrap(), BytecodeStep::Complete)); - assert!(client.code_requests().is_empty()); - } - - #[tokio::test] - async fn code_fetched_before_the_pivot_moved_is_not_committed() { - let accounts = accounts(); - let (factory, range) = started(&accounts); - let (client, mut download) = - download([byte_codes(1, &[code(1), code(2)])], factory.clone()); - let provider = factory.database_provider_rw().unwrap(); - provider.advance_snap_pivot(range.write(), generation(2, state_root(&accounts))).unwrap(); - provider.commit().unwrap(); - - assert!(matches!(download.next(&range).await, Err(SnapSyncError::StaleWrite { .. }))); - assert!(client.code_requests().is_empty()); - } } diff --git a/crates/snap-sync/src/bytecode/store.rs b/crates/snap-sync/src/bytecode/store.rs index 0c5a33fec18..7062f4e7fe7 100644 --- a/crates/snap-sync/src/bytecode/store.rs +++ b/crates/snap-sync/src/bytecode/store.rs @@ -99,8 +99,7 @@ impl SnapBytecodeStore for T { mod tests { use super::*; use crate::test_utils::{ - account, generation, hashed_factory, insert_generation_headers, key, state_root, - verified_range, + account, generation, hashed_factory_with_roots, key, state_root, verified_range, }; use alloy_primitives::bytes; use reth_provider::{ @@ -122,8 +121,7 @@ mod tests { } fn started(accounts: &[(B256, TrieAccount)]) -> (Factory, SnapWrite) { - let factory = hashed_factory(); - insert_generation_headers(&factory); + let factory = hashed_factory_with_roots(&[state_root(accounts); 3]); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(accounts))).unwrap(); provider.commit().unwrap(); @@ -163,32 +161,6 @@ mod tests { assert!(is_stored(&provider, &shared)); } - #[test] - fn a_scan_stops_once_it_has_the_hashes_a_request_can_carry() { - let hashes: Vec<_> = (1..=4).map(|byte| keccak256(code(byte))).collect(); - let (factory, write) = started(&[(key(1), contract(1, &code(1)))]); - let provider = factory.database_provider_ro().unwrap(); - - assert_eq!(provider.missing_code(write, &hashes, 2).unwrap(), hashes[..2]); - } - - #[test] - fn code_already_stored_is_not_requested_again() { - let stored = code(1); - let accounts = vec![(key(1), contract(1, &stored))]; - let (factory, write) = started(&accounts); - let provider = factory.database_provider_rw().unwrap(); - provider.commit_bytecodes(write, vec![(keccak256(&stored), stored.clone())]).unwrap(); - provider.commit().unwrap(); - - let provider = factory.database_provider_ro().unwrap(); - assert!(provider - .missing_code(write, &[keccak256(&stored)], usize::MAX) - .unwrap() - .is_empty()); - assert!(is_stored(&provider, &stored)); - } - #[test] fn code_that_does_not_hash_to_its_requested_hash_is_refused() { let wanted = code(1); @@ -229,7 +201,7 @@ mod tests { let accounts = vec![(key(1), contract(1, &wanted))]; let (factory, write) = started(&accounts); let provider = factory.database_provider_rw().unwrap(); - provider.advance_snap_pivot(write, generation(2, B256::repeat_byte(0xcc))).unwrap(); + provider.advance_snap_pivot(write, generation(2, state_root(&accounts))).unwrap(); let refused = provider.commit_bytecodes(write, vec![(keccak256(&wanted), wanted.clone())]); diff --git a/crates/snap-sync/src/catch_up/apply.rs b/crates/snap-sync/src/catch_up/apply.rs index efaa524e50d..d74b65c34eb 100644 --- a/crates/snap-sync/src/catch_up/apply.rs +++ b/crates/snap-sync/src/catch_up/apply.rs @@ -64,6 +64,7 @@ pub enum DownloadedAccount { #[cfg(test)] mod tests { + use super::*; use crate::{test_utils::hashed_factory, AccountCoverage, SnapCatchUpStore, StorageProgress}; use alloy_consensus::{Header, TxLegacy}; @@ -87,7 +88,7 @@ mod tests { use reth_storage_api::DatabaseProviderFactory; use reth_trie_common::{HashedStorage, KeccakKeyHasher}; use revm::{ - database::{states::bundle_state::BundleRetention, CacheDB, EmptyDB, State}, + database::{states::bundle_state::BundleRetention, InMemoryDB, State}, state::{AccountInfo, Bytecode}, }; use std::{collections::BTreeMap, sync::Arc}; @@ -127,7 +128,7 @@ mod tests { #[test] fn read_only_entries_write_nothing() { - let changes = AccountChanges::new(ACCOUNT).with_storage_read(U256::from(1)); + let changes = AccountChanges::new(ACCOUNT).with_storage_read(U256::ONE); let update = apply(&changes, DownloadedAccount::Unknown); @@ -136,26 +137,14 @@ mod tests { #[test] fn empty_slot_entries_write_nothing() { - let changes = AccountChanges::new(ACCOUNT) - .with_storage_change(SlotChanges::new(U256::from(1), vec![])); + let changes = + AccountChanges::new(ACCOUNT).with_storage_change(SlotChanges::new(U256::ONE, vec![])); let update = apply(&changes, DownloadedAccount::Unknown); assert_eq!(update, BalStateUpdate::default()); } - #[test] - fn account_changes_with_empty_slots_write_no_storage() { - let changes = AccountChanges::new(ACCOUNT) - .with_balance_change(BalanceChange::new(index(1), U256::from(10))) - .with_storage_change(SlotChanges::new(U256::from(1), vec![])); - - let update = apply(&changes, DownloadedAccount::Absent); - - assert_eq!(update.state.accounts[&keccak256(ACCOUNT)].unwrap().balance, U256::from(10)); - assert!(update.state.storages.is_empty()); - } - #[test] fn accounts_not_downloaded_yet_are_left_unresolved() { // Even a fully determined account is left to the download against the later root. @@ -171,37 +160,14 @@ mod tests { assert!(update.bytecodes.is_empty()); } - #[test] - fn untouched_fields_keep_their_downloaded_values() { - let existing = - Account { balance: U256::from(9), nonce: 4, bytecode_hash: Some(B256::repeat_byte(1)) }; - let changes = AccountChanges::new(ACCOUNT) - .with_balance_change(BalanceChange::new(index(1), U256::from(10))) - .with_balance_change(BalanceChange::new(index(2), U256::from(20))); - - let update = apply(&changes, DownloadedAccount::Present(existing)); - assert_eq!( - update.state.accounts[&keccak256(ACCOUNT)], - Some(Account { balance: U256::from(20), ..existing }) - ); - - // An absent account starts from the empty one. - let update = apply(&changes, DownloadedAccount::Absent); - assert_eq!( - update.state.accounts[&keccak256(ACCOUNT)], - Some(Account { balance: U256::from(20), ..Default::default() }) - ); - } - #[test] fn zeroed_slots_and_cleared_code_are_written() { - let existing = - Account { balance: U256::from(1), nonce: 1, bytecode_hash: Some(B256::repeat_byte(1)) }; + let existing = Account::new(1, U256::ONE, Some(B256::repeat_byte(1))); let changes = AccountChanges::new(ACCOUNT) .with_code_change(CodeChange::new(index(1), bytes!("6001"))) .with_code_change(CodeChange::new(index(2), Bytes::new())) .with_storage_change(SlotChanges::new( - U256::from(1), + U256::ONE, vec![ StorageChange::new(index(1), U256::from(5)), StorageChange::new(index(2), U256::ZERO), @@ -211,29 +177,18 @@ mod tests { let update = apply(&changes, DownloadedAccount::Present(existing)); let hashed_address = keccak256(ACCOUNT); - assert_eq!(update.state.accounts[&hashed_address].unwrap().bytecode_hash, None); + assert_eq!(update.state.accounts[&hashed_address].as_ref().unwrap().bytecode_hash, None); assert_eq!( update.state.storages[&hashed_address], - HashedStorage::from_iter([(keccak256(B256::from(U256::from(1))), U256::ZERO)]) + HashedStorage::from_iter([(keccak256(B256::with_last_byte(1)), U256::ZERO)]) ); assert!(update.bytecodes.is_empty()); } - #[test] - fn an_account_the_block_empties_is_removed() { - let changes = AccountChanges::new(ACCOUNT) - .with_balance_change(BalanceChange::new(index(1), U256::from(100))) - .with_balance_change(BalanceChange::new(index(2), U256::ZERO)); - - let update = apply(&changes, DownloadedAccount::Absent); - - assert_eq!(update.state.accounts[&keccak256(ACCOUNT)], None); - } - // Final state as a flat map: accounts, and non-zero slots by hashed address and slot. type FlatState = (BTreeMap, BTreeMap<(B256, B256), U256>); - fn flatten(db: &CacheDB) -> FlatState { + fn flatten(db: &InMemoryDB) -> FlatState { let mut state = FlatState::default(); for (address, account) in &db.cache.accounts { let hashed_address = keccak256(address); @@ -247,6 +202,7 @@ mod tests { state } + #[allow(clippy::clone_on_copy)] fn fold(mut state: FlatState, update: &HashedPostState) -> FlatState { for (hashed_address, storage) in &update.storages { for (slot, value) in &storage.storage { @@ -260,7 +216,7 @@ mod tests { for (hashed_address, account) in &update.accounts { match account { Some(account) => { - state.0.insert(*hashed_address, *account); + state.0.insert(*hashed_address, account.clone()); } None => { state.0.remove(hashed_address); @@ -271,38 +227,7 @@ mod tests { state } - #[test] - fn flat_state_excludes_zero_slots_and_deleted_account_storage() { - let mut db = CacheDB::::default(); - for address in [ACCOUNT, SENDER] { - db.insert_account_info(address, AccountInfo::from_balance(U256::from(1))); - db.insert_account_storage(address, U256::from(1), U256::from(5)).unwrap(); - db.insert_account_storage(address, U256::from(2), U256::ZERO).unwrap(); - } - let pre = flatten(&db); - assert_eq!(pre.1.len(), 2); - - let mut update = HashedPostState::default(); - update.accounts.insert(keccak256(ACCOUNT), None); - let post = fold(pre, &update); - - assert_eq!( - post.0, - BTreeMap::from([( - keccak256(SENDER), - Account { balance: U256::from(1), ..Default::default() }, - )]) - ); - assert_eq!( - post.1, - BTreeMap::from([( - (keccak256(SENDER), keccak256(B256::from(U256::from(1)))), - U256::from(5) - )]) - ); - } - - fn insert(db: &mut CacheDB, address: Address, nonce: u64, code: Bytes) { + fn insert(db: &mut InMemoryDB, address: Address, nonce: u64, code: Bytes) { let code = Bytecode::new_raw(code); let info = AccountInfo { nonce, @@ -330,11 +255,12 @@ mod tests { } #[test] + #[allow(clippy::clone_on_copy)] fn applying_the_list_matches_execution() { let contract = Address::repeat_byte(0xc0); let beneficiary = Address::repeat_byte(0xbe); - let mut db = CacheDB::::new(Default::default()); + let mut db = InMemoryDB::default(); insert(&mut db, BEACON_ROOTS_ADDRESS, 1, BEACON_ROOTS_CODE.clone()); insert(&mut db, HISTORY_STORAGE_ADDRESS, 1, HISTORY_STORAGE_CODE.clone()); insert( @@ -346,7 +272,7 @@ mod tests { db.insert_account_info(SENDER, AccountInfo::from_balance(U256::from(u64::MAX))); // Zeroes slot 1, reads slot 3, stores the block number in slot 2 and the call value in 4. insert(&mut db, contract, 1, bytes!("6000600155600354504360025534600455")); - db.insert_account_storage(contract, U256::from(1), U256::from(5)).unwrap(); + db.insert_account_storage(contract, U256::ONE, U256::from(5)).unwrap(); db.insert_account_storage(contract, U256::from(3), U256::from(7)).unwrap(); let pre = flatten(&db); @@ -403,7 +329,7 @@ mod tests { let update = state_update( &bal, AccountCoverage::COMPLETE, - pre.0.iter().map(|(address, account)| (*address, *account)), + pre.0.iter().map(|(address, account)| (*address, account.clone())), ); let executed = HashedPostState::from_bundle_state::(bundle.state()); diff --git a/crates/snap-sync/src/catch_up/download.rs b/crates/snap-sync/src/catch_up/download.rs index 8baebfc3164..faca261bbb3 100644 --- a/crates/snap-sync/src/catch_up/download.rs +++ b/crates/snap-sync/src/catch_up/download.rs @@ -3,10 +3,10 @@ //! Headers are read again each request, so a reorged anchor is caught before peers are asked. use crate::{ - common::DownloadContext, CatchUpProgress, SnapAttemptStore, SnapCatchUpStore, SnapSyncError, - SnapWrite, + common::DownloadContext, BlockWithAccessListHash, CatchUpProgress, SnapAttemptStore, + SnapCatchUpStore, SnapSyncError, SnapWrite, }; -use alloy_eips::BlockNumHash; +use alloy_eips::eip7928::bal::DecodedBal; use reth_db_api::transaction::DbTxMut; use reth_downloaders::snap::{BlockAccessListDownloader, BlockAccessListOutcome}; use reth_eth_wire_types::snap::GetBlockAccessListsMessage; @@ -82,18 +82,8 @@ where return Ok(CatchUpStep::Complete) } - let request = GetBlockAccessListsMessage { - request_id: self.context.next_request_id(), - block_hashes: headers.iter().map(SealedHeader::hash).collect(), - response_bytes: self.context.response_bytes(), - }; - let downloader = BlockAccessListDownloader::new( - self.context.client().clone(), - request, - &headers, - self.context.runtime().clone(), - )?; - let verified = match downloader.await? { + let requested = headers.iter().map(BlockWithAccessListHash::from).collect::>(); + let verified = match self.request(&requested).await? { BlockAccessListOutcome::Verified(verified) => verified, BlockAccessListOutcome::Unavailable { peer_id } => { return Ok(CatchUpStep::Unavailable { peer_id }) @@ -106,11 +96,8 @@ where let applied = verified .into_block_access_lists() .into_iter() - .zip(&headers) - .map_while(|((_, list), header)| { - let block = BlockNumHash::new(header.number(), header.hash()); - list.map(|list| (block, header.parent_hash(), list)) - }) + .zip(&requested) + .map_while(|((_, list), kept)| list.map(|list| (kept.block(), list))) .collect::>(); if applied.is_empty() { return Ok(CatchUpStep::Unavailable { peer_id }) @@ -121,11 +108,11 @@ where .context .commit(move |provider| { let mut progress = None; - for (block, parent, list) in applied { + for (block, list) in applied { progress = Some(provider.commit_block_access_list( write, - block, - parent, + block.block, + block.parent, list.as_bal(), )?); } @@ -134,6 +121,52 @@ where .await?; Ok(CatchUpStep::Applied { progress, blocks }) } + + /// Fetches the lists of `blocks`, which a reorg orphaned, in order. + /// + /// A response cut short at the byte limit keeps the lists it served and the rest is asked for + /// again. `Ok(None)` once a request serves none of the remaining lists, since repairing the + /// state needs every one. + pub async fn orphaned_lists( + &mut self, + blocks: &[BlockWithAccessListHash], + ) -> Result>, SnapSyncError> { + let mut lists = Vec::with_capacity(blocks.len()); + while lists.len() < blocks.len() { + let end = blocks.len().min(lists.len() + self.max_blocks as usize); + let BlockAccessListOutcome::Verified(verified) = + self.request(&blocks[lists.len()..end]).await? + else { + return Ok(None) + }; + let served = lists.len(); + lists + .extend(verified.into_block_access_lists().into_iter().map_while(|(_, list)| list)); + if lists.len() == served { + return Ok(None) + } + } + Ok(Some(lists)) + } + + // Requests the lists of `blocks`, each authenticated against its block access list hash. + async fn request( + &mut self, + blocks: &[BlockWithAccessListHash], + ) -> Result { + let request = GetBlockAccessListsMessage { + request_id: self.context.next_request_id(), + block_hashes: blocks.iter().map(|kept| kept.block().block.hash).collect(), + response_bytes: self.context.response_bytes(), + }; + let downloader = BlockAccessListDownloader::with_access_list_hashes( + self.context.client().clone(), + request, + blocks.iter().map(BlockWithAccessListHash::block_access_list_hash).collect(), + self.context.runtime().clone(), + )?; + Ok(downloader.await?) + } } impl fmt::Debug for BlockAccessListCatchUp { @@ -194,8 +227,8 @@ fn pending_headers( if headers.is_empty() { return Err(SnapSyncError::MissingHeader { block: progress.next() }) } - // A list is only authenticated by the header of the block it belongs to, so a gap in them - // ends the run as surely as a gap in the lists themselves. + // A list is only authenticated by the header of the block it belongs to, so a gap in the + // headers fails the request, unlike a gap in the lists, which only ends the run. for (number, header) in (progress.next()..=end).zip(&headers) { if header.number() != number { return Err(SnapSyncError::MissingHeader { block: number }) @@ -254,7 +287,7 @@ mod tests { // then moved to the chain's last block. fn started(chain: &BalChain, accounts: &[(B256, TrieAccount)]) -> (Factory, SnapWrite) { let factory = hashed_factory(); - insert_headers(&factory, &chain.headers); + insert_headers(&factory, &chain.headers_with_root(state_root(accounts))); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(chain.generation(state_root(accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -276,7 +309,16 @@ mod tests { assert_ne!(chain.tip(), replacement.tip()); let mut accounts = accounts(); let factory = hashed_factory(); - insert_headers(&factory, &chain.headers); + let mut current = accounts.clone(); + current.iter_mut().find(|(key, _)| *key == keccak256(CHANGED)).unwrap().1.balance = + U256::from(30); + insert_headers( + &factory, + &chain.headers_with_roots(&[ + (PIVOT, state_root(&accounts)), + (chain.tip().number, state_root(¤t)), + ]), + ); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(chain.generation(state_root(&accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -308,6 +350,16 @@ mod tests { (client, catch_up.with_max_blocks(max_blocks)) } + // Blocks of `chain` after the pivot, kept as a reorg orphaning it finds them, with their + // hashes. + fn orphaned(chain: &BalChain) -> (Vec, Vec) { + let headers = &chain.headers[PIVOT as usize + 1..]; + ( + headers.iter().map(BlockWithAccessListHash::from).collect(), + headers.iter().map(SealedHeader::hash).collect(), + ) + } + async fn applied(catch_up: &mut CatchUp, write: SnapWrite, target: u64) -> CatchUpProgress { match catch_up.next(write, target).await.unwrap() { CatchUpStep::Applied { progress, .. } => progress, @@ -360,7 +412,7 @@ mod tests { served: usize, ) -> (Factory, SnapWrite) { let factory = hashed_factory(); - insert_headers(&factory, &chain.headers); + insert_headers(&factory, &chain.headers_with_roots(&[(PIVOT, state_root(accounts))])); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(chain.generation(state_root(accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -456,27 +508,6 @@ mod tests { assert_eq!(client.block_requests()[1], [chain.block(3).hash]); } - #[tokio::test] - async fn a_reply_repeating_an_applied_list_cannot_apply_it_again() { - let chain = chain(); - let (factory, write) = started(&chain, &accounts()); - // The first block's list, served again for every attempt at the second block. - let duplicate = std::iter::repeat_with(|| chain.response(2, [Some(1)])).take(4); - let (_, mut catch_up) = catch_up_with( - std::iter::once(chain.response(1, [Some(1)])).chain(duplicate), - factory.clone(), - 1, - ); - applied(&mut catch_up, write, PIVOT + 3).await; - - // It belongs to a block the applied state already covers, so it authenticates against - // nothing the request asked for. - let repeated = catch_up.next(write, PIVOT + 3).await; - - assert!(matches!(repeated, Err(SnapSyncError::Request(_)))); - assert_eq!(balance(&factory), U256::from(10)); - } - #[tokio::test] async fn a_peer_holding_no_list_for_the_next_block_applies_nothing() { let chain = chain(); @@ -486,7 +517,7 @@ mod tests { let step = catch_up.next(write, PIVOT + 3).await.unwrap(); assert!(matches!(step, CatchUpStep::Unavailable { .. })); - assert_eq!(balance(&factory), U256::from(1)); + assert_eq!(balance(&factory), U256::ONE); } #[tokio::test] @@ -498,7 +529,7 @@ mod tests { let progress = applied(&mut catch_up, write, PIVOT + 1).await; assert_eq!(progress.applied(), chain.block(1)); - assert_eq!(balance(&factory), U256::from(1)); + assert_eq!(balance(&factory), U256::ONE); } #[tokio::test] @@ -528,21 +559,11 @@ mod tests { ); } - #[tokio::test] - async fn a_target_already_applied_needs_no_request() { - let chain = chain(); - let (factory, write) = started(&chain, &accounts()); - let (client, mut catch_up) = catch_up([], factory); - - assert!(matches!(catch_up.next(write, PIVOT).await.unwrap(), CatchUpStep::Complete)); - assert!(client.block_requests().is_empty()); - } - #[tokio::test] async fn a_target_past_the_pivot_is_held_at_it() { let chain = chain(); let factory = hashed_factory(); - insert_headers(&factory, &chain.headers); + insert_headers(&factory, &chain.headers_with_root(state_root(&accounts()))); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(chain.generation(state_root(&accounts()))).unwrap(); provider.commit().unwrap(); @@ -559,11 +580,12 @@ mod tests { // The chain the node holds instead, forking before the pivot as a reorg leaves it. let reorged = BalChain::new(0, [credit(1), credit(2), credit(3)]); let factory = hashed_factory(); - insert_headers(&factory, &reorged.headers); + insert_headers(&factory, &chain.headers_with_root(state_root(&accounts))); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(chain.generation(state_root(&accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); provider.commit().unwrap(); + reorged.replace_after(&factory, 0); let (_, mut catch_up) = catch_up([], factory); let forked = catch_up.next(write, PIVOT + 3).await; @@ -624,27 +646,14 @@ mod tests { assert_eq!(SnapStateSnapshot::read(&provider), before); } - #[tokio::test] - async fn a_reply_to_an_earlier_request_is_ignored() { - let chain = chain(); - let (factory, write) = started(&chain, &accounts()); - // A reply carrying another request's id, as a delayed one does. - let stale = chain.response(99, [Some(1), Some(2), Some(3)]); - let (_, mut catch_up) = - catch_up([stale, chain.response(1, [Some(1), Some(2), Some(3)])], factory.clone()); - - // The retry is what the authenticated lists arrive on. - let progress = applied(&mut catch_up, write, PIVOT + 3).await; - - assert_eq!(progress.applied(), chain.block(3)); - assert_eq!(balance(&factory), U256::from(30)); - } - #[tokio::test] async fn downloads_split_across_pivots_converge_on_the_latest_pivot_state() { const FAR: B256 = B256::repeat_byte(0xfe); - let chain = chain(); let at = |balance| accounts_with(balance, [(FAR, account(3))]); + let chain = chain().with_state_roots(&[ + (PIVOT + 1, state_root(&at(10))), + (PIVOT + 2, state_root(&at(20))), + ]); assert_eq!(at(1).last().unwrap().0, FAR); // The pivot serves every account but the last. let (factory, write) = partially_downloaded(&chain, &at(1), 2); @@ -676,8 +685,8 @@ mod tests { #[tokio::test] async fn an_account_lists_reach_before_its_range_is_taken_from_the_new_root() { - let chain = chain(); let at = |balance| accounts_with(balance, []); + let chain = chain().with_state_roots(&[(PIVOT + 2, state_root(&at(20)))]); // Only the account before the changed one is downloaded at the pivot. assert_eq!(at(1)[1].0, keccak256(CHANGED)); let (factory, write) = partially_downloaded(&chain, &at(1), 1); @@ -699,4 +708,45 @@ mod tests { assert_eq!(balance(&factory), U256::from(20)); assert_eq!(downloaded_root(&factory), state_root(&at(20))); } + + #[tokio::test] + async fn orphaned_lists_are_fetched_in_order_across_requests() { + let chain = chain(); + let (orphaned, hashes) = orphaned(&chain); + let responses = [chain.response(1, [Some(1), Some(2)]), chain.response(2, [Some(3)])]; + let (client, mut catch_up) = catch_up_with(responses, hashed_factory(), 2); + + let lists = catch_up.orphaned_lists(&orphaned).await.unwrap().unwrap(); + + let lists: Vec<_> = lists.iter().map(|list| list.as_bal().to_vec()).collect(); + assert_eq!(lists, [credit(10), credit(20), credit(30)]); + assert_eq!(*client.block_requests(), [hashes[..2].to_vec(), hashes[2..].to_vec()]); + } + + #[tokio::test] + async fn a_missing_orphaned_list_fetches_nothing() { + let chain = chain(); + let (orphaned, _) = orphaned(&chain); + // No peer holds the last orphaned block's list any more. + let responses = [chain.response(1, [Some(1), Some(2), None]), chain.response(2, [None])]; + let (client, mut catch_up) = catch_up_with(responses, hashed_factory(), 3); + + assert!(catch_up.orphaned_lists(&orphaned).await.unwrap().is_none()); + assert_eq!(client.block_requests().len(), 2); + } + + #[tokio::test] + async fn a_response_cut_short_keeps_its_lists_and_fetches_the_rest() { + let chain = chain(); + let (orphaned, hashes) = orphaned(&chain); + // The first response stops at the byte limit after one list. + let responses = [chain.response(1, [Some(1)]), chain.response(2, [Some(2), Some(3)])]; + let (client, mut catch_up) = catch_up_with(responses, hashed_factory(), 3); + + let lists = catch_up.orphaned_lists(&orphaned).await.unwrap().unwrap(); + + let lists: Vec<_> = lists.iter().map(|list| list.as_bal().to_vec()).collect(); + assert_eq!(lists, [credit(10), credit(20), credit(30)]); + assert_eq!(*client.block_requests(), [hashes.clone(), hashes[1..].to_vec()]); + } } diff --git a/crates/snap-sync/src/catch_up/store.rs b/crates/snap-sync/src/catch_up/store.rs index 63a411c2b85..3d0470ffd8d 100644 --- a/crates/snap-sync/src/catch_up/store.rs +++ b/crates/snap-sync/src/catch_up/store.rs @@ -4,9 +4,9 @@ //! state it describes, and a block whose list is still missing leaves every later one pending. use crate::{ - common::SnapRecord, AccountCoverage, BalStateUpdate, DownloadedAccount, SnapAccountStore, - SnapAttemptStore, SnapBytecodeStore, SnapStorageStore, SnapSyncError, SnapWrite, - StorageProgress, + common::SnapRecord, repair::StoredRepairs, AccountCoverage, BalStateUpdate, DownloadedAccount, + SnapAccountStore, SnapAttemptStore, SnapBytecodeStore, SnapStorageStore, SnapSyncError, + SnapWrite, StorageProgress, }; use alloy_eip7928::AccountChanges; use alloy_eips::BlockNumHash; @@ -54,7 +54,7 @@ pub trait SnapCatchUpStore { /// Applies `bal` to the downloaded state and records `block` as the last one applied. /// - /// The list must be authenticated against `block`'s header commitment, and `block` must be + /// The list must be authenticated against `block`'s header hash, and `block` must be /// canonical and the child of the last applied one. fn commit_block_access_list( &self, @@ -85,6 +85,15 @@ impl CatchUpProgress { self.applied } + /// Last applied block still canonical after a reorg back to `ancestor`. + pub const fn resume_after(&self, ancestor: BlockNumHash) -> BlockNumHash { + if self.applied.number <= ancestor.number { + self.applied + } else { + ancestor + } + } + /// Number of the block whose list comes next. pub const fn next(&self) -> u64 { self.applied.number + 1 @@ -269,8 +278,24 @@ impl SnapCatchUpStore for T { self.remove::(*hashed_address..=*hashed_address)?; } } + // A canonical list overwrites the stale values of the fields it changes, in the accounts it + // writes. + let mut repairs = self.snap_repairs(write)?; + let mut resolved = false; + for changes in bal { + if repairs.is_empty() { + break + } + let hashed_address = keccak256(changes.address()); + if state.accounts.contains_key(&hashed_address) { + resolved |= repairs.resolve_changes(hashed_address, changes); + } + } self.commit_bytecodes(write, bytecodes.into_iter().collect())?; self.write_hashed_state(&state.into_sorted())?; + if resolved { + StoredRepairs::store(self, write.attempt(), repairs)?; + } advanced.write(self, write.attempt())?; Ok(advanced) } @@ -283,9 +308,11 @@ mod tests { test_utils::{ account, hashed_factory, key, state_root, storage_root_of, BalChain, SnapStateSnapshot, }, - SnapAccountStore, SnapGeneration, StorageChunk, + SnapAccountStore, SnapGeneration, StateRepairs, StorageChunk, + }; + use alloy_eip7928::{ + BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, StorageChange, }; - use alloy_eip7928::{BalanceChange, BlockAccessIndex, CodeChange, SlotChanges, StorageChange}; use alloy_primitives::{bytes, keccak256, map::B256Map, Address, Bytes, U256}; use reth_primitives_traits::Account; use reth_provider::{ @@ -331,9 +358,10 @@ mod tests { // block access list progress recorded, then moved to pivot 2. fn started(accounts: &[(B256, TrieAccount)], served: usize) -> (Factory, SnapWrite) { let factory = hashed_factory(); - insert_headers(&factory, &chain().headers); + let root = state_root(accounts); + insert_headers(&factory, &chain().headers_with_roots(&[(1, root), (2, root), (3, root)])); let provider = factory.database_provider_rw().unwrap(); - let write = provider.start_snap_attempt(generation(1, state_root(accounts))).unwrap(); + let write = provider.start_snap_attempt(generation(1, root)).unwrap(); provider.start_account_coverage(write).unwrap(); // A partial range needs a proof placing the accounts it leaves out. let targets = (served < accounts.len()).then(|| accounts[served - 1].0); @@ -460,21 +488,6 @@ mod tests { assert_eq!(provider.catch_up_progress(write).unwrap().unwrap().applied(), block); } - #[test] - fn an_interrupted_application_leaves_nothing_behind() { - let accounts = accounts(); - let (factory, write) = started(&accounts, accounts.len()); - let (block, parent) = block(2); - - let provider = factory.database_provider_rw().unwrap(); - provider.commit_block_access_list(write, block, parent, &credit(10)).unwrap(); - drop(provider); - - let provider = factory.database_provider_ro().unwrap(); - assert_eq!(stored(&provider, keccak256(CHANGED)).unwrap().balance, U256::from(1)); - assert_eq!(provider.catch_up_progress(write).unwrap().unwrap().applied().number, 1); - } - #[test] fn a_block_already_applied_is_refused() { let accounts = accounts(); @@ -499,7 +512,7 @@ mod tests { let gap = provider.commit_block_access_list(write, block, parent, &credit(10)); assert!(matches!(gap, Err(SnapSyncError::OutOfOrderBlock { expected: 2, got: 3 }))); - assert_eq!(stored(&provider, keccak256(CHANGED)).unwrap().balance, U256::from(1)); + assert_eq!(stored(&provider, keccak256(CHANGED)).unwrap().balance, U256::ONE); } #[test] @@ -513,7 +526,7 @@ mod tests { let refused = provider.commit_block_access_list(write, orphaned, parent, &credit(10)); assert!(matches!(refused, Err(SnapSyncError::NonCanonicalBlock { block: 2, .. }))); - assert_eq!(stored(&provider, keccak256(CHANGED)).unwrap().balance, U256::from(1)); + assert_eq!(stored(&provider, keccak256(CHANGED)).unwrap().balance, U256::ONE); } #[test] @@ -573,21 +586,6 @@ mod tests { assert_eq!(provider.catch_up_progress(write).unwrap().unwrap().applied().number, 1); } - #[test] - fn a_block_that_changes_nothing_still_carries_the_state_past_it() { - let accounts = accounts(); - let (factory, write) = started(&accounts, accounts.len()); - let provider = factory.database_provider_rw().unwrap(); - let (block, parent) = block(2); - // A list a peer holds but that touches no state, as against one it does not hold. - let read_only = vec![AccountChanges::new(CHANGED).with_storage_read(SLOT)]; - - let progress = provider.commit_block_access_list(write, block, parent, &read_only).unwrap(); - - assert_eq!(progress.applied(), block); - assert_eq!(stored(&provider, keccak256(CHANGED)).unwrap().balance, U256::from(1)); - } - #[test] fn an_account_the_list_empties_loses_its_storage() { let accounts = accounts(); @@ -635,7 +633,13 @@ mod tests { #[test] fn slots_persisted_ahead_of_their_range_follow_the_lists() { let factory = hashed_factory(); - insert_headers(&factory, &chain().headers); + let mut moved = accounts(); + let position = changed_index(&moved); + moved[position].1.storage_root = storage_root_of(&[(hashed_slot(), U256::from(9))]); + insert_headers( + &factory, + &chain().headers_with_roots(&[(1, state_root(&accounts())), (2, state_root(&moved))]), + ); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(&accounts()))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -648,9 +652,6 @@ mod tests { None, ); provider.commit_storage_chunk(write, B256::ZERO, chunk).unwrap(); - let mut moved = accounts(); - let position = changed_index(&moved); - moved[position].1.storage_root = storage_root_of(&[(hashed_slot(), U256::from(9))]); let write = provider.advance_snap_pivot(write, generation(2, state_root(&moved))).unwrap(); let (block, parent) = block(2); let changes = vec![AccountChanges::new(CHANGED).with_storage_change(SlotChanges::new( @@ -673,15 +674,26 @@ mod tests { } #[test] + #[allow(clippy::clone_on_copy)] fn skipped_storage_from_an_abandoned_attempt_is_not_reused() { let factory = hashed_factory(); - insert_headers(&factory, &chain().headers); - let provider = factory.database_provider_rw().unwrap(); let (changed, later) = (keccak256(CHANGED), B256::repeat_byte(0xff)); let stale = (B256::ZERO, U256::from(7)); let mut contract = account(1); contract.storage_root = storage_root_of(&[stale]); - let mut accounts = vec![(changed, contract), (later, contract)]; + let mut accounts = vec![(changed, contract.clone()), (later, contract.clone())]; + let first = state_root(&accounts); + accounts[0].1.storage_root = reth_trie_common::EMPTY_ROOT_HASH; + let second = state_root(&accounts); + let new_slot = (hashed_slot(), U256::from(9)); + accounts[0].1.storage_root = storage_root_of(&[new_slot]); + let third = state_root(&accounts); + accounts[0].1.storage_root = contract.storage_root; + insert_headers( + &factory, + &chain().headers_with_roots(&[(1, first), (2, second), (3, third)]), + ); + let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(&accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); provider @@ -693,7 +705,7 @@ mod tests { .unwrap(); accounts[0].1.storage_root = reth_trie_common::EMPTY_ROOT_HASH; - let write = provider.start_snap_attempt(generation(1, state_root(&accounts))).unwrap(); + let write = provider.start_snap_attempt(generation(2, state_root(&accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); provider .commit_storage_chunk( @@ -703,11 +715,10 @@ mod tests { ) .unwrap(); - let new_slot = (hashed_slot(), U256::from(9)); accounts[0].1.storage_root = storage_root_of(&[new_slot]); let write = - provider.advance_snap_pivot(write, generation(2, state_root(&accounts))).unwrap(); - let (block, parent) = block(2); + provider.advance_snap_pivot(write, generation(3, state_root(&accounts))).unwrap(); + let (block, parent) = block(3); let changes = vec![AccountChanges::new(CHANGED).with_storage_change(SlotChanges::new( SLOT, vec![StorageChange::new(index(1), new_slot.1)], @@ -728,7 +739,7 @@ mod tests { let accounts = accounts(); let (factory, write) = started(&accounts, accounts.len()); let provider = factory.database_provider_rw().unwrap(); - provider.advance_snap_pivot(write, generation(3, B256::repeat_byte(0xcc))).unwrap(); + provider.advance_snap_pivot(write, generation(3, state_root(&accounts))).unwrap(); let (block, parent) = block(2); let refused = provider.commit_block_access_list(write, block, parent, &credit(10)); @@ -752,4 +763,45 @@ mod tests { }) )); } + + #[test] + fn a_list_resolves_the_repairs_it_overwrites() { + let accounts = accounts(); + let (factory, write) = started(&accounts, accounts.len()); + let provider = factory.database_provider_rw().unwrap(); + // The orphaned branch changed the balance and the nonce. + let orphaned = AccountChanges::new(CHANGED) + .with_balance_change(BalanceChange::new(index(1), U256::ONE)) + .with_nonce_change(NonceChange::new(index(1), 1)); + let mut repairs = StateRepairs::default(); + repairs.insert_changes(keccak256(CHANGED), &orphaned); + provider.schedule_snap_repairs(write, repairs).unwrap(); + let (block, parent) = block(2); + + // The new branch changes the balance only. + provider.commit_block_access_list(write, block, parent, &credit(10)).unwrap(); + + let mut expected = StateRepairs::default(); + expected.insert_changes( + keccak256(CHANGED), + &AccountChanges::new(CHANGED).with_nonce_change(NonceChange::new(index(1), 1)), + ); + assert_eq!(provider.snap_repairs(write).unwrap(), expected); + } + + #[test] + fn repairs_of_an_account_outside_the_coverage_stay_scheduled() { + let accounts = accounts(); + let (factory, write) = started(&accounts, changed_index(&accounts)); + let provider = factory.database_provider_rw().unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_account(keccak256(CHANGED)); + provider.schedule_snap_repairs(write, repairs.clone()).unwrap(); + let (block, parent) = block(2); + + // The list changes the balance, but leaves the account to its range. + provider.commit_block_access_list(write, block, parent, &credit(10)).unwrap(); + + assert_eq!(provider.snap_repairs(write).unwrap(), repairs); + } } diff --git a/crates/snap-sync/src/common/download.rs b/crates/snap-sync/src/common/download.rs index 973b64ac075..526c3748429 100644 --- a/crates/snap-sync/src/common/download.rs +++ b/crates/snap-sync/src/common/download.rs @@ -6,7 +6,6 @@ use crate::SnapSyncError; use alloy_primitives::B256; use reth_storage_api::{DBProvider, DatabaseProviderFactory}; -use reth_storage_errors::provider::ProviderError; use reth_tasks::Runtime; use std::fmt; @@ -14,7 +13,7 @@ use std::fmt; pub const DEFAULT_RESPONSE_BYTES: u64 = 512 * 1024; /// Inclusive upper bound covering the full trie keyspace. -pub const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); +pub const MAX_HASH: B256 = B256::repeat_byte(0xff); /// Client, database and request settings a domain download sends and commits through. pub(crate) struct DownloadContext { @@ -73,7 +72,7 @@ where self.runtime .spawn_blocking(move || read(&factory.database_provider_ro()?)) .await - .map_err(|error| SnapSyncError::Provider(ProviderError::other(error)))? + .map_err(SnapSyncError::blocking_task)? } /// Runs `write` in one transaction on the blocking pool, committing only if it succeeds. @@ -90,7 +89,7 @@ where Ok(output) }) .await - .map_err(|error| SnapSyncError::Provider(ProviderError::other(error)))? + .map_err(SnapSyncError::blocking_task)? } } diff --git a/crates/snap-sync/src/common/record.rs b/crates/snap-sync/src/common/record.rs index a39120ad25a..03e0d2fd92a 100644 --- a/crates/snap-sync/src/common/record.rs +++ b/crates/snap-sync/src/common/record.rs @@ -21,13 +21,15 @@ pub(crate) trait SnapRecord: Serialize + DeserializeOwned { /// Reads the record, reporting one written at another version instead of misreading it. fn read(provider: &impl MetadataProvider) -> Result, SnapSyncError> { let Some(bytes) = provider.get_metadata(Self::KEY)? else { return Ok(None) }; - let value: serde_json::Value = - serde_json::from_slice(&bytes).map_err(ProviderError::other)?; + let value = serde_json::from_slice::(&bytes) + .map_err(|source| SnapSyncError::UnreadableRecord { key: Self::KEY, source })?; let version = value.get("version").and_then(serde_json::Value::as_u64); if version != Some(u64::from(Self::VERSION)) { return Err(SnapSyncError::UnsupportedRecord { key: Self::KEY, version }) } - Ok(Some(serde_json::from_value(value).map_err(ProviderError::other)?)) + serde_json::from_value(value) + .map(Some) + .map_err(|source| SnapSyncError::UnreadableRecord { key: Self::KEY, source }) } /// Removes the record, whichever version wrote it. diff --git a/crates/snap-sync/src/error.rs b/crates/snap-sync/src/error.rs index 7a6fc4fe450..df60a147b03 100644 --- a/crates/snap-sync/src/error.rs +++ b/crates/snap-sync/src/error.rs @@ -1,7 +1,9 @@ //! Failures raised while assembling a snap state generation. use alloy_primitives::B256; -use reth_downloaders::snap::{InvalidBlockAccessListRequest, InvalidStorageRangeRequest}; +use reth_downloaders::snap::{ + InvalidAccountRange, InvalidBlockAccessListRequest, InvalidStorageRangeRequest, +}; use reth_network_p2p::error::RequestError; use reth_storage_api::SnapAttemptId; use reth_storage_errors::{db::DatabaseError, provider::ProviderError}; @@ -15,6 +17,9 @@ pub enum SnapSyncError { /// A request for state failed. #[error(transparent)] Request(#[from] RequestError), + /// An account range was requested with its origin past its limit. + #[error(transparent)] + AccountRangeRequest(#[from] InvalidAccountRange), /// A storage request did not match the accounts it was built from. #[error(transparent)] StorageRequest(#[from] InvalidStorageRangeRequest), @@ -43,6 +48,16 @@ pub enum SnapSyncError { /// Root the range was proved against. got: B256, }, + /// A generation carries a state root other than the one its target header commits to. + #[error("block {block} commits to state root {expected}, not {got}")] + HeaderRootMismatch { + /// Target block of the generation. + block: u64, + /// State root of the canonical header. + expected: B256, + /// State root the generation carries. + got: B256, + }, /// No catch-up progress is recorded for the attempt. #[error("no catch-up progress is recorded for the attempt")] NoCatchUpProgress, @@ -112,6 +127,17 @@ pub enum SnapSyncError { /// Version found on disk, absent when the record carries no numeric version. version: Option, }, + /// A persisted progress record that cannot be decoded. + #[error("{key} record is unreadable: {source}")] + UnreadableRecord { + /// Metadata key the record is stored under. + key: &'static str, + /// Why the record could not be decoded. + source: serde_json::Error, + }, + /// A blocking task panicked or the runtime shut down before it finished. + #[error("blocking task failed: {0}")] + BlockingTask(#[source] Box), /// A range was downloaded from somewhere other than the next key to cover. #[error("account range starts at {got}, coverage continues at {expected:?}")] OutOfOrderRange { @@ -170,6 +196,12 @@ pub enum SnapSyncError { /// Hash of the supplied code. got: B256, }, + /// Scheduled accounts remain to be fetched again at the pivot. + #[error("{accounts} accounts remain to be fetched again at the pivot")] + PendingRepairs { + /// Number of accounts still scheduled. + accounts: usize, + }, /// Account ranges remain to be downloaded. #[error("accounts from {next} are not downloaded yet")] IncompleteAccounts { @@ -182,9 +214,22 @@ pub enum SnapSyncError { /// Work stopped because its session was cancelled. #[error("snap synchronization was cancelled")] Cancelled, + /// A reorg was recovered to a pivot below the last block both branches share. + #[error("pivot {target} is below reorg ancestor {ancestor}")] + PivotBelowAncestor { + /// Last block both branches share. + ancestor: u64, + /// Block the pivot was moved to. + target: u64, + }, } impl SnapSyncError { + /// Wraps the failure of a blocking task, which is a panic or a shutting-down runtime. + pub(crate) fn blocking_task(error: impl core::error::Error + Send + Sync + 'static) -> Self { + Self::BlockingTask(Box::new(error)) + } + /// Whether the node's progress resolves this error, as new peers serve the state or missing /// headers are downloaded. /// @@ -197,6 +242,50 @@ impl SnapSyncError { } } + /// Whether retrying can't help: storage failed, the node's storage layout or on-disk records + /// don't support snap, a blocking task failed, or the network is gone. + /// + /// Exhaustive so that every new variant is classified on purpose. + pub const fn is_fatal(&self) -> bool { + match self { + Self::Provider(_) | + Self::UnsupportedStorage | + Self::UnsupportedRecord { .. } | + Self::UnreadableRecord { .. } | + Self::BlockingTask(_) => true, + Self::Request(error) => error.is_channel_closed(), + Self::AccountRangeRequest(_) | + Self::StorageRequest(_) | + Self::BlockAccessListRequest(_) | + Self::NoAttempt | + Self::StaleWrite { .. } | + Self::RootMismatch { .. } | + Self::HeaderRootMismatch { .. } | + Self::NoCatchUpProgress | + Self::MissingHeader { .. } | + Self::OutOfOrderBlock { .. } | + Self::BlockPastPivot { .. } | + Self::PivotNotAdvanced { .. } | + Self::CatchUpBehindPivot { .. } | + Self::NonCanonicalBlock { .. } | + Self::ForkedBlock { .. } | + Self::NoCoverage | + Self::OutOfOrderRange { .. } | + Self::NoProgress { .. } | + Self::OutOfOrderStorage { .. } | + Self::MissingStorage { .. } | + Self::StorageRootMismatch { .. } | + Self::UnexpectedStorage { .. } | + Self::MissingCode { .. } | + Self::CodeMismatch { .. } | + Self::PendingRepairs { .. } | + Self::IncompleteAccounts { .. } | + Self::GenesisPivot | + Self::Cancelled | + Self::PivotBelowAncestor { .. } => false, + } + } + /// Whether a block the attempt builds on left the canonical chain, so its downloaded state /// belongs to another fork. pub const fn is_reorg(&self) -> bool { diff --git a/crates/snap-sync/src/generation.rs b/crates/snap-sync/src/generation.rs index defc67e34db..256917791b2 100644 --- a/crates/snap-sync/src/generation.rs +++ b/crates/snap-sync/src/generation.rs @@ -1,4 +1,4 @@ -//! Identifies one attempt at downloading state, and how far it has progressed. +//! Identifies one attempt at downloading state. use crate::SnapSyncError; use alloy_eips::BlockNumHash; @@ -16,14 +16,12 @@ pub struct SnapGeneration { target: BlockNumHash, // Root downloaded ranges authenticate against. state_root: B256, - // Stage reached so far. - phase: SnapPhase, } impl SnapGeneration { /// Creates a generation anchored to the given pivot, before any range is downloaded. pub const fn new(target: BlockNumHash, state_root: B256) -> Self { - Self { target, state_root, phase: SnapPhase::Accounts } + Self { target, state_root } } /// Pivot block this generation is anchored to. @@ -36,11 +34,6 @@ impl SnapGeneration { self.state_root } - /// Stage this generation has reached. - pub const fn phase(&self) -> SnapPhase { - self.phase - } - /// Returns how far the canonical head has moved past this generation's anchor. pub const fn lag(&self, head: u64) -> u64 { head.saturating_sub(self.target.number) @@ -54,22 +47,4 @@ impl SnapGeneration { let header = provider.sealed_header(self.target.number)?; Ok(header.is_some_and(|header| header.hash() == self.target.hash)) } - - /// Returns this generation moved to `phase`. - #[cfg(test)] - pub(crate) const fn with_phase(mut self, phase: SnapPhase) -> Self { - self.phase = phase; - self - } -} - -/// The stage a [`SnapGeneration`] has reached. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SnapPhase { - /// Account, storage and bytecode ranges are being downloaded. - Accounts, - /// Authenticated block access lists are being applied. - BlockAccessLists, - /// The final state trie is being rebuilt and checked. - Trie, } diff --git a/crates/snap-sync/src/lib.rs b/crates/snap-sync/src/lib.rs index 4a8cdbfb2a6..da34e465905 100644 --- a/crates/snap-sync/src/lib.rs +++ b/crates/snap-sync/src/lib.rs @@ -15,6 +15,8 @@ mod common; mod error; mod generation; mod pivot; +mod reorg; +mod repair; mod session; mod storage; mod verify; @@ -36,11 +38,13 @@ pub use catch_up::{ }; pub use common::{DEFAULT_RESPONSE_BYTES, MAX_HASH}; pub use error::SnapSyncError; -pub use generation::{SnapGeneration, SnapPhase}; -pub use pivot::SnapPivotPolicy; +pub use generation::SnapGeneration; +pub use pivot::{SnapPivotPolicy, DEFAULT_HEAD_DISTANCE}; +pub use reorg::{BlockWithAccessListHash, SnapReorg}; +pub use repair::StateRepairs; pub use session::{SnapSyncSession, SnapSyncSessionState}; pub use storage::{ SnapStorageStore, StorageChunk, StorageProgress, StorageRangeDownload, StorageRangeStep, - DEFAULT_STORAGE_ACCOUNTS, + DEFAULT_REPAIR_SLOTS, DEFAULT_STORAGE_ACCOUNTS, }; pub use verify::{SnapStateVerifier, VerifiedSnapState, DEFAULT_SCAN_CHUNK}; diff --git a/crates/snap-sync/src/pivot.rs b/crates/snap-sync/src/pivot.rs index bfa4ed267a5..ffebc640074 100644 --- a/crates/snap-sync/src/pivot.rs +++ b/crates/snap-sync/src/pivot.rs @@ -10,13 +10,13 @@ //! is available, and re-anchoring starts once a pivot lags by 96 blocks rather than at the edge of //! the window peers still serve state for. -use crate::{SnapGeneration, SnapPhase, SnapSyncError}; +use crate::{SnapGeneration, SnapSyncError}; use alloy_eip7928::BAL_RETENTION_PERIOD_SLOTS; use reth_primitives_traits::AlloyBlockHeader; use reth_storage_api::HeaderProvider; -// EIP-8189's example anchor, matching go-ethereum's `fsMinFullBlocks`. -const DEFAULT_HEAD_DISTANCE: u64 = 64; +/// Blocks behind the head a generation is anchored to when no finalized block is available +pub const DEFAULT_HEAD_DISTANCE: u64 = 64; // Blocks of state history a serving peer is assumed to still hold, mirroring reth's own // `SNAPSHOT_STATE_RETENTION`. @@ -90,13 +90,6 @@ impl SnapPivotPolicy { generation.lag(head) > self.advance_after } - /// Returns whether the block access lists `generation` still needs remain servable. - /// - /// Once they are not, its state cannot be carried forward and the attempt has to restart. - pub const fn is_catchable(&self, generation: SnapGeneration, head: u64) -> bool { - self.is_catchable_from(generation.target().number, head) - } - /// Returns whether the list of the block after `applied` remains servable under `head`. /// /// Catch-up continues from the last applied block, which trails the pivot while lists are @@ -127,11 +120,10 @@ impl SnapPivotPolicy { Ok(None) } - /// Returns whether an interrupted generation is still worth finishing under `head`. - /// - /// A fully downloaded generation only needs its trie rebuilt, so it always is. - pub const fn is_finishable(&self, generation: SnapGeneration, head: u64) -> bool { - matches!(generation.phase(), SnapPhase::Trie) || self.is_catchable(generation, head) + /// Returns whether the lists of blocks a reorg orphaned after `ancestor` are still worth + /// waiting for under `head`, since peers may keep lists only for canonical blocks. + pub const fn awaits_orphaned_lists(&self, ancestor: u64, head: u64) -> bool { + head.saturating_sub(ancestor) <= SERVED_STATE_WINDOW } } @@ -153,7 +145,6 @@ mod tests { assert_eq!(generation.target().number, 2); assert_eq!(generation.target().hash, expected.hash_slow()); assert_eq!(generation.state_root(), expected.state_root); - assert_eq!(generation.phase(), SnapPhase::Accounts); } #[test] @@ -198,11 +189,11 @@ mod tests { } #[test] - fn pivot_without_a_bal_commitment_is_not_selectable() { + fn pivot_without_a_block_access_list_hash_is_not_selectable() { let provider = provider_with(chain(Some(3))); assert_eq!(policy().select(&provider, 3, None).unwrap(), None); - // Neither the finalized anchor nor the fallback carries a commitment. + // Neither the finalized anchor nor the fallback carries a block access list hash. assert_eq!(policy().select(&provider, 3, Some(1)).unwrap(), None); } @@ -230,35 +221,10 @@ mod tests { } #[test] - fn generation_outside_the_bal_window_is_not_finishable() { - let headers = chain(Some(0)); - let anchor = headers[1].clone(); - let provider = provider_with(headers); - let generation = - SnapGeneration::new(BlockNumHash::new(1, anchor.hash_slow()), anchor.state_root); + fn generation_outside_the_bal_window_is_not_catchable() { let policy = policy(); - assert!(generation.is_canonical(&provider).unwrap()); - assert!(policy.is_finishable(generation, 9)); - assert!(!policy.is_finishable(generation, 10)); - } - - #[test] - fn downloaded_state_finishes_outside_the_bal_window() { - let anchor = chain(Some(0))[1].clone(); - let generation = - SnapGeneration::new(BlockNumHash::new(1, anchor.hash_slow()), anchor.state_root) - .with_phase(SnapPhase::Trie); - - assert!(policy().is_finishable(generation, 1_000)); - } - - #[test] - fn reorged_anchor_is_not_canonical() { - let provider = provider_with(chain(Some(0))); - let generation = - SnapGeneration::new(BlockNumHash::new(1, B256::repeat_byte(0xff)), B256::ZERO); - - assert!(!generation.is_canonical(&provider).unwrap()); + assert!(policy.is_catchable_from(1, 9)); + assert!(!policy.is_catchable_from(1, 10)); } } diff --git a/crates/snap-sync/src/reorg.rs b/crates/snap-sync/src/reorg.rs new file mode 100644 index 00000000000..71225914c95 --- /dev/null +++ b/crates/snap-sync/src/reorg.rs @@ -0,0 +1,390 @@ +//! Recovers downloaded state from a reorg that orphans the pivot. +//! +//! [EIP-8189](https://eips.ethereum.org/EIPS/eip-8189#synchronization-algorithm) repairs it from +//! the abandoned branch's lists: every field they change is fetched again unless a list of the +//! new branch overwrites it first. The reorg removes that branch's blocks from the canonical +//! chain, so the attempt keeps what recovery needs of them from the moment it anchors. + +use crate::{common::SnapRecord, SnapSyncError}; +use alloy_eips::{eip1898::BlockWithParent, BlockNumHash}; +use alloy_primitives::{Sealable, B256}; +use reth_primitives_traits::{AlloyBlockHeader, SealedHeader}; +use reth_storage_api::{ + BlockHashReader, HeaderProvider, MetadataProvider, MetadataWriter, SnapAttempt, SnapAttemptId, +}; +use serde::{Deserialize, Serialize}; + +// Blocks an attempt keeps through its pivot, bounding how deep a recoverable reorg can reach. A +// deeper reorg restarts the attempt. +const KEPT_BLOCKS: u64 = 64; + +/// Where a reorg left an attempt: the last block both branches share and the orphaned blocks +/// after it. +#[derive(Clone, Debug)] +pub struct SnapReorg { + // Last block both branches share. + ancestor: BlockNumHash, + // Orphaned blocks through the pivot, oldest first. Empty while the pivot is canonical. + orphaned: Vec, +} + +impl SnapReorg { + /// Last block both branches share. + pub const fn ancestor(&self) -> BlockNumHash { + self.ancestor + } + + /// Orphaned blocks through the pivot, oldest first. + pub fn orphaned(&self) -> &[BlockWithAccessListHash] { + &self.orphaned + } +} + +/// A block an attempt keeps through its pivot, holding what recovery from a reorg reads of it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockWithAccessListHash { + // Block number and hash, with its parent's hash. + block: BlockWithParent, + // Block access list hash, absent before Amsterdam. + block_access_list_hash: Option, +} + +impl BlockWithAccessListHash { + /// Block number and hash, with its parent's hash. + pub const fn block(&self) -> BlockWithParent { + self.block + } + + /// Block access list hash, absent before Amsterdam. + pub const fn block_access_list_hash(&self) -> Option { + self.block_access_list_hash + } +} + +impl From<&SealedHeader> for BlockWithAccessListHash { + fn from(header: &SealedHeader) -> Self { + Self { + block: header.block_with_parent(), + block_access_list_hash: header.block_access_list_hash(), + } + } +} + +// Blocks an attempt keeps through its pivot. +#[derive(Serialize, Deserialize)] +pub(crate) struct StoredAncestry { + // Encoding version, checked before the rest is decoded. + version: u32, + // Attempt the blocks belong to. + attempt: SnapAttemptId, + // Kept blocks, oldest first and ending at the pivot. + blocks: Vec, +} + +impl SnapRecord for StoredAncestry { + const KEY: &'static str = "snap_ancestry"; + const VERSION: u32 = 2; +} + +impl StoredAncestry { + // Keeps the canonical blocks of the last `KEPT_BLOCKS` through `pivot` for `attempt`. Keeps + // none unless they reach the pivot, leaving a reorg unrecoverable. + pub(crate) fn record( + provider: &P, + attempt: SnapAttemptId, + pivot: BlockNumHash, + ) -> Result<(), SnapSyncError> { + let from = pivot.number.saturating_sub(KEPT_BLOCKS) + 1; + let headers = provider.sealed_headers_range(from..=pivot.number)?; + let contiguous = headers.windows(2).all(|pair| pair[1].parent_hash() == pair[0].hash()); + if !contiguous || headers.last().map(SealedHeader::num_hash) != Some(pivot) { + return Self::clear(provider) + } + let blocks = headers.iter().map(BlockWithAccessListHash::from).collect(); + Self { version: Self::VERSION, attempt, blocks }.write(provider) + } + + // Finds where the canonical chain diverges from the blocks `attempt` keeps. `None` when they + // do not reach back to where the branches part. + pub(crate) fn reorg( + provider: &P, + attempt: &SnapAttempt, + ) -> Result, SnapSyncError> { + let Some(mut blocks) = Self::read(provider)? + .filter(|stored| stored.attempt == attempt.id()) + .map(|stored| stored.blocks) + else { + return Ok(None) + }; + // Kept blocks end at the pivot they were recorded for. + if blocks.last().map(|kept| kept.block.block) != Some(attempt.pivot()) { + return Ok(None) + } + + // The highest kept block still canonical is where the branches part. + let mut split = None; + for (index, kept) in blocks.iter().enumerate().rev() { + let block = kept.block.block; + if provider.block_hash(block.number)? == Some(block.hash) { + split = Some((block, index + 1)); + break + } + } + let (ancestor, from) = match split { + Some(split) => split, + None => { + // Otherwise they part just below the lowest kept block, or further down. + let lowest = blocks[0].block; + let Some(parent) = lowest.block.number.checked_sub(1) else { return Ok(None) }; + if provider.block_hash(parent)? != Some(lowest.parent) { + return Ok(None) + } + (BlockNumHash::new(parent, lowest.parent), 0) + } + }; + Ok(Some(SnapReorg { ancestor, orphaned: blocks.split_off(from) })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + test_utils::{account, hashed_factory, state_root, verified_range, BalChain}, + SnapAccountStore, SnapAttemptStore, SnapCatchUpStore, SnapGeneration, SnapWrite, + StateRepairs, + }; + use alloy_eip7928::{ + AccountChanges, BalanceChange, BlockAccessIndex, NonceChange, SlotChanges, StorageChange, + }; + use alloy_eips::eip7928::bal::{Bal, DecodedBal}; + use alloy_primitives::{keccak256, Address, B256, U256}; + use reth_provider::{ + test_utils::{insert_headers, MockNodeTypesWithDB}, + DatabaseProviderFactory, ProviderFactory, + }; + use reth_storage_api::DBProvider; + use reth_trie_common::TrieAccount; + + type Factory = ProviderFactory; + + // Block both branches share. + const ANCESTOR: u64 = 2; + + fn credit(address: Address, balance: u64) -> Vec { + vec![AccountChanges::new(address) + .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::from(balance)))] + } + + // Blocks 3 and 4 of the orphaned branch, and 3 through 5 of the one replacing it. + fn branches() -> (BalChain, BalChain) { + let address = Address::repeat_byte(0x11); + ( + BalChain::new(ANCESTOR, [credit(address, 1), credit(address, 2)]) + .with_state_root(root()), + BalChain::new( + ANCESTOR, + [credit(address, 10), credit(address, 20), credit(address, 30)], + ) + .with_state_root(root()), + ) + } + + // Root of the accounts every chain commits to. + fn root() -> B256 { + state_root(&accounts().1) + } + + // Three accounts, of which the first two are downloaded. + fn accounts() -> (Vec

, Vec<(B256, TrieAccount)>) { + let mut addresses: Vec<_> = (1..=3).map(Address::repeat_byte).collect(); + addresses.sort_by_key(|address| keccak256(address)); + let accounts = addresses + .iter() + .enumerate() + .map(|(nonce, address)| (keccak256(address), account(nonce as u64))) + .collect(); + (addresses, accounts) + } + + // An attempt anchored at `chain`'s block `number`, with the first two accounts downloaded. + fn started(chain: &BalChain, number: u64) -> (Factory, SnapWrite) { + let (_, accounts) = accounts(); + let factory = hashed_factory(); + insert_headers(&factory, &chain.headers); + let provider = factory.database_provider_rw().unwrap(); + let pivot = chain.headers[number as usize].num_hash(); + let write = + provider.start_snap_attempt(SnapGeneration::new(pivot, state_root(&accounts))).unwrap(); + provider.start_account_coverage(write).unwrap(); + let range = verified_range(&accounts, 0..2, B256::ZERO, &[B256::ZERO, accounts[1].0]); + provider.commit_account_range(write, &range, Default::default(), Vec::new()).unwrap(); + provider.commit().unwrap(); + (factory, write) + } + + fn reorg(factory: &Factory, write: SnapWrite) -> Option { + factory.database_provider_ro().unwrap().snap_reorg(write).unwrap() + } + + fn kept(headers: &[SealedHeader]) -> Vec { + headers.iter().map(BlockWithAccessListHash::from).collect() + } + + // A verified list carrying `changes`. + fn list(changes: Vec) -> DecodedBal { + DecodedBal::from_rlp_bytes(alloy_rlp::encode(Bal::from(changes)).into()).unwrap() + } + + #[test] + fn a_canonical_pivot_orphans_nothing() { + let (old, _) = branches(); + let (factory, write) = started(&old, 4); + + let reorg = reorg(&factory, write).unwrap(); + + assert_eq!(reorg.ancestor(), old.tip()); + assert!(reorg.orphaned().is_empty()); + } + + #[test] + fn the_ancestor_is_the_last_block_both_branches_share() { + let (old, new) = branches(); + let (factory, write) = started(&old, 4); + new.replace_after(&factory, ANCESTOR); + + let reorg = reorg(&factory, write).unwrap(); + + assert_eq!(reorg.ancestor(), old.block(0)); + assert_eq!(reorg.orphaned(), kept(&old.headers[3..])); + } + + #[test] + fn the_ancestor_can_sit_just_below_the_kept_blocks() { + let address = Address::repeat_byte(0x11); + let old = + BalChain::new(0, [credit(address, 1), credit(address, 2)]).with_state_root(root()); + let new = BalChain::new(0, [credit(address, 10)]).with_state_root(root()); + let (factory, write) = started(&old, 2); + new.replace_after(&factory, 0); + + let reorg = reorg(&factory, write).unwrap(); + + assert_eq!(reorg.ancestor(), old.block(0)); + assert_eq!(reorg.orphaned(), kept(&old.headers[1..])); + } + + #[test] + fn a_reorg_below_the_kept_blocks_is_unrecoverable() { + let address = Address::repeat_byte(0x11); + let depth = KEPT_BLOCKS + 2; + let old = BalChain::new(1, (0..depth).map(|n| credit(address, n))).with_state_root(root()); + let new = + BalChain::new(1, (0..depth).map(|n| credit(address, n + 100))).with_state_root(root()); + let (factory, write) = started(&old, 1 + depth); + new.replace_after(&factory, 1); + + assert!(reorg(&factory, write).is_none()); + } + + #[test] + fn recovery_rewinds_catch_up_to_the_ancestor_and_refuses_old_writes() { + let (old, new) = branches(); + let (factory, write) = started(&old, 4); + new.replace_after(&factory, ANCESTOR); + let (_, accounts) = accounts(); + let generation = SnapGeneration::new(new.tip(), state_root(&accounts)); + + let provider = factory.database_provider_rw().unwrap(); + let recovered = + provider.commit_reorg_recovery(write, old.block(0), &[], generation).unwrap(); + provider.commit().unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.catch_up_progress(recovered).unwrap().unwrap().applied(), old.block(0)); + assert!(matches!( + provider.authorize_snap_write(write), + Err(SnapSyncError::StaleWrite { .. }) + )); + drop(provider); + // The kept blocks follow the new pivot. + let reorg = reorg(&factory, recovered).unwrap(); + assert_eq!(reorg.ancestor(), new.tip()); + } + + #[test] + fn a_new_pivot_reorged_meanwhile_is_refused_without_scheduling() { + let (old, new) = branches(); + let (factory, write) = started(&old, 4); + new.replace_after(&factory, ANCESTOR); + let (addresses, accounts) = accounts(); + // The chain moved again, so the selected pivot is no longer canonical. + let generation = SnapGeneration::new(old.tip(), state_root(&accounts)); + let lists = [list(credit(addresses[0], 5))]; + + let provider = factory.database_provider_rw().unwrap(); + let refused = provider.commit_reorg_recovery(write, old.block(0), &lists, generation); + assert!(matches!(refused, Err(SnapSyncError::NonCanonicalBlock { block: 4, .. }))); + drop(provider); + + // The refused transaction is dropped, leaving the attempt to recover on the next pass. + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.snap_repairs(write).unwrap().is_empty()); + assert_eq!(reorg(&factory, write).unwrap().ancestor(), old.block(0)); + } + + #[test] + fn catch_up_below_the_ancestor_stays_where_it_is() { + let (old, new) = branches(); + let (factory, write) = started(&old, 1); + let (_, accounts) = accounts(); + let provider = factory.database_provider_rw().unwrap(); + let write = provider + .advance_snap_pivot(write, SnapGeneration::new(old.tip(), state_root(&accounts))) + .unwrap(); + provider.commit().unwrap(); + new.replace_after(&factory, ANCESTOR); + + let provider = factory.database_provider_rw().unwrap(); + let generation = SnapGeneration::new(new.tip(), state_root(&accounts)); + let recovered = + provider.commit_reorg_recovery(write, old.block(0), &[], generation).unwrap(); + + assert_eq!( + provider.catch_up_progress(recovered).unwrap().unwrap().applied(), + old.headers[1].num_hash() + ); + } + + #[test] + fn orphaned_changes_to_downloaded_accounts_are_scheduled() { + let (old, new) = branches(); + let (factory, write) = started(&old, 4); + new.replace_after(&factory, ANCESTOR); + let (addresses, accounts) = accounts(); + let slot = SlotChanges::new( + U256::from(7), + vec![StorageChange::new(BlockAccessIndex::new(1), U256::ONE)], + ); + let downloaded = credit(addresses[0], 5).remove(0).with_storage_change(slot); + let lists = [ + list(vec![downloaded.clone()]), + list(vec![ + // Only reads the account, which changes nothing. + AccountChanges::new(addresses[1]).with_storage_read(U256::ONE), + // Past the cursor, so downloaded whole later. + AccountChanges::new(addresses[2]) + .with_nonce_change(NonceChange::new(BlockAccessIndex::new(1), 1)), + ]), + ]; + let generation = SnapGeneration::new(new.tip(), state_root(&accounts)); + + let provider = factory.database_provider_rw().unwrap(); + let recovered = + provider.commit_reorg_recovery(write, old.block(0), &lists, generation).unwrap(); + + let mut expected = StateRepairs::default(); + expected.insert_changes(keccak256(addresses[0]), &downloaded); + assert_eq!(provider.snap_repairs(recovered).unwrap(), expected); + } +} diff --git a/crates/snap-sync/src/repair.rs b/crates/snap-sync/src/repair.rs new file mode 100644 index 00000000000..08e8ca28d08 --- /dev/null +++ b/crates/snap-sync/src/repair.rs @@ -0,0 +1,285 @@ +//! Entries the downloaded state holds values for that no list will overwrite. +//! +//! Lists only overwrite the fields their blocks change, so a value the state holds for another +//! reason, such as a block a reorg orphaned, survives catch-up. Such entries are scheduled here and +//! fetched again on their own once catch-up reaches the pivot, where the pivot's values leave the +//! whole state at one block. + +use crate::{common::SnapRecord, SnapSyncError}; +use alloy_eip7928::AccountChanges; +use alloy_primitives::{keccak256, B256, U256}; +use reth_storage_api::{MetadataWriter, SnapAttemptId}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{btree_map::Entry, BTreeMap, BTreeSet}, + mem, +}; + +/// Accounts and storage slots to fetch again at the pivot, in key order. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct StateRepairs { + // Hashed addresses, each with the fields and hashed slots its state holds stale values for. + accounts: BTreeMap, +} + +impl StateRepairs { + /// Schedules every field of the account at `hashed_address`. + pub fn insert_account(&mut self, hashed_address: B256) { + self.accounts.entry(hashed_address).or_default().mark_fields_stale(); + } + + /// Schedules `hashed_slot` of the storage at `hashed_address`, along with its account. + pub fn insert_slot(&mut self, hashed_address: B256, hashed_slot: B256) { + self.accounts.entry(hashed_address).or_default().slots.insert(hashed_slot); + } + + /// Schedules the fields and slots `changes` writes for the account at `hashed_address`. + pub fn insert_changes(&mut self, hashed_address: B256, changes: &AccountChanges) { + let stale = self.accounts.entry(hashed_address).or_default(); + stale.insert_changes(changes); + if stale.is_resolved() { + self.accounts.remove(&hashed_address); + } + } + + /// Returns whether nothing is scheduled. + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + /// Number of accounts scheduled. + pub fn len(&self) -> usize { + self.accounts.len() + } + + /// First account scheduled, in key order. + pub fn first(&self) -> Option { + self.accounts.keys().next().copied() + } + + /// Slots scheduled for the storage at `hashed_address`, in key order. + pub fn slots(&self, hashed_address: B256) -> impl Iterator + '_ { + self.accounts + .get(&hashed_address) + .into_iter() + .flat_map(|account| account.slots.iter()) + .copied() + } + + // Adds what `other` schedules. + pub(crate) fn extend(&mut self, other: Self) { + for (hashed_address, stale) in other.accounts { + self.accounts.entry(hashed_address).or_default().extend(stale); + } + } + + // Drops the fields and slots a canonical list overwrites, as `changes` records them. Returns + // whether anything was dropped. + pub(crate) fn resolve_changes( + &mut self, + hashed_address: B256, + changes: &AccountChanges, + ) -> bool { + let Entry::Occupied(mut entry) = self.accounts.entry(hashed_address) else { return false }; + let resolved = entry.get_mut().resolve_changes(changes); + if entry.get().is_resolved() { + entry.remove(); + } + resolved + } + + // Drops the account at `hashed_address`, fetched whole, with the `slots` fetched along with it, + // or every slot when it has no storage at the pivot. Slots scheduled after the fetch stay, and + // keep their account scheduled. + pub(crate) fn resolve(&mut self, hashed_address: B256, slots: Option<&[(B256, U256)]>) { + let Entry::Occupied(mut entry) = self.accounts.entry(hashed_address) else { return }; + let account = entry.get_mut(); + account.clear_fields(); + match slots { + Some(slots) => { + for (slot, _) in slots { + account.slots.remove(slot); + } + } + None => account.slots.clear(), + } + if account.is_resolved() { + entry.remove(); + } + } +} + +// What the state holds stale values for in one account. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +struct StaleAccount { + // Whether the balance is stale. + balance: bool, + // Whether the nonce is stale. + nonce: bool, + // Whether the code hash is stale. + code: bool, + // Hashed slots of its storage. + slots: BTreeSet, +} + +impl StaleAccount { + // Whether nothing about the account is stale any more. + fn is_resolved(&self) -> bool { + !self.balance && !self.nonce && !self.code && self.slots.is_empty() + } + + // Marks the fields and slots `changes` writes as stale. + fn insert_changes(&mut self, changes: &AccountChanges) { + let info = changes.account_info(); + self.balance |= info.balance.is_some(); + self.nonce |= info.nonce.is_some(); + self.code |= info.code_hash.is_some(); + self.slots.extend(Self::slots_of(changes)); + } + + // Marks what `other` holds stale as stale. + fn extend(&mut self, other: Self) { + self.balance |= other.balance; + self.nonce |= other.nonce; + self.code |= other.code; + self.slots.extend(other.slots); + } + + // Clears the fields and slots `changes` overwrites, returning whether any was stale. + fn resolve_changes(&mut self, changes: &AccountChanges) -> bool { + let info = changes.account_info(); + let mut resolved = false; + resolved |= info.balance.is_some() && mem::take(&mut self.balance); + resolved |= info.nonce.is_some() && mem::take(&mut self.nonce); + resolved |= info.code_hash.is_some() && mem::take(&mut self.code); + for slot in Self::slots_of(changes) { + resolved |= self.slots.remove(&slot); + } + resolved + } + + // Hashed keys of the slots `changes` writes. + fn slots_of(changes: &AccountChanges) -> impl Iterator + '_ { + changes.storage_post_states().map(|(slot, _)| keccak256(B256::from(slot))) + } + + // Marks every account-level field stale. + const fn mark_fields_stale(&mut self) { + self.balance = true; + self.nonce = true; + self.code = true; + } + + // Marks every account-level field fetched. + const fn clear_fields(&mut self) { + self.balance = false; + self.nonce = false; + self.code = false; + } +} + +// The schedule as persisted, tied to the attempt that recorded it. +#[derive(Serialize, Deserialize)] +pub(crate) struct StoredRepairs { + // Encoding version, checked before the rest is decoded. + version: u32, + // Attempt the repairs belong to. + pub(crate) attempt: SnapAttemptId, + // What that attempt still has to fetch again. + pub(crate) repairs: StateRepairs, +} + +impl SnapRecord for StoredRepairs { + const KEY: &'static str = "snap_state_repairs"; + const VERSION: u32 = 1; +} + +impl StoredRepairs { + // Persists `repairs` for `attempt`, removing the record once nothing is left. + pub(crate) fn store( + provider: &impl MetadataWriter, + attempt: SnapAttemptId, + repairs: StateRepairs, + ) -> Result<(), SnapSyncError> { + if repairs.is_empty() { + return Self::clear(provider) + } + Self { version: Self::VERSION, attempt, repairs }.write(provider) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eip7928::{BalanceChange, BlockAccessIndex, NonceChange, SlotChanges, StorageChange}; + use alloy_primitives::Address; + + const ACCOUNT: Address = Address::repeat_byte(0xaa); + + fn hashed() -> B256 { + keccak256(ACCOUNT) + } + + fn balance(value: u64) -> AccountChanges { + AccountChanges::new(ACCOUNT) + .with_balance_change(BalanceChange::new(BlockAccessIndex::new(1), U256::from(value))) + } + + fn nonce(value: u64) -> AccountChanges { + AccountChanges::new(ACCOUNT) + .with_nonce_change(NonceChange::new(BlockAccessIndex::new(1), value)) + } + + fn slots(slots: &[u64]) -> AccountChanges { + slots.iter().fold(AccountChanges::new(ACCOUNT), |changes, slot| { + changes.with_storage_change(SlotChanges::new( + U256::from(*slot), + vec![StorageChange::new(BlockAccessIndex::new(1), U256::ONE)], + )) + }) + } + + fn scheduled(changes: &AccountChanges) -> StateRepairs { + let mut repairs = StateRepairs::default(); + repairs.insert_changes(hashed(), changes); + repairs + } + + #[test] + fn a_field_both_branches_change_needs_no_repair() { + let mut repairs = scheduled(&balance(1)); + + repairs.resolve_changes(hashed(), &balance(2)); + + assert!(repairs.is_empty()); + } + + #[test] + fn a_field_only_the_old_branch_changes_stays_scheduled() { + let mut repairs = scheduled(&balance(1)); + + repairs.resolve_changes(hashed(), &nonce(2)); + + assert_eq!(repairs, scheduled(&balance(1))); + } + + #[test] + fn slots_are_resolved_one_by_one() { + let mut repairs = scheduled(&slots(&[1, 2])); + + repairs.resolve_changes(hashed(), &slots(&[1])); + + let remaining: Vec<_> = repairs.slots(hashed()).collect(); + assert_eq!(remaining, [keccak256(B256::with_last_byte(2))]); + } + + #[test] + fn a_fetched_account_resolves_every_field() { + let mut repairs = + scheduled(&balance(1).with_nonce_change(NonceChange::new(BlockAccessIndex::new(1), 1))); + + repairs.resolve(hashed(), None); + + assert!(repairs.is_empty()); + } +} diff --git a/crates/snap-sync/src/session.rs b/crates/snap-sync/src/session.rs index b29b5d0bdd1..9d51d38d38d 100644 --- a/crates/snap-sync/src/session.rs +++ b/crates/snap-sync/src/session.rs @@ -2,7 +2,6 @@ use crate::{SnapAttemptStore, SnapGeneration, SnapPivotPolicy, SnapSyncError, SnapWrite}; use reth_storage_api::{BlockHashReader, HeaderProvider, MetadataProvider, MetadataWriter}; -use tokio_util::sync::CancellationToken; /// One snap synchronization attempt, from the pivot it targets to the work it owns. /// @@ -14,18 +13,12 @@ pub struct SnapSyncSession { policy: SnapPivotPolicy, // How far the attempt has got. state: SnapSyncSessionState, - // Cancelled once, watched by whatever took the target. - cancellation: CancellationToken, } impl SnapSyncSession { /// Creates a session waiting for its first eligible target. - pub fn new(policy: SnapPivotPolicy) -> Self { - Self { - policy, - state: SnapSyncSessionState::Waiting, - cancellation: CancellationToken::new(), - } + pub const fn new(policy: SnapPivotPolicy) -> Self { + Self { policy, state: SnapSyncSessionState::Waiting } } /// What the session is doing. @@ -38,11 +31,6 @@ impl SnapSyncSession { self.state.target() } - /// Returns whether this session has been cancelled. - pub fn is_cancelled(&self) -> bool { - self.cancellation.is_cancelled() - } - /// Selects a target under `head`, or waits while no block is eligible. /// /// A target no work has taken yet is replaced by a newer eligible one and dropped when none is @@ -63,27 +51,28 @@ impl SnapSyncSession { Ok(&self.state) } - /// Hands the selected target, and the token to watch, to the work downloading against it. + /// Hands the selected target to the work downloading against it. /// /// Taking the target is what starts it, so only the first caller gets one: a target already - /// being downloaded has an owner, and a waiting or cancelled session has nothing to hand out. - pub fn start(&mut self) -> Option<(SnapGeneration, CancellationToken)> { + /// being downloaded has an owner, and a waiting session has nothing to hand out. + pub const fn start(&mut self) -> Option { let SnapSyncSessionState::Selected(generation) = self.state else { return None }; self.state = SnapSyncSessionState::Downloading(generation); - Some((generation, self.cancellation.clone())) + Some(generation) } /// Takes over the target of an attempt a previous run recorded, as [`Self::start`] does for a /// selected one. /// - /// Only a session that has not handed out a target can take one over. - pub fn resume(&mut self, generation: SnapGeneration) -> Option { + /// Only a session that has not handed out a target can take one over, so this returns whether + /// it did. + pub const fn resume(&mut self, generation: SnapGeneration) -> bool { if !matches!(self.state, SnapSyncSessionState::Waiting | SnapSyncSessionState::Selected(_)) { - return None + return false } self.state = SnapSyncSessionState::Downloading(generation); - Some(self.cancellation.clone()) + true } /// Re-anchors the downloaded target once it lags too far behind `head`, returning the new @@ -119,14 +108,6 @@ impl SnapSyncSession { self.state = SnapSyncSessionState::Downloading(next); Ok(Some(write)) } - - /// Signals outstanding work to stop and ends the session. - /// - /// Terminal: a later attempt needs a new session. - pub fn cancel(&mut self) { - self.cancellation.cancel(); - self.state = SnapSyncSessionState::Cancelled; - } } /// What a [`SnapSyncSession`] is doing. @@ -138,8 +119,6 @@ pub enum SnapSyncSessionState { Selected(SnapGeneration), /// Work is outstanding against the target. Downloading(SnapGeneration), - /// The session was cancelled and its outstanding work signalled to stop. - Cancelled, } impl SnapSyncSessionState { @@ -147,7 +126,7 @@ impl SnapSyncSessionState { pub const fn target(&self) -> Option<&SnapGeneration> { match self { Self::Selected(generation) | Self::Downloading(generation) => Some(generation), - Self::Waiting | Self::Cancelled => None, + Self::Waiting => None, } } } @@ -175,37 +154,12 @@ mod tests { let provider = factory.database_provider_rw().unwrap(); let mut session = SnapSyncSession::new(policy().with_advance_after(1)); session.select(&provider, 2, None).unwrap(); - let (generation, _) = session.start().unwrap(); + let generation = session.start().unwrap(); let write = provider.start_snap_attempt(generation).unwrap(); provider.commit().unwrap(); (factory, session, write) } - #[test] - fn waits_while_no_block_is_eligible() { - // The only block access list commitment is at the head, past the head distance. - let provider = provider_with(chain(Some(3))); - let mut session = session(); - - assert_eq!(session.select(&provider, 3, None).unwrap(), &SnapSyncSessionState::Waiting); - assert_eq!(session.target(), None); - assert!(session.start().is_none()); - } - - #[test] - fn selects_an_eligible_target() { - let headers = chain(Some(0)); - let expected = headers[2].clone(); - let provider = provider_with(headers); - let mut session = session(); - - session.select(&provider, 3, None).unwrap(); - - let target = session.target().unwrap(); - assert_eq!(target.target().number, 2); - assert_eq!(target.target().hash, expected.hash_slow()); - } - #[test] fn a_target_no_work_took_is_replaced_as_the_head_advances() { let provider = provider_with(chain(Some(0))); @@ -236,23 +190,13 @@ mod tests { let provider = provider_with(chain(Some(0))); let mut session = session(); session.select(&provider, 2, None).unwrap(); - let (started, _) = session.start().unwrap(); + let started = session.start().unwrap(); session.select(&provider, 3, None).unwrap(); assert_eq!(session.state(), &SnapSyncSessionState::Downloading(started)); } - #[test] - fn only_one_worker_takes_a_target() { - let provider = provider_with(chain(Some(0))); - let mut session = session(); - session.select(&provider, 3, None).unwrap(); - - assert!(session.start().is_some()); - assert!(session.start().is_none()); - } - #[test] fn a_recorded_target_is_resumed_once() { let (factory, _, write) = downloading(); @@ -261,8 +205,8 @@ mod tests { let recorded = SnapGeneration::new(attempt.pivot(), attempt.state_root()); let mut session = SnapSyncSession::new(policy().with_advance_after(1)); - assert!(session.resume(recorded).is_some()); - assert!(session.resume(recorded).is_none()); + assert!(session.resume(recorded)); + assert!(!session.resume(recorded)); assert!(session.start().is_none()); assert_eq!(session.state(), &SnapSyncSessionState::Downloading(recorded)); // A resumed target advances like a started one. @@ -270,31 +214,6 @@ mod tests { assert_eq!(session.target().unwrap().target().number, 2); } - #[test] - fn cancellation_stops_outstanding_work() { - let provider = provider_with(chain(Some(0))); - let mut session = session(); - session.select(&provider, 3, None).unwrap(); - let (_, outstanding) = session.start().unwrap(); - - session.cancel(); - - assert!(outstanding.is_cancelled()); - assert!(session.is_cancelled()); - assert_eq!(session.state(), &SnapSyncSessionState::Cancelled); - assert_eq!(session.target(), None); - } - - #[test] - fn a_cancelled_session_selects_nothing() { - let provider = provider_with(chain(Some(0))); - let mut session = session(); - session.cancel(); - - assert_eq!(session.select(&provider, 3, None).unwrap(), &SnapSyncSessionState::Cancelled); - assert!(session.start().is_none()); - } - #[test] fn a_lagging_target_advances_with_the_attempt() { let (factory, mut session, write) = downloading(); diff --git a/crates/snap-sync/src/storage/download.rs b/crates/snap-sync/src/storage/download.rs index 258dd8371fa..84c8cecc552 100644 --- a/crates/snap-sync/src/storage/download.rs +++ b/crates/snap-sync/src/storage/download.rs @@ -1,10 +1,11 @@ //! Downloads the storage of an account range's contracts, committing every verified response. use crate::{ - common::DownloadContext, SnapStorageStore, SnapSyncError, StorageChunk, StorageProgress, - VerifiedRange, MAX_HASH, + common::DownloadContext, SnapAccountStore, SnapStorageStore, SnapSyncError, StorageChunk, + StorageProgress, VerifiedRange, MAX_HASH, }; -use alloy_primitives::B256; +use alloy_primitives::{B256, U256}; +use futures::future::join_all; use reth_db_api::transaction::DbTxMut; use reth_downloaders::snap::{ StorageRangeDownloader, StorageRangeOutcome, VerifiedAccountBatch, VerifiedStorageRanges, @@ -21,14 +22,19 @@ use std::fmt; /// Default number of contracts asked for per storage request. pub const DEFAULT_STORAGE_ACCOUNTS: usize = 128; -/// Downloads the storage an account range's contracts still need, one request at a time. +/// Default number of scheduled slots fetched again per repair batch. +pub const DEFAULT_REPAIR_SLOTS: usize = 128; + +/// Downloads the storage an account range's contracts still need, and repaired slots again. /// -/// Every verified response is committed before the next request, so at most one response is held -/// however large a contract is, and a download resumes from the persisted progress. +/// Each range response is committed before the next request, so at most one is held however large +/// a contract is, and a download resumes from the persisted progress. pub struct StorageRangeDownload { context: DownloadContext, // Contracts asked for per request. max_accounts: usize, + // Scheduled slots fetched again per repair batch. + max_repair_slots: usize, } impl StorageRangeDownload { @@ -37,6 +43,7 @@ impl StorageRangeDownload { Self { context: DownloadContext::new(client, factory, runtime), max_accounts: DEFAULT_STORAGE_ACCOUNTS, + max_repair_slots: DEFAULT_REPAIR_SLOTS, } } @@ -51,6 +58,13 @@ impl StorageRangeDownload { self.max_accounts = if max_accounts == 0 { 1 } else { max_accounts }; self } + + /// Returns this download fetching at most `max_repair_slots` scheduled slots again per repair + /// batch, at least one. + pub const fn with_max_repair_slots(mut self, max_repair_slots: usize) -> Self { + self.max_repair_slots = if max_repair_slots == 0 { 1 } else { max_repair_slots }; + self + } } impl StorageRangeDownload @@ -112,6 +126,59 @@ where .await?; Ok(StorageRangeStep::Committed(committed)) } + + /// Fetches the pivot's values of the next batch of slots scheduled for repair at `range`'s + /// origin concurrently, zero where its storage holds none. Unserved slots stay scheduled, and + /// `Ok(None)` when none was served. + pub async fn repair_slots( + &mut self, + range: &VerifiedRange, + ) -> Result>, SnapSyncError> { + let (write, account) = (range.write(), range.origin()); + let repairs = self.context.factory().database_provider_ro()?.snap_repairs(write)?; + // Only a contract the pivot holds has slots to fetch; for any other account they are zero. + let contracts = range.range().storage_batch(); + let Some(batch) = contracts.range(0..1).filter(|batch| batch.accounts()[0].0 == account) + else { + return Ok(Some(Vec::new())) + }; + + let mut requests = Vec::new(); + for slot in repairs.slots(account).take(self.max_repair_slots) { + let request = GetStorageRangesMessage { + request_id: self.context.next_request_id(), + root_hash: batch.state_root(), + account_hashes: vec![account], + starting_hash: slot.into(), + limit_hash: slot.into(), + response_bytes: self.context.response_bytes(), + }; + let downloader = StorageRangeDownloader::new( + self.context.client().clone(), + request, + &batch, + self.context.runtime().clone(), + )?; + requests.push(async move { downloader.await.map(|outcome| (slot, outcome)) }); + } + + let requested = requests.len(); + let mut values = Vec::new(); + for response in join_all(requests).await { + let (slot, StorageRangeOutcome::Verified(ranges)) = response? else { continue }; + // Proved from the slot, so one keyed past it means the storage holds none there. + let value = ranges + .into_ranges() + .into_iter() + .next() + .and_then(|range| range.slots.first().copied()) + .filter(|(key, _)| *key == slot) + .map_or(U256::ZERO, |(_, value)| value); + values.push((slot, value)); + } + // An account scheduled without slots has nothing to wait for. + Ok((!values.is_empty() || requested == 0).then_some(values)) + } } impl fmt::Debug for StorageRangeDownload { @@ -119,6 +186,7 @@ impl fmt::Debug for StorageRangeDownload { f.debug_struct("StorageRangeDownload") .field("context", &self.context) .field("max_accounts", &self.max_accounts) + .field("max_repair_slots", &self.max_repair_slots) .finish() } } @@ -169,10 +237,10 @@ mod tests { use super::*; use crate::{ test_utils::{ - account, generation, hashed_factory, insert_generation_headers, key, state_root, - storage_ranges, storage_root_of, stored_slots, verified_range, ScriptedSnapClient, + account, generation, hashed_factory_with_roots, key, state_root, storage_ranges, + storage_root_of, stored_slots, verified_range, verified_repair, ScriptedSnapClient, }, - SnapAccountStore, SnapAttemptStore, SnapCatchUpStore, + SnapAccountStore, SnapAttemptStore, SnapCatchUpStore, StateRepairs, }; use alloy_eips::BlockNumHash; use alloy_primitives::U256; @@ -215,8 +283,15 @@ mod tests { // An attempt that fetched all of `accounts` as one range, not yet committed. fn started(accounts: &[(B256, TrieAccount)]) -> (Factory, VerifiedRange) { - let factory = hashed_factory(); - insert_generation_headers(&factory); + started_moving_to(accounts, accounts) + } + + // `started`, with the block after the pivot committing to the state `moved`. + fn started_moving_to( + accounts: &[(B256, TrieAccount)], + moved: &[(B256, TrieAccount)], + ) -> (Factory, VerifiedRange) { + let factory = hashed_factory_with_roots(&[state_root(accounts), state_root(moved)]); let provider = factory.database_provider_rw().unwrap(); let write = provider.start_snap_attempt(generation(1, state_root(accounts))).unwrap(); provider.start_account_coverage(write).unwrap(); @@ -311,32 +386,6 @@ mod tests { assert_eq!(origins, [B256::ZERO, key(2), key(2)]); } - #[tokio::test] - async fn a_new_download_resumes_from_the_last_committed_response() { - let accounts = accounts(); - let (factory, range) = started(&accounts); - let (large, small) = (large(), small()); - let first = storage_ranges(1, &[&large[..1]], &large, &[B256::ZERO, key(1)]); - let (_, mut interrupted) = download([first], factory.clone()); - committed(&mut interrupted, &range).await; - drop(interrupted); - - let responses = [ - storage_ranges(1, &[&large[1..]], &large, &[key(2), key(3)]), - storage_ranges(2, &[&small[..]], &small, &[]), - ]; - let (client, mut resumed) = download(responses, factory.clone()); - committed(&mut resumed, &range).await; - committed(&mut resumed, &range).await; - - assert!(matches!(resumed.next(&range).await.unwrap(), StorageRangeStep::Complete)); - assert_eq!( - *client.storage_requests(), - [(vec![key(2), key(3)], key(2)), (vec![key(3)], B256::ZERO)] - ); - assert_eq!(slots_of(&factory, key(2)), large); - } - #[tokio::test] async fn requests_ask_for_at_most_the_configured_contracts() { let accounts = accounts(); @@ -388,11 +437,11 @@ mod tests { #[tokio::test] async fn storage_part_way_through_resumes_at_the_new_root() { let accounts = accounts(); - let (factory, range) = started(&accounts); let (large, small) = (large(), small()); // The new pivot turned the first account into a contract, ahead of the carried one. let mut moved = accounts.clone(); moved[0].1 = contract(1, &small); + let (factory, range) = started_moving_to(&accounts, &moved); let responses = [ storage_ranges(1, &[&large[..1]], &large, &[B256::ZERO, key(1)]), storage_ranges(2, &[&small[..]], &small, &[]), @@ -465,4 +514,94 @@ mod tests { assert!(next <= key(2)); assert_eq!(provider.storage_progress(write, next).unwrap().resume_at(key(2)), Some(key(2))); } + + // `accounts`' large contract fetched on its own, with `slots` scheduled for repair. + fn repairing(accounts: &[(B256, TrieAccount)], slots: &[B256]) -> (Factory, VerifiedRange) { + let (factory, _) = started(accounts); + let provider = factory.database_provider_rw().unwrap(); + let write = provider.active_snap_write().unwrap().unwrap(); + let mut repairs = StateRepairs::default(); + for slot in slots { + repairs.insert_slot(key(2), *slot); + } + provider.schedule_snap_repairs(write, repairs).unwrap(); + provider.commit().unwrap(); + let range = verified_repair(accounts, 1..2, key(2), &[key(2)]); + (factory, VerifiedRange::new(write, range)) + } + + #[tokio::test] + async fn repair_slots_read_zero_where_the_pivot_holds_none() { + let accounts = accounts(); + let (factory, range) = repairing(&accounts, &[key(1), key(5)]); + let large = large(); + let responses = [ + storage_ranges(1, &[&large[..1]], &large, &[key(1)]), + // Nothing is stored at key 5, which the proof shows. + storage_ranges(2, &[&[]], &large, &[key(5)]), + ]; + let (client, mut download) = download(responses, factory); + + let values = download.repair_slots(&range).await.unwrap(); + + assert_eq!(values, Some(vec![(key(1), U256::from(11)), (key(5), U256::ZERO)])); + assert_eq!(*client.storage_requests(), [(vec![key(2)], key(1)), (vec![key(2)], key(5))]); + } + + #[tokio::test] + async fn a_contract_scheduled_without_slots_fetches_none() { + let accounts = accounts(); + let (factory, range) = repairing(&accounts, &[]); + let provider = factory.database_provider_rw().unwrap(); + let mut repairs = StateRepairs::default(); + repairs.insert_account(key(2)); + provider.schedule_snap_repairs(range.write(), repairs).unwrap(); + provider.commit().unwrap(); + let (client, mut download) = download([], factory); + + assert_eq!(download.repair_slots(&range).await.unwrap(), Some(Vec::new())); + assert!(client.storage_requests().is_empty()); + } + + #[tokio::test] + async fn an_unserved_repair_slot_fetches_nothing() { + let accounts = accounts(); + let (factory, range) = repairing(&accounts, &[key(1)]); + let (_, mut download) = download([storage_ranges(1, &[], &[], &[])], factory); + + assert_eq!(download.repair_slots(&range).await.unwrap(), None); + } + + #[tokio::test] + async fn served_repair_slots_survive_an_unserved_one() { + let accounts = accounts(); + let (factory, range) = repairing(&accounts, &[key(1), key(5)]); + let large = large(); + let responses = [ + storage_ranges(1, &[&large[..1]], &large, &[key(1)]), + storage_ranges(2, &[], &[], &[]), + ]; + let (_, mut download) = download(responses, factory); + + let values = download.repair_slots(&range).await.unwrap(); + + // Key 5 stays scheduled for the next batch. + assert_eq!(values, Some(vec![(key(1), U256::from(11))])); + } + + #[tokio::test] + async fn repair_slots_are_fetched_in_bounded_batches() { + let accounts = accounts(); + let (factory, range) = repairing(&accounts, &[key(1), key(5)]); + let large = large(); + let responses = [storage_ranges(1, &[&large[..1]], &large, &[key(1)])]; + let (client, download) = download(responses, factory); + let mut download = download.with_max_repair_slots(1); + + let values = download.repair_slots(&range).await.unwrap(); + + // Key 5 waits for the next batch, after the account commits this one. + assert_eq!(values, Some(vec![(key(1), U256::from(11))])); + assert_eq!(*client.storage_requests(), [(vec![key(2)], key(1))]); + } } diff --git a/crates/snap-sync/src/storage/mod.rs b/crates/snap-sync/src/storage/mod.rs index d6433124c36..45e3f558b9b 100644 --- a/crates/snap-sync/src/storage/mod.rs +++ b/crates/snap-sync/src/storage/mod.rs @@ -3,6 +3,8 @@ mod download; mod store; -pub use download::{StorageRangeDownload, StorageRangeStep, DEFAULT_STORAGE_ACCOUNTS}; +pub use download::{ + StorageRangeDownload, StorageRangeStep, DEFAULT_REPAIR_SLOTS, DEFAULT_STORAGE_ACCOUNTS, +}; pub(crate) use store::{persisted_storage_root, StoredProgress}; pub use store::{SnapStorageStore, StorageChunk, StorageProgress}; diff --git a/crates/snap-sync/src/storage/store.rs b/crates/snap-sync/src/storage/store.rs index e3373bbf3af..d832e5adb22 100644 --- a/crates/snap-sync/src/storage/store.rs +++ b/crates/snap-sync/src/storage/store.rs @@ -282,11 +282,14 @@ impl SnapStorageStore for T { if chunk.next.is_none() { // Skipped accounts have empty storage at this pivot. Clear earlier attempts' slots // before the completion watermark makes them eligible for reuse and BAL updates. - let start = complete - .map_or(origin, |account| (U256::from_be_bytes(account.0) + U256::from(1)).into()); - self.remove::(start..chunk.account)?; + let start = complete.map_or(Some(origin), |account| { + U256::from_be_bytes(account.0).checked_add(U256::ONE).map(B256::from) + }); + if let Some(start) = start { + self.remove::(start..chunk.account)?; + } } - if chunk.from == B256::ZERO { + if chunk.from.is_zero() { self.remove::(chunk.account..=chunk.account)?; } let state = HashedPostState::default() @@ -315,8 +318,7 @@ pub(crate) fn persisted_storage_root(tx: &impl DbTx, account: B256) -> Result (ProviderFactory, SnapWrite) { - let factory = hashed_factory(); - insert_generation_headers(&factory); - let provider = factory.database_provider_rw().unwrap(); let mut contract = account(1); contract.storage_root = root(); - let generation = generation(1, state_root(&[(CONTRACT, contract)])); + let state_root = state_root(&[(CONTRACT, contract)]); + let factory = hashed_factory_with_roots(&[state_root]); + let provider = factory.database_provider_rw().unwrap(); + let generation = generation(1, state_root); let write = provider.start_snap_attempt(generation).unwrap(); provider.start_account_coverage(write).unwrap(); provider.commit().unwrap(); @@ -439,7 +441,7 @@ mod tests { let first = chunk(0..1, B256::ZERO, Some(slot(2))); provider.commit_storage_chunk(write, B256::ZERO, first).unwrap(); let advanced = - provider.advance_snap_pivot(write, generation(2, B256::repeat_byte(0xcc))).unwrap(); + provider.advance_snap_pivot(write, generation(2, B256::repeat_byte(2))).unwrap(); provider.commit().unwrap(); (factory, write, advanced) } @@ -504,7 +506,7 @@ mod tests { // Both stay resumable, even across another pivot move. let advanced = - provider.advance_snap_pivot(advanced, generation(3, B256::repeat_byte(0xdd))).unwrap(); + provider.advance_snap_pivot(advanced, generation(3, B256::repeat_byte(3))).unwrap(); let progress = provider.storage_progress(advanced, B256::ZERO).unwrap(); assert_eq!(progress.resume_at(before), Some(slot(2))); assert_eq!(progress.resume_at(CONTRACT), Some(slot(2))); @@ -556,5 +558,15 @@ mod tests { Err(SnapSyncError::UnsupportedRecord { .. }) )); } + + for record in [b"not json".to_vec(), br#"{"version":2}"#.to_vec()] { + let provider = factory.database_provider_rw().unwrap(); + provider.write_metadata(StoredProgress::KEY, record).unwrap(); + + assert!(matches!( + provider.storage_progress(write, B256::ZERO), + Err(SnapSyncError::UnreadableRecord { .. }) + )); + } } } diff --git a/crates/snap-sync/src/test_utils.rs b/crates/snap-sync/src/test_utils.rs index 02454668ee8..0b26848a04e 100644 --- a/crates/snap-sync/src/test_utils.rs +++ b/crates/snap-sync/src/test_utils.rs @@ -57,7 +57,8 @@ pub(crate) fn policy() -> SnapPivotPolicy { SnapPivotPolicy::default().with_head_distance(1).with_advance_after(4).with_history(8) } -/// A header with a state root distinctive to its number, and a commitment when one is given. +/// A header with a state root distinctive to its number, and a block access list hash when one +/// is given. pub(crate) fn header( number: u64, parent_hash: B256, @@ -72,7 +73,7 @@ pub(crate) fn header( } } -/// Blocks `0..=3`, carrying a block access list commitment from `bal_from` onwards. +/// Blocks `0..=3`, carrying a block access list hash from `bal_from` onwards. pub(crate) fn chain(bal_from: Option) -> Vec
{ let mut headers = Vec::new(); let mut parent = B256::ZERO; @@ -98,12 +99,20 @@ pub(crate) fn generation(block: u64, state_root: B256) -> SnapGeneration { } /// Writes blocks `0..=3` hashed the way [`generation`] anchors them, so an attempt started from -/// one finds its pivot on the canonical chain. -pub(crate) fn insert_generation_headers(factory: &ProviderFactory) { +/// one finds its pivot on the canonical chain. `roots` are the state roots of blocks `1..`, the +/// ones attempts download against; later blocks keep their distinctive root. +pub(crate) fn insert_generation_headers( + factory: &ProviderFactory, + roots: &[B256], +) { let headers: Vec<_> = (0..=3u64) .map(|number| { let parent = number.checked_sub(1).map_or(B256::ZERO, |n| B256::repeat_byte(n as u8)); - SealedHeader::new(header(number, parent, None), B256::repeat_byte(number as u8)) + let mut header = header(number, parent, None); + if let Some(root) = number.checked_sub(1).and_then(|i| roots.get(i as usize)) { + header.state_root = *root; + } + SealedHeader::new(header, B256::repeat_byte(number as u8)) }) .collect(); insert_headers(factory, &headers); @@ -120,6 +129,13 @@ pub(crate) fn hashed_factory() -> ProviderFactory { factory } +/// A hashed database holding the blocks of [`insert_generation_headers`] with `roots`. +pub(crate) fn hashed_factory_with_roots(roots: &[B256]) -> ProviderFactory { + let factory = hashed_factory(); + insert_generation_headers(&factory, roots); + factory +} + /// A hashed account key in the lowest part of the key space. pub(crate) fn key(value: u64) -> B256 { B256::left_padding_from(&value.to_be_bytes()) @@ -127,12 +143,13 @@ pub(crate) fn key(value: u64) -> B256 { /// An account without storage or code, distinguished by its nonce. pub(crate) fn account(nonce: u64) -> TrieAccount { - TrieAccount { nonce, balance: U256::from(1), ..Default::default() } + TrieAccount { nonce, balance: U256::ONE, ..Default::default() } } /// Root of the account trie holding `accounts`. +#[allow(clippy::cloned_instead_of_copied)] pub(crate) fn state_root(accounts: &[(B256, TrieAccount)]) -> B256 { - state_root_unsorted(accounts.iter().copied()) + state_root_unsorted(accounts.iter().cloned()) } // Root of the account trie, and the proof nodes on the paths to `targets`. @@ -195,13 +212,35 @@ pub(crate) fn verified_range( served: Range, origin: B256, proof_targets: &[B256], +) -> VerifiedAccountRange { + verified_accounts(accounts, served, origin, MAX_HASH, proof_targets) +} + +/// Like [`verified_range`], but for a repair's request of the account at `origin` alone, so an +/// account served past it only proves the interval and is dropped. +pub(crate) fn verified_repair( + accounts: &[(B256, TrieAccount)], + served: Range, + origin: B256, + proof_targets: &[B256], +) -> VerifiedAccountRange { + verified_accounts(accounts, served, origin, origin, proof_targets) +} + +// Verifies [`account_range`]'s answer to a request from `origin` through `limit`. +fn verified_accounts( + accounts: &[(B256, TrieAccount)], + served: Range, + origin: B256, + limit: B256, + proof_targets: &[B256], ) -> VerifiedAccountRange { let client = ScriptedSnapClient::new([account_range(1, accounts, served, proof_targets)]); let request = GetAccountRangeMessage { request_id: 1, root_hash: state_root(accounts), starting_hash: origin, - limit_hash: MAX_HASH, + limit_hash: limit, response_bytes: DEFAULT_RESPONSE_BYTES, }; let downloader = AccountRangeDownloader::new(client, request, Runtime::test()).unwrap(); @@ -251,7 +290,7 @@ pub(crate) struct BalChain { impl BalChain { /// A chain anchored at `pivot`, carrying one block per entry of `lists` after it. pub(crate) fn new(pivot: u64, lists: impl IntoIterator>) -> Self { - let (commitments, lists): (Vec, Vec) = lists + let (access_list_hashes, lists): (Vec, Vec) = lists .into_iter() .map(|changes| { ( @@ -266,15 +305,52 @@ impl BalChain { headers.push(SealedHeader::seal_slow(header(number, parent, None))); parent = headers[number as usize].hash(); } - for (index, commitment) in commitments.into_iter().enumerate() { - let sealed = - SealedHeader::seal_slow(header(pivot + index as u64 + 1, parent, Some(commitment))); + for (index, access_list_hash) in access_list_hashes.into_iter().enumerate() { + let sealed = SealedHeader::seal_slow(header( + pivot + index as u64 + 1, + parent, + Some(access_list_hash), + )); parent = sealed.hash(); headers.push(sealed); } Self { headers, pivot, lists } } + /// The chain's headers with the state roots of the given blocks replaced, hashes unchanged, so + /// an attempt downloading against a root finds it in the canonical header. + pub(crate) fn headers_with_roots(&self, roots: &[(u64, B256)]) -> Vec> { + self.headers + .iter() + .map(|sealed| { + let mut header = sealed.header().clone(); + if let Some((_, root)) = roots.iter().find(|(number, _)| *number == header.number) { + header.state_root = *root; + } + SealedHeader::new(header, sealed.hash()) + }) + .collect() + } + + /// The chain's headers all committing to `state_root`, hashes unchanged. + pub(crate) fn headers_with_root(&self, state_root: B256) -> Vec> { + let roots: Vec<_> = + self.headers.iter().map(|sealed| (sealed.number(), state_root)).collect(); + self.headers_with_roots(&roots) + } + + /// This chain with the state roots of the given blocks replaced, hashes unchanged. + pub(crate) fn with_state_roots(mut self, roots: &[(u64, B256)]) -> Self { + self.headers = self.headers_with_roots(roots); + self + } + + /// This chain with every header committing to `state_root`, hashes unchanged. + pub(crate) fn with_state_root(mut self, state_root: B256) -> Self { + self.headers = self.headers_with_root(state_root); + self + } + /// Generation anchored to this chain's pivot, downloading against `state_root`. pub(crate) fn generation(&self, state_root: B256) -> SnapGeneration { SnapGeneration::new(self.block(0), state_root) @@ -296,6 +372,15 @@ impl BalChain { writer.commit().unwrap(); } + /// Replaces the canonical blocks after `ancestor` with this chain's, as a reorg to it does. + pub(crate) fn replace_after( + &self, + factory: &ProviderFactory, + ancestor: u64, + ) { + factory.replace_headers_after(ancestor, &self.headers[ancestor as usize + 1..]); + } + /// The block `nth` after the pivot, which is the pivot itself at zero. pub(crate) fn block(&self, nth: usize) -> BlockNumHash { let header = &self.headers[self.pivot as usize + nth]; @@ -321,6 +406,27 @@ impl BalChain { } } +/// Reorgs of a test database's canonical headers. +pub(crate) trait ReorgFactoryExt { + /// Replaces the canonical blocks after `ancestor` with `headers`, as a reorg to them does. + fn replace_headers_after(&self, ancestor: u64, headers: &[SealedHeader
]); +} + +impl ReorgFactoryExt for ProviderFactory { + fn replace_headers_after(&self, ancestor: u64, headers: &[SealedHeader
]) { + let static_files = self.static_file_provider(); + let highest = + static_files.get_highest_static_file_block(StaticFileSegment::Headers).unwrap(); + let mut writer = static_files.latest_writer(StaticFileSegment::Headers).unwrap(); + writer.prune_headers(highest - ancestor).unwrap(); + writer.commit().unwrap(); + for header in headers { + writer.append_header(header.header(), &header.hash()).unwrap(); + } + writer.commit().unwrap(); + } +} + /// Slots persisted for `account`, in key order. pub(crate) fn stored_slots(provider: &impl DBProvider, account: B256) -> Vec<(B256, U256)> { let mut cursor = provider.tx_ref().cursor_dup_read::().unwrap(); diff --git a/crates/snap-sync/src/verify.rs b/crates/snap-sync/src/verify.rs index 75b5277051a..bc88c32dbd2 100644 --- a/crates/snap-sync/src/verify.rs +++ b/crates/snap-sync/src/verify.rs @@ -9,12 +9,18 @@ use crate::{ }; use alloy_eips::BlockNumHash; use alloy_primitives::{B256, KECCAK256_EMPTY}; -use reth_db_api::{cursor::DbCursorRO, tables, transaction::DbTx, RawKey, RawTable}; +use reth_db_api::{ + cursor::DbCursorRO, + tables, + transaction::{DbTx, DbTxMut}, + RawKey, RawTable, +}; use reth_primitives_traits::{AlloyBlockHeader, GotExpected}; +use reth_prune_types::{PruneCheckpoint, PruneSegment}; use reth_stages_types::{StageCheckpoint, StageId}; use reth_storage_api::{ - BlockHashReader, DBProvider, HeaderProvider, MetadataProvider, MetadataWriter, SnapAttemptId, - StageCheckpointReader, StageCheckpointWriter, + BlockHashReader, BlockWriter, DBProvider, HeaderProvider, MetadataProvider, MetadataWriter, + PruneCheckpointWriter, SnapAttemptId, StageCheckpointReader, StageCheckpointWriter, }; use reth_storage_errors::provider::{ProviderError, RootMismatch}; use serde::{Deserialize, Serialize}; @@ -24,9 +30,26 @@ use tokio_util::sync::CancellationToken; /// running. pub const DEFAULT_SCAN_CHUNK: u64 = 100_000; +// Stages publishing moves to the pivot, since the downloaded state replaces their output. Headers +// and era import run on their own, the merkle stage still rebuilds the trie, and `Finish` waits +// for verification. +const PUBLISHED_STAGES: [StageId; 11] = [ + StageId::Bodies, + StageId::SenderRecovery, + StageId::Execution, + StageId::PruneSenderRecovery, + StageId::MerkleUnwind, + StageId::AccountHashing, + StageId::StorageHashing, + StageId::TransactionLookup, + StageId::IndexStorageHistory, + StageId::IndexAccountHistory, + StageId::Prune, +]; + /// Decides whether downloaded state can be trusted as the node's state. /// -/// Writes join the caller's transaction, so a refused check changes nothing. +/// Verification writes join the caller's transaction, so a refused check changes nothing. pub trait SnapStateVerifier { /// Hands complete state to the merkle stage, which rebuilds its trie from scratch. /// @@ -52,6 +75,21 @@ pub trait SnapStateVerifier { where Self: DBProvider; + /// Publishes the state downloaded at `pivot`: makes it the node's state at that block, so the + /// pipeline resumes at `pivot + 1` instead of executing from genesis. + /// + /// - Moves the stages the downloaded state covers to `pivot`. + /// - Records history below `pivot` as pruned. + /// - Clears body indices, database receipts and transaction lookups, so transaction numbers + /// restart at 0 above `pivot`. `RocksDB` lookups are cleared immediately, not on commit. + /// + /// Publishing is not verification: the merkle stage and `Finish` wait for the trie rebuild. + /// It cannot be undone, the node must not unwind below `pivot`, and the caller resets the + /// static files to `pivot` in the same commit. + fn publish_snap_state(&self, pivot: u64) -> Result<(), SnapSyncError> + where + Self: BlockWriter + PruneCheckpointWriter + StageCheckpointWriter + DBProvider; + /// Returns whether `write`'s state was handed to the merkle stage, so a resumed sync does not /// reset its rebuild. fn is_trie_rebuild_started(&self, write: SnapWrite) -> Result; @@ -117,6 +155,39 @@ impl SnapStateVerifier for T { Ok(()) } + fn publish_snap_state(&self, pivot: u64) -> Result<(), SnapSyncError> + where + Self: BlockWriter + PruneCheckpointWriter + StageCheckpointWriter + DBProvider, + { + if pivot == 0 { + return Err(SnapSyncError::GenesisPivot) + } + // Bodies downloaded before the pivot moved allocated transaction numbers that the emptied + // transaction segments no longer hold. + self.tx_ref().clear::()?; + self.tx_ref().clear::()?; + // Their withdrawals and ommers are appended per block, so writing above the pivot again + // would fail on the rows left behind. These are the Ethereum body tables, a chain storage + // with body tables of its own has to clear them as well. + self.tx_ref().clear::()?; + self.tx_ref().clear::()?; + // Receipt log filtering keeps receipts in MDBX even with storage v2. Their transaction + // numbers must be reusable after publication. + self.tx_ref().clear::()?; + self.clear_transaction_lookup()?; + + let checkpoint = StageCheckpoint::new(pivot); + for stage in PUBLISHED_STAGES { + self.save_stage_checkpoint(stage, checkpoint)?; + } + // Snap sync wrote no history below the pivot. + let pruned = PruneCheckpoint::pruned_through(pivot); + for segment in PruneSegment::variants() { + self.save_prune_checkpoint(segment, pruned)?; + } + Ok(()) + } + fn verify_completeness( &self, write: SnapWrite, @@ -139,6 +210,10 @@ impl SnapStateVerifier for T { pivot: attempt.pivot().number, }) } + let repairs = self.snap_repairs(write)?; + if !repairs.is_empty() { + return Err(SnapSyncError::PendingRepairs { accounts: repairs.len() }) + } ensure_code_present(self.tx_ref(), chunk, cancel) } @@ -227,19 +302,26 @@ mod tests { use super::*; use crate::{ test_utils::{account, hashed_factory, header, key, state_root}, - SnapGeneration, SnapStorageStore, StorageChunk, + SnapGeneration, }; - use alloy_primitives::{map::B256Map, Bytes, U256}; + use alloy_consensus::TxLegacy; + use alloy_eips::eip4895::{Withdrawal, Withdrawals}; + use alloy_primitives::{map::B256Map, Address, Bytes, Signature, U256}; use reth_db_api::transaction::DbTxMut; + use reth_ethereum_primitives::{BlockBody, Receipt, Transaction, TransactionSigned}; use reth_primitives_traits::SealedHeader; use reth_provider::{ test_utils::{insert_headers, MockNodeTypesWithDB}, - DatabaseProviderFactory, ProviderFactory, + BlockBodyIndicesProvider, DatabaseProviderFactory, EitherWriter, ProviderFactory, + PruneCheckpointReader, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, + StorageSettings, StorageSettingsCache, TransactionsProvider, }; + use reth_prune_types::{PruneMode, PruneModes, ReceiptsLogPruneConfig}; use reth_stages::stages::MerkleStage; use reth_stages_api::{ExecInput, Stage, StageError}; use reth_trie_common::{root::storage_root_unsorted, HashedStorage, TrieAccount}; use revm::bytecode::Bytecode; + use std::collections::BTreeMap; type Factory = ProviderFactory; type Provider = ::ProviderRW; @@ -355,19 +437,6 @@ mod tests { assert_eq!(merkle_checkpoint(&provider), None); } - #[test] - fn storage_persisted_ahead_of_its_range_prevents_the_hand_off() { - let (factory, write, _) = downloaded(state_root(&accounts()), 2); - let provider = factory.database_provider_rw().unwrap(); - let origin = provider.account_coverage(write).unwrap().unwrap().next().unwrap(); - let root = accounts()[2].1.storage_root; - let chunk = - StorageChunk::new(CONTRACT, root, B256::ZERO, vec![(SLOT, U256::from(7))], None); - provider.commit_storage_chunk(write, origin, chunk).unwrap(); - - assert!(matches!(start(&provider, write), Err(SnapSyncError::IncompleteAccounts { .. }))); - } - #[test] fn missing_code_prevents_the_hand_off() { let (factory, write, _) = downloaded(state_root(&accounts()), accounts().len()); @@ -441,26 +510,6 @@ mod tests { provider.verify_state_root(write).unwrap(); } - #[test] - fn a_header_committing_to_another_root_is_refused() { - // Ranges authenticate against the attempt's root, but completion answers to the header. - let header_root = B256::repeat_byte(0xcc); - let (factory, write, _) = downloaded(header_root, accounts().len()); - let provider = factory.database_provider_rw().unwrap(); - start(&provider, write).unwrap(); - - assert!(matches!(run_merkle(&provider, 1), Err(StageError::Block { .. }))); - match provider.verify_state_root(write) { - Err(SnapSyncError::Provider(ProviderError::StateRootMismatch(mismatch))) => { - assert_eq!( - mismatch.root, - GotExpected { got: state_root(&accounts()), expected: header_root } - ); - } - other => panic!("expected a state root mismatch, got {other:?}"), - } - } - #[test] fn a_rebuild_for_an_earlier_attempt_is_not_trusted() { let root = state_root(&accounts()); @@ -506,4 +555,256 @@ mod tests { assert!(matches!(start(&provider, write), Err(SnapSyncError::GenesisPivot))); assert_eq!(merkle_checkpoint(&provider), None); } + + #[test] + fn publishing_moves_the_covered_stages_and_prunes_the_history_below_the_pivot() { + let factory = hashed_factory(); + let provider = factory.database_provider_rw().unwrap(); + + provider.publish_snap_state(7).unwrap(); + + // Every stage must be classified, so a new one fails here until it is. + let kept = [StageId::Era, StageId::Headers, StageId::MerkleExecute, StageId::Finish]; + for stage in StageId::ALL { + let published = PUBLISHED_STAGES.contains(&stage); + assert_ne!(published, kept.contains(&stage), "{stage} must be published or kept"); + let expected = published.then(|| StageCheckpoint::new(7)); + assert_eq!(provider.get_stage_checkpoint(stage).unwrap(), expected, "{stage}"); + } + + // The history below the pivot is recorded as pruned for every segment. + for segment in PruneSegment::variants() { + let checkpoint = provider.get_prune_checkpoint(segment).unwrap(); + assert_eq!(checkpoint, Some(PruneCheckpoint::pruned_through(7)), "{segment}"); + } + } + + #[test] + fn publishing_refuses_the_genesis_pivot() { + let factory = hashed_factory(); + let provider = factory.database_provider_rw().unwrap(); + + assert!(matches!(provider.publish_snap_state(0), Err(SnapSyncError::GenesisPivot))); + } + + #[test] + fn bodies_downloaded_past_the_pivot_can_be_written_again_after_publishing() { + let factory = hashed_factory(); + insert_chain(&factory, B256::ZERO); + let body = BlockBody { + withdrawals: Some(Withdrawals::new(vec![Withdrawal::default()])), + ..Default::default() + }; + let provider = factory.database_provider_rw().unwrap(); + provider + .append_block_bodies(vec![ + (0, None), + (1, Some(&body)), + (2, Some(&body)), + (3, Some(&body)), + ]) + .unwrap(); + provider.commit().unwrap(); + + // Publish at block 1 and reset the transaction file to it, as the caller does. + let static_files = factory.static_file_provider(); + let provider = factory.database_provider_rw().unwrap(); + provider.publish_snap_state(1).unwrap(); + static_files.delete_segment(StaticFileSegment::Transactions).unwrap(); + static_files + .latest_writer(StaticFileSegment::Transactions) + .unwrap() + .ensure_at_block(1) + .unwrap(); + provider.commit().unwrap(); + + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&body))]).unwrap(); + provider.commit().unwrap(); + } + + #[test] + fn published_state_can_unwind_bodies_to_the_pivot() { + for tx_count in [0, 2] { + let factory = hashed_factory(); + insert_chain(&factory, B256::ZERO); + let provider = factory.database_provider_rw().unwrap(); + provider.publish_snap_state(1).unwrap(); + factory + .static_file_provider() + .latest_writer(StaticFileSegment::Transactions) + .unwrap() + .ensure_at_block(1) + .unwrap(); + provider.commit().unwrap(); + + let body = BlockBody { + transactions: (0..tx_count) + .map(|nonce| { + TransactionSigned::new_unhashed( + Transaction::Legacy(TxLegacy { nonce, ..Default::default() }), + Signature::test_signature(), + ) + }) + .collect(), + ..Default::default() + }; + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&body))]).unwrap(); + provider.commit().unwrap(); + + let provider = factory.database_provider_rw().unwrap(); + assert_eq!(provider.block_body_indices(1).unwrap(), None); + assert_eq!(provider.next_tx_num_after_block(1).unwrap(), 0); + assert_eq!(provider.next_tx_num_after_block(2).unwrap(), tx_count); + provider.remove_bodies_above(1).unwrap(); + provider.commit().unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.block_body_indices(1).unwrap(), None); + assert_eq!(provider.block_body_indices(2).unwrap(), None); + let static_files = factory.static_file_provider(); + assert_eq!( + static_files.get_highest_static_file_block(StaticFileSegment::Transactions), + Some(1) + ); + assert_eq!( + static_files.get_highest_static_file_tx(StaticFileSegment::Transactions), + None + ); + } + } + + #[test] + fn startup_heals_an_interrupted_first_write_after_publishing() { + // An empty block is covered too, since transaction counts alone can't see its height. + for tx_count in [0, 1] { + let factory = hashed_factory(); + insert_chain(&factory, B256::ZERO); + let static_files = factory.static_file_provider(); + let provider = factory.database_provider_rw().unwrap(); + provider.publish_snap_state(1).unwrap(); + static_files + .latest_writer(StaticFileSegment::Transactions) + .unwrap() + .ensure_at_block(1) + .unwrap(); + provider.commit().unwrap(); + + let body = BlockBody { + transactions: (0..tx_count) + .map(|nonce| { + TransactionSigned::new_unhashed( + Transaction::Legacy(TxLegacy { nonce, ..Default::default() }), + Signature::test_signature(), + ) + }) + .collect(), + ..Default::default() + }; + + // The static files commit, the database does not. + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&body))]).unwrap(); + static_files.commit().unwrap(); + drop(provider); + + let provider = factory.provider().unwrap(); + assert_eq!(static_files.check_consistency(&provider).unwrap(), None, "{tx_count}"); + assert_eq!( + static_files.get_highest_static_file_block(StaticFileSegment::Transactions), + Some(1), + "{tx_count}" + ); + drop(provider); + + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&body))]).unwrap(); + provider.commit().unwrap(); + } + } + + #[test] + fn publishing_clears_transaction_lookups_in_the_active_backend() { + for settings in [StorageSettings::v1(), StorageSettings::v2()] { + let factory = hashed_factory(); + factory.set_storage_settings_cache(settings); + let old_hash = B256::repeat_byte(0xaa); + let new_hash = B256::repeat_byte(0xbb); + let insert_lookup = |hash| { + let provider = factory.database_provider_rw().unwrap(); + provider + .with_rocksdb_batch(|batch| { + let mut writer = + EitherWriter::new_transaction_hash_numbers(&provider, batch)?; + writer.put_transaction_hash_numbers_batch(vec![(hash, 0)], false)?; + Ok(((), writer.into_raw_rocksdb_batch())) + }) + .unwrap(); + provider.commit().unwrap(); + }; + insert_lookup(old_hash); + assert_eq!( + factory.database_provider_ro().unwrap().transaction_id(old_hash).unwrap(), + Some(0) + ); + + let provider = factory.database_provider_rw().unwrap(); + provider.publish_snap_state(7).unwrap(); + provider.commit().unwrap(); + assert_eq!( + factory.database_provider_ro().unwrap().transaction_id(old_hash).unwrap(), + None + ); + + insert_lookup(new_hash); + let provider = factory.database_provider_ro().unwrap(); + assert_eq!(provider.transaction_id(old_hash).unwrap(), None); + assert_eq!(provider.transaction_id(new_hash).unwrap(), Some(0)); + } + } + + #[test] + fn publishing_resets_database_receipts_before_reusing_transaction_numbers() { + let factory = hashed_factory().with_prune_modes(PruneModes { + receipts_log_filter: ReceiptsLogPruneConfig(BTreeMap::from([( + Address::ZERO, + PruneMode::Before(0), + )])), + ..Default::default() + }); + insert_chain(&factory, B256::ZERO); + let body = BlockBody { + transactions: (0..2) + .map(|nonce| { + TransactionSigned::new_unhashed( + Transaction::Legacy(TxLegacy { nonce, ..Default::default() }), + Signature::test_signature(), + ) + }) + .collect(), + ..Default::default() + }; + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(0, None), (1, Some(&body))]).unwrap(); + { + let mut writer = EitherWriter::new_receipts(&provider, 1).unwrap(); + assert!(matches!(&writer, EitherWriter::Database(_))); + writer.append_receipt(0, &Receipt::default()).unwrap(); + writer.append_receipt(1, &Receipt::default()).unwrap(); + } + provider.commit().unwrap(); + let provider = factory.database_provider_rw().unwrap(); + provider.anchor_pruned_static_files(1).unwrap(); + provider.publish_snap_state(1).unwrap(); + provider.commit().unwrap(); + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&body))]).unwrap(); + { + let mut writer = EitherWriter::new_receipts(&provider, 2).unwrap(); + writer.append_receipt(0, &Receipt::default()).unwrap(); + writer.append_receipt(1, &Receipt::default()).unwrap(); + } + provider.commit().unwrap(); + } } diff --git a/crates/stages/api/src/error.rs b/crates/stages/api/src/error.rs index fab6e849d0e..f4ec811fa72 100644 --- a/crates/stages/api/src/error.rs +++ b/crates/stages/api/src/error.rs @@ -6,6 +6,7 @@ use reth_errors::{BlockExecutionError, DatabaseError, RethError}; use reth_network_p2p::error::DownloadError; use reth_provider::ProviderError; use reth_prune::{PruneSegment, PruneSegmentError, PrunerError, UnwindTargetPrunedError}; +use reth_stages_types::StageId; use reth_static_file_types::StaticFileSegment; use thiserror::Error; use tokio::sync::broadcast::error::SendError; @@ -173,4 +174,7 @@ pub enum PipelineError { /// Unwind target pruned error. #[error(transparent)] UnwindTargetPruned(#[from] UnwindTargetPrunedError), + /// A partial run named a stage this pipeline does not contain. + #[error("pipeline does not contain stage {0}")] + MissingStage(StageId), } diff --git a/crates/stages/api/src/pipeline/mod.rs b/crates/stages/api/src/pipeline/mod.rs index 7ae0ecb9ac8..72d65de6dc7 100644 --- a/crates/stages/api/src/pipeline/mod.rs +++ b/crates/stages/api/src/pipeline/mod.rs @@ -9,7 +9,8 @@ use reth_primitives_traits::constants::BEACON_CONSENSUS_REORG_UNWIND_DEPTH; use reth_provider::{ providers::ProviderNodeTypes, BlockHashReader, BlockNumReader, ChainStateBlockReader, ChainStateBlockWriter, DBProvider, DatabaseProviderFactory, ProviderFactory, - PruneCheckpointReader, StageCheckpointReader, StageCheckpointWriter, StorageSettingsCache, + PruneCheckpointReader, StageCheckpointReader, StageCheckpointWriter, StaticFileProviderFactory, + StorageSettingsCache, }; use reth_prune::PrunerBuilder; use reth_static_file::StaticFileProducer; @@ -221,6 +222,49 @@ impl Pipeline { /// the pipeline (for example the `Finish` stage). Or [`ControlFlow::Unwind`] of the stage /// that caused the unwind. pub async fn run_loop(&mut self) -> Result { + self.run_loop_until(None).await + } + + /// Runs one pipeline pass through `last_stage`, including that stage. + /// + /// Each stage in the selected prefix runs to completion; later stages are not executed. + /// A [`PipelineTarget::Sync`] updates the tip before running the prefix, while a + /// [`PipelineTarget::Unwind`] unwinds the entire pipeline and returns without executing stages. + /// Stage errors that request an unwind also unwind the entire pipeline. Normal static-file + /// migration and pruning still run. + /// + /// Returns the control flow of the partial pass, or [`PipelineError::MissingStage`] if + /// `last_stage` is absent from the pipeline. + pub async fn run_until( + &mut self, + last_stage: StageId, + target: Option, + ) -> Result { + let _ = self.register_metrics(); + if !self.stages.iter().any(|stage| stage.id() == last_stage) { + return Err(PipelineError::MissingStage(last_stage)) + } + + match target { + Some(PipelineTarget::Sync(tip)) => self.set_tip(tip), + // An unwind rewinds every stage, so stopping part way through has no meaning. + Some(PipelineTarget::Unwind(target)) => { + self.move_to_static_files()?; + self.unwind(target, None)?; + self.progress.update(target); + return Ok(ControlFlow::Continue { block_number: target }) + } + None => {} + } + + self.run_loop_until(Some(last_stage)).await + } + + // Shared stage loop. `last_stage` stops the pass once that stage has been executed. + async fn run_loop_until( + &mut self, + last_stage: Option, + ) -> Result { self.move_to_static_files()?; let mut previous_stage = None; @@ -253,6 +297,10 @@ impl Pipeline { .unwrap_or_default() .block_number, ); + + if last_stage == Some(stage_id) { + break + } } Ok(self.progress.next_ctrl()) @@ -574,7 +622,7 @@ impl Pipeline { .saturating_sub( BEACON_CONSENSUS_REORG_UNWIND_DEPTH.saturating_mul(self.detached_head_attempts), ) - .max(1); + .max(self.provider_factory.static_file_provider().genesis_block_number()); self.last_detached_head_unwind_target = self.provider_factory.block_hash(unwind_to)?; Ok(Some(ControlFlow::Unwind { target: unwind_to, bad_block: local_head })) @@ -668,10 +716,14 @@ mod tests { use super::*; use crate::{test_utils::TestStage, UnwindOutput}; + use alloy_eips::{eip1898::BlockWithParent, BlockNumHash}; use assert_matches::assert_matches; use reth_consensus::ConsensusError; use reth_errors::ProviderError; - use reth_provider::test_utils::{create_test_provider_factory, MockNodeTypesWithDB}; + use reth_provider::test_utils::{ + create_test_provider_factory, create_test_provider_factory_with_genesis_block_number, + MockNodeTypesWithDB, + }; use reth_prune::PruneModes; use reth_testing_utils::generators::{self, random_block_with_parent}; use tokio_stream::StreamExt; @@ -703,18 +755,63 @@ mod tests { assert_eq!(progress.next_ctrl(), ControlFlow::Continue { block_number: 1 }); } + /// Runs only the requested prefix of the pipeline. + #[tokio::test] + async fn run_until_stops_after_the_named_stage() { + let provider_factory = create_test_provider_factory(); + + let mut pipeline = Pipeline::::builder() + .add_stage( + TestStage::new(StageId::Other("A")) + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))), + ) + // No execution is queued for B, so running it would panic on the empty script. + .add_stage(TestStage::new(StageId::Other("B"))) + .with_max_block(10) + .build( + provider_factory.clone(), + StaticFileProducer::new(provider_factory.clone(), PruneModes::default()), + ); + + pipeline.run_until(StageId::Other("A"), None).await.unwrap(); + + let provider = provider_factory.provider().unwrap(); + assert_eq!( + provider.get_stage_checkpoint(StageId::Other("A")).unwrap(), + Some(StageCheckpoint::new(10)) + ); + assert_eq!(provider.get_stage_checkpoint(StageId::Other("B")).unwrap(), None); + } + + /// A prefix run naming a stage the pipeline does not have is an error, not a full run. + #[tokio::test] + async fn run_until_rejects_an_unknown_stage() { + let provider_factory = create_test_provider_factory(); + + let mut pipeline = Pipeline::::builder() + .add_stage(TestStage::new(StageId::Other("A"))) + .build( + provider_factory.clone(), + StaticFileProducer::new(provider_factory.clone(), PruneModes::default()), + ); + + let result = pipeline.run_until(StageId::Other("Z"), None).await; + + assert_matches!(result, Err(PipelineError::MissingStage(StageId::Other("Z")))); + } + /// Runs a simple pipeline. #[tokio::test] async fn run_pipeline() { let provider_factory = create_test_provider_factory(); let stage_a = TestStage::new(StageId::Other("A")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(20), done: true })); + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(20)))); let (stage_a, post_execute_commit_counter_a) = stage_a.with_post_execute_commit_counter(); let (stage_a, post_unwind_commit_counter_a) = stage_a.with_post_unwind_commit_counter(); let stage_b = TestStage::new(StageId::Other("B")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })); + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))); let (stage_b, post_execute_commit_counter_b) = stage_b.with_post_execute_commit_counter(); let (stage_b, post_unwind_commit_counter_b) = stage_b.with_post_unwind_commit_counter(); @@ -752,7 +849,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 1, total: 2 }, stage_id: StageId::Other("A"), - result: ExecOutput { checkpoint: StageCheckpoint::new(20), done: true }, + result: ExecOutput::done(StageCheckpoint::new(20)), }, PipelineEvent::Prepare { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, @@ -769,7 +866,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, stage_id: StageId::Other("B"), - result: ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }, + result: ExecOutput::done(StageCheckpoint::new(10)), }, ] ); @@ -787,19 +884,19 @@ mod tests { let provider_factory = create_test_provider_factory(); let stage_a = TestStage::new(StageId::Other("A")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(100), done: true })) + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(100)))) .add_unwind(Ok(UnwindOutput { checkpoint: StageCheckpoint::new(1) })); let (stage_a, post_execute_commit_counter_a) = stage_a.with_post_execute_commit_counter(); let (stage_a, post_unwind_commit_counter_a) = stage_a.with_post_unwind_commit_counter(); let stage_b = TestStage::new(StageId::Other("B")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })) + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))) .add_unwind(Ok(UnwindOutput { checkpoint: StageCheckpoint::new(1) })); let (stage_b, post_execute_commit_counter_b) = stage_b.with_post_execute_commit_counter(); let (stage_b, post_unwind_commit_counter_b) = stage_b.with_post_unwind_commit_counter(); let stage_c = TestStage::new(StageId::Other("C")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(20), done: true })) + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(20)))) .add_unwind(Ok(UnwindOutput { checkpoint: StageCheckpoint::new(1) })); let (stage_c, post_execute_commit_counter_c) = stage_c.with_post_execute_commit_counter(); let (stage_c, post_unwind_commit_counter_c) = stage_c.with_post_unwind_commit_counter(); @@ -844,7 +941,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 1, total: 3 }, stage_id: StageId::Other("A"), - result: ExecOutput { checkpoint: StageCheckpoint::new(100), done: true }, + result: ExecOutput::done(StageCheckpoint::new(100)), }, PipelineEvent::Prepare { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 3 }, @@ -861,7 +958,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 3 }, stage_id: StageId::Other("B"), - result: ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }, + result: ExecOutput::done(StageCheckpoint::new(10)), }, PipelineEvent::Prepare { pipeline_stages_progress: PipelineStagesProgress { current: 3, total: 3 }, @@ -878,7 +975,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 3, total: 3 }, stage_id: StageId::Other("C"), - result: ExecOutput { checkpoint: StageCheckpoint::new(20), done: true }, + result: ExecOutput::done(StageCheckpoint::new(20)), }, // Unwinding PipelineEvent::Unwind { @@ -938,12 +1035,12 @@ mod tests { let mut pipeline = Pipeline::::builder() .add_stage( TestStage::new(StageId::Other("A")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(100), done: true })) + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(100)))) .add_unwind(Ok(UnwindOutput { checkpoint: StageCheckpoint::new(50) })), ) .add_stage( TestStage::new(StageId::Other("B")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })), + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))), ) .with_max_block(10) .build( @@ -981,7 +1078,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 1, total: 2 }, stage_id: StageId::Other("A"), - result: ExecOutput { checkpoint: StageCheckpoint::new(100), done: true }, + result: ExecOutput::done(StageCheckpoint::new(100)), }, PipelineEvent::Prepare { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, @@ -998,7 +1095,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, stage_id: StageId::Other("B"), - result: ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }, + result: ExecOutput::done(StageCheckpoint::new(10)), }, // Unwinding // Nothing to unwind in stage "B" @@ -1038,9 +1135,9 @@ mod tests { let mut pipeline = Pipeline::::builder() .add_stage( TestStage::new(StageId::Other("A")) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })) + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))) .add_unwind(Ok(UnwindOutput { checkpoint: StageCheckpoint::new(0) })) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })), + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))), ) .add_stage( TestStage::new(StageId::Other("B")) @@ -1053,7 +1150,7 @@ mod tests { error: BlockErrorKind::Validation(ConsensusError::BaseFeeMissing), })) .add_unwind(Ok(UnwindOutput { checkpoint: StageCheckpoint::new(0) })) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })), + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))), ) .with_max_block(10) .build( @@ -1086,7 +1183,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 1, total: 2 }, stage_id: StageId::Other("A"), - result: ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }, + result: ExecOutput::done(StageCheckpoint::new(10)), }, PipelineEvent::Prepare { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, @@ -1128,7 +1225,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 1, total: 2 }, stage_id: StageId::Other("A"), - result: ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }, + result: ExecOutput::done(StageCheckpoint::new(10)), }, PipelineEvent::Prepare { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, @@ -1145,7 +1242,7 @@ mod tests { PipelineEvent::Ran { pipeline_stages_progress: PipelineStagesProgress { current: 2, total: 2 }, stage_id: StageId::Other("B"), - result: ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }, + result: ExecOutput::done(StageCheckpoint::new(10)), }, ] ); @@ -1160,7 +1257,7 @@ mod tests { .add_stage( TestStage::new(StageId::Other("NonFatal")) .add_exec(Err(StageError::Recoverable(Box::new(std::fmt::Error)))) - .add_exec(Ok(ExecOutput { checkpoint: StageCheckpoint::new(10), done: true })), + .add_exec(Ok(ExecOutput::done(StageCheckpoint::new(10)))), ) .with_max_block(10) .build( @@ -1188,4 +1285,31 @@ mod tests { ))) ); } + + #[test] + fn detached_head_unwinds_no_lower_than_genesis() { + for genesis in [0, 5] { + let factory = create_test_provider_factory_with_genesis_block_number(genesis); + let mut pipeline = Pipeline::::builder() + .build(factory.clone(), StaticFileProducer::new(factory, PruneModes::default())); + let block = |number| { + Box::new(BlockWithParent { + parent: B256::ZERO, + block: BlockNumHash::new(number, B256::repeat_byte(1)), + }) + }; + let error = StageError::DetachedHead { + local_head: block(genesis + 1), + header: block(genesis + 2), + error: Box::new(ConsensusError::BaseFeeMissing), + }; + + let flow = pipeline.on_stage_error(StageId::Headers, None, error).unwrap(); + + assert_eq!( + flow, + Some(ControlFlow::Unwind { target: genesis, bad_block: block(genesis + 1) }) + ); + } + } } diff --git a/crates/stages/stages/src/stages/bodies.rs b/crates/stages/stages/src/stages/bodies.rs index 649b48b86e5..ad9b366d811 100644 --- a/crates/stages/stages/src/stages/bodies.rs +++ b/crates/stages/stages/src/stages/bodies.rs @@ -7,8 +7,8 @@ use reth_db_api::{ }; use reth_network_p2p::bodies::{downloader::BodyDownloader, response::BlockResponse}; use reth_provider::{ - providers::StaticFileWriter, BlockReader, BlockWriter, DBProvider, ProviderError, - StaticFileProviderFactory, StatsReader, + providers::StaticFileWriter, BlockReader, BlockWriter, DBProvider, StaticFileProviderFactory, + StatsReader, }; use reth_stages_api::{ EntitiesCheckpoint, ExecInput, ExecOutput, Stage, StageCheckpoint, StageError, StageId, @@ -70,6 +70,7 @@ impl BodyStage { /// Ensures that static files and database are in sync. pub(crate) fn ensure_consistency( provider: &Provider, + checkpoint: u64, unwind_block: Option, ) -> Result<(), StageError> where @@ -96,15 +97,28 @@ where // If static files are ahead, we are currently unwinding the stage or we didn't reach // the database commit in a previous stage run. So, our only solution is to unwind the // static files and proceed from the database expected height. - Ordering::Greater => { - let highest_db_block = provider.tx_ref().entries::()? as u64; - let mut static_file_producer = - static_file_provider.latest_writer(StaticFileSegment::Transactions)?; - static_file_producer - .prune_transactions(next_static_file_tx_num - next_tx_num, highest_db_block)?; - // Since this is a database <-> static file inconsistency, we commit the change - // straight away. - static_file_producer.commit()?; + Ordering::Greater | Ordering::Equal => { + let highest_db_block = provider + .tx_ref() + .cursor_read::()? + .last()? + .map(|(block, _)| block) + // Nothing is stored when the bodies up to the checkpoint were never downloaded. + .unwrap_or(checkpoint); + // Empty blocks advance the file's height without changing its transaction count. + if next_static_file_tx_num > next_tx_num || + static_file_provider + .get_highest_static_file_block(StaticFileSegment::Transactions) + .is_some_and(|block| block > highest_db_block) + { + let mut static_file_producer = + static_file_provider.latest_writer(StaticFileSegment::Transactions)?; + static_file_producer + .prune_transactions(next_static_file_tx_num - next_tx_num, highest_db_block)?; + // Since this is a database <-> static file inconsistency, we commit the change + // straight away. + static_file_producer.commit()?; + } } // If static files are behind, then there was some corruption or loss of files. This // error will trigger an unwind, that will bring the database to the same height as the @@ -113,10 +127,7 @@ where // If we are already in the process of unwind, this might be fine because we will // fix the inconsistency right away. if let Some(unwind_to) = unwind_block { - let next_tx_num_after_unwind = provider - .block_body_indices(unwind_to)? - .map(|b| b.next_tx_num()) - .ok_or(ProviderError::BlockBodyIndicesNotFound(unwind_to))?; + let next_tx_num_after_unwind = provider.next_tx_num_after_block(unwind_to)?; // This means we need a deeper unwind. if next_tx_num_after_unwind > next_static_file_tx_num { @@ -136,7 +147,6 @@ where )?) } } - Ordering::Equal => {} } Ok(()) @@ -192,7 +202,7 @@ where } let (from_block, to_block) = input.next_block_range().into_inner(); - ensure_consistency(provider, None)?; + ensure_consistency(provider, input.checkpoint().block_number, None)?; debug!(target: "sync::stages::bodies", stage_progress = from_block, target = to_block, "Commencing sync"); @@ -224,7 +234,7 @@ where ) -> Result { self.buffer.take(); - ensure_consistency(provider, Some(input.unwind_to))?; + ensure_consistency(provider, input.checkpoint.block_number, Some(input.unwind_to))?; provider.remove_bodies_above(input.unwind_to)?; Ok(UnwindOutput { @@ -254,10 +264,14 @@ where mod tests { use super::*; use crate::test_utils::{ - stage_test_suite_ext, ExecuteStageTestRunner, StageTestRunner, UnwindStageTestRunner, + stage_test_suite_ext, ExecuteStageTestRunner, StageTestRunner, TestStageDB, + UnwindStageTestRunner, }; + use alloy_consensus::TxLegacy; + use alloy_primitives::Signature; use assert_matches::assert_matches; - use reth_provider::StaticFileProviderFactory; + use reth_ethereum_primitives::{BlockBody, Transaction, TransactionSigned}; + use reth_provider::{DatabaseProviderFactory, StaticFileProviderFactory}; use reth_stages_api::StageUnitCheckpoint; use test_utils::*; @@ -467,6 +481,87 @@ mod tests { assert_matches!(runner.validate_unwind(input), Ok(_), "unwind validation"); } + #[test] + fn ensure_consistency_heals_to_the_highest_db_block() { + for empty in [false, true] { + let db = TestStageDB::default(); + let factory = &db.factory; + let static_files = factory.static_file_provider(); + let body = |nonce| BlockBody { + transactions: vec![TransactionSigned::new_unhashed( + Transaction::Legacy(TxLegacy { nonce, ..Default::default() }), + Signature::test_signature(), + )], + ..Default::default() + }; + + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(0, None), (1, Some(&body(0)))]).unwrap(); + provider.commit().unwrap(); + + // The static files commit block 2, the database does not. + let interrupted_body = if empty { BlockBody::default() } else { body(1) }; + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&interrupted_body))]).unwrap(); + static_files.commit().unwrap(); + drop(provider); + + // Bodies stage healing alone, without startup recovery. + let provider = factory.database_provider_rw().unwrap(); + ensure_consistency(&provider, 1, None).unwrap(); + provider.commit().unwrap(); + assert_eq!( + static_files.get_highest_static_file_block(StaticFileSegment::Transactions), + Some(1) + ); + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(2, Some(&interrupted_body))]).unwrap(); + } + } + + #[test] + fn ensure_consistency_heals_to_the_checkpoint_without_stored_bodies() { + const CHECKPOINT: u64 = 10; + for empty in [false, true] { + let db = TestStageDB::default(); + let factory = &db.factory; + let static_files = factory.static_file_provider(); + let body = BlockBody { + transactions: if empty { + Vec::new() + } else { + vec![TransactionSigned::new_unhashed( + Transaction::Legacy(TxLegacy::default()), + Signature::test_signature(), + )] + }, + ..Default::default() + }; + + // The transaction files start after the checkpoint, no body is stored up to it. + static_files + .latest_writer(StaticFileSegment::Transactions) + .unwrap() + .ensure_at_block(CHECKPOINT) + .unwrap(); + static_files.commit().unwrap(); + + // The static files commit the next block, the database does not. + let provider = factory.database_provider_rw().unwrap(); + provider.append_block_bodies(vec![(CHECKPOINT + 1, Some(&body))]).unwrap(); + static_files.commit().unwrap(); + drop(provider); + + let provider = factory.database_provider_rw().unwrap(); + ensure_consistency(&provider, CHECKPOINT, None).unwrap(); + assert_eq!( + static_files.get_highest_static_file_block(StaticFileSegment::Transactions), + Some(CHECKPOINT) + ); + provider.append_block_bodies(vec![(CHECKPOINT + 1, Some(&body))]).unwrap(); + } + } + mod test_utils { use crate::{ stages::bodies::BodyStage, diff --git a/crates/stages/stages/src/stages/execution/mod.rs b/crates/stages/stages/src/stages/execution/mod.rs index 5742c1d05ba..a97fc001b59 100644 --- a/crates/stages/stages/src/stages/execution/mod.rs +++ b/crates/stages/stages/src/stages/execution/mod.rs @@ -13,10 +13,10 @@ use reth_exex::{ExExManagerHandle, ExExNotification, ExExNotificationSource}; use reth_primitives_traits::{format_gas_throughput, BlockBody, NodePrimitives}; use reth_provider::{ providers::{StaticFileProvider, StaticFileWriter}, - BlockHashReader, BlockReader, DBProvider, EitherWriter, ExecutionOutcome, - HashedPostStateProvider, HeaderProvider, LatestStateProviderRef, OriginalValuesKnown, - ProviderError, StateProvider, StateWriteConfig, StateWriter, StaticFileProviderFactory, - StatsReader, StoragePath, StorageSettingsCache, TransactionVariant, + BlockReader, DBProvider, EitherWriter, ExecutionOutcome, HashedPostStateProvider, + HeaderProvider, LatestStateProviderRef, OriginalValuesKnown, ProviderError, StateProvider, + StateWriteConfig, StateWriter, StaticFileProviderFactory, StatsReader, StoragePath, + StorageSettingsCache, TransactionVariant, }; use reth_revm::database::StateProviderDatabase; use reth_stages_api::{ @@ -193,11 +193,7 @@ where unwind_to: Option, ) -> Result<(), StageError> where - Provider: StaticFileProviderFactory - + DBProvider - + BlockReader - + HeaderProvider - + StorageSettingsCache, + Provider: StaticFileProviderFactory + DBProvider + BlockReader + StorageSettingsCache, { // On old nodes, if there's any receipts pruning configured, receipts are written directly // to database and inconsistencies are expected. @@ -272,7 +268,6 @@ where > + StaticFileProviderFactory< Primitives: NodePrimitives, > + StatsReader - + BlockHashReader + StateWriter::Receipt> + StorageSettingsCache + StoragePath @@ -1050,7 +1045,7 @@ mod tests { db_tx .put::(acc2, Account { balance, ..Default::default() }) .unwrap(); - db_tx.put::(code_hash, Bytecode::new_raw(code.to_vec().into())).unwrap(); + db_tx.put::(code_hash, Bytecode::new_raw(code.into())).unwrap(); provider.commit().unwrap(); // execute @@ -1189,7 +1184,7 @@ mod tests { db_tx.put::(acc1, acc1_info.clone()).unwrap(); db_tx.put::(acc2, acc2_info.clone()).unwrap(); - db_tx.put::(code_hash, Bytecode::new_raw(code.to_vec().into())).unwrap(); + db_tx.put::(code_hash, Bytecode::new_raw(code.into())).unwrap(); provider.commit().unwrap(); // execute @@ -1379,7 +1374,7 @@ mod tests { .unwrap(); provider .tx_ref() - .put::(code_hash, Bytecode::new_raw(code.to_vec().into())) + .put::(code_hash, Bytecode::new_raw(code.into())) .unwrap(); // set storage to check when account gets destroyed. provider @@ -1393,7 +1388,7 @@ mod tests { .tx_ref() .put::( destroyed_address, - StorageEntry { key: B256::with_last_byte(1), value: U256::from(1u64) }, + StorageEntry { key: B256::with_last_byte(1), value: U256::ONE }, ) .unwrap(); @@ -1464,7 +1459,7 @@ mod tests { ), ( (block.number, destroyed_address).into(), - StorageEntry { key: B256::with_last_byte(1), value: U256::from(1u64) } + StorageEntry { key: B256::with_last_byte(1), value: U256::ONE } ) ] ); diff --git a/crates/stages/stages/src/stages/execution/slot_preimages.rs b/crates/stages/stages/src/stages/execution/slot_preimages.rs index 2bdd776dd9f..c312b4fd8e5 100644 --- a/crates/stages/stages/src/stages/execution/slot_preimages.rs +++ b/crates/stages/stages/src/stages/execution/slot_preimages.rs @@ -140,7 +140,7 @@ pub(super) fn inject_plain_wipe_slots( let mut seen_hashes = HashSet::new(); for account in state.bundle.state().values() { for &slot_key in account.storage.keys() { - let plain = B256::from(slot_key.to_be_bytes()); + let plain = B256::from(slot_key); let hashed = keccak256(plain); if seen_hashes.insert(hashed) { preimage_entries.push((hashed, plain)); @@ -150,7 +150,7 @@ pub(super) fn inject_plain_wipe_slots( for block_reverts in state.bundle.reverts.iter() { for (_, revert) in block_reverts { for &slot_key in revert.storage.keys() { - let plain = B256::from(slot_key.to_be_bytes()); + let plain = B256::from(slot_key); let hashed = keccak256(plain); if seen_hashes.insert(hashed) { preimage_entries.push((hashed, plain)); diff --git a/crates/stages/stages/src/stages/finish.rs b/crates/stages/stages/src/stages/finish.rs index 8d676c35b99..fc0a13dcbf1 100644 --- a/crates/stages/stages/src/stages/finish.rs +++ b/crates/stages/stages/src/stages/finish.rs @@ -20,7 +20,7 @@ impl Stage for FinishStage { _provider: &Provider, input: ExecInput, ) -> Result { - Ok(ExecOutput { checkpoint: StageCheckpoint::new(input.target()), done: true }) + Ok(ExecOutput::done(StageCheckpoint::new(input.target()))) } fn unwind( diff --git a/crates/stages/stages/src/stages/hashing_account.rs b/crates/stages/stages/src/stages/hashing_account.rs index d1d350cb35a..4c5a525d7a4 100644 --- a/crates/stages/stages/src/stages/hashing_account.rs +++ b/crates/stages/stages/src/stages/hashing_account.rs @@ -117,8 +117,8 @@ impl AccountHashingStage { let Account { nonce, balance, .. } = acc; let prev_acc = Account { nonce: nonce - 1, - balance: balance - U256::from(1), - bytecode_hash: None, + balance: balance - U256::ONE, + ..Default::default() }; let acc_before_tx = AccountBeforeTx { address: *addr, info: Some(prev_acc) }; acc_changeset_cursor.append(t, &acc_before_tx)?; @@ -235,7 +235,7 @@ where ..Default::default() }); - Ok(ExecOutput { checkpoint, done: true }) + Ok(ExecOutput::done(checkpoint)) } else { // Stream changesets entry-by-entry, bounded by both block count // (commit_threshold) and entry count (commit_entries), whichever comes first. @@ -462,7 +462,7 @@ mod tests { let Account { nonce, balance, .. } = account; let old_acc = Account { nonce: nonce - 1, - balance: balance - U256::from(1), + balance: balance - U256::ONE, ..Default::default() }; let hashed_addr = keccak256(address); diff --git a/crates/stages/stages/src/stages/hashing_storage.rs b/crates/stages/stages/src/stages/hashing_storage.rs index e2680636917..eb415132c17 100644 --- a/crates/stages/stages/src/stages/hashing_storage.rs +++ b/crates/stages/stages/src/stages/hashing_storage.rs @@ -175,7 +175,7 @@ where ..Default::default() }); - Ok(ExecOutput { checkpoint, done: true }) + Ok(ExecOutput::done(checkpoint)) } else { // Stream changesets entry-by-entry, bounded by both block count // (commit_threshold) and entry count (commit_entries), whichever comes first. @@ -557,7 +557,7 @@ mod tests { .expect("failed to delete entry"); e } - _ => StorageEntry { key: entry.key, value: U256::from(0) }, + _ => StorageEntry { key: entry.key, value: U256::ZERO }, }; tx.put::(bn_address.address(), entry)?; diff --git a/crates/stages/stages/src/stages/headers.rs b/crates/stages/stages/src/stages/headers.rs index a128d82c671..7beda91e6c3 100644 --- a/crates/stages/stages/src/stages/headers.rs +++ b/crates/stages/stages/src/stages/headers.rs @@ -299,8 +299,10 @@ where self.hash_collector.clear(); self.header_collector.clear(); - Ok(ExecOutput { - checkpoint: StageCheckpoint::new(last_header_number).with_headers_stage_checkpoint( + // We only reach here if all headers have been downloaded by ETL, and pushed to DB all + // in one stage run. + Ok(ExecOutput::done( + StageCheckpoint::new(last_header_number).with_headers_stage_checkpoint( HeadersCheckpoint { block_range: CheckpointBlockRange { from: input.checkpoint().block_number, @@ -312,10 +314,7 @@ where }, }, ), - // We only reach here if all headers have been downloaded by ETL, and pushed to DB all - // in one stage run. - done: true, - }) + )) } /// Unwind the stage. diff --git a/crates/stages/stages/src/stages/index_account_history.rs b/crates/stages/stages/src/stages/index_account_history.rs index 617be0a5ee7..03686d2fa9e 100644 --- a/crates/stages/stages/src/stages/index_account_history.rs +++ b/crates/stages/stages/src/stages/index_account_history.rs @@ -66,6 +66,12 @@ where provider: &Provider, mut input: ExecInput, ) -> Result { + // Read the durable checkpoint before the prune target advances it. Otherwise a table that + // must be rebuilt from empty takes the incremental path and reads a missing last shard for + // every key. + let first_sync = input.checkpoint().block_number == 0; + let mut prune_target_applied = false; + if let Some((target_prunable_block, prune_mode)) = self .prune_mode .map(|mode| { @@ -79,6 +85,7 @@ where .flatten() && target_prunable_block > input.checkpoint().block_number { + prune_target_applied = true; input.checkpoint = Some(StageCheckpoint::new(target_prunable_block)); // Save prune checkpoint only if we don't have one already. @@ -100,7 +107,6 @@ where } let mut range = input.next_block_range(); - let first_sync = input.checkpoint().block_number == 0; let use_rocksdb = provider.cached_storage_settings().storage_v2; // On first sync we might have history coming from genesis. We clear the table since it's @@ -115,7 +121,10 @@ where } else { provider.tx_ref().clear::()?; } - range = 0..=*input.next_block_range().end(); + // Pruned history starts after the prune target, otherwise include genesis. + if !prune_target_applied { + range = 0..=*range.end(); + } } info!(target: "sync::stages::index_account_history::exec", ?first_sync, ?use_rocksdb, "Collecting indices"); @@ -147,7 +156,7 @@ where provider.rocksdb_provider().flush(&[Tables::AccountsHistory.name()])?; } - Ok(ExecOutput { checkpoint: StageCheckpoint::new(*range.end()), done: true }) + Ok(ExecOutput::done(StageCheckpoint::new(*range.end()))) } /// Unwind the stage. @@ -168,12 +177,13 @@ where #[cfg(test)] mod tests { + use super::*; use crate::test_utils::{ stage_test_suite_ext, ExecuteStageTestRunner, StageTestRunner, TestRunnerError, TestStageDB, UnwindStageTestRunner, }; - use alloy_primitives::{address, Address, BlockNumber, B256}; + use alloy_primitives::{Address, BlockNumber, B256}; use itertools::Itertools; use reth_db_api::{ cursor::DbCursorRO, @@ -191,7 +201,7 @@ mod tests { }; use std::collections::BTreeMap; - const ADDRESS: Address = address!("0x0000000000000000000000000000000000000001"); + const ADDRESS: Address = Address::with_last_byte(1); const LAST_BLOCK_IN_FULL_SHARD: BlockNumber = NUM_OF_INDICES_IN_SHARD as BlockNumber; const MAX_BLOCK: BlockNumber = NUM_OF_INDICES_IN_SHARD as BlockNumber + 2; @@ -246,7 +256,7 @@ mod tests { let mut stage = IndexAccountHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(run_to), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(run_to))); provider.commit().unwrap(); } @@ -494,6 +504,7 @@ mod tests { tx.put::(20, acc()).unwrap(); tx.put::(36, acc()).unwrap(); tx.put::(100, acc()).unwrap(); + tx.put::(shard(u64::MAX), list(&[1])).unwrap(); Ok(()) }) .unwrap(); @@ -506,7 +517,7 @@ mod tests { }; let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(20000), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(20000))); provider.commit().unwrap(); // verify @@ -597,10 +608,7 @@ mod tests { return Ok(()) } - assert_eq!( - output, - ExecOutput { checkpoint: StageCheckpoint::new(input.target()), done: true } - ); + assert_eq!(output, ExecOutput::done(StageCheckpoint::new(input.target()))); let provider = self.db.factory.provider()?; let mut changeset_cursor = @@ -704,7 +712,7 @@ mod tests { let mut stage = IndexAccountHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(10))); provider.commit().unwrap(); // Verify MDBX table is empty (data should be in RocksDB) @@ -724,6 +732,46 @@ mod tests { assert_eq!(blocks, (0..=10).collect::>()); } + #[tokio::test] + async fn prune_rebuild_retries_after_checkpoint_rollback() { + let db = TestStageDB::default(); + setup_v2_account_data(&db, 0..=10); + let rocksdb = db.factory.rocksdb_provider(); + rocksdb.put::(shard(u64::MAX), &list(&[1, 2, 3])).unwrap(); + let input = || ExecInput { target: Some(20_000), checkpoint: None }; + let mut stage = IndexAccountHistoryStage { + prune_mode: Some(PruneMode::Before(6)), + ..Default::default() + }; + + let provider = db.factory.database_provider_rw().unwrap(); + let output = stage.execute(&provider, input()).unwrap(); + assert_eq!(output, ExecOutput::done(StageCheckpoint::new(20_000))); + drop(provider); + + let result = rocksdb.get::(shard(u64::MAX)).unwrap().unwrap(); + assert_eq!(result.iter().collect::>(), (6..=10).collect::>()); + let provider = db.factory.provider().unwrap(); + assert!(provider.get_prune_checkpoint(PruneSegment::AccountHistory).unwrap().is_none()); + drop(provider); + + let provider = db.factory.database_provider_rw().unwrap(); + let output = stage.execute(&provider, input()).unwrap(); + assert_eq!(output, ExecOutput::done(StageCheckpoint::new(20_000))); + provider.commit().unwrap(); + let result = rocksdb.get::(shard(u64::MAX)).unwrap().unwrap(); + assert_eq!(result.iter().collect::>(), (6..=10).collect::>()); + let provider = db.factory.provider().unwrap(); + assert_eq!( + provider + .get_prune_checkpoint(PruneSegment::AccountHistory) + .unwrap() + .unwrap() + .block_number, + Some(5) + ); + } + /// Test that unwind works correctly when `account_history_in_rocksdb` is enabled. #[tokio::test] async fn unwind_works_when_rocksdb_enabled() { @@ -734,7 +782,7 @@ mod tests { let mut stage = IndexAccountHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(10))); provider.commit().unwrap(); // Verify RocksDB has blocks 0-10 before unwind @@ -770,7 +818,7 @@ mod tests { let mut stage = IndexAccountHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(5), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(5))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); @@ -782,7 +830,7 @@ mod tests { let input = ExecInput { target: Some(10), checkpoint: Some(StageCheckpoint::new(5)) }; let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(10))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); diff --git a/crates/stages/stages/src/stages/index_storage_history.rs b/crates/stages/stages/src/stages/index_storage_history.rs index 182bc1c96f5..c2c0983606c 100644 --- a/crates/stages/stages/src/stages/index_storage_history.rs +++ b/crates/stages/stages/src/stages/index_storage_history.rs @@ -57,8 +57,7 @@ where + StorageSettingsCache + RocksDBProviderFactory + StorageChangeSetReader - + StaticFileProviderFactory - + reth_provider::NodePrimitivesProvider, + + StaticFileProviderFactory, { /// Return the id of the stage fn id(&self) -> StageId { @@ -71,6 +70,12 @@ where provider: &Provider, mut input: ExecInput, ) -> Result { + // Read the durable checkpoint before the prune target advances it. Otherwise a table that + // must be rebuilt from empty takes the incremental path and reads a missing last shard for + // every key. + let first_sync = input.checkpoint().block_number == 0; + let mut prune_target_applied = false; + if let Some((target_prunable_block, prune_mode)) = self .prune_mode .map(|mode| { @@ -84,6 +89,7 @@ where .flatten() && target_prunable_block > input.checkpoint().block_number { + prune_target_applied = true; input.checkpoint = Some(StageCheckpoint::new(target_prunable_block)); // Save prune checkpoint only if we don't have one already. @@ -105,7 +111,6 @@ where } let mut range = input.next_block_range(); - let first_sync = input.checkpoint().block_number == 0; let use_rocksdb = provider.cached_storage_settings().storage_v2; // On first sync we might have history coming from genesis. We clear the table since it's @@ -120,7 +125,10 @@ where } else { provider.tx_ref().clear::()?; } - range = 0..=*input.next_block_range().end(); + // Pruned history starts after the prune target, otherwise include genesis. + if !prune_target_applied { + range = 0..=*range.end(); + } } info!(target: "sync::stages::index_storage_history::exec", ?first_sync, ?use_rocksdb, "Collecting indices"); @@ -152,7 +160,7 @@ where provider.rocksdb_provider().flush(&[Tables::StoragesHistory.name()])?; } - Ok(ExecOutput { checkpoint: StageCheckpoint::new(*range.end()), done: true }) + Ok(ExecOutput::done(StageCheckpoint::new(*range.end()))) } /// Unwind the stage. @@ -172,12 +180,13 @@ where #[cfg(test)] mod tests { + use super::*; use crate::test_utils::{ stage_test_suite_ext, ExecuteStageTestRunner, StageTestRunner, TestRunnerError, TestStageDB, UnwindStageTestRunner, }; - use alloy_primitives::{address, b256, Address, BlockNumber, B256, U256}; + use alloy_primitives::{Address, BlockNumber, B256, U256}; use itertools::Itertools; use reth_db_api::{ cursor::DbCursorRO, @@ -196,9 +205,8 @@ mod tests { }; use std::collections::BTreeMap; - const ADDRESS: Address = address!("0x0000000000000000000000000000000000000001"); - const STORAGE_KEY: B256 = - b256!("0x0000000000000000000000000000000000000000000000000000000000000001"); + const ADDRESS: Address = Address::with_last_byte(1); + const STORAGE_KEY: B256 = B256::with_last_byte(1); const LAST_BLOCK_IN_FULL_SHARD: BlockNumber = NUM_OF_INDICES_IN_SHARD as BlockNumber; const MAX_BLOCK: BlockNumber = NUM_OF_INDICES_IN_SHARD as BlockNumber + 2; @@ -264,7 +272,7 @@ mod tests { let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(run_to), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(run_to))); provider.commit().unwrap(); } @@ -515,6 +523,7 @@ mod tests { .unwrap(); tx.put::(block_number_address(100), storage(STORAGE_KEY)) .unwrap(); + tx.put::(shard(u64::MAX), list(&[1])).unwrap(); Ok(()) }) .unwrap(); @@ -527,7 +536,7 @@ mod tests { }; let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(20000), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(20000))); provider.commit().unwrap(); // verify @@ -618,10 +627,7 @@ mod tests { return Ok(()) } - assert_eq!( - output, - ExecOutput { checkpoint: StageCheckpoint::new(input.target()), done: true } - ); + assert_eq!(output, ExecOutput::done(StageCheckpoint::new(input.target()))); let provider = self.db.factory.provider()?; let mut changeset_cursor = @@ -741,7 +747,7 @@ mod tests { let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(10))); provider.commit().unwrap(); let mdbx_table = db.table::().unwrap(); @@ -759,6 +765,46 @@ mod tests { assert_eq!(blocks, (0..=10).collect::>()); } + #[tokio::test] + async fn prune_rebuild_retries_after_checkpoint_rollback() { + let db = TestStageDB::default(); + setup_v2_storage_data(&db, 0..=10); + let rocksdb = db.factory.rocksdb_provider(); + rocksdb.put::(shard(u64::MAX), &list(&[1, 2, 3])).unwrap(); + let input = || ExecInput { target: Some(20_000), checkpoint: None }; + let mut stage = IndexStorageHistoryStage { + prune_mode: Some(PruneMode::Before(6)), + ..Default::default() + }; + + let provider = db.factory.database_provider_rw().unwrap(); + let output = stage.execute(&provider, input()).unwrap(); + assert_eq!(output, ExecOutput::done(StageCheckpoint::new(20_000))); + drop(provider); + + let result = rocksdb.get::(shard(u64::MAX)).unwrap().unwrap(); + assert_eq!(result.iter().collect::>(), (6..=10).collect::>()); + let provider = db.factory.provider().unwrap(); + assert!(provider.get_prune_checkpoint(PruneSegment::StorageHistory).unwrap().is_none()); + drop(provider); + + let provider = db.factory.database_provider_rw().unwrap(); + let output = stage.execute(&provider, input()).unwrap(); + assert_eq!(output, ExecOutput::done(StageCheckpoint::new(20_000))); + provider.commit().unwrap(); + let result = rocksdb.get::(shard(u64::MAX)).unwrap().unwrap(); + assert_eq!(result.iter().collect::>(), (6..=10).collect::>()); + let provider = db.factory.provider().unwrap(); + assert_eq!( + provider + .get_prune_checkpoint(PruneSegment::StorageHistory) + .unwrap() + .unwrap() + .block_number, + Some(5) + ); + } + /// Test that unwind works correctly when `storages_history_in_rocksdb` is enabled. #[tokio::test] async fn unwind_works_when_rocksdb_enabled() { @@ -769,7 +815,7 @@ mod tests { let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(10))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); @@ -806,7 +852,7 @@ mod tests { let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(5), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(5))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); @@ -837,7 +883,7 @@ mod tests { let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(5), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(5))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); @@ -849,7 +895,7 @@ mod tests { let input = ExecInput { target: Some(10), checkpoint: Some(StageCheckpoint::new(5)) }; let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!(out, ExecOutput { checkpoint: StageCheckpoint::new(10), done: true }); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(10))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); @@ -872,10 +918,7 @@ mod tests { let mut stage = IndexStorageHistoryStage::default(); let provider = db.factory.database_provider_rw().unwrap(); let out = stage.execute(&provider, input).unwrap(); - assert_eq!( - out, - ExecOutput { checkpoint: StageCheckpoint::new(num_blocks - 1), done: true } - ); + assert_eq!(out, ExecOutput::done(StageCheckpoint::new(num_blocks - 1))); provider.commit().unwrap(); let rocksdb = db.factory.rocksdb_provider(); diff --git a/crates/stages/stages/src/stages/merkle.rs b/crates/stages/stages/src/stages/merkle.rs index a6eca099b26..5c7b7729bc8 100644 --- a/crates/stages/stages/src/stages/merkle.rs +++ b/crates/stages/stages/src/stages/merkle.rs @@ -1,4 +1,4 @@ -use alloy_consensus::{constants::KECCAK_EMPTY, BlockHeader}; +use alloy_consensus::BlockHeader; use alloy_primitives::{BlockNumber, Sealable, B256}; use reth_codecs::Compact; use reth_consensus::ConsensusError; @@ -292,21 +292,16 @@ where .map(StoredSubNode::from) .collect(), storage_state.state.hash_builder.into(), - storage_state.account.nonce, - storage_state.account.balance, - storage_state.account.bytecode_hash.unwrap_or(KECCAK_EMPTY), + storage_state.account, )); } self.save_execution_checkpoint(provider, Some(checkpoint))?; entities_checkpoint.processed += hashed_entries_walked as u64; - return Ok(ExecOutput { - checkpoint: input - .checkpoint() - .with_entities_stage_checkpoint(entities_checkpoint), - done: false, - }) + return Ok(ExecOutput::in_progress( + input.checkpoint().with_entities_stage_checkpoint(entities_checkpoint), + )) } StateRootProgress::Complete(root, hashed_entries_walked, updates) => { provider.write_trie_updates(updates)?; @@ -366,11 +361,9 @@ where validate_state_root(trie_root, SealedHeader::seal_slow(target_block), to_block)?; - Ok(ExecOutput { - checkpoint: StageCheckpoint::new(to_block) - .with_entities_stage_checkpoint(entities_checkpoint), - done: true, - }) + Ok(ExecOutput::done( + StageCheckpoint::new(to_block).with_entities_stage_checkpoint(entities_checkpoint), + )) } /// Unwind the stage. diff --git a/crates/stages/stages/src/stages/mod.rs b/crates/stages/stages/src/stages/mod.rs index cfac4cf0b32..b99a3599be7 100644 --- a/crates/stages/stages/src/stages/mod.rs +++ b/crates/stages/stages/src/stages/mod.rs @@ -137,7 +137,7 @@ mod tests { .unwrap(); provider_rw .tx_ref() - .put::(code_hash, Bytecode::new_raw(code.to_vec().into())) + .put::(code_hash, Bytecode::new_raw(code.into())) .unwrap(); provider_rw.commit().unwrap(); diff --git a/crates/stages/stages/src/stages/prune.rs b/crates/stages/stages/src/stages/prune.rs index 8ae849d1560..96f1f121870 100644 --- a/crates/stages/stages/src/stages/prune.rs +++ b/crates/stages/stages/src/stages/prune.rs @@ -64,7 +64,7 @@ where let result = pruner.run_with_provider(provider, input.target())?; if result.progress.is_finished() { - Ok(ExecOutput { checkpoint: StageCheckpoint::new(input.target()), done: true }) + Ok(ExecOutput::done(StageCheckpoint::new(input.target()))) } else { if let Some((last_segment, last_segment_output)) = result.segments.last() { match last_segment_output { @@ -96,7 +96,7 @@ where } // We cannot set the checkpoint yet, because prune segments may have different highest // pruned block numbers - Ok(ExecOutput { checkpoint: input.checkpoint(), done: false }) + Ok(ExecOutput::in_progress(input.checkpoint())) } } diff --git a/crates/stages/stages/src/stages/sender_recovery.rs b/crates/stages/stages/src/stages/sender_recovery.rs index 2aa99804145..ab2cfb2624c 100644 --- a/crates/stages/stages/src/stages/sender_recovery.rs +++ b/crates/stages/stages/src/stages/sender_recovery.rs @@ -140,11 +140,10 @@ where .unwrap_or_default(), )? .ensure_at_block(input.target())?; - return Ok(ExecOutput { - checkpoint: StageCheckpoint::new(input.target()) + return Ok(ExecOutput::done( + StageCheckpoint::new(input.target()) .with_entities_stage_checkpoint(stage_checkpoint(provider)?), - done: true, - }) + )) }; let end_block = *range_output.block_range.end(); @@ -206,10 +205,7 @@ where if self.prune_mode.is_none_or(|mode| !mode.is_full()) { // Lookup the next tx id after unwind_to block (first tx to remove) - let unwind_tx_from = provider - .block_body_indices(unwind_to)? - .ok_or(ProviderError::BlockBodyIndicesNotFound(unwind_to))? - .next_tx_num(); + let unwind_tx_from = provider.next_tx_num_after_block(unwind_to)?; EitherWriter::new_senders(provider, unwind_to)? .prune_senders(unwind_tx_from, unwind_to)?; @@ -624,16 +620,15 @@ mod tests { assert_matches!(result, Ok(_)); assert_eq!( result.unwrap(), - ExecOutput { - checkpoint: StageCheckpoint::new(expected_progress).with_entities_stage_checkpoint( + ExecOutput::in_progress( + StageCheckpoint::new(expected_progress).with_entities_stage_checkpoint( EntitiesCheckpoint { processed: runner.db.count_entries::().unwrap() as u64, total: total_transactions } - ), - done: false - } + ) + ) ); // Execute second time to completion @@ -646,12 +641,9 @@ mod tests { assert_matches!(result, Ok(_)); assert_eq!( result.as_ref().unwrap(), - &ExecOutput { - checkpoint: StageCheckpoint::new(previous_stage).with_entities_stage_checkpoint( - EntitiesCheckpoint { processed: total_transactions, total: total_transactions } - ), - done: true - } + &ExecOutput::done(StageCheckpoint::new(previous_stage).with_entities_stage_checkpoint( + EntitiesCheckpoint { processed: total_transactions, total: total_transactions } + )) ); assert!(runner.validate_execution(first_input, result.ok()).is_ok(), "validation failed"); diff --git a/crates/stages/stages/src/stages/tx_lookup.rs b/crates/stages/stages/src/stages/tx_lookup.rs index ae808a0e7c8..39b44b2a3b7 100644 --- a/crates/stages/stages/src/stages/tx_lookup.rs +++ b/crates/stages/stages/src/stages/tx_lookup.rs @@ -11,9 +11,9 @@ use reth_db_api::{ use reth_etl::Collector; use reth_primitives_traits::{NodePrimitives, SignedTransaction}; use reth_provider::{ - BlockReader, DBProvider, EitherWriter, PruneCheckpointReader, PruneCheckpointWriter, - RocksDBProviderFactory, StaticFileProviderFactory, StatsReader, StorageSettingsCache, - TransactionsProvider, TransactionsProviderExt, + DBProvider, EitherWriter, PruneCheckpointReader, PruneCheckpointWriter, RocksDBProviderFactory, + StaticFileProviderFactory, StatsReader, StorageSettingsCache, TransactionsProvider, + TransactionsProviderExt, }; use reth_prune_types::{PruneCheckpoint, PruneMode, PrunePurpose, PruneSegment}; use reth_stages_api::{ @@ -61,7 +61,6 @@ impl Stage for TransactionLookupStage where Provider: DBProvider + PruneCheckpointWriter - + BlockReader + PruneCheckpointReader + StatsReader + StaticFileProviderFactory> @@ -203,11 +202,10 @@ where provider.rocksdb_provider().flush(&[Tables::TransactionHashNumbers.name()])?; } - Ok(ExecOutput { - checkpoint: StageCheckpoint::new(input.target()) + Ok(ExecOutput::done( + StageCheckpoint::new(input.target()) .with_entities_stage_checkpoint(stage_checkpoint(provider)?), - done: true, - }) + )) } /// Unwind the stage. diff --git a/crates/stages/stages/tests/pipeline.rs b/crates/stages/stages/tests/pipeline.rs index 784f2e21b5d..ba8ba473233 100644 --- a/crates/stages/stages/tests/pipeline.rs +++ b/crates/stages/stages/tests/pipeline.rs @@ -59,7 +59,7 @@ const COUNTER_DEPLOYED_BYTECODE: Bytes = bytes!( const INCREMENT_SELECTOR: [u8; 4] = [0xd0, 0x9d, 0xe0, 0x8a]; /// Contract address (deterministic for test) -const CONTRACT_ADDRESS: Address = Address::new([0x42; 20]); +const CONTRACT_ADDRESS: Address = Address::repeat_byte(0x42); /// Creates a `FileClient` populated with the given blocks. fn create_file_client_from_blocks(blocks: Vec>) -> Arc> { @@ -228,7 +228,7 @@ async fn run_pipeline_forward_and_unwind( let signer_address = public_key_to_address(key_pair.public_key()); // Recipient address for ETH transfers - let recipient_address = Address::new([0x11; 20]); + let recipient_address = Address::repeat_byte(0x11); // Create a chain spec with: // - Signer pre-funded with 1000 ETH @@ -302,7 +302,7 @@ async fn run_pipeline_forward_and_unwind( max_priority_fee_per_gas: 0, to: TxKind::Call(CONTRACT_ADDRESS), value: U256::ZERO, - input: Bytes::from(INCREMENT_SELECTOR.to_vec()), + input: INCREMENT_SELECTOR.into(), ..Default::default() }), ); diff --git a/crates/stages/stages/tests/preimage.rs b/crates/stages/stages/tests/preimage.rs index d87849ce204..757e84d8746 100644 --- a/crates/stages/stages/tests/preimage.rs +++ b/crates/stages/stages/tests/preimage.rs @@ -60,7 +60,7 @@ use tokio::sync::watch; type TestProviderFactory = reth_provider::ProviderFactory; -const TEST_SELFDESTRUCT_BENEFICIARY: Address = Address::new([0x77; 20]); +const TEST_SELFDESTRUCT_BENEFICIARY: Address = Address::repeat_byte(0x77); const TEST_CREATE2_SALT: B256 = B256::with_last_byte(0x42); /// Scenario coverage: @@ -405,7 +405,7 @@ fn setup_selfdestruct_scenario() -> eyre::Result { let mut rng = generators::rng(); let key_pair = generate_key(&mut rng); let signer_address = public_key_to_address(key_pair.public_key()); - let selfdestruct_contract = Address::new([0x66; 20]); + let selfdestruct_contract = Address::repeat_byte(0x66); let chain_spec = build_selfdestruct_chain_spec(signer_address, selfdestruct_contract); let blocks = { // Build blocks via direct execution first, so each header has a valid state root. @@ -445,7 +445,7 @@ fn setup_selfdestruct_scenario() -> eyre::Result { Bytes::new(), TxKind::Call(TEST_SELFDESTRUCT_BENEFICIARY), 21_000_u64, - U256::from(1), + U256::ONE, ), ] { // Block behavior by timestamp: @@ -494,9 +494,9 @@ fn setup_create2_selfdestruct_scenario() -> eyre::Result eyre::Result eyre::Result eyre::Result Address { - let init_hash = keccak256(init_code.as_ref()); - let mut preimage = [0_u8; 85]; - preimage[0] = 0xff; - preimage[1..21].copy_from_slice(factory.as_slice()); - preimage[21..53].copy_from_slice(salt.as_slice()); - preimage[53..85].copy_from_slice(init_hash.as_slice()); - - let hash = keccak256(preimage); - Address::from_slice(&hash.as_slice()[12..]) -} - fn assert_preimage_rows(preimage_path: &Path, slots: &[B256]) -> eyre::Result<()> { let mut builder = Environment::builder(); builder.set_max_dbs(1); diff --git a/crates/stages/types/Cargo.toml b/crates/stages/types/Cargo.toml index 83276b46f2d..185981c76f3 100644 --- a/crates/stages/types/Cargo.toml +++ b/crates/stages/types/Cargo.toml @@ -14,6 +14,7 @@ workspace = true [dependencies] reth-codecs = { workspace = true, optional = true } reth-trie-common.workspace = true +reth-primitives-traits.workspace = true alloy-primitives.workspace = true serde = { workspace = true, optional = true } @@ -34,11 +35,13 @@ bytes.workspace = true modular-bitfield.workspace = true [features] +account-ext = ["reth-codecs?/account-ext", "reth-primitives-traits/account-ext"] default = ["std"] std = [ "alloy-primitives/std", "bytes?/std", "reth-trie-common/std", + "reth-primitives-traits/std", "serde?/std", "reth-codecs?/std", ] @@ -52,6 +55,7 @@ test-utils = [ "arbitrary", "reth-codecs?/test-utils", "reth-trie-common/test-utils", + "reth-primitives-traits/test-utils", ] arbitrary = [ "std", @@ -59,6 +63,7 @@ arbitrary = [ "alloy-primitives/arbitrary", "reth-codecs?/arbitrary", "reth-trie-common/arbitrary", + "reth-primitives-traits/arbitrary", ] serde = [ "dep:serde", @@ -67,4 +72,5 @@ serde = [ "rand/serde", "reth-codecs?/serde", "reth-trie-common/serde", + "reth-primitives-traits/serde", ] diff --git a/crates/stages/types/src/checkpoints.rs b/crates/stages/types/src/checkpoints.rs index b56f8596f33..60090cd9715 100644 --- a/crates/stages/types/src/checkpoints.rs +++ b/crates/stages/types/src/checkpoints.rs @@ -4,6 +4,7 @@ use alloc::vec; use alloc::{format, string::String, vec::Vec}; use alloy_primitives::{Address, BlockNumber, B256, U256}; use core::ops::RangeInclusive; +use reth_primitives_traits::Account; use reth_trie_common::{hash_builder::HashBuilderState, StoredSubNode}; /// Saves the progress of Merkle stage. @@ -73,8 +74,9 @@ impl reth_codecs::Compact for MerkleCheckpoint { len } - fn from_compact(mut buf: &[u8], _len: usize) -> (Self, &[u8]) { + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { use bytes::Buf; + let (mut buf, trailing) = if len == 0 { (buf, &[][..]) } else { buf.split_at(len) }; let target_block = buf.get_u64(); let last_account_key = B256::from_slice(&buf[..32]); @@ -103,7 +105,10 @@ impl reth_codecs::Compact for MerkleCheckpoint { } }; - (Self { target_block, last_account_key, walker_stack, state, storage_root_checkpoint }, buf) + ( + Self { target_block, last_account_key, walker_stack, state, storage_root_checkpoint }, + if len == 0 { buf } else { trailing }, + ) } } @@ -124,25 +129,41 @@ pub struct StorageRootMerkleCheckpoint { pub account_balance: U256, /// The account bytecode hash. pub account_bytecode_hash: B256, + /// Payload of the account whose storage root is in progress. + #[cfg(feature = "account-ext")] + pub account_extension: reth_primitives_traits::AccountExtension, } impl StorageRootMerkleCheckpoint { /// Creates a new storage root merkle checkpoint. - pub const fn new( + pub fn new( last_storage_key: B256, walker_stack: Vec, state: HashBuilderState, - account_nonce: u64, - account_balance: U256, - account_bytecode_hash: B256, + account: Account, ) -> Self { Self { last_storage_key, walker_stack, state, - account_nonce, - account_balance, - account_bytecode_hash, + account_nonce: account.nonce, + account_balance: account.balance, + account_bytecode_hash: account.get_bytecode_hash(), + #[cfg(feature = "account-ext")] + account_extension: account.extension, + } + } + + /// Returns the account whose storage root is in progress. + #[allow(clippy::needless_update)] + pub fn account(&self) -> Account { + Account { + nonce: self.account_nonce, + balance: self.account_balance, + bytecode_hash: Some(self.account_bytecode_hash), + #[cfg(feature = "account-ext")] + extension: self.account_extension.clone(), + ..Default::default() } } } @@ -177,12 +198,22 @@ impl reth_codecs::Compact for StorageRootMerkleCheckpoint { buf.put_slice(self.account_bytecode_hash.as_slice()); len += 32; + #[cfg(feature = "account-ext")] + if !self.account_extension.is_empty() { + let extension_len = u16::try_from(self.account_extension.len()) + .expect("account extension exceeds compact encoding limit"); + buf.put_u16(extension_len); + len += 2; + buf.put_slice(&self.account_extension); + len += self.account_extension.len(); + } len } - fn from_compact(mut buf: &[u8], _len: usize) -> (Self, &[u8]) { + fn from_compact(buf: &[u8], len: usize) -> (Self, &[u8]) { use bytes::Buf; + let (mut buf, trailing) = if len == 0 { (buf, &[][..]) } else { buf.split_at(len) }; let last_storage_key = B256::from_slice(&buf[..32]); buf.advance(32); @@ -203,6 +234,16 @@ impl reth_codecs::Compact for StorageRootMerkleCheckpoint { let (account_balance, mut buf) = U256::from_compact(buf, balance_len); let account_bytecode_hash = B256::from_slice(&buf[..32]); buf.advance(32); + #[cfg(feature = "account-ext")] + let (account_extension, buf) = if buf.is_empty() { + (reth_primitives_traits::AccountExtension::default(), buf) + } else { + let account_extension_len = buf.get_u16() as usize; + let account_extension = reth_primitives_traits::AccountExtension::copy_from_slice( + &buf[..account_extension_len], + ); + (account_extension, &buf[account_extension_len..]) + }; ( Self { @@ -212,8 +253,10 @@ impl reth_codecs::Compact for StorageRootMerkleCheckpoint { account_nonce, account_balance, account_bytecode_hash, + #[cfg(feature = "account-ext")] + account_extension, }, - buf, + if len == 0 { buf } else { trailing }, ) } } @@ -609,8 +652,10 @@ stage_unit_checkpoints!( mod tests { use super::*; use alloy_primitives::b256; + use proptest::{collection::vec, option, prelude::*}; use rand::Rng; use reth_codecs::Compact; + use reth_trie_common::{RlpNode, TrieMask}; #[test] fn merkle_checkpoint_roundtrip() { @@ -647,12 +692,33 @@ mod tests { account_nonce: 0, account_balance: U256::ZERO, account_bytecode_hash: B256::ZERO, + #[cfg(feature = "account-ext")] + account_extension: vec![0x42; 32].into(), }; let mut buf = Vec::new(); let encoded = checkpoint.to_compact(&mut buf); - let (decoded, _) = StorageRootMerkleCheckpoint::from_compact(&buf, encoded); + assert_eq!(encoded, buf.len()); + + #[cfg(feature = "account-ext")] + { + let mut empty_checkpoint = checkpoint.clone(); + empty_checkpoint.account_extension = Default::default(); + let mut empty_buf = Vec::new(); + let empty_encoded = empty_checkpoint.to_compact(&mut empty_buf); + assert_eq!(encoded, empty_encoded + 2 + checkpoint.account_extension.len()); + assert_eq!(empty_encoded, empty_buf.len()); + empty_buf.extend_from_slice(&[0x12, 0x34]); + let (decoded, rest) = + StorageRootMerkleCheckpoint::from_compact(&empty_buf, empty_encoded); + assert_eq!(decoded, empty_checkpoint); + assert_eq!(rest, &[0x12, 0x34]); + } + + buf.extend_from_slice(&[0x12, 0x34]); + let (decoded, rest) = StorageRootMerkleCheckpoint::from_compact(&buf, encoded); assert_eq!(decoded, checkpoint); + assert_eq!(rest, &[0x12, 0x34]); } #[test] @@ -669,10 +735,12 @@ mod tests { }], state: HashBuilderState::default(), account_nonce: 1, - account_balance: U256::from(1), + account_balance: U256::ONE, account_bytecode_hash: b256!( "0x0fffffffffffffffffffffffffffffff0fffffffffffffffffffffffffffffff" ), + #[cfg(feature = "account-ext")] + account_extension: vec![0x42; 32].into(), }; // Create a merkle checkpoint with the storage root checkpoint @@ -694,16 +762,385 @@ mod tests { assert_eq!(decoded, checkpoint); } - #[test] - fn finish_checkpoint_roundtrip() { - let finish_checkpoint = FinishCheckpoint { partial_state_trie: Some(21) }; - let checkpoint = StageCheckpoint::new(42).with_finish_stage_checkpoint(finish_checkpoint); + /// Bytes following a checkpoint record, to check that decoding stays within the record. + const TRAILING: [u8; 2] = [0xab, 0xcd]; - let mut buf = Vec::new(); - let encoded = checkpoint.to_compact(&mut buf); - let (decoded, _) = StageCheckpoint::from_compact(&buf, encoded); + /// [`StorageRootMerkleCheckpoint`] with its encoding from before account extensions. + #[derive(Debug, Clone, PartialEq, Eq)] + struct LegacyStorageRootMerkleCheckpoint { + last_storage_key: B256, + walker_stack: Vec, + state: HashBuilderState, + account_nonce: u64, + account_balance: U256, + account_bytecode_hash: B256, + } - assert_eq!(decoded, checkpoint); - assert_eq!(decoded.finish_stage_checkpoint().unwrap().partial_state_trie(), Some(21)); + impl Compact for LegacyStorageRootMerkleCheckpoint { + fn to_compact(&self, buf: &mut B) -> usize + where + B: bytes::BufMut + AsMut<[u8]>, + { + let mut len = 0; + + buf.put_slice(self.last_storage_key.as_slice()); + len += self.last_storage_key.len(); + + buf.put_u16(self.walker_stack.len() as u16); + len += 2; + for item in &self.walker_stack { + len += item.to_compact(buf); + } + + len += self.state.to_compact(buf); + + buf.put_u64(self.account_nonce); + len += 8; + + let balance_len = self.account_balance.byte_len() as u8; + buf.put_u8(balance_len); + len += 1; + len += self.account_balance.to_compact(buf); + + buf.put_slice(self.account_bytecode_hash.as_slice()); + len += 32; + + len + } + + fn from_compact(mut buf: &[u8], _len: usize) -> (Self, &[u8]) { + use bytes::Buf; + + let last_storage_key = B256::from_slice(&buf[..32]); + buf.advance(32); + + let walker_stack_len = buf.get_u16() as usize; + let mut walker_stack = Vec::with_capacity(walker_stack_len); + for _ in 0..walker_stack_len { + let (item, rest) = StoredSubNode::from_compact(buf, 0); + walker_stack.push(item); + buf = rest; + } + + let (state, mut buf) = HashBuilderState::from_compact(buf, 0); + + let account_nonce = buf.get_u64(); + let balance_len = buf.get_u8() as usize; + let (account_balance, mut buf) = U256::from_compact(buf, balance_len); + let account_bytecode_hash = B256::from_slice(&buf[..32]); + buf.advance(32); + + ( + Self { + last_storage_key, + walker_stack, + state, + account_nonce, + account_balance, + account_bytecode_hash, + }, + buf, + ) + } + } + + impl From for StorageRootMerkleCheckpoint { + fn from(legacy: LegacyStorageRootMerkleCheckpoint) -> Self { + Self { + last_storage_key: legacy.last_storage_key, + walker_stack: legacy.walker_stack, + state: legacy.state, + account_nonce: legacy.account_nonce, + account_balance: legacy.account_balance, + account_bytecode_hash: legacy.account_bytecode_hash, + #[cfg(feature = "account-ext")] + account_extension: Default::default(), + } + } + } + + /// [`MerkleCheckpoint`] with its encoding from before account extensions. + #[derive(Debug, Clone, PartialEq, Eq)] + struct LegacyMerkleCheckpoint { + target_block: BlockNumber, + last_account_key: B256, + walker_stack: Vec, + state: HashBuilderState, + storage_root_checkpoint: Option, + } + + impl Compact for LegacyMerkleCheckpoint { + fn to_compact(&self, buf: &mut B) -> usize + where + B: bytes::BufMut + AsMut<[u8]>, + { + let mut len = 0; + + buf.put_u64(self.target_block); + len += 8; + + buf.put_slice(self.last_account_key.as_slice()); + len += self.last_account_key.len(); + + buf.put_u16(self.walker_stack.len() as u16); + len += 2; + for item in &self.walker_stack { + len += item.to_compact(buf); + } + + len += self.state.to_compact(buf); + + match &self.storage_root_checkpoint { + Some(checkpoint) => { + buf.put_u8(1); + len += 1; + len += checkpoint.to_compact(buf); + } + None => { + buf.put_u8(0); + len += 1; + } + } + + len + } + + fn from_compact(mut buf: &[u8], _len: usize) -> (Self, &[u8]) { + use bytes::Buf; + let target_block = buf.get_u64(); + + let last_account_key = B256::from_slice(&buf[..32]); + buf.advance(32); + + let walker_stack_len = buf.get_u16() as usize; + let mut walker_stack = Vec::with_capacity(walker_stack_len); + for _ in 0..walker_stack_len { + let (item, rest) = StoredSubNode::from_compact(buf, 0); + walker_stack.push(item); + buf = rest; + } + + let (state, mut buf) = HashBuilderState::from_compact(buf, 0); + + let (storage_root_checkpoint, buf) = if buf.is_empty() { + (None, buf) + } else { + match buf.get_u8() { + 1 => { + let (checkpoint, rest) = + LegacyStorageRootMerkleCheckpoint::from_compact(buf, 0); + (Some(checkpoint), rest) + } + _ => (None, buf), + } + }; + + ( + Self { + target_block, + last_account_key, + walker_stack, + state, + storage_root_checkpoint, + }, + buf, + ) + } + } + + impl From for MerkleCheckpoint { + fn from(legacy: LegacyMerkleCheckpoint) -> Self { + Self { + target_block: legacy.target_block, + last_account_key: legacy.last_account_key, + walker_stack: legacy.walker_stack, + state: legacy.state, + storage_root_checkpoint: legacy.storage_root_checkpoint.map(Into::into), + } + } + } + + // Walker entries carry no branch node: `StoredSubNode` decodes a branch node by consuming + // the rest of the buffer, so one can only round-trip at the end of a record. + fn stored_sub_node() -> impl Strategy { + (vec(0u8..16, 0..=64), option::of(0u8..16)).prop_map(|(key, nibble)| StoredSubNode { + key, + nibble, + node: None, + }) + } + + fn hash_builder_state() -> impl Strategy { + ( + vec(0u8..16, 0..=64), + vec(any::(), 0..=64), + vec(vec(any::(), 0..=32), 0..=8), + vec(any::(), 0..=8), + vec(any::(), 0..=8), + vec(any::(), 0..=8), + any::(), + ) + .prop_map( + |(key, value, stack, groups, tree_masks, hash_masks, stored_in_database)| { + let mut state = HashBuilderState { + key, + stack: stack.iter().map(|node| RlpNode::from_raw(node).unwrap()).collect(), + groups: groups.into_iter().map(TrieMask::new).collect(), + tree_masks: tree_masks.into_iter().map(TrieMask::new).collect(), + hash_masks: hash_masks.into_iter().map(TrieMask::new).collect(), + stored_in_database, + ..Default::default() + }; + state.value.set_bytes_owned(value); + state + }, + ) + } + + fn legacy_storage_root_checkpoint() -> impl Strategy + { + ( + any::(), + vec(stored_sub_node(), 0..=4), + hash_builder_state(), + any::(), + any::(), + any::(), + ) + .prop_map( + |( + last_storage_key, + walker_stack, + state, + account_nonce, + account_balance, + account_bytecode_hash, + )| { + LegacyStorageRootMerkleCheckpoint { + last_storage_key, + walker_stack, + state, + account_nonce, + account_balance, + account_bytecode_hash, + } + }, + ) + } + + fn legacy_merkle_checkpoint() -> impl Strategy { + ( + any::(), + any::(), + vec(stored_sub_node(), 0..=4), + hash_builder_state(), + option::of(legacy_storage_root_checkpoint()), + ) + .prop_map( + |(target_block, last_account_key, walker_stack, state, storage_root_checkpoint)| { + LegacyMerkleCheckpoint { + target_block, + last_account_key, + walker_stack, + state, + storage_root_checkpoint, + } + }, + ) + } + + proptest! { + #[test] + fn storage_root_merkle_checkpoint_matches_legacy_encoding( + legacy in legacy_storage_root_checkpoint(), + ) { + let mut legacy_buf = Vec::new(); + let legacy_len = legacy.to_compact(&mut legacy_buf); + prop_assert_eq!(legacy_len, legacy_buf.len()); + + // Without an extension, the encoding is the legacy one. + let checkpoint = StorageRootMerkleCheckpoint::from(legacy.clone()); + let mut buf = Vec::new(); + let len = checkpoint.to_compact(&mut buf); + prop_assert_eq!(len, buf.len()); + prop_assert_eq!(&buf, &legacy_buf); + + // Legacy records decode unchanged and leave the following bytes alone. + legacy_buf.extend_from_slice(&TRAILING); + let (decoded, rest) = StorageRootMerkleCheckpoint::from_compact(&legacy_buf, legacy_len); + prop_assert_eq!(decoded, checkpoint); + prop_assert_eq!(rest, &TRAILING[..]); + + // The legacy decoder still reads records without an extension. + let (decoded, _) = LegacyStorageRootMerkleCheckpoint::from_compact(&buf, len); + prop_assert_eq!(decoded, legacy); + } + + #[test] + fn merkle_checkpoint_matches_legacy_encoding(legacy in legacy_merkle_checkpoint()) { + let mut legacy_buf = Vec::new(); + let legacy_len = legacy.to_compact(&mut legacy_buf); + prop_assert_eq!(legacy_len, legacy_buf.len()); + + let checkpoint = MerkleCheckpoint::from(legacy.clone()); + let mut buf = Vec::new(); + let len = checkpoint.to_compact(&mut buf); + prop_assert_eq!(len, buf.len()); + prop_assert_eq!(&buf, &legacy_buf); + + // The merkle stage decodes the whole stored value. + let (decoded, rest) = MerkleCheckpoint::from_compact(&legacy_buf, legacy_buf.len()); + prop_assert_eq!(&decoded, &checkpoint); + prop_assert!(rest.is_empty()); + + legacy_buf.extend_from_slice(&TRAILING); + let (decoded, rest) = MerkleCheckpoint::from_compact(&legacy_buf, legacy_len); + prop_assert_eq!(decoded, checkpoint); + prop_assert_eq!(rest, &TRAILING[..]); + + let (decoded, _) = LegacyMerkleCheckpoint::from_compact(&buf, len); + prop_assert_eq!(decoded, legacy); + } + } + + #[cfg(feature = "account-ext")] + proptest! { + #[test] + fn storage_root_merkle_checkpoint_extension_roundtrip( + legacy in legacy_storage_root_checkpoint(), + extension in vec(any::(), 0..=64), + ) { + let mut expected = Vec::new(); + legacy.to_compact(&mut expected); + + let checkpoint = StorageRootMerkleCheckpoint { + account_extension: extension.clone().into(), + ..StorageRootMerkleCheckpoint::from(legacy) + }; + let mut buf = Vec::new(); + let len = checkpoint.to_compact(&mut buf); + prop_assert_eq!(len, buf.len()); + + // A nonempty extension is appended to the legacy layout behind a length prefix. + if !extension.is_empty() { + expected.extend_from_slice(&(extension.len() as u16).to_be_bytes()); + expected.extend_from_slice(&extension); + } + prop_assert_eq!(&buf, &expected); + + buf.extend_from_slice(&TRAILING); + let (decoded, rest) = StorageRootMerkleCheckpoint::from_compact(&buf, len); + prop_assert_eq!(&decoded, &checkpoint); + prop_assert_eq!(rest, &TRAILING[..]); + + let merkle = MerkleCheckpoint { + storage_root_checkpoint: Some(checkpoint), + ..Default::default() + }; + let mut buf = Vec::new(); + merkle.to_compact(&mut buf); + let (decoded, _) = MerkleCheckpoint::from_compact(&buf, buf.len()); + prop_assert_eq!(decoded, merkle); + } } } diff --git a/crates/storage/db-api/src/cursor.rs b/crates/storage/db-api/src/cursor.rs index 73bc369ee58..c5f01cfbb0f 100644 --- a/crates/storage/db-api/src/cursor.rs +++ b/crates/storage/db-api/src/cursor.rs @@ -58,6 +58,19 @@ pub trait DbCursorRO { ) -> Result, DatabaseError> where Self: Sized; + + /// Enables or disables best-effort read-ahead for subsequent value reads on this cursor. + /// + /// Overrides the setting inherited when the cursor was created, without changing its position, + /// transaction, or other cursors. Persists across reads, walks, and writes until changed again. + /// Returns the cursor for chaining. Backends without support ignore the setting. + #[inline] + fn prefetch(&mut self, _enabled: bool) -> &mut Self + where + Self: Sized, + { + self + } } /// A read-only cursor over the dup table `T`. diff --git a/crates/storage/db-api/src/database.rs b/crates/storage/db-api/src/database.rs index e9d13d5f101..086fab6ac7a 100644 --- a/crates/storage/db-api/src/database.rs +++ b/crates/storage/db-api/src/database.rs @@ -1,8 +1,4 @@ -use crate::{ - table::TableImporter, - transaction::{DbTx, DbTxMut}, - DatabaseError, -}; +use crate::{table::TableImporter, transaction::DbTx, DatabaseError}; use std::{fmt::Debug, path::PathBuf, sync::Arc}; /// Main Database trait that can open read-only and read-write transactions. @@ -10,9 +6,9 @@ use std::{fmt::Debug, path::PathBuf, sync::Arc}; /// Sealed trait which cannot be implemented by 3rd parties, exposed only for consumption. pub trait Database: Send + Sync + Debug { /// Read-Only database transaction - type TX: DbTx + Send + Sync + Debug + 'static; + type TX: DbTx + Sync + 'static; /// Read-Write database transaction - type TXMut: DbTxMut + DbTx + TableImporter + Send + Sync + Debug + 'static; + type TXMut: DbTx + TableImporter + Sync + 'static; /// Create read only transaction. #[track_caller] diff --git a/crates/storage/db-api/src/models/integer_list.rs b/crates/storage/db-api/src/models/integer_list.rs index 06a993e75e8..cc5fb8ead17 100644 --- a/crates/storage/db-api/src/models/integer_list.rs +++ b/crates/storage/db-api/src/models/integer_list.rs @@ -204,27 +204,4 @@ mod tests { let blist = ef_list.to_bytes(); assert_eq!(IntegerList::from_bytes(&blist).unwrap(), ef_list) } - - #[test] - fn remove_range_matches_filtering() { - // Spans more than one 2^16 roaring container so multi-container removal is covered. - let values = [1u64, 2, 100, 65_535, 65_536, 70_000, 200_000]; - - for to_block in [0u64, 1, 99, 100, 65_535, 65_536, 199_999, 200_000, 200_001] { - let mut list = IntegerList::new(values).unwrap(); - let removed = list.remove_range(0..=to_block); - - let expected = values.into_iter().filter(|value| *value > to_block).collect::>(); - assert_eq!(list.iter().collect::>(), expected, "to_block {to_block}"); - assert_eq!(removed, (values.len() - expected.len()) as u64, "to_block {to_block}"); - assert_eq!(list.is_empty(), expected.is_empty(), "to_block {to_block}"); - } - } - - #[test] - fn remove_range_on_empty_list_removes_nothing() { - let mut list = IntegerList::empty(); - assert_eq!(list.remove_range(0..=100), 0); - assert!(list.is_empty()); - } } diff --git a/crates/storage/db-api/src/table.rs b/crates/storage/db-api/src/table.rs index 03edb0aa9af..b0a6b5c5f0f 100644 --- a/crates/storage/db-api/src/table.rs +++ b/crates/storage/db-api/src/table.rs @@ -41,7 +41,7 @@ impl IntoVec for arrayvec::ArrayVec { /// Trait that will transform the data to be saved in the DB. pub trait Encode: Send + Sync + Sized + Debug { /// Encoded type. - type Encoded: AsRef<[u8]> + IntoVec + Send + Sync + Ord + Debug; + type Encoded: IntoVec + Send + Sync + Ord + Debug; /// Encodes data going into the database. fn encode(self) -> Self::Encoded; diff --git a/crates/storage/db-api/src/transaction.rs b/crates/storage/db-api/src/transaction.rs index d92c4509800..8d29d450ddd 100644 --- a/crates/storage/db-api/src/transaction.rs +++ b/crates/storage/db-api/src/transaction.rs @@ -46,6 +46,17 @@ pub trait DbTx: Debug + Send { fn entries(&self) -> Result; /// Disables long-lived read transaction safety guarantees. fn disable_long_read_transaction_safety(&mut self); + + /// Enables or disables best-effort read-ahead for this transaction. + /// + /// Defaults to disabled and persists across operations until explicitly changed. Applies to + /// gets in every table. New read and write cursors inherit the setting; existing cursors keep + /// their own setting, configurable with [`DbCursorRO::prefetch`]. Returns the transaction for + /// chaining. Backends without support ignore the setting. + #[inline] + fn prefetch(&self, _enabled: bool) -> &Self { + self + } } /// Read write transaction that allows writing to database diff --git a/crates/storage/db-common/src/init.rs b/crates/storage/db-common/src/init.rs index 39b6958960d..4d821c08a44 100644 --- a/crates/storage/db-common/src/init.rs +++ b/crates/storage/db-common/src/init.rs @@ -29,9 +29,9 @@ use reth_provider::{ errors::provider::ProviderResult, providers::StaticFileWriter, BlockHashReader, BlockNumReader, BundleStateInit, ChainSpecProvider, DBProvider, DatabaseProviderFactory, ExecutionOutcome, HashingWriter, HeaderProvider, HistoryWriter, MetadataProvider, MetadataWriter, - NodePrimitivesProvider, OriginalValuesKnown, ProviderError, RevertsInit, - RocksDBProviderFactory, StageCheckpointReader, StageCheckpointWriter, StateWriteConfig, - StateWriter, StaticFileProviderFactory, StorageSettings, StorageSettingsCache, TrieWriter, + OriginalValuesKnown, ProviderError, RevertsInit, RocksDBProviderFactory, StageCheckpointReader, + StageCheckpointWriter, StateWriteConfig, StateWriter, StaticFileProviderFactory, + StorageSettings, StorageSettingsCache, TrieWriter, }; use reth_stages_types::{StageCheckpoint, StageId}; use reth_static_file_types::StaticFileSegment; @@ -129,7 +129,6 @@ where + ChainSpecProvider + StorageSettingsCache + RocksDBProviderFactory - + NodePrimitivesProvider + AsRef, PF::ChainSpec: EthChainSpec
::BlockHeader>, { @@ -160,7 +159,6 @@ where + ChainSpecProvider + StorageSettingsCache + RocksDBProviderFactory - + NodePrimitivesProvider + AsRef, PF::ChainSpec: EthChainSpec
::BlockHeader>, { @@ -193,7 +191,6 @@ where + ChainSpecProvider + StorageSettingsCache + RocksDBProviderFactory - + NodePrimitivesProvider + AsRef, PF::ChainSpec: EthChainSpec
::BlockHeader>, { @@ -406,15 +403,7 @@ where state_init.insert( *address, - ( - None, - Some(Account { - nonce: account.nonce.unwrap_or_default(), - balance: account.balance, - bytecode_hash, - }), - storage, - ), + (None, Some(Account { bytecode_hash, ..Account::from(account) }), storage), ); } let all_reverts_init: RevertsInit = HashMap::from_iter([(block, reverts_init)]); @@ -530,7 +519,6 @@ where ProviderRW: StaticFileProviderFactory + DBProvider + BlockNumReader - + BlockHashReader + ChainSpecProvider + StageCheckpointWriter + HistoryWriter @@ -540,7 +528,6 @@ where + StateWriter + StorageSettingsCache + RocksDBProviderFactory - + NodePrimitivesProvider + AsRef, >, { @@ -690,10 +677,7 @@ fn dump_state( ) -> Result<(), eyre::Error> where PF: DatabaseProviderFactory>, - PF::ProviderRW: StaticFileProviderFactory - + StorageSettingsCache - + RocksDBProviderFactory - + NodePrimitivesProvider, + PF::ProviderRW: StaticFileProviderFactory + StorageSettingsCache + RocksDBProviderFactory, { let storage_settings = provider_factory.database_provider_rw()?.cached_storage_settings(); if storage_settings.storage_v2 { @@ -769,8 +753,7 @@ where ProviderRW: StaticFileProviderFactory + DBProvider + StorageSettingsCache - + RocksDBProviderFactory - + NodePrimitivesProvider, + + RocksDBProviderFactory, >, { let accounts_len = collector.len(); @@ -994,11 +977,7 @@ fn write_account_to_db( None }; - let account = Account { - nonce: genesis_account.nonce.unwrap_or_default(), - balance: genesis_account.balance, - bytecode_hash, - }; + let account = Account { bytecode_hash, ..Account::from(genesis_account) }; let hashed_address = keccak256(address); @@ -1082,11 +1061,7 @@ where None }; - let account = Account { - nonce: genesis_account.nonce.unwrap_or_default(), - balance: genesis_account.balance, - bytecode_hash, - }; + let account = Account { bytecode_hash, ..Account::from(genesis_account) }; let hashed_address = keccak256(address); let (account_changeset_writer, storage_changeset_writer) = changeset_writers; @@ -1344,7 +1319,7 @@ mod tests { assert_eq!( accounts, vec![ - (Address::with_last_byte(1), U256::from(1)), + (Address::with_last_byte(1), U256::ONE), (Address::with_last_byte(2), U256::from(2)) ] ); @@ -1600,7 +1575,7 @@ mod tests { alloc: BTreeMap::from([ ( address_with_balance, - GenesisAccount { balance: U256::from(1), ..Default::default() }, + GenesisAccount { balance: U256::ONE, ..Default::default() }, ), ( address_with_storage, diff --git a/crates/storage/db-models/Cargo.toml b/crates/storage/db-models/Cargo.toml index 339faca5377..1b01c1acd30 100644 --- a/crates/storage/db-models/Cargo.toml +++ b/crates/storage/db-models/Cargo.toml @@ -33,10 +33,11 @@ arbitrary = { workspace = true, features = ["derive"], optional = true } [dev-dependencies] # reth reth-primitives-traits = { workspace = true, features = ["arbitrary", "reth-codec"] } -reth-codecs.workspace = true +reth-codecs = { workspace = true, features = ["alloy"] } bytes.workspace = true arbitrary = { workspace = true, features = ["derive"] } +modular-bitfield.workspace = true proptest.workspace = true proptest-arbitrary-interop.workspace = true @@ -74,6 +75,7 @@ arbitrary = [ ] reth-codec = [ "dep:reth-codecs", + "reth-codecs/alloy", "dep:modular-bitfield", "dep:bytes", "reth-primitives-traits/reth-codec", diff --git a/crates/storage/db/Cargo.toml b/crates/storage/db/Cargo.toml index 6c01c4b62f7..8067f633a7d 100644 --- a/crates/storage/db/Cargo.toml +++ b/crates/storage/db/Cargo.toml @@ -68,6 +68,7 @@ proptest.workspace = true assert_matches.workspace = true [features] +dst = ["reth-libmdbx?/dst"] default = ["mdbx"] mdbx = [ "dep:reth-libmdbx", diff --git a/crates/storage/db/src/implementation/mdbx/cursor.rs b/crates/storage/db/src/implementation/mdbx/cursor.rs index cd642980576..e89340bd937 100644 --- a/crates/storage/db/src/implementation/mdbx/cursor.rs +++ b/crates/storage/db/src/implementation/mdbx/cursor.rs @@ -33,14 +33,25 @@ pub struct Cursor { metrics: Option, /// Phantom data to enforce encoding/decoding. _dbi: PhantomData, + /// Whether all value reads through this cursor should request read-ahead. + #[cfg(target_os = "linux")] + prefetch: bool, } impl Cursor { pub(crate) const fn new_with_metrics( inner: reth_libmdbx::Cursor, metrics: Option, + #[cfg(target_os = "linux")] prefetch: bool, ) -> Self { - Self { inner, buf: Vec::new(), metrics, _dbi: PhantomData } + Self { + inner, + buf: Vec::new(), + metrics, + _dbi: PhantomData, + #[cfg(target_os = "linux")] + prefetch, + } } /// If `self.metrics` is `Some(...)`, record a metric with the provided operation and value @@ -88,38 +99,68 @@ macro_rules! compress_to_buf_or_ref { }; } +/// Selects the value decoder inside the existing MDBX cursor lookup. +macro_rules! read_pair { + ($self:ident, $method:ident($($arg:expr),* $(,)?)) => {{ + #[cfg(target_os = "linux")] + if $self.prefetch { + $self.inner.$method::, super::value_prefetch::PrefetchValue<'_>>($($arg),*) + .map(|entry| entry.map(|(key, value)| (key, value.0))) + } else { + $self.inner.$method($($arg),*) + } + #[cfg(not(target_os = "linux"))] + $self.inner.$method($($arg),*) + }}; +} + +/// Selects the decoder for cursor operations returning only a value. +macro_rules! read_value { + ($self:ident, $method:ident($($arg:expr),* $(,)?)) => {{ + #[cfg(target_os = "linux")] + if $self.prefetch { + $self.inner.$method::>($($arg),*) + .map(|entry| entry.map(|value| value.0)) + } else { + $self.inner.$method($($arg),*) + } + #[cfg(not(target_os = "linux"))] + $self.inner.$method($($arg),*) + }}; +} + impl DbCursorRO for Cursor { fn first(&mut self) -> PairResult { - decode::(self.inner.first()) + decode::(read_pair!(self, first())) } fn seek_exact(&mut self, key: ::Key) -> PairResult { - decode::(self.inner.set_key(key.encode().as_ref())) + decode::(read_pair!(self, set_key(key.encode().as_ref()))) } fn seek(&mut self, key: ::Key) -> PairResult { - decode::(self.inner.set_range(key.encode().as_ref())) + decode::(read_pair!(self, set_range(key.encode().as_ref()))) } fn next(&mut self) -> PairResult { - decode::(self.inner.next()) + decode::(read_pair!(self, next())) } fn prev(&mut self) -> PairResult { - decode::(self.inner.prev()) + decode::(read_pair!(self, prev())) } fn last(&mut self) -> PairResult { - decode::(self.inner.last()) + decode::(read_pair!(self, last())) } fn current(&mut self) -> PairResult { - decode::(self.inner.get_current()) + decode::(read_pair!(self, get_current())) } fn walk(&mut self, start_key: Option) -> Result, DatabaseError> { let start = if let Some(start_key) = start_key { - decode::(self.inner.set_range(start_key.encode().as_ref())).transpose() + self.seek(start_key).transpose() } else { self.first().transpose() }; @@ -132,13 +173,13 @@ impl DbCursorRO for Cursor { range: impl RangeBounds, ) -> Result, DatabaseError> { let start = match range.start_bound().cloned() { - Bound::Included(key) => self.inner.set_range(key.encode().as_ref()), + Bound::Included(key) => self.seek(key), Bound::Excluded(_key) => { unreachable!("Rust doesn't allow for Bound::Excluded in starting bounds"); } - Bound::Unbounded => self.inner.first(), + Bound::Unbounded => self.first(), }; - let start = decode::(start).transpose(); + let start = start.transpose(); Ok(RangeWalker::new(self, start, range.end_bound().cloned())) } @@ -146,32 +187,34 @@ impl DbCursorRO for Cursor { &mut self, start_key: Option, ) -> Result, DatabaseError> { - let start = if let Some(start_key) = start_key { - decode::(self.inner.set_range(start_key.encode().as_ref())) - } else { - self.last() - } - .transpose(); + let start = + if let Some(start_key) = start_key { self.seek(start_key) } else { self.last() } + .transpose(); Ok(ReverseWalker::new(self, start)) } + + #[cfg(target_os = "linux")] + fn prefetch(&mut self, enabled: bool) -> &mut Self { + self.prefetch = enabled; + self + } } impl DbDupCursorRO for Cursor { /// Returns the previous `(key, value)` pair of a DUPSORT table. fn prev_dup(&mut self) -> PairResult { - decode::(self.inner.prev_dup()) + decode::(read_pair!(self, prev_dup())) } /// Returns the next `(key, value)` pair of a DUPSORT table. fn next_dup(&mut self) -> PairResult { - decode::(self.inner.next_dup()) + decode::(read_pair!(self, next_dup())) } /// Returns the last `value` of the current duplicate `key`. fn last_dup(&mut self) -> ValueOnlyResult { - self.inner - .last_dup() + read_value!(self, last_dup()) .map_err(|e| DatabaseError::Read(e.into()))? .map(decode_one::) .transpose() @@ -179,13 +222,12 @@ impl DbDupCursorRO for Cursor { /// Returns the next `(key, value)` pair skipping the duplicates. fn next_no_dup(&mut self) -> PairResult { - decode::(self.inner.next_nodup()) + decode::(read_pair!(self, next_nodup())) } /// Returns the next `value` of a duplicate `key`. fn next_dup_val(&mut self) -> ValueOnlyResult { - self.inner - .next_dup() + read_pair!(self, next_dup()) .map_err(|e| DatabaseError::Read(e.into()))? .map(decode_value::) .transpose() @@ -196,8 +238,7 @@ impl DbDupCursorRO for Cursor { key: ::Key, subkey: ::SubKey, ) -> ValueOnlyResult { - self.inner - .get_both_range(key.encode().as_ref(), subkey.encode().as_ref()) + read_value!(self, get_both_range(key.encode().as_ref(), subkey.encode().as_ref())) .map_err(|e| DatabaseError::Read(e.into()))? .map(decode_one::) .transpose() @@ -216,25 +257,25 @@ impl DbDupCursorRO for Cursor { let start = match (key, subkey) { (Some(key), Some(subkey)) => { let encoded_key = key.encode(); - self.inner - .get_both_range(encoded_key.as_ref(), subkey.encode().as_ref()) + read_value!(self, get_both_range(encoded_key.as_ref(), subkey.encode().as_ref())) .map_err(|e| DatabaseError::Read(e.into()))? .map(|val| decoder::((Cow::Borrowed(encoded_key.as_ref()), val))) } (Some(key), None) => { let encoded_key = key.encode(); - self.inner - .set(encoded_key.as_ref()) + read_value!(self, set(encoded_key.as_ref())) .map_err(|e| DatabaseError::Read(e.into()))? .map(|val| decoder::((Cow::Borrowed(encoded_key.as_ref()), val))) } (None, Some(subkey)) => { if let Some((key, _)) = self.first()? { let encoded_key = key.encode(); - self.inner - .get_both_range(encoded_key.as_ref(), subkey.encode().as_ref()) - .map_err(|e| DatabaseError::Read(e.into()))? - .map(|val| decoder::((Cow::Borrowed(encoded_key.as_ref()), val))) + read_value!( + self, + get_both_range(encoded_key.as_ref(), subkey.encode().as_ref()) + ) + .map_err(|e| DatabaseError::Read(e.into()))? + .map(|val| decoder::((Cow::Borrowed(encoded_key.as_ref()), val))) } else { Some(Err(DatabaseError::Read(MDBXError::NotFound.into()))) } @@ -361,12 +402,13 @@ impl DbDupCursorRW for Cursor { #[cfg(test)] mod tests { + use crate::{ mdbx::{DatabaseArguments, DatabaseEnv, DatabaseEnvKind}, tables::StorageChangeSets, Database, }; - use alloy_primitives::{address, Address, B256, U256}; + use alloy_primitives::{Address, B256, U256}; use reth_db_api::{ cursor::{DbCursorRO, DbDupCursorRW}, models::{BlockNumberAddress, ClientVersion}, @@ -390,9 +432,9 @@ mod tests { #[test] fn test_import_table_with_range_works_on_dupsort() { - let addr1 = address!("0000000000000000000000000000000000000001"); - let addr2 = address!("0000000000000000000000000000000000000002"); - let addr3 = address!("0000000000000000000000000000000000000003"); + let addr1 = Address::with_last_byte(1); + let addr2 = Address::with_last_byte(2); + let addr3 = Address::with_last_byte(3); let source_db = create_test_db(); let target_db = create_test_db(); let test_data = vec![ diff --git a/crates/storage/db/src/implementation/mdbx/mod.rs b/crates/storage/db/src/implementation/mdbx/mod.rs index bd5024ff8e4..64b44883cf2 100644 --- a/crates/storage/db/src/implementation/mdbx/mod.rs +++ b/crates/storage/db/src/implementation/mdbx/mod.rs @@ -35,6 +35,8 @@ pub mod cursor; pub mod tx; mod utils; +#[cfg(target_os = "linux")] +mod value_prefetch; /// 1 KB in bytes pub const KILOBYTE: usize = 1024; @@ -676,6 +678,7 @@ impl Deref for DatabaseEnv { #[cfg(test)] mod tests { + use super::*; use crate::{ tables::{ @@ -694,7 +697,6 @@ mod tests { use reth_libmdbx::Error; use reth_primitives_traits::{Account, StorageEntry}; use reth_storage_errors::db::{DatabaseWriteError, DatabaseWriteOperation}; - use std::str::FromStr; use tempfile::TempDir; /// Create database for testing. Returns the `TempDir` to prevent cleanup until test ends. @@ -722,7 +724,6 @@ mod tests { const ERROR_COMMIT: &str = "Not able to commit transaction."; const ERROR_RETURN_VALUE: &str = "Mismatching result."; const ERROR_INIT_TX: &str = "Failed to create a MDBX transaction."; - const ERROR_ETH_ADDRESS: &str = "Invalid address."; #[test] fn db_creation() { @@ -795,8 +796,8 @@ mod tests { let mut dup_cursor = tx.cursor_dup_write::().unwrap(); - let entry_0 = StorageEntry { key: B256::with_last_byte(1), value: U256::from(0) }; - let entry_1 = StorageEntry { key: B256::with_last_byte(1), value: U256::from(1) }; + let entry_0 = StorageEntry { key: B256::with_last_byte(1), value: U256::ZERO }; + let entry_1 = StorageEntry { key: B256::with_last_byte(1), value: U256::ONE }; dup_cursor.upsert(Address::with_last_byte(1), &entry_0).expect(ERROR_UPSERT); dup_cursor.upsert(Address::with_last_byte(1), &entry_1).expect(ERROR_UPSERT); @@ -1334,7 +1335,7 @@ mod tests { let mut dup_cursor = tx.cursor_dup_write::().unwrap(); let subkey = B256::random(); - let value = U256::from(1); + let value = U256::ONE; let entry1 = StorageEntry { key: subkey, value }; dup_cursor.upsert(key, &entry1).expect(ERROR_UPSERT); assert_eq!(dup_cursor.seek_by_key_subkey(key, subkey).unwrap(), Some(entry1)); @@ -1423,8 +1424,7 @@ mod tests { balance: U256::MAX, ..Default::default() }; - let key = Address::from_str("0xa2c122be93b0074270ebee7f6b7292c7deb45047") - .expect(ERROR_ETH_ADDRESS); + let key = address!("0xa2c122be93b0074270ebee7f6b7292c7deb45047"); { let env = create_test_db_with_path(DatabaseEnvKind::RW, path); @@ -1454,8 +1454,7 @@ mod tests { #[test] fn db_dup_sort() { let (_tempdir, env) = create_test_db(DatabaseEnvKind::RW); - let key = Address::from_str("0xa2c122be93b0074270ebee7f6b7292c7deb45047") - .expect(ERROR_ETH_ADDRESS); + let key = address!("0xa2c122be93b0074270ebee7f6b7292c7deb45047"); // PUT (0,0) let value00 = StorageEntry::default(); @@ -1466,7 +1465,7 @@ mod tests { env.update(|tx| tx.put::(key, value22).expect(ERROR_PUT)).unwrap(); // PUT (1,1) - let value11 = StorageEntry { key: B256::with_last_byte(1), value: U256::from(1) }; + let value11 = StorageEntry { key: B256::with_last_byte(1), value: U256::ONE }; env.update(|tx| tx.put::(key, value11).expect(ERROR_PUT)).unwrap(); // Iterate with cursor @@ -1498,8 +1497,7 @@ mod tests { #[test] fn db_walk_dup_with_not_existing_key() { let (_tempdir, env) = create_test_db(DatabaseEnvKind::RW); - let key = Address::from_str("0xa2c122be93b0074270ebee7f6b7292c7deb45047") - .expect(ERROR_ETH_ADDRESS); + let key = address!("0xa2c122be93b0074270ebee7f6b7292c7deb45047"); // PUT (0,0) let value00 = StorageEntry::default(); @@ -1510,7 +1508,7 @@ mod tests { env.update(|tx| tx.put::(key, value22).expect(ERROR_PUT)).unwrap(); // PUT (1,1) - let value11 = StorageEntry { key: B256::with_last_byte(1), value: U256::from(1) }; + let value11 = StorageEntry { key: B256::with_last_byte(1), value: U256::ONE }; env.update(|tx| tx.put::(key, value11).expect(ERROR_PUT)).unwrap(); // Try to walk_dup with not existing key should immediately return None @@ -1526,17 +1524,15 @@ mod tests { #[test] fn db_iterate_over_all_dup_values() { let (_tempdir, env) = create_test_db(DatabaseEnvKind::RW); - let key1 = Address::from_str("0x1111111111111111111111111111111111111111") - .expect(ERROR_ETH_ADDRESS); - let key2 = Address::from_str("0x2222222222222222222222222222222222222222") - .expect(ERROR_ETH_ADDRESS); + let key1 = Address::repeat_byte(0x11); + let key2 = Address::repeat_byte(0x22); // PUT key1 (0,0) let value00 = StorageEntry::default(); env.update(|tx| tx.put::(key1, value00).expect(ERROR_PUT)).unwrap(); // PUT key1 (1,1) - let value11 = StorageEntry { key: B256::with_last_byte(1), value: U256::from(1) }; + let value11 = StorageEntry { key: B256::with_last_byte(1), value: U256::ONE }; env.update(|tx| tx.put::(key1, value11).expect(ERROR_PUT)).unwrap(); // PUT key2 (2,2) @@ -1572,11 +1568,11 @@ mod tests { #[test] fn dup_value_with_same_subkey() { let (_tempdir, env) = create_test_db(DatabaseEnvKind::RW); - let key1 = Address::new([0x11; 20]); - let key2 = Address::new([0x22; 20]); + let key1 = Address::repeat_byte(0x11); + let key2 = Address::repeat_byte(0x22); // PUT key1 (0,1) - let value01 = StorageEntry { key: B256::with_last_byte(0), value: U256::from(1) }; + let value01 = StorageEntry { key: B256::with_last_byte(0), value: U256::ONE }; env.update(|tx| tx.put::(key1, value01).expect(ERROR_PUT)).unwrap(); // PUT key1 (0,0) diff --git a/crates/storage/db/src/implementation/mdbx/tx.rs b/crates/storage/db/src/implementation/mdbx/tx.rs index 396233508f8..ce290012b8b 100644 --- a/crates/storage/db/src/implementation/mdbx/tx.rs +++ b/crates/storage/db/src/implementation/mdbx/tx.rs @@ -41,6 +41,9 @@ pub struct Tx { /// /// If [Some], then metrics are reported. metrics_handler: Option>, + /// Persistent read-ahead setting for gets and newly created cursors. + #[cfg(target_os = "linux")] + prefetch: AtomicBool, } impl Tx { @@ -60,7 +63,13 @@ impl Tx { Ok(handler) }) .transpose()?; - Ok(Self { inner, dbis, metrics_handler }) + Ok(Self { + inner, + dbis, + metrics_handler, + #[cfg(target_os = "linux")] + prefetch: AtomicBool::new(false), + }) } /// Returns a reference to the inner libmdbx transaction. @@ -102,6 +111,8 @@ impl Tx { Ok(Cursor::new_with_metrics( inner, self.metrics_handler.as_ref().map(|h| h.env_metrics.table_operation_metrics(T::NAME)), + #[cfg(target_os = "linux")] + self.prefetch.load(Ordering::Relaxed), )) } @@ -198,8 +209,8 @@ struct MetricsHandler { /// Shared database environment metrics. env_metrics: Arc, /// Backtrace of the location where the transaction has been opened. Reported only with debug - /// assertions, because capturing the backtrace on every transaction opening is expensive. - #[cfg(debug_assertions)] + /// assertions outside DST, because capturing every transaction opening is expensive. + #[cfg(all(debug_assertions, not(feature = "dst")))] open_backtrace: Backtrace, _marker: PhantomData, } @@ -213,7 +224,7 @@ impl MetricsHandler { close_recorded: false, record_backtrace: true, backtrace_recorded: AtomicBool::new(false), - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] open_backtrace: Backtrace::force_capture(), env_metrics, _marker: PhantomData, @@ -254,9 +265,9 @@ impl MetricsHandler { let open_duration = self.start.elapsed(); if open_duration >= self.long_transaction_duration { self.backtrace_recorded.store(true, Ordering::Relaxed); - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] let open_backtrace = format_args!("{}", self.open_backtrace); - #[cfg(not(debug_assertions))] + #[cfg(not(all(debug_assertions, not(feature = "dst"))))] let open_backtrace = tracing::field::Empty; warn!( target: "storage::db::mdbx", @@ -301,10 +312,17 @@ impl DbTx for Tx { key: &::Encoded, ) -> Result, DatabaseError> { self.execute_with_operation_metric::(Operation::Get, None, |tx| { - tx.get(self.get_dbi::()?, key.as_ref()) - .map_err(|e| DatabaseError::Read(e.into()))? - .map(decode_one::) - .transpose() + let dbi = self.get_dbi::()?; + #[cfg(target_os = "linux")] + let value = if self.prefetch.load(Ordering::Relaxed) { + tx.get::>(dbi, key.as_ref()) + .map(|value| value.map(|value| value.0)) + } else { + tx.get(dbi, key.as_ref()) + }; + #[cfg(not(target_os = "linux"))] + let value = tx.get(dbi, key.as_ref()); + value.map_err(|e| DatabaseError::Read(e.into()))?.map(decode_one::).transpose() }) } @@ -352,6 +370,12 @@ impl DbTx for Tx { self.inner.disable_timeout(); } + + #[cfg(target_os = "linux")] + fn prefetch(&self, enabled: bool) -> &Self { + self.prefetch.store(enabled, Ordering::Relaxed); + self + } } #[derive(Clone, Copy)] diff --git a/crates/storage/db/src/implementation/mdbx/value_prefetch.rs b/crates/storage/db/src/implementation/mdbx/value_prefetch.rs new file mode 100644 index 00000000000..97bb724dde8 --- /dev/null +++ b/crates/storage/db/src/implementation/mdbx/value_prefetch.rs @@ -0,0 +1,433 @@ +//! Targeted read-ahead for mapped table values spanning multiple OS pages. + +use reth_libmdbx::{ffi, TableObject, TransactionKind}; +use std::borrow::Cow; + +/// Hints a mapped table value's pages before returning its bytes for decoding. +pub(super) struct PrefetchValue<'a>(pub(super) Cow<'a, [u8]>); + +impl<'a> TableObject for PrefetchValue<'a> { + fn decode(_: &[u8]) -> reth_libmdbx::Result { + unreachable!("value prefetch requires the MDBX transaction context") + } + + unsafe fn decode_val( + txn: *const ffi::MDBX_txn, + value: ffi::MDBX_val, + ) -> reth_libmdbx::Result { + #[cfg(test)] + tests::PREFETCH_READS.with(|count| count.set(count.get() + 1)); + // SAFETY: MDBX invokes this decoder with a value from the live transaction. + if let Some((start, len)) = unsafe { prefetch_range::(txn, value) } { + // MDBX disables general read-ahead. Hint only this value's pages to avoid + // serial faults while decoding large values, without enabling general read-ahead. + // SAFETY: The complete rounded range belongs to the live MDBX file mapping. + // Advice is best-effort and must not turn a successful lookup into an error. + let _ = unsafe { libc::madvise(start as *mut libc::c_void, len, libc::MADV_WILLNEED) }; + } + // SAFETY: Preserve libmdbx's borrowing/copying behavior for the original value. + unsafe { as TableObject>::decode_val::(txn, value).map(Self) } + } +} + +/// Returns the OS pages to prefetch, excluding small and dirty values. +/// +/// # Safety +/// `value` must be the unmodified value returned by MDBX for the live `txn` of kind `K`. +unsafe fn prefetch_range( + txn: *const ffi::MDBX_txn, + value: ffi::MDBX_val, +) -> Option<(usize, usize)> { + let page_size = page_size::get(); + if value.iov_len <= page_size { + return None; + } + // Unchanged values can also be read through writable transactions. + // Dirty values can be heap-backed; never advise them, even with `return-borrowed`. + // SAFETY: This is the original value pointer, before any copy or transaction mutation. + if !K::IS_READ_ONLY && unsafe { ffi::mdbx_is_dirty(txn, value.iov_base) } != ffi::MDBX_SUCCESS { + return None; + } + page_range(value.iov_base as usize, value.iov_len, page_size) +} + +/// Rounds a nonempty value's range to complete pages, rejecting invalid sizes and overflow. +fn page_range(address: usize, len: usize, page_size: usize) -> Option<(usize, usize)> { + if len == 0 || !page_size.is_power_of_two() { + return None; + } + let start = address & !(page_size - 1); + let end = address.checked_add(len)?.checked_add(page_size - 1)? & !(page_size - 1); + Some((start, end.checked_sub(start)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + mdbx::DatabaseArguments, tables, test_utils::create_test_rw_db, DatabaseEnv, + DatabaseEnvKind, + }; + use alloy_primitives::{Address, B256, U256}; + use reth_db_api::{ + cursor::{DbCursorRO, DbCursorRW, DbDupCursorRO}, + database::Database, + models::{ClientVersion, ShardedKey}, + table::Encode, + transaction::{DbTx, DbTxMut}, + }; + use reth_primitives_traits::{Bytecode, StorageEntry}; + use std::cell::Cell; + + thread_local! { + pub(super) static PREFETCH_READS: Cell = const { Cell::new(0) }; + } + + /// Verifies decoder selection, including for small values whose advice is skipped. + fn with_prefetch_reads(expected: usize, f: impl FnOnce() -> R) -> R { + let before = PREFETCH_READS.get(); + let result = f(); + assert_eq!(PREFETCH_READS.get() - before, expected); + result + } + + #[test] + fn rounds_to_os_pages() { + assert_eq!(page_range(0x1014, 27_693, 4096), Some((0x1000, 7 * 4096))); + assert_eq!(page_range(0x1000, 4096, 4096), Some((0x1000, 4096))); + assert_eq!(page_range(0x1fff, 2, 4096), Some((0x1000, 8192))); + assert_eq!(page_range(0x1014, 0xfec, 4096), Some((0x1000, 4096))); + assert_eq!(page_range(0x1ffff, 2, 65536), Some((0x10000, 2 * 65536))); + assert_eq!(page_range(0x1000, 0, 4096), None); + assert_eq!(page_range(usize::MAX - 10, 20, 4096), None); + assert_eq!(page_range(usize::MAX - 10, 1, 4096), None); + assert_eq!(page_range(0x1000, 4096, 0), None); + assert_eq!(page_range(0x1000, 4096, 3), None); + } + + /// Inspects the same prefetch eligibility decision used by the value decoder. + struct PrefetchRange(Option<(usize, usize)>); + + impl TableObject for PrefetchRange { + fn decode(_: &[u8]) -> reth_libmdbx::Result { + unreachable!() + } + + unsafe fn decode_val( + txn: *const ffi::MDBX_txn, + value: ffi::MDBX_val, + ) -> reth_libmdbx::Result { + // SAFETY: MDBX supplies the original value and its live transaction. + Ok(Self(unsafe { prefetch_range::(txn, value) })) + } + } + + #[test] + fn prefetches_clean_bytecode_in_read_only_and_writable_transactions() { + let dir = tempfile::tempdir().unwrap(); + let mut db = DatabaseEnv::open( + dir.path(), + DatabaseEnvKind::RW, + DatabaseArguments::new(ClientVersion::default()), + ) + .unwrap() + .with_metrics(); + db.create_tables().unwrap(); + // Larger than an OS page even on systems with 64 KiB pages. + let code = Bytecode::new_raw(vec![0x5b; 6 * page_size::get()].into()); + let key = B256::repeat_byte(1); + let small_key = B256::repeat_byte(2); + let small_code = Bytecode::new_raw(vec![0].into()); + let tx = db.tx_mut().unwrap(); + let dbi = tx.get_dbi::().unwrap(); + DbTxMut::put::(&tx, key, code.clone()).unwrap(); + DbTxMut::put::(&tx, small_key, small_code.clone()).unwrap(); + assert!(tx + .inner() + .get::(dbi, key.encode().as_ref()) + .unwrap() + .unwrap() + .0 + .is_none()); + assert_eq!(tx.prefetch(true).get::(key).unwrap(), Some(code.clone())); + assert_eq!(tx.get::(key).unwrap(), Some(code.clone())); + tx.commit().unwrap(); + + // Catch-up execution reads clean, mapped values through a writable transaction. + let tx = db.tx_mut().unwrap(); + let range = tx + .inner() + .get::(dbi, key.encode().as_ref()) + .unwrap() + .unwrap() + .0 + .unwrap(); + assert_eq!(range.0 % page_size::get(), 0); + assert!(range.1 >= code.original_byte_slice().len()); + assert_eq!( + tx.prefetch(true).get_by_encoded_key::(&key.encode()).unwrap(), + Some(code.clone()) + ); + // A value becomes ineligible again when overwritten in the same transaction. + let replacement = Bytecode::new_raw(vec![0; 6 * page_size::get()].into()); + DbTxMut::put::(&tx, key, replacement.clone()).unwrap(); + assert!(tx + .inner() + .get::(dbi, key.encode().as_ref()) + .unwrap() + .unwrap() + .0 + .is_none()); + assert_eq!(tx.prefetch(true).get::(key).unwrap(), Some(replacement)); + tx.abort(); + + let tx = db.tx().unwrap(); + assert!(tx + .inner() + .get::(dbi, key.encode().as_ref()) + .unwrap() + .unwrap() + .0 + .is_some()); + assert_eq!(tx.prefetch(true).get::(key).unwrap(), Some(code)); + assert!(tx + .inner() + .get::(dbi, small_key.encode().as_ref()) + .unwrap() + .unwrap() + .0 + .is_none()); + assert_eq!( + tx.prefetch(true).get::(small_key).unwrap(), + Some(small_code) + ); + assert!(tx + .inner() + .get::(dbi, B256::ZERO.encode().as_ref()) + .unwrap() + .is_none()); + assert_eq!(tx.prefetch(true).get::(B256::ZERO).unwrap(), None); + } + + #[test] + fn prefetched_reads_preserve_decode_errors() { + let dir = tempfile::tempdir().unwrap(); + let mut db = DatabaseEnv::open( + dir.path(), + DatabaseEnvKind::RW, + DatabaseArguments::new(ClientVersion::default()), + ) + .unwrap(); + db.create_tables().unwrap(); + let key = ShardedKey::new(Address::ZERO, 1); + // One RoaringTreemap entry with an invalid bitmap header. + let mut invalid_history = vec![0; 6 * page_size::get()]; + invalid_history[..8].copy_from_slice(&1u64.to_le_bytes()); + let tx = db.tx_mut().unwrap(); + tx.put::>( + tables::RawKey::new(key.clone()), + tables::RawValue::from_vec(invalid_history), + ) + .unwrap(); + tx.commit().unwrap(); + + let tx = db.tx().unwrap(); + with_prefetch_reads(1, || { + assert_eq!( + tx.prefetch(true) + .get::(key.clone()) + .unwrap_err() + .to_string(), + tx.prefetch(false) + .get::(key.clone()) + .unwrap_err() + .to_string() + ) + }); + tx.prefetch(true); + with_prefetch_reads(2, || { + assert!(tx.get::(key.clone()).is_err()); + assert!(tx.get::(key).is_err()); + }); + } + + #[test] + fn transaction_and_cursor_prefetch_persist_until_changed() { + let db = create_test_rw_db(); + let tx = db.tx_mut().unwrap(); + let key = B256::repeat_byte(1); + let code = Bytecode::new_raw(vec![0x5b; 6 * page_size::get()].into()); + let mut ordinary = tx.cursor_read::().unwrap(); + assert!(std::ptr::eq(tx.prefetch(true), &raw const tx)); + // Writes, statistics, gets, misses, and cursor creation preserve the setting. + DbTxMut::put::(&tx, key, code.clone()).unwrap(); + assert_eq!(tx.entries::().unwrap(), 1); + with_prefetch_reads(2, || { + assert_eq!(tx.get::(key).unwrap(), Some(code.clone())); + assert_eq!( + tx.get_by_encoded_key::(&key).unwrap(), + Some(code.clone()) + ); + }); + with_prefetch_reads(1, || { + assert!(tx.get::(B256::ZERO).unwrap().is_none()); + tx.get::(key).unwrap(); + }); + let mut prefetched = tx.cursor_write::().unwrap(); + let mut sibling = tx.cursor_read::().unwrap(); + with_prefetch_reads(0, || ordinary.first().unwrap()); + with_prefetch_reads(3, || { + assert_eq!(prefetched.first().unwrap(), Some((key, code))); + sibling.first().unwrap(); + tx.get::(key).unwrap(); + }); + + // A cursor can disable its inherited setting without changing its position or siblings. + with_prefetch_reads(0, || prefetched.prefetch(false).current().unwrap()); + with_prefetch_reads(2, || { + sibling.current().unwrap(); + tx.get::(key).unwrap(); + }); + tx.prefetch(false); + with_prefetch_reads(0, || { + tx.get::(key).unwrap(); + tx.cursor_read::().unwrap().first().unwrap(); + }); + with_prefetch_reads(1, || sibling.current().unwrap()); + + // Cursor overrides are independent of the transaction and persist through writes. + with_prefetch_reads(1, || prefetched.prefetch(true).current().unwrap()); + prefetched.upsert(key, &Bytecode::new_raw(vec![0].into())).unwrap(); + with_prefetch_reads(1, || prefetched.current().unwrap()); + with_prefetch_reads(0, || { + ordinary.current().unwrap(); + tx.get::(key).unwrap(); + }); + } + + #[test] + fn prefetch_covers_cursor_reads_and_walks() { + let db = create_test_rw_db(); + let tx = db.tx_mut().unwrap(); + let code = Bytecode::new_raw(vec![0x5b; 6 * page_size::get()].into()); + let keys = [B256::repeat_byte(1), B256::repeat_byte(2), B256::repeat_byte(3)]; + for key in keys { + DbTxMut::put::(&tx, key, code.clone()).unwrap(); + } + tx.commit().unwrap(); + let tx = db.tx().unwrap(); + let mut cursor = tx.prefetch(true).cursor_read::().unwrap(); + with_prefetch_reads(7, || { + assert_eq!(cursor.first().unwrap(), Some((keys[0], code.clone()))); + assert_eq!(cursor.next().unwrap(), Some((keys[1], code.clone()))); + assert_eq!(cursor.prev().unwrap(), Some((keys[0], code.clone()))); + assert_eq!(cursor.last().unwrap(), Some((keys[2], code.clone()))); + assert_eq!(cursor.current().unwrap(), Some((keys[2], code.clone()))); + assert_eq!(cursor.seek(keys[1]).unwrap(), Some((keys[1], code.clone()))); + assert_eq!(cursor.seek_exact(keys[0]).unwrap(), Some((keys[0], code.clone()))); + }); + let expected = keys.map(|key| (key, code.clone())).to_vec(); + for start in [None, Some(keys[0])] { + with_prefetch_reads(3, || { + assert_eq!( + cursor.walk(start).unwrap().collect::, _>>().unwrap(), + expected + ); + }); + } + for start in [None, Some(keys[2])] { + with_prefetch_reads(3, || { + assert_eq!( + cursor.walk_back(start).unwrap().collect::, _>>().unwrap(), + expected.iter().rev().cloned().collect::>() + ); + }); + } + with_prefetch_reads(3, || { + assert_eq!( + cursor + .walk_range(keys[0]..=keys[2]) + .unwrap() + .collect::, _>>() + .unwrap(), + expected + ); + }); + with_prefetch_reads(3, || { + assert_eq!( + cursor.walk_range(..).unwrap().collect::, _>>().unwrap(), + expected + ); + }); + with_prefetch_reads(0, || { + assert_eq!( + cursor.prefetch(false).walk(None).unwrap().collect::, _>>().unwrap(), + expected + ); + }); + with_prefetch_reads(3, || { + assert_eq!( + cursor.prefetch(true).walk(None).unwrap().collect::, _>>().unwrap(), + expected + ); + }); + } + + #[test] + fn prefetch_covers_duplicate_cursor_reads() { + let db = create_test_rw_db(); + let tx = db.tx_mut().unwrap(); + let address = Address::repeat_byte(1); + let next_address = Address::repeat_byte(2); + let entries = + [1, 2, 3].map(|i| StorageEntry { key: B256::repeat_byte(i), value: U256::from(i) }); + for entry in entries { + DbTxMut::put::(&tx, address, entry).unwrap(); + } + DbTxMut::put::(&tx, next_address, entries[0]).unwrap(); + tx.commit().unwrap(); + let tx = db.tx_mut().unwrap(); + // Both read-only and writable duplicate cursor factories inherit the flag. + let mut cursor = tx.prefetch(true).cursor_dup_read::().unwrap(); + with_prefetch_reads(7, || { + assert_eq!(cursor.first().unwrap(), Some((address, entries[0]))); + assert_eq!(cursor.next_dup().unwrap(), Some((address, entries[1]))); + assert_eq!(cursor.prev_dup().unwrap(), Some((address, entries[0]))); + assert_eq!(cursor.next_dup_val().unwrap(), Some(entries[1])); + assert_eq!(cursor.last_dup().unwrap(), Some(entries[2])); + assert_eq!(cursor.next_no_dup().unwrap(), Some((next_address, entries[0]))); + assert_eq!( + cursor.seek_by_key_subkey(address, entries[1].key).unwrap(), + Some(entries[1]) + ); + }); + let mut cursor = tx.prefetch(true).cursor_dup_write::().unwrap(); + for (key, subkey, reads) in [ + (Some(address), Some(entries[0].key), 3), + (Some(address), None, 3), + (None, Some(entries[0].key), 4), + (None, None, 3), + ] { + with_prefetch_reads(reads, || { + assert_eq!( + cursor.walk_dup(key, subkey).unwrap().collect::, _>>().unwrap(), + entries.map(|entry| (address, entry)).to_vec() + ); + }); + } + with_prefetch_reads(0, || { + assert_eq!( + cursor.prefetch(false).seek_by_key_subkey(address, entries[0].key).unwrap(), + Some(entries[0]) + ); + assert_eq!(cursor.next_dup_val().unwrap(), Some(entries[1])); + }); + with_prefetch_reads(2, || { + assert_eq!( + cursor.prefetch(true).seek_by_key_subkey(address, entries[0].key).unwrap(), + Some(entries[0]) + ); + assert_eq!(cursor.next_dup_val().unwrap(), Some(entries[1])); + }); + } +} diff --git a/crates/storage/errors/src/provider.rs b/crates/storage/errors/src/provider.rs index 8c61f4b95b2..449ed45e98a 100644 --- a/crates/storage/errors/src/provider.rs +++ b/crates/storage/errors/src/provider.rs @@ -200,12 +200,25 @@ pub enum ProviderError { /// Version this build writes. supported: u32, }, + /// Snap sync was selected on a database that predates the hashed state layout it downloads + /// into. + #[error("snap sync requires the hashed state layout, which this database predates")] + SnapStorageLayoutUnsupported, /// State a snap attempt is still downloading was about to be marked complete. #[error("snap attempt {attempt} has not verified the downloaded state")] UnverifiedSnapState { /// Attempt that owns the unverified state. attempt: u64, }, + /// The staged pipeline was selected on a database holding state a snap attempt left + /// unverified, which only snap can finish or replace. + #[error( + "snap attempt {attempt} left unverified state, restart with snap sync or wipe the database" + )] + SnapStateRequiresSnapSync { + /// Attempt that owns the unverified state. + attempt: u64, + }, /// Any other error type wrapped into a cloneable [`AnyError`]. #[error(transparent)] Other(#[from] AnyError), @@ -240,7 +253,7 @@ impl ProviderError { /// Returns true if this type is a [`ProviderError::Other`] of that error /// type. Returns false otherwise. pub fn is_other(&self) -> bool { - self.as_other().map(|err| err.is::()).unwrap_or(false) + self.as_other().is_some_and(|err| err.is::()) } } diff --git a/crates/storage/libmdbx-rs/Cargo.toml b/crates/storage/libmdbx-rs/Cargo.toml index 540bdedd33b..9bdf3901ddd 100644 --- a/crates/storage/libmdbx-rs/Cargo.toml +++ b/crates/storage/libmdbx-rs/Cargo.toml @@ -26,6 +26,7 @@ tracing.workspace = true dashmap = { workspace = true, features = ["inline"], optional = true } [features] +dst = [] default = [] return-borrowed = [] read-tx-timeouts = ["dep:dashmap"] diff --git a/crates/storage/libmdbx-rs/src/txn_manager.rs b/crates/storage/libmdbx-rs/src/txn_manager.rs index f927c09fe2d..6251ccb23a4 100644 --- a/crates/storage/libmdbx-rs/src/txn_manager.rs +++ b/crates/storage/libmdbx-rs/src/txn_manager.rs @@ -191,7 +191,7 @@ mod read_transactions { /// comparable. The time of transaction opening is stored as a value. /// /// The backtrace of the transaction opening is recorded only when debug assertions are - /// enabled. + /// enabled outside DST. active: DashMap>)>, /// List of timed out transactions that were not aborted by the user yet, hence have a /// dangling read transaction pointer. @@ -210,7 +210,8 @@ mod read_transactions { ( tx, Instant::now(), - cfg!(debug_assertions).then(|| Arc::new(Backtrace::force_capture())), + cfg!(all(debug_assertions, not(feature = "dst"))) + .then(|| Arc::new(Backtrace::force_capture())), ), ); } @@ -341,7 +342,10 @@ mod read_transactions { txn_manager::read_transactions::READ_TRANSACTIONS_CHECK_INTERVAL, Environment, Error, MaxReadTransactionDuration, }; - use std::{thread::sleep, time::Duration}; + use std::{ + thread::sleep, + time::{Duration, Instant}, + }; use tempfile::tempdir; #[test] @@ -361,6 +365,10 @@ mod read_transactions { let tx = env.begin_ro_txn().unwrap(); let tx_ptr = tx.txn() as usize; assert!(read_transactions.active.contains_key(&tx_ptr)); + assert_eq!( + read_transactions.active.get(&tx_ptr).unwrap().2.is_some(), + cfg!(all(debug_assertions, not(feature = "dst"))), + ); tx.open_db(None).unwrap(); drop(tx); @@ -388,7 +396,11 @@ mod read_transactions { assert!(read_transactions.active.contains_key(&tx_ptr)); // Wait until the transaction is timed out by the manager. - sleep(MAX_DURATION + READ_TRANSACTIONS_CHECK_INTERVAL); + let deadline = Instant::now() + MAX_DURATION + READ_TRANSACTIONS_CHECK_INTERVAL; + while !read_transactions.timed_out_not_aborted.contains(&tx_ptr) { + assert!(Instant::now() < deadline, "transaction was not timed out"); + sleep(Duration::from_millis(10)); + } // Ensure that the transaction is not in the list of active transactions anymore, // and is in the list of timed out but not aborted transactions. @@ -429,7 +441,6 @@ mod read_transactions { assert!(env.txn_manager().read_transactions.is_none()); let tx = env.begin_ro_txn().unwrap(); - sleep(READ_TRANSACTIONS_CHECK_INTERVAL); assert!(tx.commit().is_ok()) } } diff --git a/crates/storage/nippy-jar/src/cursor.rs b/crates/storage/nippy-jar/src/cursor.rs index 4fc74a57264..7e62c53a222 100644 --- a/crates/storage/nippy-jar/src/cursor.rs +++ b/crates/storage/nippy-jar/src/cursor.rs @@ -150,13 +150,18 @@ impl<'a, H: NippyJarHeader> NippyJarCursor<'a, H> { let offset_pos = self.row as usize * self.jar.columns + column; let value_offset = self.reader.offset(offset_pos)? as usize; - let column_offset_range = if self.jar.rows * self.jar.columns == offset_pos + 1 { - // It's the last column of the last row - value_offset..self.reader.size() - } else { - let next_value_offset = self.reader.offset(offset_pos + 1)? as usize; - value_offset..next_value_offset - }; + // A value ends where the next one starts. After the last value, the final offset marks the + // end of the committed data, which is not the end of the data file while a writer appends + // rows it has not committed yet. + let next_value_offset = self.reader.offset(offset_pos + 1)? as usize; + + // Offsets read through a stale mmap while a writer truncates the files, e.g. a concurrent + // prune, can be out of order or past the mapped data. Slicing with them would panic. + if next_value_offset < value_offset || next_value_offset > self.reader.size() { + return Err(NippyJarError::InconsistentState) + } + + let column_offset_range = value_offset..next_value_offset; if let Some(compression) = self.jar.compressor() { // The decompressors write into the spare capacity of the buffer, so it has to fit any diff --git a/crates/storage/nippy-jar/src/lib.rs b/crates/storage/nippy-jar/src/lib.rs index c1d6cefb316..fc1a381f3ef 100644 --- a/crates/storage/nippy-jar/src/lib.rs +++ b/crates/storage/nippy-jar/src/lib.rs @@ -449,7 +449,10 @@ mod tests { use super::*; use compression::Compression; use rand::{rngs::SmallRng, seq::SliceRandom, RngCore, SeedableRng}; - use std::{fs::OpenOptions, io::Read}; + use std::{ + fs::OpenOptions, + io::{Read, Seek, SeekFrom}, + }; type ColumnResults = Vec>; type ColumnValues = Vec>; @@ -841,6 +844,148 @@ mod tests { } } + #[test] + fn test_read_last_row_while_appending() { + let (col1, col2) = test_data(None); + let num_columns = 2; + let file_path = tempfile::NamedTempFile::new().unwrap(); + + append_two_rows(num_columns, file_path.path(), &col1, &col2); + + // Appends a third row to the data file, without committing its offsets and the row count. + let nippy = NippyJar::load_without_header(file_path.path()).unwrap(); + let mut writer = NippyJarWriter::new(nippy).unwrap(); + writer.append_column(Some(Ok(&col1[2]))).unwrap(); + writer.append_column(Some(Ok(&col2[2]))).unwrap(); + writer.data_file().flush().unwrap(); + + // A jar loaded meanwhile reads the last committed row as it was written. + let nippy = NippyJar::load_without_header(file_path.path()).unwrap(); + assert_eq!(nippy.rows, 2); + let mut cursor = NippyJarCursor::new(&nippy).unwrap(); + assert_eq!( + cursor.row_by_number(1).unwrap().unwrap().as_slice(), + [col1[1].as_slice(), col2[1].as_slice()] + ); + assert!(cursor.row_by_number(2).unwrap().is_none()); + } + + #[test] + fn test_read_last_row_with_invalid_final_offset() { + let (col1, col2) = test_data(None); + let num_columns = 2; + + // Overwrites the final offset of a committed jar, then reads its last row. + let assert_inconsistent = |final_offset: fn(u64) -> u64| { + let file_path = tempfile::NamedTempFile::new().unwrap(); + append_two_rows(num_columns, file_path.path(), &col1, &col2); + + let nippy = NippyJar::load_without_header(file_path.path()).unwrap(); + let data_len = File::open(nippy.data_path()).unwrap().metadata().unwrap().len(); + let mut offsets = + OpenOptions::new().read(true).write(true).open(nippy.offsets_path()).unwrap(); + offsets.seek(SeekFrom::End(-8)).unwrap(); + offsets.write_all(&final_offset(data_len).to_le_bytes()).unwrap(); + offsets.sync_all().unwrap(); + + let nippy = NippyJar::load_without_header(file_path.path()).unwrap(); + let mut cursor = NippyJarCursor::new(&nippy).unwrap(); + assert!(matches!(cursor.row_by_number(1), Err(NippyJarError::InconsistentState))); + }; + + // Before the start of the last value. + assert_inconsistent(|_| 0); + // Past the end of the data file. + assert_inconsistent(|data_len| data_len + 1); + } + + #[test] + fn test_prune_partially_uncommitted_rows() { + let (col1, col2) = test_data(None); + let num_columns = 2; + let file_path = tempfile::NamedTempFile::new().unwrap(); + + // Two committed rows. + append_two_rows(num_columns, file_path.path(), &col1, &col2); + + let nippy = NippyJar::load_without_header(file_path.path()).unwrap(); + let mut writer = NippyJarWriter::new(nippy).unwrap(); + + // Two uncommitted rows, of which only one is pruned again. + append_row(&mut writer, &col1, &col2, 2); + append_row(&mut writer, &col1, &col2, 3); + writer.prune_rows(1).unwrap(); + assert_eq!(writer.rows(), 3); + + // The config on disk must never claim rows whose offsets are not on disk. + let expected_offsets_len = 1 + 3 * num_columns as u64 * 8 + 8; + assert_eq!( + File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(), + expected_offsets_len + ); + let expected_data_len = col1[..3].iter().chain(&col2[..3]).map(Vec::len).sum::(); + assert_eq!( + File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize, + expected_data_len + ); + assert_jar_rows(file_path.path(), &col1, &col2, 3); + + // Committing must not change anything, and the writer remains usable. + writer.commit().unwrap(); + assert_eq!( + File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(), + expected_offsets_len + ); + assert_jar_rows(file_path.path(), &col1, &col2, 3); + + append_row(&mut writer, &col1, &col2, 3); + writer.commit().unwrap(); + assert_eq!( + File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(), + 1 + 4 * num_columns as u64 * 8 + 8 + ); + assert_jar_rows(file_path.path(), &col1, &col2, 4); + } + + #[test] + fn test_prune_uncommitted_and_committed_rows() { + let (col1, col2) = test_data(None); + let num_columns = 2; + let file_path = tempfile::NamedTempFile::new().unwrap(); + + // Two committed rows. + append_two_rows(num_columns, file_path.path(), &col1, &col2); + + let nippy = NippyJar::load_without_header(file_path.path()).unwrap(); + let mut writer = NippyJarWriter::new(nippy).unwrap(); + + // One uncommitted row, which is pruned together with one committed row. + append_row(&mut writer, &col1, &col2, 2); + writer.prune_rows(2).unwrap(); + assert_eq!(writer.rows(), 1); + + // Committing must not write the stale in-memory offset of the pruned rows. + writer.commit().unwrap(); + assert_eq!( + File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(), + 1 + num_columns as u64 * 8 + 8 + ); + assert_eq!( + File::open(writer.data_path()).unwrap().metadata().unwrap().len() as usize, + col1[0].len() + col2[0].len() + ); + assert_jar_rows(file_path.path(), &col1, &col2, 1); + + // Rows appended through the same writer afterwards read back correctly. + append_row(&mut writer, &col1, &col2, 1); + writer.commit().unwrap(); + assert_eq!( + File::open(writer.offsets_path()).unwrap().metadata().unwrap().len(), + 1 + 2 * num_columns as u64 * 8 + 8 + ); + assert_jar_rows(file_path.path(), &col1, &col2, 2); + } + fn test_append_consistency_partial_commit( file_path: &Path, col1: &[Vec], @@ -1097,4 +1242,26 @@ mod tests { // runs the consistency check. let _ = NippyJarWriter::new(nippy).unwrap(); } + + fn append_row(writer: &mut NippyJarWriter, col1: &[Vec], col2: &[Vec], row: usize) { + writer.append_column(Some(Ok(&col1[row]))).unwrap(); + writer.append_column(Some(Ok(&col2[row]))).unwrap(); + } + + /// Asserts that the jar on disk is consistent, claims exactly `rows` rows, and that every + /// one of them reads back as the corresponding entry of `col1` and `col2`. + fn assert_jar_rows(file_path: &Path, col1: &[Vec], col2: &[Vec], rows: usize) { + let nippy = NippyJar::load_without_header(file_path).unwrap(); + assert_eq!(nippy.rows, rows); + NippyJarChecker::new(NippyJar::load_without_header(file_path).unwrap()) + .check_consistency() + .unwrap(); + + let mut cursor = NippyJarCursor::new(&nippy).unwrap(); + for row in 0..rows { + let values = cursor.row_by_number(row).unwrap().unwrap(); + assert_eq!((values[0], values[1]), (col1[row].as_slice(), col2[row].as_slice())); + } + assert!(cursor.row_by_number(rows).unwrap().is_none()); + } } diff --git a/crates/storage/nippy-jar/src/writer.rs b/crates/storage/nippy-jar/src/writer.rs index 44fd47d0d42..6bcc7bf3831 100644 --- a/crates/storage/nippy-jar/src/writer.rs +++ b/crates/storage/nippy-jar/src/writer.rs @@ -319,6 +319,18 @@ impl NippyJarWriter { } } + if remaining_to_prune > 0 { + // Every uncommitted row was pruned, so the remaining in-memory offset (the previous + // data file end) is stale: the on-disk prune shrank the data file below it. + self.offsets.clear(); + } else { + // The config written below counts the uncommitted rows that were not pruned, so their + // offsets must be on disk too. If we crash before the config is written, the offsets + // file is longer than the config expects and the checker heals it as an interrupted + // append. + self.commit_offsets_inner()?; + } + self.offsets_file.get_ref().sync_all()?; self.data_file.get_ref().sync_all()?; diff --git a/crates/storage/provider/Cargo.toml b/crates/storage/provider/Cargo.toml index b2d8be7e29c..3b695a3253a 100644 --- a/crates/storage/provider/Cargo.toml +++ b/crates/storage/provider/Cargo.toml @@ -106,3 +106,12 @@ test-utils = [ "reth-tasks/test-utils", "dep:tokio", ] +account-ext = [ + "reth-stages-types/account-ext", + "alloy-genesis/account-ext", + "reth-codecs/account-ext", + "reth-primitives-traits/account-ext", + "revm/account-ext", + "reth-trie/account-ext", + "reth-trie-db/account-ext", +] diff --git a/crates/storage/provider/src/bal.rs b/crates/storage/provider/src/bal.rs index b75f2c66291..40feb091260 100644 --- a/crates/storage/provider/src/bal.rs +++ b/crates/storage/provider/src/bal.rs @@ -1,14 +1,11 @@ use alloy_eip7928::BAL_RETENTION_PERIOD_SLOTS; use alloy_eips::NumHash; -use alloy_primitives::{BlockHash, BlockNumber, Bytes}; +use alloy_primitives::{map::B256Map, BlockHash, BlockNumber, Bytes}; use parking_lot::RwLock; use reth_prune_types::PruneMode; use reth_storage_api::{BalStore, GetBlockAccessListLimit, RawBal}; use reth_storage_errors::provider::ProviderResult; -use std::{ - collections::{BTreeMap, HashMap}, - sync::Arc, -}; +use std::{collections::BTreeMap, sync::Arc}; mod rocksdb; pub use rocksdb::RocksDBBalStore; @@ -68,7 +65,7 @@ impl Default for BalConfig { #[derive(Debug, Default)] struct InMemoryBalStoreInner { - entries: HashMap, + entries: B256Map, hashes_by_number: BTreeMap>, highest_block_number: Option, } diff --git a/crates/storage/provider/src/bal/rocksdb.rs b/crates/storage/provider/src/bal/rocksdb.rs index 6940693ccd6..b7c89a3c02b 100644 --- a/crates/storage/provider/src/bal/rocksdb.rs +++ b/crates/storage/provider/src/bal/rocksdb.rs @@ -1,7 +1,7 @@ use crate::providers::RocksDBProvider; use alloy_eip7928::BAL_RETENTION_PERIOD_SLOTS; use alloy_eips::NumHash; -use alloy_primitives::{BlockHash, BlockNumber, Bytes}; +use alloy_primitives::{map::B256Map, BlockHash, BlockNumber, Bytes}; use parking_lot::RwLock; use reth_db_api::{ models::{StoredBlockAccessList, StoredBlockAccessListKey}, @@ -12,7 +12,7 @@ use reth_prune_types::PruneMode; use reth_storage_api::{BalStore, GetBlockAccessListLimit, RawBal}; use reth_storage_errors::provider::{ProviderError, ProviderResult}; use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet}, sync::Arc, }; @@ -212,7 +212,7 @@ impl BalStore for RocksDBBalStore { #[derive(Debug, Default)] struct RocksDBBalStoreBuffer { /// Hash index for serving recent hash-only lookups. - entries: HashMap, + entries: B256Map, /// Block-number index for pruning buffered entries. hashes_by_number: BTreeMap>, /// Validated BALs waiting to be confirmed canonical and flushed. @@ -365,31 +365,6 @@ mod tests { blocks.iter().map(|block| store.get_by_hash(block.hash).unwrap()).collect() } - #[test] - fn inserts_and_reads_by_hash() { - let (_dir, store) = test_store(); - let hash = B256::random(); - let missing = NumHash::new(1, B256::random()); - let bal = Bytes::from_static(&[0xc1, 0x01]); - - store.insert(NumHash::new(1, hash), RawBal::from(bal.clone())).unwrap(); - - assert_eq!(read_many(&store, &[NumHash::new(1, hash), missing]), vec![Some(bal), None]); - } - - #[test] - fn hash_lookup_reads_persisted_bal_through_store() { - let (_dir, store) = test_store(); - let block = NumHash::new(1, B256::random()); - let bal = Bytes::from_static(&[0xc1, 0x01]); - - store.insert(block, RawBal::from(bal.clone())).unwrap(); - store.flush(&[block]).unwrap(); - - let store_with_empty_buffer = RocksDBBalStore::new(store.rocksdb_provider().clone()); - assert_eq!(store_with_empty_buffer.get_by_hash(block.hash).unwrap(), Some(bal)); - } - #[test] fn flush_prunes_buffer_retention() { let (_dir, store) = test_store(); @@ -480,25 +455,6 @@ mod tests { ); } - #[test] - fn sparse_numbers_are_valid() { - let (_dir, store) = test_store(); - let hash_a = B256::with_last_byte(1); - let hash_b = B256::with_last_byte(2); - let bal_a = Bytes::from_static(&[0xc1, 0x01]); - let bal_b = Bytes::from_static(&[0xc1, 0x02]); - - let block_a = NumHash::new(2, hash_a); - let block_b = NumHash::new(200, hash_b); - - store.insert(block_a, RawBal::from(bal_a.clone())).unwrap(); - store.flush(&[block_a]).unwrap(); - store.insert(block_b, RawBal::from(bal_b.clone())).unwrap(); - store.flush(&[block_b]).unwrap(); - - assert_eq!(read_many(&store, &[block_a, block_b]), vec![Some(bal_a), Some(bal_b)]); - } - #[test] fn missing_and_empty_bal_are_distinct() { let (_dir, store) = test_store(); diff --git a/crates/storage/provider/src/either_writer.rs b/crates/storage/provider/src/either_writer.rs index 0a448d1f19b..20243e9bbed 100644 --- a/crates/storage/provider/src/either_writer.rs +++ b/crates/storage/provider/src/either_writer.rs @@ -99,7 +99,7 @@ impl<'a> EitherWriter<'a, (), ()> { block_number: BlockNumber, ) -> ProviderResult>>> where - P: DBProvider + NodePrimitivesProvider + StorageSettingsCache + StaticFileProviderFactory, + P: DBProvider + StorageSettingsCache + StaticFileProviderFactory, P::Tx: DbTxMut, ReceiptTy: Value, { @@ -120,7 +120,7 @@ impl<'a> EitherWriter<'a, (), ()> { block_number: BlockNumber, ) -> ProviderResult> where - P: DBProvider + NodePrimitivesProvider + StorageSettingsCache + StaticFileProviderFactory, + P: DBProvider + StorageSettingsCache + StaticFileProviderFactory, P::Tx: DbTxMut, { if EitherWriterDestination::senders(provider).is_static_file() { @@ -142,7 +142,7 @@ impl<'a> EitherWriter<'a, (), ()> { block_number: BlockNumber, ) -> ProviderResult> where - P: DBProvider + NodePrimitivesProvider + StorageSettingsCache + StaticFileProviderFactory, + P: DBProvider + StorageSettingsCache + StaticFileProviderFactory, P::Tx: DbTxMut, { if provider.cached_storage_settings().storage_v2 { @@ -163,7 +163,7 @@ impl<'a> EitherWriter<'a, (), ()> { block_number: BlockNumber, ) -> ProviderResult> where - P: DBProvider + NodePrimitivesProvider + StorageSettingsCache + StaticFileProviderFactory, + P: DBProvider + StorageSettingsCache + StaticFileProviderFactory, P::Tx: DbTxMut, { if provider.cached_storage_settings().storage_v2 { @@ -689,7 +689,7 @@ impl<'a, 'db> EitherReader<'a, 'db, (), ()> { provider: &P, ) -> ProviderResult> where - P: DBProvider + NodePrimitivesProvider + StorageSettingsCache + StaticFileProviderFactory, + P: DBProvider + StorageSettingsCache + StaticFileProviderFactory, P::Tx: DbTx, { if EitherWriterDestination::senders(provider).is_static_file() { @@ -770,7 +770,7 @@ impl<'a, 'db> EitherReader<'a, 'db, (), ()> { provider: &P, ) -> ProviderResult> where - P: DBProvider + NodePrimitivesProvider + StorageSettingsCache + StaticFileProviderFactory, + P: DBProvider + StorageSettingsCache + StaticFileProviderFactory, P::Tx: DbTx, { if EitherWriterDestination::account_changesets(provider).is_static_file() { @@ -1179,8 +1179,8 @@ mod rocksdb_tests { // Enable RocksDB for transaction hash numbers factory.set_storage_settings_cache(StorageSettings::v2()); - let hash1 = B256::from([1u8; 32]); - let hash2 = B256::from([2u8; 32]); + let hash1 = B256::repeat_byte(1u8); + let hash2 = B256::repeat_byte(2u8); let tx_num1 = 100u64; let tx_num2 = 200u64; @@ -1221,7 +1221,7 @@ mod rocksdb_tests { // Enable RocksDB for transaction hash numbers factory.set_storage_settings_cache(StorageSettings::v2()); - let hash = B256::from([1u8; 32]); + let hash = B256::repeat_byte(1u8); let tx_num = 100u64; // First, write a value directly to RocksDB @@ -1250,8 +1250,8 @@ mod rocksdb_tests { fn test_rocksdb_batch_transaction_hash_numbers() { let (_temp_dir, provider) = create_rocksdb_provider(); - let hash1 = B256::from([1u8; 32]); - let hash2 = B256::from([2u8; 32]); + let hash1 = B256::repeat_byte(1u8); + let hash2 = B256::repeat_byte(2u8); let tx_num1 = 100u64; let tx_num2 = 200u64; @@ -1267,7 +1267,7 @@ mod rocksdb_tests { assert_eq!(tx.get::(hash2).unwrap(), Some(tx_num2)); // Test missing key - let missing_hash = B256::from([99u8; 32]); + let missing_hash = B256::repeat_byte(99u8); assert_eq!(tx.get::(missing_hash).unwrap(), None); } @@ -1276,7 +1276,7 @@ mod rocksdb_tests { let (_temp_dir, provider) = create_rocksdb_provider(); let address = Address::random(); - let storage_key = B256::from([1u8; 32]); + let storage_key = B256::repeat_byte(1u8); let key = StorageShardedKey::new(address, storage_key, 1000); let value = IntegerList::new([1, 5, 10, 50]).unwrap(); @@ -1322,7 +1322,7 @@ mod rocksdb_tests { fn test_rocksdb_batch_delete_transaction_hash_number() { let (_temp_dir, provider) = create_rocksdb_provider(); - let hash = B256::from([1u8; 32]); + let hash = B256::repeat_byte(1u8); let tx_num = 100u64; // First write @@ -1343,7 +1343,7 @@ mod rocksdb_tests { let (_temp_dir, provider) = create_rocksdb_provider(); let address = Address::random(); - let storage_key = B256::from([1u8; 32]); + let storage_key = B256::repeat_byte(1u8); let key = StorageShardedKey::new(address, storage_key, 1000); let value = IntegerList::new([1, 5, 10]).unwrap(); @@ -1601,7 +1601,7 @@ mod rocksdb_tests { /// 4. Pruning boundary - `lowest_available` boundary behavior (block at/after boundary) #[test] fn test_account_history_info_both_backends() { - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Scenario 1: Single shard with blocks [100, 200, 300] run_account_history_scenario( @@ -1673,7 +1673,7 @@ mod rocksdb_tests { ); // Scenario 3: No history for address - let address_without_history = Address::from([0x43; 20]); + let address_without_history = Address::repeat_byte(0x43); run_account_history_scenario( "no_history", address_without_history, @@ -1714,9 +1714,9 @@ mod rocksdb_tests { /// Tests storage history lookups across both MDBX and `RocksDB` backends. #[test] fn test_storage_history_info_both_backends() { - let address = Address::from([0x42; 20]); - let storage_key = B256::from([0x01; 32]); - let other_storage_key = B256::from([0x02; 32]); + let address = Address::repeat_byte(0x42); + let storage_key = B256::repeat_byte(0x01); + let other_storage_key = B256::repeat_byte(0x02); // Single shard with blocks [100, 200, 300] run_storage_history_scenario( @@ -1769,8 +1769,8 @@ mod rocksdb_tests { // Enable RocksDB for transaction hash numbers factory.set_storage_settings_cache(StorageSettings::v2()); - let hash1 = B256::from([1u8; 32]); - let hash2 = B256::from([2u8; 32]); + let hash1 = B256::repeat_byte(1u8); + let hash2 = B256::repeat_byte(2u8); let tx_num1 = 100u64; let tx_num2 = 200u64; diff --git a/crates/storage/provider/src/providers/blockchain_provider.rs b/crates/storage/provider/src/providers/blockchain_provider.rs index 78e2bdfcd38..8a8d33dae49 100644 --- a/crates/storage/provider/src/providers/blockchain_provider.rs +++ b/crates/storage/provider/src/providers/blockchain_provider.rs @@ -1021,8 +1021,6 @@ impl StateReader for BlockchainProvider { let provider = self.database.provider()?; let Some(block_body) = provider.block_body_indices(block)? else { return Ok(None) }; - let from_transaction_num = block_body.first_tx_num(); - let to_transaction_num = block_body.last_tx_num(); let account_changeset = provider.account_changesets_range(block..=block)?; let storage_changeset = provider.storage_changeset(block)?; @@ -1037,7 +1035,7 @@ impl StateReader for BlockchainProvider { |address| state_provider.basic_account(&address), |address, storage_key| state_provider.storage(address, storage_key), )?; - let receipts = provider.receipts_by_tx_range(from_transaction_num..=to_transaction_num)?; + let receipts = provider.receipts_by_tx_range(block_body.tx_num_range())?; Ok(Some(ExecutionOutcome::new_init( state, @@ -1054,7 +1052,7 @@ impl StateReader for BlockchainProvider { #[cfg(test)] #[allow(clippy::clone_on_copy)] mod tests { - use super::SNAPSHOT_STATE_RETENTION; + use super::*; use crate::{ providers::BlockchainProvider, test_utils::{ @@ -1095,7 +1093,7 @@ mod tests { self, random_block, random_block_range, random_changeset_range, random_eoa_accounts, random_receipt, BlockParams, BlockRangeParams, }; - use reth_trie::{updates::TrieUpdates, ComputedTrieData, HashedPostState, HashedStorage}; + use reth_trie::{updates::TrieUpdates, HashedPostState, HashedStorage, SortedTrieData}; use revm::database::{BundleState, OriginalValuesKnown}; use std::{ collections::{BTreeMap, HashMap}, @@ -1113,7 +1111,7 @@ mod tests { let address = Address::with_last_byte(1); let other_address = Address::with_last_byte(2); let hashed_address = keccak256(address); - let account = Account { balance: U256::from(1), ..Default::default() }; + let account = Account { balance: U256::ONE, ..Default::default() }; // Branches 00, 01 and 02 share the cached row at 0. The first two change // in different blocks; the third keeps proof-v2's branch-collapse path inactive. let slots: Vec = (0..=2u8) @@ -1124,7 +1122,7 @@ mod tests { .take(2) }) .collect(); - let mut storage: BTreeMap<_, _> = slots.iter().map(|&slot| (slot, U256::from(1))).collect(); + let mut storage: BTreeMap<_, _> = slots.iter().map(|&slot| (slot, U256::ONE)).collect(); let mut trie = TrieTestHarness::new( storage.iter().map(|(slot, value)| (keccak256(slot), *value)).collect(), ); @@ -1160,14 +1158,10 @@ mod tests { .state_present_account_info(address, info) .state_storage( address, - std::iter::once((U256::from_be_bytes(slot.0), (U256::from(1), value))) + std::iter::once((U256::from_be_bytes(slot.0), (U256::ONE, value))) .collect(), ) - .revert_storage( - number, - address, - vec![(U256::from_be_bytes(slot.0), U256::from(1))], - ) + .revert_storage(number, address, vec![(U256::from_be_bytes(slot.0), U256::ONE)]) .build(); ( HashedPostState::from_hashed_storage( @@ -1199,10 +1193,7 @@ mod tests { blocks.push(ExecutedBlock::new( Arc::new(RecoveredBlock::new_unhashed(block, vec![])), Arc::new(output), - ComputedTrieData::new( - Arc::new(state.into_sorted()), - Arc::new(updates.into_sorted()), - ), + SortedTrieData::new(Arc::new(state.into_sorted()), Arc::new(updates.into_sorted())), )); } @@ -1233,11 +1224,11 @@ mod tests { let root = blocks[number].recovered_block().state_root; state .multiproof_v2( - Default::default(), MultiProofTargetsV2 { account_targets: vec![keccak256(other_address).into()], ..Default::default() }, + false, )? .account_proof(other_address, &[])? .verify(root)?; @@ -3238,21 +3229,129 @@ mod tests { #[test] fn state_range_provider_serves_persisted_root_with_in_memory_overlay() -> eyre::Result<()> { let mut rng = generators::rng(); - let (provider, _, _, _) = provider_with_random_blocks( + + // State A: one account, persisted at block 1. Block 1 is not the database head, so only + // the persisted-history fallback can resolve its root. + let (address, account_a) = random_account(1); + let hashed_address = keccak256(address); + let factory = test_provider_factory_with_genesis()?; + let provider_rw = factory.provider_rw()?; + provider_rw.insert_account_for_hashing([(address, Some(account_a.clone()))])?; + provider_rw.commit()?; + let persisted_root = factory.latest()?.state_root(HashedPostState::default())?; + + let genesis_hash = factory.sealed_header(0)?.unwrap().hash(); + let mut persisted_block = random_block( &mut rng, - TEST_BLOCKS_COUNT - 1, 1, - BlockRangeParams::default(), - )?; - assert!(provider.canonical_in_memory_state.head_state().is_some()); - let provider_rw = provider.database.provider_rw()?; - provider_rw.save_stage_checkpoint( - StageId::Finish, - StageCheckpoint::new((TEST_BLOCKS_COUNT - 2) as u64), + BlockParams { parent: Some(genesis_hash), tx_count: Some(0), ..Default::default() }, + ) + .unseal(); + persisted_block.header.state_root = persisted_root; + let persisted_block = + persisted_block.seal_slow().try_recover().expect("failed to seal block with senders"); + let persisted_hash = persisted_block.hash(); + + let provider_rw = factory.provider_rw()?; + provider_rw.insert_block(&persisted_block)?; + provider_rw.save_stage_checkpoint(StageId::Finish, StageCheckpoint::new(1))?; + provider_rw.commit()?; + + // State B: block 2 changes the account and becomes the database head, with a root + // different from block 1's. + let account_b = Account { nonce: 2, balance: U256::from(2), ..account_a.clone() }; + let mut state_b = HashedPostState::default(); + state_b.accounts.insert(hashed_address, Some(account_b.clone())); + let head_root = factory.latest()?.state_root(state_b.clone())?; + assert_ne!(head_root, persisted_root); + + let mut head_block = random_block( + &mut rng, + 2, + BlockParams { parent: Some(persisted_hash), tx_count: Some(0), ..Default::default() }, + ) + .unseal(); + head_block.header.state_root = head_root; + let head_block = + head_block.seal_slow().try_recover().expect("failed to seal block with senders"); + let head_hash = head_block.hash(); + + let provider_rw = factory.provider_rw()?; + provider_rw.append_blocks_with_state( + vec![head_block], + &ExecutionOutcome { + bundle: BundleState::new( + [( + address, + Some(account_a.clone().into()), + Some(account_b.into()), + Default::default(), + )], + [[(address, Some(Some(account_a.clone().into())), [])]], + [], + ), + first_block: 2, + ..Default::default() + }, + state_b.into_sorted(), )?; + provider_rw.save_stage_checkpoint(StageId::Finish, StageCheckpoint::new(2))?; provider_rw.commit()?; - assert!(provider.state_range_provider(EMPTY_ROOT_HASH)?.is_some()); + let provider = BlockchainProvider::new(factory)?; + + // An in-memory block on top of the head adds another account under a root of its own. + let (memory_address, memory_account) = random_account(3); + let mut memory_state = HashedPostState::default(); + memory_state.accounts.insert(keccak256(memory_address), Some(memory_account)); + + let memory_root = B256::repeat_byte(0x77); + assert_ne!(memory_root, persisted_root); + let mut memory_block = random_block( + &mut rng, + 3, + BlockParams { parent: Some(head_hash), tx_count: Some(0), ..Default::default() }, + ) + .unseal(); + memory_block.header.state_root = memory_root; + let memory_block = + memory_block.seal_slow().try_recover().expect("failed to seal block with senders"); + let trie_data = SortedTrieData::new( + Arc::new(memory_state.into_sorted()), + Arc::new(TrieUpdates::default().into_sorted()), + ); + let execution_output = BlockExecutionOutput { + result: BlockExecutionResult { + receipts: Default::default(), + requests: Default::default(), + gas_used: 0, + blob_gas_used: 0, + }, + state: Default::default(), + }; + let executed = + ExecutedBlock::new(Arc::new(memory_block), Arc::new(execution_output), trie_data); + provider.database.overlay_manager().insert_block(executed.clone()); + provider + .canonical_in_memory_state + .update_chain(NewCanonicalChain::Commit { new: vec![executed] }); + + // No in-memory block carries the persisted root, so the persisted-history fallback must + // resolve it, ignoring both the later database block and the in-memory block above it. + assert!(!provider + .canonical_in_memory_state + .canonical_chain() + .any(|state| state.state_root() == persisted_root)); + let state = + provider.state_range_provider(persisted_root)?.expect("persisted root must resolve"); + let range = state.account_range(B256::ZERO, B256::repeat_byte(0xff), 10_000)?; + assert_eq!(range.items, vec![(hashed_address, account_a)]); + + // The first node of a boundary proof is the trie root, so this is the root the view + // reports. + let proof = state.account_range_proof(&[hashed_address])?; + assert!(!proof.is_empty()); + assert_eq!(keccak256(&proof[0]), persisted_root); Ok(()) } @@ -3308,7 +3407,7 @@ mod tests { let executed = ExecutedBlock::new( Arc::new(block), Arc::new(execution_output), - ComputedTrieData::new( + SortedTrieData::new( Arc::new(hashed_state.into_sorted()), Arc::new(TrieUpdates::default().into_sorted()), ), @@ -3339,57 +3438,6 @@ mod tests { Ok(()) } - #[test] - fn state_range_provider_resolves_root_from_in_memory_block() -> eyre::Result<()> { - let mut rng = generators::rng(); - let factory = test_provider_factory_with_genesis()?; - let provider = BlockchainProvider::new(factory)?; - - let (address, account) = random_account(1); - let hashed_address = keccak256(address); - let mut hashed_state = HashedPostState::default(); - hashed_state.accounts.insert(hashed_address, Some(account.clone())); - - // A root only the in-memory block carries, so a match proves the in-memory path (not - // persisted history, which has no block with this root) resolved it. - let unique_root = B256::repeat_byte(0x77); - let parent = provider.canonical_in_memory_state.get_canonical_head(); - let mut block = random_block( - &mut rng, - parent.number + 1, - BlockParams { parent: Some(parent.hash()), tx_count: Some(0), ..Default::default() }, - ) - .unseal(); - block.header.state_root = unique_root; - let block = block.seal_slow().try_recover().expect("failed to seal block with senders"); - - let trie_data = ComputedTrieData::new( - Arc::new(hashed_state.into_sorted()), - Arc::new(TrieUpdates::default().into_sorted()), - ); - let execution_output = BlockExecutionOutput { - result: BlockExecutionResult { - receipts: Default::default(), - requests: Default::default(), - gas_used: 0, - blob_gas_used: 0, - }, - state: Default::default(), - }; - let executed = ExecutedBlock::new(Arc::new(block), Arc::new(execution_output), trie_data); - provider.database.overlay_manager().insert_block(executed.clone()); - provider - .canonical_in_memory_state - .update_chain(NewCanonicalChain::Commit { new: vec![executed] }); - - let state = - provider.state_range_provider(unique_root)?.expect("in-memory root must resolve"); - let range = state.account_range(B256::ZERO, B256::repeat_byte(0xff), 10_000)?; - assert_eq!(range.items, vec![(hashed_address, account)]); - - Ok(()) - } - #[test] fn state_range_provider_reverts_database_advancement_past_anchor() -> eyre::Result<()> { let mut rng = generators::rng(); @@ -3412,7 +3460,7 @@ mod tests { .unseal(); block.header.state_root = unique_root; let block = block.seal_slow().try_recover().expect("failed to seal block with senders"); - let trie_data = ComputedTrieData::new( + let trie_data = SortedTrieData::new( Arc::new(target_state.into_sorted()), Arc::new(TrieUpdates::default().into_sorted()), ); @@ -3483,7 +3531,7 @@ mod tests { let slot_key = B256::with_last_byte(1); let slot = U256::from_be_bytes(slot_key.0); let hashed_slot = keccak256(slot_key); - let value_a = U256::from(1); + let value_a = U256::ONE; let factory = test_provider_factory_with_genesis()?; let provider_rw = factory.provider_rw()?; @@ -3566,4 +3614,53 @@ mod tests { Ok(()) } + + #[test] + fn get_state_preserves_empty_block_receipts() -> eyre::Result<()> { + let mut rng = generators::rng(); + let factory = create_test_provider_factory(); + let writer = factory.provider_rw()?; + let mut parent = B256::ZERO; + let mut expected = Vec::new(); + // Persist empty blocks before receipt zero, between transactions and at the tip. + for (number, tx_count) in [0, 0, 1, 0, 1, 0].into_iter().enumerate() { + let block = random_block( + &mut rng, + number as u64, + BlockParams { + parent: Some(parent), + tx_count: Some(tx_count), + ..Default::default() + }, + ) + .try_recover() + .expect("failed to recover test block"); + parent = block.hash(); + expected.push( + block + .body() + .transactions + .iter() + .map(|tx| random_receipt(&mut rng, tx, Some(0), Some(0))) + .collect::>(), + ); + writer.insert_block(&block)?; + } + writer.write_state( + &ExecutionOutcome { first_block: 0, receipts: expected.clone(), ..Default::default() }, + OriginalValuesKnown::No, + StateWriteConfig::default(), + )?; + writer.save_stage_checkpoint(StageId::Finish, StageCheckpoint::new(5))?; + writer.commit()?; + + let provider = BlockchainProvider::new(factory)?; + assert!(provider.canonical_in_memory_state.head_state().is_none()); + assert!(provider.receipt(0)?.is_some()); + for (number, receipts) in expected.into_iter().enumerate() { + let outcome = provider.get_state(number as u64)?.expect("persisted block state"); + assert_eq!(outcome.receipts, vec![receipts], "block {number}"); + } + Ok(()) + } } diff --git a/crates/storage/provider/src/providers/consistent.rs b/crates/storage/provider/src/providers/consistent.rs index 8dd750ef921..f0193b00e16 100644 --- a/crates/storage/provider/src/providers/consistent.rs +++ b/crates/storage/provider/src/providers/consistent.rs @@ -566,7 +566,7 @@ impl BlockReader for ConsistentProvider { hash: B256, source: BlockSource, ) -> ProviderResult> { - if matches!(source, BlockSource::Canonical | BlockSource::Any) && + if source.is_canonical() && let Some(block) = self.get_in_memory_or_storage_by_block( hash.into(), |db_provider| db_provider.find_block_by_hash(hash, BlockSource::Canonical), @@ -576,7 +576,7 @@ impl BlockReader for ConsistentProvider { return Ok(Some(block)) } - if matches!(source, BlockSource::Pending | BlockSource::Any) { + if source.is_pending() { return Ok(self .canonical_in_memory_state .pending_block() @@ -592,7 +592,7 @@ impl BlockReader for ConsistentProvider { hash: B256, source: BlockSource, ) -> ProviderResult>> { - if matches!(source, BlockSource::Canonical | BlockSource::Any) && + if source.is_canonical() && let Some(block) = self.get_in_memory_or_storage_by_block( hash.into(), |db_provider| { @@ -608,7 +608,7 @@ impl BlockReader for ConsistentProvider { return Ok(Some(block)) } - if matches!(source, BlockSource::Pending | BlockSource::Any) && + if source.is_pending() && let Some(block_state) = self.canonical_in_memory_state.pending_state() { let recovered_block = Arc::clone(&block_state.block_ref().recovered_block); @@ -1121,10 +1121,9 @@ impl StorageChangeSetReader for ConsistentProvider { .flatten() .flat_map(|revert: PlainStorageRevert| { revert.storage_revert.into_iter().map(move |(key, value)| { - let plain_key = B256::from(key.to_be_bytes()); ( BlockNumberAddress((block_number, revert.address)), - StorageEntry { key: plain_key, value: value.to_previous_value() }, + StorageEntry { key: key.into(), value: value.to_previous_value() }, ) }) }) @@ -1177,7 +1176,7 @@ impl StorageChangeSetReader for ConsistentProvider { return None } revert.storage_revert.into_iter().find_map(|(key, value)| { - let plain_key = B256::from(key.to_be_bytes()); + let plain_key = B256::from(key); (plain_key == storage_key).then(|| StorageEntry { key: plain_key, value: value.to_previous_value(), @@ -1226,10 +1225,9 @@ impl StorageChangeSetReader for ConsistentProvider { .flatten() .flat_map(|revert: PlainStorageRevert| { revert.storage_revert.into_iter().map(move |(key, value)| { - let plain_key = B256::from(key.to_be_bytes()); ( BlockNumberAddress((state.number(), revert.address)), - StorageEntry { key: plain_key, value: value.to_previous_value() }, + StorageEntry { key: key.into(), value: value.to_previous_value() }, ) }) }); diff --git a/crates/storage/provider/src/providers/database/mod.rs b/crates/storage/provider/src/providers/database/mod.rs index fa55e1a946c..238dec62e8d 100644 --- a/crates/storage/provider/src/providers/database/mod.rs +++ b/crates/storage/provider/src/providers/database/mod.rs @@ -1005,10 +1005,14 @@ mod tests { use super::*; use crate::{ providers::{StaticFileProvider, StaticFileWriter}, - test_utils::{blocks::TEST_BLOCK, create_test_provider_factory, MockNodeTypesWithDB}, + test_utils::{ + blocks::TEST_BLOCK, create_test_provider_factory, + create_test_provider_factory_with_genesis_block_number, MockNodeTypesWithDB, + }, BlockHashReader, BlockNumReader, BlockWriter, DBProvider, HeaderSyncGapProvider, - TransactionsProvider, + StageCheckpointWriter, TransactionsProvider, }; + use alloy_eips::BlockNumHash; use alloy_primitives::{TxNumber, B256}; use assert_matches::assert_matches; use reth_chainspec::ChainSpecBuilder; @@ -1016,9 +1020,10 @@ mod tests { mdbx::DatabaseArguments, test_utils::{create_test_rocksdb_dir, create_test_static_files_dir, ERROR_TEMPDIR}, }; - use reth_db_api::tables; + use reth_db_api::{models::SnapAttempt, tables}; use reth_primitives_traits::SignerRecoverable; use reth_prune_types::{PruneMode, PruneModes}; + use reth_storage_api::MetadataWriter; use reth_storage_errors::provider::ProviderError; use reth_testing_utils::generators::{self, random_block, random_header, BlockParams}; use std::{ops::RangeInclusive, sync::Arc}; @@ -1233,4 +1238,178 @@ mod tests { assert_eq!(local_head, head); } + + #[test] + fn anchored_static_files_resume_after_the_pivot() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let provider = factory.database_provider_rw().unwrap(); + provider.anchor_pruned_static_files(10).unwrap(); + provider.commit().unwrap(); + + let static_files = factory.static_file_provider(); + for segment in StaticFileSegment::iter().filter(|segment| !segment.is_headers()) { + assert_eq!(static_files.get_highest_static_file_block(segment), Some(10), "{segment}"); + } + } + + #[test] + fn anchored_static_files_expire_history_below_the_pivot() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let provider = factory.database_provider_rw().unwrap(); + provider.anchor_pruned_static_files(10).unwrap(); + provider.commit().unwrap(); + + let static_files = factory.static_file_provider(); + assert_eq!(static_files.earliest_history_height(), 10); + assert_matches!( + factory.provider().unwrap().block(5.into()), + Err(ProviderError::BlockExpired { requested: 5, earliest_available: 10 }) + ); + + static_files.initialize_index().unwrap(); + assert_eq!(static_files.earliest_history_height(), 10); + } + + #[test] + fn anchoring_static_files_requires_storage_v2() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v1()); + let provider = factory.database_provider_rw().unwrap(); + assert!(provider.anchor_pruned_static_files(10).is_err()); + + let static_files = factory.static_file_provider(); + for segment in StaticFileSegment::iter().filter(|segment| !segment.is_headers()) { + assert_eq!(static_files.get_highest_static_file_block(segment), None, "{segment}"); + } + } + + #[test] + fn rejected_unwind_below_the_anchor_leaves_the_database_untouched() { + for segment in StaticFileSegment::iter().filter(|segment| !segment.is_headers()) { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + let provider = factory.database_provider_rw().unwrap(); + provider.anchor_pruned_static_files(10).unwrap(); + provider.save_stage_checkpoint(StageId::Execution, StageCheckpoint::new(10)).unwrap(); + provider.commit().unwrap(); + + // An unwind must fail before its updated MDBX checkpoint can be committed. + let provider = factory.unwind_provider_rw().unwrap(); + provider.save_stage_checkpoint(StageId::Execution, StageCheckpoint::new(5)).unwrap(); + let queued = { + let static_files = factory.static_file_provider(); + let mut writer = static_files.latest_writer(segment).unwrap(); + match segment { + StaticFileSegment::Transactions => writer.prune_transactions(0, 5), + StaticFileSegment::Receipts => writer.prune_receipts(0, 5), + StaticFileSegment::TransactionSenders => writer.prune_transaction_senders(0, 5), + StaticFileSegment::AccountChangeSets => writer.prune_account_changesets(5), + StaticFileSegment::StorageChangeSets => writer.prune_storage_changesets(5), + StaticFileSegment::Headers => unreachable!(), + } + }; + assert!(queued.and_then(|()| provider.commit()).is_err(), "{segment}"); + + let provider = factory.provider().unwrap(); + assert_eq!( + provider.get_stage_checkpoint(StageId::Execution).unwrap(), + Some(StageCheckpoint::new(10)), + "{segment}" + ); + assert_eq!(factory.static_file_provider().check_consistency(&provider).unwrap(), None); + } + } + + #[test] + fn a_sync_the_database_cannot_continue_is_refused() { + let factory = create_test_provider_factory(); + factory.set_storage_settings_cache(StorageSettings::v2()); + + // Without snap state, either sync may run. + let provider = factory.database_provider_ro().unwrap(); + assert!(provider.ensure_sync_mode(false).is_ok()); + assert!(provider.ensure_sync_mode(true).is_ok()); + drop(provider); + + let unfinished = SnapAttempt::start( + None, + BlockNumHash::new(10, B256::repeat_byte(1)), + B256::repeat_byte(2), + ); + let mut abandoned = unfinished; + abandoned.abandon(); + let mut verified = unfinished; + verified.verify(); + + // Only snap finishes or replaces what an unverified attempt left in the state tables. + for attempt in [unfinished, abandoned] { + let provider = factory.database_provider_rw().unwrap(); + provider.write_snap_attempt(&attempt).unwrap(); + provider.commit().unwrap(); + + let provider = factory.database_provider_ro().unwrap(); + assert!(matches!( + provider.ensure_sync_mode(false).err(), + Some(ProviderError::SnapStateRequiresSnapSync { attempt: 0 }) + )); + assert!(provider.ensure_sync_mode(true).is_ok()); + } + + // A verified attempt leaves the pipeline free to continue above the pivot. + let provider = factory.database_provider_rw().unwrap(); + provider.write_snap_attempt(&verified).unwrap(); + provider.commit().unwrap(); + assert!(factory.database_provider_ro().unwrap().ensure_sync_mode(false).is_ok()); + } + + #[test] + fn the_legacy_layout_refuses_snap_until_execution_passes_genesis() { + // Databases from before stored settings fall back to the legacy layout. + let factory = create_test_provider_factory_with_genesis_block_number(5); + let provider = factory.database_provider_rw().unwrap(); + provider.save_stage_checkpoint(StageId::Execution, StageCheckpoint::new(5)).unwrap(); + provider.commit().unwrap(); + + // A fresh database would snap sync into a layout snap can't write. + assert!(matches!( + factory.database_provider_ro().unwrap().ensure_sync_mode(true).err(), + Some(ProviderError::SnapStorageLayoutUnsupported) + )); + + // Executed state keeps the staged pipeline, so its layout doesn't matter. + let provider = factory.database_provider_rw().unwrap(); + provider.save_stage_checkpoint(StageId::Execution, StageCheckpoint::new(42)).unwrap(); + provider.commit().unwrap(); + assert!(factory.database_provider_ro().unwrap().ensure_sync_mode(true).is_ok()); + + // An unverified attempt is finished by snap whatever executed, so the layout matters again. + let provider = factory.database_provider_rw().unwrap(); + provider + .write_snap_attempt(&SnapAttempt::start( + None, + BlockNumHash::new(10, B256::repeat_byte(1)), + B256::repeat_byte(2), + )) + .unwrap(); + provider.commit().unwrap(); + assert!(matches!( + factory.database_provider_ro().unwrap().ensure_sync_mode(true).err(), + Some(ProviderError::SnapStorageLayoutUnsupported) + )); + } + + #[test] + fn a_database_without_an_execution_checkpoint_needs_the_snap_layout() { + // Startup checks run after genesis, so a missing checkpoint counts as nothing executed. + let factory = create_test_provider_factory(); + assert!(matches!( + factory.database_provider_ro().unwrap().ensure_sync_mode(true).err(), + Some(ProviderError::SnapStorageLayoutUnsupported) + )); + + factory.set_storage_settings_cache(StorageSettings::v2()); + assert!(factory.database_provider_ro().unwrap().ensure_sync_mode(true).is_ok()); + } } diff --git a/crates/storage/provider/src/providers/database/provider.rs b/crates/storage/provider/src/providers/database/provider.rs index 4aee893a1bd..aeb3b1b72fd 100644 --- a/crates/storage/provider/src/providers/database/provider.rs +++ b/crates/storage/provider/src/providers/database/provider.rs @@ -75,7 +75,7 @@ use reth_storage_errors::provider::{ProviderResult, StaticFileWriterError}; use reth_storage_overlay::OverlayManager; use reth_trie::{ updates::{StorageTrieUpdatesSorted, TrieUpdatesSorted}, - ComputedTrieData, HashedPostStateSorted, + HashedPostStateSorted, SortedTrieData, }; use reth_trie_db::{DatabaseStorageTrieCursor, TrieTableAdapter}; use revm::database::states::{ @@ -144,7 +144,7 @@ impl AsRef::TXMut, } } -impl DatabaseProviderRW { +impl DatabaseProviderRW { /// Commit database transaction and static file if it exists. pub fn commit(self) -> ProviderResult<()> { self.0.commit() @@ -879,6 +879,16 @@ impl DatabaseProvider ProviderResult<()> { + let finish = + self.get_stage_checkpoint(reth_stages_types::StageId::Finish)?.ok_or_else(|| { + ProviderError::InsufficientChangesets { requested: from, available: 0..=0 } + })?; + + // Calculate changesets before modifying hashed state used to reconstruct historical tries. + let trie_revert = self + .overlay_manager + .get_or_compute_cached_changesets_range(self, from..=finish.block_number)?; + let changed_accounts = self.account_changesets_range(from..)?; // Unwind account hashes. @@ -895,20 +905,6 @@ impl DatabaseProvider DatabaseProvider { Ok(()) } + + /// Deletes the transaction, receipt, sender and changeset static files and restarts each + /// segment after `pivot`, so the next block appended is `pivot + 1`. Headers are kept. + /// + /// Each segment gets an empty file anchored at `pivot`. Blocks below it read as expired history + /// although the pruner never ran, and `pivot` itself reads as missing since nothing is stored + /// for it. + /// + /// CAUTION: destructive. The files are deleted immediately, while the anchor is written on + /// commit. The caller moves the stage and prune checkpoints to `pivot` in the same commit and + /// must be able to resume if the process stops between the static file and database commits. + /// Errors unless storage v2 is enabled. + pub fn anchor_pruned_static_files(&self, pivot: BlockNumber) -> ProviderResult<()> { + if !self.cached_storage_settings().storage_v2 { + return Err(ProviderError::other(StaticFileWriterError::new( + "pruned anchor requires storage v2", + ))) + } + let static_files = self.static_file_provider(); + for segment in StaticFileSegment::iter().filter(|segment| !segment.is_headers()) { + static_files.delete_segment(segment)?; + static_files.get_writer(pivot, segment)?.initialize_pruned_anchor(pivot)?; + } + // The pivot's own body is never stored, so reads of `pivot` find nothing while blocks + // below it are reported as expired. + static_files.set_earliest_history_height(pivot); + Ok(()) + } } impl DatabaseProvider { @@ -1462,6 +1486,46 @@ impl DatabaseProvider { _ => Ok(()), } } + + /// Refuses snap sync on a database without the hashed state layout it downloads into. + pub fn ensure_snap_sync_layout(&self) -> ProviderResult<()> { + if self.cached_storage_settings().use_hashed_state() { + Ok(()) + } else { + Err(ProviderError::SnapStorageLayoutUnsupported) + } + } + + /// Returns whether snap sync bootstraps this database: an attempt is left to finish or + /// replace, or nothing has executed past genesis. + /// + /// Call this after genesis is initialized: until then the cached settings fall back to the + /// legacy layout and no execution checkpoint exists. + pub fn snap_bootstraps(&self) -> ProviderResult { + if let Some(attempt) = self.snap_attempt()? { + return Ok(!attempt.is_verified()) + } + let genesis = self.chain_spec().genesis_header().number(); + Ok(self + .get_stage_checkpoint(StageId::Execution)? + .is_none_or(|checkpoint| checkpoint.block_number == genesis)) + } + + /// Refuses a database the selected sync can't continue: snap needs the hashed state layout + /// when it bootstraps, and only snap can finish or replace an unverified snap attempt. + /// + /// Call this after genesis is initialized, see [`Self::snap_bootstraps`]. + pub fn ensure_sync_mode(&self, snap_enabled: bool) -> ProviderResult<()> { + if snap_enabled { + return if self.snap_bootstraps()? { self.ensure_snap_sync_layout() } else { Ok(()) } + } + match self.snap_attempt()? { + Some(attempt) if !attempt.is_verified() => { + Err(ProviderError::SnapStateRequiresSnapSync { attempt: attempt.id().into() }) + } + _ => Ok(()), + } + } } impl AccountReader for DatabaseProvider { @@ -2305,7 +2369,7 @@ impl StageCheckpointWriter for DatabaseProvide id: StageId, checkpoint: StageCheckpoint, ) -> ProviderResult<()> { - if id == StageId::Finish { + if id.is_finish() { self.ensure_finish_may_advance(&checkpoint)?; } Ok(self.tx.put::(id.to_string(), checkpoint)?) @@ -2645,7 +2709,7 @@ impl StateWriter for PlainStorageRevert { address, wiped, storage_revert } in storage_changes { let mut storage = storage_revert .into_iter() - .map(|(k, v)| (B256::from(k.to_be_bytes()), v)) + .map(|(k, v)| (B256::from(k), v)) .collect::>(); // sort storage slots by key. storage.par_sort_unstable_by_key(|a| a.0); @@ -2830,10 +2894,7 @@ impl StateWriter } // get transaction receipts - let from_transaction_num = self - .block_body_indices(block)? - .map(|b| b.next_tx_num()) - .ok_or(ProviderError::BlockBodyIndicesNotFound(block))?; + let from_transaction_num = self.next_tx_num_after_block(block)?; let storage_range = BlockNumberAddress::range(range.clone()); let storage_changeset = if self.cached_storage_settings().storage_v2 { @@ -3598,7 +3659,7 @@ impl BlockWriter }, state: Default::default(), }), - ComputedTrieData::default(), + SortedTrieData::default(), ); self.save_blocks_inner( @@ -3690,10 +3751,7 @@ impl BlockWriter .prune_headers(highest_static_file_block.saturating_sub(block))?; // First transaction to be removed - let unwind_tx_from = self - .block_body_indices(block)? - .map(|b| b.next_tx_num()) - .ok_or(ProviderError::BlockBodyIndicesNotFound(block))?; + let unwind_tx_from = self.next_tx_num_after_block(block)?; // Last transaction to be removed let unwind_tx_to = self @@ -3732,10 +3790,7 @@ impl BlockWriter self.storage.writer().remove_block_bodies_above(self, block)?; // First transaction to be removed - let unwind_tx_from = self - .block_body_indices(block)? - .map(|b| b.next_tx_num()) - .ok_or(ProviderError::BlockBodyIndicesNotFound(block))?; + let unwind_tx_from = self.next_tx_num_after_block(block)?; self.remove::(block + 1..)?; self.remove::(unwind_tx_from..)?; @@ -3795,7 +3850,7 @@ impl BlockWriter for (address, account_revert) in block_reverts { account_transitions.entry(*address).or_default().push(block_number); for storage_key in account_revert.storage.keys() { - let key = B256::from(storage_key.to_be_bytes()); + let key = B256::from(*storage_key); storage_transitions.entry((*address, key)).or_default().push(block_number); } } @@ -3851,6 +3906,14 @@ impl BlockWriter Ok(()) } + + fn clear_transaction_lookup(&self) -> ProviderResult<()> { + if self.cached_storage_settings().storage_v2 { + self.rocksdb_provider.clear::() + } else { + self.tx.clear::().map_err(Into::into) + } + } } impl PruneCheckpointReader for DatabaseProvider { @@ -3934,7 +3997,7 @@ impl ChainStateBlockWriter for DatabaseProvider DbTxProvider for DatabaseProvider { +impl DbTxProvider for DatabaseProvider { type Tx = TX; fn tx(&self) -> &Self::Tx { @@ -3942,7 +4005,7 @@ impl DbTxProvider for DatabaseProvid } } -impl DBProvider for DatabaseProvider { +impl DBProvider for DatabaseProvider { fn tx_mut(&mut self) -> &mut Self::Tx { &mut self.tx } @@ -4041,7 +4104,7 @@ mod tests { use reth_ethereum_primitives::Receipt; use reth_execution_types::{AccountRevertInit, BlockExecutionOutput, BlockExecutionResult}; use reth_primitives_traits::SealedBlock; - use reth_storage_api::{DatabaseProviderFactory, MetadataProvider, MetadataWriter}; + use reth_storage_api::{DatabaseProviderFactory, MetadataWriter}; use reth_testing_utils::generators::{self, random_block, BlockParams}; use reth_trie::{ HashedPostState, KeccakKeyHasher, Nibbles, SortedTrieData, StoredNibbles, @@ -4193,22 +4256,6 @@ mod tests { assert_eq!(result, Vec::>::new()); } - #[test] - fn metadata_can_be_deleted() { - let factory = create_test_provider_factory(); - let key = "metadata-delete-test"; - - let provider_rw = factory.provider_rw().unwrap(); - provider_rw.write_metadata(key, vec![1]).unwrap(); - provider_rw.commit().unwrap(); - assert_eq!(factory.provider().unwrap().get_metadata(key).unwrap(), Some(vec![1])); - - let provider_rw = factory.provider_rw().unwrap(); - provider_rw.delete_metadata(key).unwrap(); - provider_rw.commit().unwrap(); - assert_eq!(factory.provider().unwrap().get_metadata(key).unwrap(), None); - } - #[test] fn unwind_commit_waits_for_pre_commit_readers() { let factory = create_test_provider_factory(); @@ -4509,8 +4556,8 @@ mod tests { } // Pre-populate storage tries with data - let storage_address1 = B256::from([1u8; 32]); - let storage_address2 = B256::from([2u8; 32]); + let storage_address1 = B256::repeat_byte(1u8); + let storage_address2 = B256::repeat_byte(2u8); { let tx = provider_rw.tx_ref(); let mut storage_cursor = tx.cursor_dup_write::().unwrap(); @@ -4724,10 +4771,7 @@ mod tests { (masked_account, Some(Account { nonce: 1, ..Default::default() })), ], B256Map::from_iter([ - ( - kept_storage, - HashedStorageSorted { storage_slots: vec![(kept_slot, U256::from(1))] }, - ), + (kept_storage, HashedStorageSorted { storage_slots: vec![(kept_slot, U256::ONE)] }), ( masked_storage, HashedStorageSorted { storage_slots: vec![(masked_slot, U256::from(2))] }, @@ -4758,7 +4802,7 @@ mod tests { let full_persist_block = ExecutedBlock::new( Arc::clone(&full_persist_base.recovered_block), Arc::clone(&full_persist_base.execution_output), - ComputedTrieData::new( + SortedTrieData::new( Arc::new(full_persist_hashed_state), Arc::new(full_persist_trie_updates), ), @@ -4783,7 +4827,7 @@ mod tests { let deferred_trie_block = ExecutedBlock::new( Arc::clone(&deferred_trie_base.recovered_block), Arc::clone(&deferred_trie_base.execution_output), - ComputedTrieData::new( + SortedTrieData::new( Arc::new(deferred_trie_hashed_state), Arc::new(deferred_trie_updates), ), @@ -4940,6 +4984,50 @@ mod tests { ); } + #[test] + fn unwind_restores_trie_nodes_masked_above_the_frontier() { + let factory = create_test_provider_factory(); + let blocks = TestBlockBuilder::eth().get_executed_blocks(0..11).collect::>(); + let provider = factory.provider_rw().unwrap(); + for block in &blocks { + provider.insert_block(block.recovered_block()).unwrap(); + } + for block in &blocks[6..] { + factory.overlay_manager().insert_block(block.clone()); + } + provider + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(10) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: Some(5) }), + ) + .unwrap(); + let address = Address::with_last_byte(1); + // Block 9 deletes storage that existed at block 4. Its masking leaves the trie tables + // empty despite the persisted frontier being block 5; the changeset restores that storage. + provider + .tx_ref() + .put::( + 9, + AccountBeforeTx { address, info: Some(Account { nonce: 1, ..Default::default() }) }, + ) + .unwrap(); + for slot in 0..257 { + provider + .tx_ref() + .put::( + BlockNumberAddress((9, address)), + StorageEntry { key: B256::from(U256::from(slot)), value: U256::from(1) }, + ) + .unwrap(); + } + provider.commit().unwrap(); + let provider = factory.provider_rw().unwrap(); + assert_eq!(provider.tx_ref().entries::().unwrap(), 0); + provider.unwind_trie_state_from(9).unwrap(); + assert!(provider.tx_ref().entries::().unwrap() > 0); + } + #[test] fn remove_block_and_execution_above_returns_persistence_frontiers() { let factory = create_test_provider_factory(); @@ -5494,7 +5582,7 @@ mod tests { let genesis = SealedBlock::::from_sealed_parts( SealedHeader::new( - Header { number: 0, difficulty: U256::from(1), ..Default::default() }, + Header { number: 0, difficulty: U256::ONE, ..Default::default() }, B256::ZERO, ), Default::default(), @@ -5511,7 +5599,7 @@ mod tests { }, state: Default::default(), }), - ComputedTrieData::default(), + SortedTrieData::default(), ); let provider_rw = factory.provider_rw().unwrap(); save_genesis(&provider_rw, &genesis_executed).unwrap(); @@ -5560,7 +5648,7 @@ mod tests { let header = Header { number: block_num, parent_hash, - difficulty: U256::from(1), + difficulty: U256::ONE, ..Default::default() }; let block = SealedBlock::::seal_parts( @@ -5580,9 +5668,7 @@ mod tests { }, state: bundle, }), - ComputedTrieData { - sorted: SortedTrieData::new(Arc::new(hashed_state), Default::default()), - }, + SortedTrieData::new(Arc::new(hashed_state), Default::default()), ); blocks.push(executed); } @@ -5907,7 +5993,7 @@ mod tests { factory.set_storage_settings_cache(StorageSettings::v2()); let address = Address::with_last_byte(1); - let slot_key = B256::from(U256::from(42)); + let slot_key = B256::with_last_byte(42); { let rocksdb = factory.rocksdb_provider(); diff --git a/crates/storage/provider/src/providers/rocksdb/invariants.rs b/crates/storage/provider/src/providers/rocksdb/invariants.rs index ded24e92438..d069a7889b6 100644 --- a/crates/storage/provider/src/providers/rocksdb/invariants.rs +++ b/crates/storage/provider/src/providers/rocksdb/invariants.rs @@ -522,7 +522,7 @@ mod tests { .unwrap(); // Insert some data - let tx_hash = B256::from([1u8; 32]); + let tx_hash = B256::repeat_byte(1u8); provider.put::(tx_hash, &100).unwrap(); // RocksDB has data diff --git a/crates/storage/provider/src/providers/rocksdb/provider.rs b/crates/storage/provider/src/providers/rocksdb/provider.rs index b681c382c64..bd6d49fb61a 100644 --- a/crates/storage/provider/src/providers/rocksdb/provider.rs +++ b/crates/storage/provider/src/providers/rocksdb/provider.rs @@ -1567,7 +1567,7 @@ impl RocksDBProvider { for storage_block_reverts in reverts.storage { for revert in storage_block_reverts { for (slot, _) in revert.storage_revert { - let plain_key = B256::new(slot.to_be_bytes()); + let plain_key = B256::from(slot); storage_history .entry((revert.address, plain_key)) .or_default() @@ -3173,7 +3173,7 @@ mod tests { let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); // Should be able to write/read TransactionHashNumbers - let tx_hash = TxHash::from(B256::from([1u8; 32])); + let tx_hash = TxHash::from(B256::repeat_byte(1u8)); provider.put::(tx_hash, &100).unwrap(); assert_eq!(provider.get::(tx_hash).unwrap(), Some(100)); @@ -3357,7 +3357,7 @@ mod tests { .build() .unwrap(); - let tx_hash = TxHash::from(B256::from([1u8; 32])); + let tx_hash = TxHash::from(B256::repeat_byte(1u8)); // Insert and retrieve provider.put::(tx_hash, &100).unwrap(); @@ -3367,7 +3367,7 @@ mod tests { provider .write_batch(|batch| { for i in 0..10u64 { - let hash = TxHash::from(B256::from([i as u8; 32])); + let hash = TxHash::from(B256::repeat_byte(i as u8)); let value = i * 100; batch.put::(hash, &value)?; } @@ -3377,7 +3377,7 @@ mod tests { // Verify batch insertions for i in 0..10u64 { - let hash = TxHash::from(B256::from([i as u8; 32])); + let hash = TxHash::from(B256::repeat_byte(i as u8)); assert_eq!( provider.get::(hash).unwrap(), Some(i * 100) @@ -3684,7 +3684,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Create a single shard starting at block 100 let chunk = IntegerList::new([100, 200, 300]).unwrap(); @@ -3704,7 +3704,7 @@ mod tests { #[test] fn test_account_history_info_read_only_and_catch_up() { let temp_dir = TempDir::new().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); let chunk = IntegerList::new([100, 200, 300]).unwrap(); let shard_key = ShardedKey::new(address, u64::MAX); @@ -3733,7 +3733,7 @@ mod tests { assert_eq!(result, HistoryInfo::InPlainState); // Write new data via the primary. - let address2 = Address::from([0x43; 20]); + let address2 = Address::repeat_byte(0x43); let chunk2 = IntegerList::new([500, 600]).unwrap(); let shard_key2 = ShardedKey::new(address2, u64::MAX); rw_provider.put::(shard_key2, &chunk2).unwrap(); @@ -3756,7 +3756,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); provider .put::( @@ -3780,7 +3780,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); provider .put::( ShardedKey::new(address, u64::MAX), @@ -3800,7 +3800,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); provider .put::( ShardedKey::new(address, u64::MAX), @@ -3821,7 +3821,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); let limit = NUM_OF_INDICES_IN_SHARD; // Add exactly NUM_OF_INDICES_IN_SHARD + 1 indices to trigger a split @@ -3852,7 +3852,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x43; 20]); + let address = Address::repeat_byte(0x43); let limit = NUM_OF_INDICES_IN_SHARD; // First batch: add NUM_OF_INDICES_IN_SHARD indices @@ -3896,8 +3896,8 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x44; 20]); - let slot = B256::from([0x55; 32]); + let address = Address::repeat_byte(0x44); + let slot = B256::repeat_byte(0x55); let limit = NUM_OF_INDICES_IN_SHARD; // Add exactly NUM_OF_INDICES_IN_SHARD + 1 indices to trigger a split @@ -3928,8 +3928,8 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x46; 20]); - let slot = B256::from([0x57; 32]); + let address = Address::repeat_byte(0x46); + let slot = B256::repeat_byte(0x57); let limit = NUM_OF_INDICES_IN_SHARD; // First batch: add NUM_OF_INDICES_IN_SHARD indices @@ -3973,7 +3973,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); let key = ShardedKey::new(address, u64::MAX); let blocks = BlockNumberList::new_pre_sorted([1, 2, 3]); @@ -4009,7 +4009,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Add blocks 0-10 let mut batch = provider.batch(); @@ -4040,7 +4040,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Add blocks 5-10 let mut batch = provider.batch(); @@ -4063,7 +4063,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Add blocks 0-5 let mut batch = provider.batch(); @@ -4088,7 +4088,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Add blocks 0-5 (including block 0) let mut batch = provider.batch(); @@ -4114,7 +4114,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Create multiple shards by adding more than NUM_OF_INDICES_IN_SHARD entries // For testing, we'll manually create shards with specific keys @@ -4157,7 +4157,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Create two shards let mut batch = provider.batch(); @@ -4189,8 +4189,8 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); - let other_address = Address::from([0x43; 20]); + let address = Address::repeat_byte(0x42); + let other_address = Address::repeat_byte(0x43); // Add data for two addresses let mut batch = provider.batch(); @@ -4209,7 +4209,7 @@ mod tests { assert_eq!(shards[0].0.key, other_address); // Query shards for non-existent address - let non_existent = Address::from([0x99; 20]); + let non_existent = Address::repeat_byte(0x99); let shards = provider.account_history_shards(non_existent).unwrap(); assert!(shards.is_empty()); } @@ -4219,7 +4219,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Add blocks 0-10 let mut batch = provider.batch(); @@ -4241,7 +4241,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); // Create three shards with non-sentinel boundary let mut batch = provider.batch(); @@ -4443,7 +4443,7 @@ mod tests { }, ]; - let address = Address::from([0x42; 20]); + let address = Address::repeat_byte(0x42); for case in CASES { let temp_dir = TempDir::new().unwrap(); @@ -4579,8 +4579,8 @@ mod tests { }, ]; - let address = Address::from([0x42; 20]); - let storage_key = B256::from([0x01; 32]); + let address = Address::repeat_byte(0x42); + let storage_key = B256::repeat_byte(0x01); for case in CASES { let temp_dir = TempDir::new().unwrap(); @@ -4641,9 +4641,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let address = Address::from([0x42; 20]); - let slot1 = B256::from([0x01; 32]); - let slot2 = B256::from([0x02; 32]); + let address = Address::repeat_byte(0x42); + let slot1 = B256::repeat_byte(0x01); + let slot2 = B256::repeat_byte(0x02); // Two different storage slots let mut batch = provider.batch(); @@ -4681,8 +4681,8 @@ mod tests { #[test] fn test_prune_invariants() { // Test invariants: no empty shards, sentinel is always last - let address = Address::from([0x42; 20]); - let storage_key = B256::from([0x01; 32]); + let address = Address::repeat_byte(0x42); + let storage_key = B256::repeat_byte(0x01); // Test cases that exercise invariants #[expect(clippy::type_complexity)] @@ -4786,9 +4786,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let addr1 = Address::from([0x01; 20]); - let addr2 = Address::from([0x02; 20]); - let addr3 = Address::from([0x03; 20]); + let addr1 = Address::repeat_byte(0x01); + let addr2 = Address::repeat_byte(0x02); + let addr3 = Address::repeat_byte(0x03); // Setup shards for each address let mut batch = provider.batch(); @@ -4841,9 +4841,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let addr1 = Address::from([0x01; 20]); - let addr2 = Address::from([0x02; 20]); // No shards for this one - let addr3 = Address::from([0x03; 20]); + let addr1 = Address::repeat_byte(0x01); + let addr2 = Address::repeat_byte(0x02); // No shards for this one + let addr3 = Address::repeat_byte(0x03); // Only setup shards for addr1 and addr3 let mut batch = provider.batch(); @@ -4887,9 +4887,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let addr = Address::from([0x42; 20]); - let slot1 = B256::from([0x01; 32]); - let slot2 = B256::from([0x02; 32]); + let addr = Address::repeat_byte(0x42); + let slot1 = B256::repeat_byte(0x01); + let slot2 = B256::repeat_byte(0x02); // Setup shards let mut batch = provider.batch(); @@ -4973,36 +4973,13 @@ mod tests { batch.commit().unwrap(); } - #[test] - fn test_prune_storage_history_batch_leaves_shards_above_target_untouched() { - let temp_dir = TempDir::new().unwrap(); - let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - - let addr = Address::from([0x42; 20]); - let slot = B256::from([0x01; 32]); - seed_three_storage_shards(&provider, addr, slot); - - // Only the oldest shard holds blocks at or below the target. - let mut batch = provider.batch(); - let outcomes = batch.prune_storage_history_batch(&[((addr, slot), 50)]).unwrap(); - batch.commit().unwrap(); - - assert_eq!(outcomes.updated, 1); - // The trimmed shard keeps its own key. Re-keying it to the sentinel here would overwrite - // the sentinel's blocks. - assert_eq!( - storage_shard_layout(&provider, addr, slot), - vec![(100, vec![100]), (200, vec![150, 200]), (u64::MAX, vec![250, 300])] - ); - } - #[test] fn test_prune_storage_history_batch_trims_sentinel_once_earlier_shards_expire() { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let addr = Address::from([0x42; 20]); - let slot = B256::from([0x01; 32]); + let addr = Address::repeat_byte(0x42); + let slot = B256::repeat_byte(0x01); seed_three_storage_shards(&provider, addr, slot); // Every non-sentinel shard expires whole and the sentinel loses its lowest block. @@ -5014,46 +4991,13 @@ mod tests { assert_eq!(storage_shard_layout(&provider, addr, slot), vec![(u64::MAX, vec![300])]); } - #[test] - fn test_prune_account_history_batch_leaves_shards_above_target_untouched() { - let temp_dir = TempDir::new().unwrap(); - let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - - let addr = Address::from([0x42; 20]); - - let mut batch = provider.batch(); - batch - .put::( - ShardedKey::new(addr, 100), - &BlockNumberList::new_pre_sorted([10, 50, 100]), - ) - .unwrap(); - batch - .put::( - ShardedKey::new(addr, u64::MAX), - &BlockNumberList::new_pre_sorted([250, 300]), - ) - .unwrap(); - batch.commit().unwrap(); - - let mut batch = provider.batch(); - let outcomes = batch.prune_account_history_batch(&[(addr, 50)]).unwrap(); - batch.commit().unwrap(); - - assert_eq!(outcomes.updated, 1); - assert_eq!( - account_shard_layout(&provider, addr), - vec![(100, vec![100]), (u64::MAX, vec![250, 300])] - ); - } - #[test] fn test_prune_account_history_batch_seeks_after_stopping_early() { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let addr1 = Address::from([0x01; 20]); - let addr2 = Address::from([0x02; 20]); + let addr1 = Address::repeat_byte(0x01); + let addr2 = Address::repeat_byte(0x02); let mut batch = provider.batch(); batch @@ -5095,9 +5039,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let provider = RocksDBBuilder::new(temp_dir.path()).with_default_tables().build().unwrap(); - let addr = Address::from([0x42; 20]); - let slot1 = B256::from([0x01; 32]); - let slot2 = B256::from([0x02; 32]); + let addr = Address::repeat_byte(0x42); + let slot1 = B256::repeat_byte(0x01); + let slot2 = B256::repeat_byte(0x02); seed_three_storage_shards(&provider, addr, slot1); let mut batch = provider.batch(); diff --git a/crates/storage/provider/src/providers/state/latest.rs b/crates/storage/provider/src/providers/state/latest.rs index 788c60e9bd8..4560a9927f4 100644 --- a/crates/storage/provider/src/providers/state/latest.rs +++ b/crates/storage/provider/src/providers/state/latest.rs @@ -11,6 +11,7 @@ use reth_storage_errors::provider::{ProviderError, ProviderResult}; use reth_trie::{ hashed_cursor::{zero_destroyed_account_storage, HashedPostStateCursorFactory}, proof::{Proof, StorageProof}, + proof_v2::MultiProofCalculator, trie_cursor::InMemoryTrieCursorFactory, updates::TrieUpdates, witness::TrieWitness, @@ -29,7 +30,6 @@ type DbStorageRoot<'a, TX, A> = StorageRoot< reth_trie_db::DatabaseHashedCursorFactory<&'a TX>, >; type DbStorageProof<'a, TX, A> = StorageProof< - 'static, reth_trie_db::DatabaseTrieCursorFactory<&'a TX, A>, reth_trie_db::DatabaseHashedCursorFactory<&'a TX>, >; @@ -230,12 +230,20 @@ impl StateProofProvider fn multiproof_v2( &self, - input: TrieInput, targets: MultiProofTargetsV2, + _requires_masks: bool, ) -> ProviderResult { + // The latest database trie is consistent with its hashed state, so masks are always exact + // and `requires_masks` needs no special handling. reth_trie_db::with_adapter!(self.0, |A| { - let proof = as DatabaseProof>::from_tx(self.tx()); - proof.overlay_multiproof_v2(input, targets).map_err(ProviderError::from) + MultiProofCalculator::new( + reth_trie_db::DatabaseTrieCursorFactory::<_, A>::new(self.tx()), + reth_trie_db::DatabaseHashedCursorFactory::new(self.tx()), + Default::default(), + Default::default(), + ) + .multiproof(targets) + .map_err(ProviderError::from) }) } @@ -318,7 +326,10 @@ impl BytecodeReader { /// Get account code by its hash fn bytecode_by_hash(&self, code_hash: &B256) -> ProviderResult> { - self.tx().get_by_encoded_key::(code_hash).map_err(Into::into) + self.tx() + .prefetch(true) + .get_by_encoded_key::(code_hash) + .map_err(Into::into) } } @@ -344,9 +355,10 @@ reth_storage_api::macros::delegate_provider_impls!(LatestStateProvider #[cfg(test)] mod tests { + use super::*; use crate::test_utils::create_test_provider_factory; - use alloy_primitives::{address, b256, keccak256, U256}; + use alloy_primitives::{keccak256, U256}; use reth_db_api::{ models::StorageSettings, tables, @@ -368,8 +380,8 @@ mod tests { let factory = create_test_provider_factory(); factory.set_storage_settings_cache(StorageSettings::v2()); - let address = address!("0x0000000000000000000000000000000000000001"); - let slot = b256!("0x0000000000000000000000000000000000000000000000000000000000000001"); + let address = Address::with_last_byte(1); + let slot = B256::with_last_byte(1); let hashed_address = keccak256(address); let hashed_slot = keccak256(slot); @@ -387,15 +399,13 @@ mod tests { assert_eq!(provider_ref.storage(address, slot).unwrap(), Some(U256::from(42))); - let other_address = address!("0x0000000000000000000000000000000000000099"); - let other_slot = - b256!("0x0000000000000000000000000000000000000000000000000000000000000099"); + let other_address = Address::with_last_byte(0x99); + let other_slot = B256::with_last_byte(0x99); assert_eq!(provider_ref.storage(other_address, other_slot).unwrap(), None); let tx = factory.provider_rw().unwrap().into_tx(); - let plain_address = address!("0x0000000000000000000000000000000000000002"); - let plain_slot = - b256!("0x0000000000000000000000000000000000000000000000000000000000000002"); + let plain_address = Address::with_last_byte(2); + let plain_slot = B256::with_last_byte(2); tx.put::( plain_address, StorageEntry { key: plain_slot, value: U256::from(99) }, @@ -413,8 +423,8 @@ mod tests { let factory = create_test_provider_factory(); factory.set_storage_settings_cache(StorageSettings::v2()); - let address = address!("0x0000000000000000000000000000000000000001"); - let slot = b256!("0x0000000000000000000000000000000000000000000000000000000000000001"); + let address = Address::with_last_byte(1); + let slot = B256::with_last_byte(1); let db = factory.provider().unwrap(); let provider_ref = LatestStateProviderRef::new(&db); @@ -426,8 +436,8 @@ mod tests { let factory = create_test_provider_factory(); assert!(!factory.provider().unwrap().cached_storage_settings().use_hashed_state()); - let address = address!("0x0000000000000000000000000000000000000001"); - let slot = b256!("0x0000000000000000000000000000000000000000000000000000000000000005"); + let address = Address::with_last_byte(1); + let slot = B256::with_last_byte(5); let tx = factory.provider_rw().unwrap().into_tx(); tx.put::( @@ -442,8 +452,7 @@ mod tests { assert_eq!(provider_ref.storage(address, slot).unwrap(), Some(U256::from(42))); - let other_slot = - b256!("0x0000000000000000000000000000000000000000000000000000000000000099"); + let other_slot = B256::with_last_byte(0x99); assert_eq!(provider_ref.storage(address, other_slot).unwrap(), None); } @@ -452,8 +461,8 @@ mod tests { let factory = create_test_provider_factory(); assert!(!factory.provider().unwrap().cached_storage_settings().use_hashed_state()); - let address = address!("0x0000000000000000000000000000000000000001"); - let slot = b256!("0x0000000000000000000000000000000000000000000000000000000000000005"); + let address = Address::with_last_byte(1); + let slot = B256::with_last_byte(5); let hashed_address = keccak256(address); let hashed_slot = keccak256(slot); diff --git a/crates/storage/provider/src/providers/static_file/manager.rs b/crates/storage/provider/src/providers/static_file/manager.rs index 4a382aef359..296f9c53450 100644 --- a/crates/storage/provider/src/providers/static_file/manager.rs +++ b/crates/storage/provider/src/providers/static_file/manager.rs @@ -594,7 +594,7 @@ impl StaticFileProvider { revert.storage_revert.into_iter().map(move |(key, revert_to_slot)| { StorageBeforeTx { address: revert.address, - key: B256::from(key.to_be_bytes()), + key: key.into(), value: revert_to_slot.to_previous_value(), } }) @@ -1572,6 +1572,29 @@ impl StaticFileProvider { .is_some_and(|checkpoint| checkpoint.prune_mode.is_full()) } + /// Returns the first transaction number after `block`, or `None` if it can't be derived. + /// + /// Falls back to the `Bodies` prune checkpoint when bodies through `block` were never stored, + /// e.g. below a snap sync pivot, where transaction numbers restart at 0. + fn next_tx_num_after( + provider: &Provider, + block: BlockNumber, + ) -> ProviderResult> + where + Provider: BlockReader + PruneCheckpointReader, + { + if let Some(indices) = provider.block_body_indices(block)? { + return Ok(Some(indices.next_tx_num())) + } + Ok(provider + .get_prune_checkpoint(PruneSegment::Bodies)? + .filter(|pruned| { + pruned.tx_number.is_none() && + pruned.block_number.is_some_and(|pruned| pruned >= block) + }) + .map(|_| 0)) + } + /// Checks consistency of the latest static file segment and throws an /// error if at fault. /// @@ -1806,8 +1829,13 @@ impl StaticFileProvider { StaticFileSegment::Transactions | StaticFileSegment::Receipts | StaticFileSegment::TransactionSenders => { - if let Some(block) = provider.block_body_indices(checkpoint_block_number)? { - let number = highest_static_file_entry - block.last_tx_num(); + if let Some(next_tx_num) = + Self::next_tx_num_after(provider, checkpoint_block_number)? + { + // `last_tx_num()` saturates to zero for an empty genesis block, but row zero + // belongs to the first non-empty block and must be removed as well. + let number = + highest_static_file_entry.saturating_add(1).saturating_sub(next_tx_num); debug!(target: "reth::providers::static_file", prune_count = number, checkpoint_block_number, "Pruning transaction based segment"); match segment { @@ -1992,6 +2020,11 @@ impl StaticFileProvider { self.earliest_history_height.load(std::sync::atomic::Ordering::Relaxed) } + /// Sets the earliest available block number, matching what [`Self::initialize_index`] derives. + pub(crate) fn set_earliest_history_height(&self, block: BlockNumber) { + self.earliest_history_height.store(block, std::sync::atomic::Ordering::Relaxed); + } + /// Gets the lowest static file's block range if it exists for a static file segment. /// /// If there is nothing on disk for the given segment, this will return [`None`]. @@ -3149,6 +3182,44 @@ mod tests { use super::StaticFileWriter; use crate::{providers::StaticFileProvider, BlockHashReader, StaticFileProviderBuilder}; + #[test] + fn recovery_prunes_transaction_zero_at_empty_genesis() -> eyre::Result<()> { + use crate::{ + test_utils::create_test_provider_factory, StaticFileProviderFactory, + TransactionsProvider, + }; + use alloy_consensus::{SignableTransaction, TxLegacy}; + use alloy_primitives::Signature; + use reth_db::{tables, transaction::DbTxMut}; + + let factory = create_test_provider_factory(); + let provider = factory.provider_rw()?; + provider.tx_ref().put::(0, Default::default())?; + provider.commit()?; + + let static_files = factory.static_file_provider(); + let segment = StaticFileSegment::Transactions; + let tx = TxLegacy::default().into_signed(Signature::test_signature()).into(); + { + let mut writer = static_files.latest_writer(segment)?; + writer.increment_block(0)?; + writer.increment_block(1)?; + writer.append_transaction(0, &tx)?; + writer.commit()?; + } + assert!(static_files.transaction_by_id(0)?.is_some()); + + assert_eq!(static_files.check_consistency(&factory.provider()?)?, None); + assert_eq!(static_files.get_highest_static_file_block(segment), Some(0)); + assert!(static_files.transaction_by_id(0)?.is_none()); + + let mut writer = static_files.latest_writer(segment)?; + writer.increment_block(1)?; + writer.append_transaction(0, &tx)?; + writer.commit()?; + Ok(()) + } + #[test] fn stale_cache_fill_does_not_survive_index_reinitialization() -> eyre::Result<()> { let (static_dir, _) = create_test_static_files_dir(); @@ -3158,7 +3229,7 @@ mod tests { .with_blocks_per_file_for_segment(StaticFileSegment::Receipts, 2) .build()?; - let hash_0 = B256::from([0x10; 32]); + let hash_0 = B256::repeat_byte(0x10); let header_0 = Header { number: 0, ..Default::default() }; { let mut writer = static_files.latest_writer(StaticFileSegment::Headers)?; @@ -3196,7 +3267,7 @@ mod tests { // Publish a newer snapshot of the same header jar, then simulate unrelated segment // pruning invalidating the whole cache while the old load remains in flight. - let hash_1 = B256::from([0x11; 32]); + let hash_1 = B256::repeat_byte(0x11); let header_1 = Header { number: 1, ..Default::default() }; { let mut writer = static_files.latest_writer(StaticFileSegment::Headers)?; @@ -3221,7 +3292,7 @@ mod tests { let (static_dir, _) = create_test_static_files_dir(); let static_files: StaticFileProvider = StaticFileProviderBuilder::read_write(&static_dir).with_blocks_per_file(10).build()?; - let hash = B256::from([0x10; 32]); + let hash = B256::repeat_byte(0x10); { let mut writer = static_files.latest_writer(StaticFileSegment::Headers)?; writer.append_header(&Header::default(), &hash)?; diff --git a/crates/storage/provider/src/providers/static_file/mod.rs b/crates/storage/provider/src/providers/static_file/mod.rs index 2fac861f3c4..b7caa7498cc 100644 --- a/crates/storage/provider/src/providers/static_file/mod.rs +++ b/crates/storage/provider/src/providers/static_file/mod.rs @@ -104,7 +104,8 @@ mod tests { find_fixed_range, SegmentRangeInclusive, DEFAULT_BLOCKS_PER_STATIC_FILE, }; use reth_storage_api::{ - ChangeSetReader, ReceiptProvider, StorageChangeSetReader, TransactionsProvider, + BlockHashReader, ChangeSetReader, ReceiptProvider, StorageChangeSetReader, + TransactionsProvider, }; use reth_testing_utils::generators::{self, random_header_range}; use std::{collections::BTreeMap, fmt::Debug, fs, ops::Range, path::Path}; @@ -614,9 +615,8 @@ mod tests { /// Returns the number of files in the provided path, excluding ".lock" files. fn count_files_without_lockfile(path: impl AsRef) -> eyre::Result { - let is_lockfile = |entry: &fs::DirEntry| { - entry.path().file_name().map(|name| name == "lock").unwrap_or(false) - }; + let is_lockfile = + |entry: &fs::DirEntry| entry.path().file_name().is_some_and(|name| name == "lock"); let count = fs::read_dir(path)? .filter_map(|entry| entry.ok()) .filter(|entry| !is_lockfile(entry)) @@ -787,9 +787,9 @@ mod tests { .expect("Failed to create static file provider"); // Setup test data - let test_address = Address::from([1u8; 20]); - let other_address = Address::from([2u8; 20]); - let missing_address = Address::from([3u8; 20]); + let test_address = Address::repeat_byte(1u8); + let other_address = Address::repeat_byte(2u8); + let missing_address = Address::repeat_byte(3u8); // Write changesets for multiple blocks { @@ -1132,9 +1132,9 @@ mod tests { let sf_rw = StaticFileProvider::::read_write(&static_dir) .expect("Failed to create static file provider"); - let test_address = Address::from([1u8; 20]); - let other_address = Address::from([2u8; 20]); - let missing_address = Address::from([3u8; 20]); + let test_address = Address::repeat_byte(1u8); + let other_address = Address::repeat_byte(2u8); + let missing_address = Address::repeat_byte(3u8); let test_key = B256::with_last_byte(1); let other_key = B256::with_last_byte(2); @@ -1330,7 +1330,7 @@ mod tests { let block_num = 0u64; let num_slots = 100; - let address = Address::from([4u8; 20]); + let address = Address::repeat_byte(4u8); let mut keys: Vec = Vec::with_capacity(num_slots); for i in 0..num_slots { @@ -1354,7 +1354,7 @@ mod tests { assert!(result.is_some()); let entry = result.unwrap(); assert_eq!(entry.key, keys[0]); - assert_eq!(entry.value, U256::from(0)); + assert_eq!(entry.value, U256::ZERO); let result = sf_rw.get_storage_before_block(block_num, address, keys[num_slots - 1]).unwrap(); @@ -1387,7 +1387,7 @@ mod tests { let sf_rw = StaticFileProvider::::read_write(&static_dir) .expect("Failed to create static file provider"); - let address = Address::from([5u8; 20]); + let address = Address::repeat_byte(5u8); let key = B256::with_last_byte(1); // Write changes for a single block without calling increment_block explicitly @@ -1417,4 +1417,34 @@ mod tests { let entry = result.unwrap(); assert_eq!(entry.value, U256::from(42)); } + + #[test] + fn test_read_headers_while_appending() { + let (static_dir, _) = create_test_static_files_dir(); + let sf_rw: StaticFileProvider = + StaticFileProviderBuilder::read_write(&static_dir) + .build() + .expect("Failed to build static file provider"); + let hash = |num: u64| B256::with_last_byte(num as u8 + 1); + + let mut header_writer = sf_rw.latest_writer(StaticFileSegment::Headers).unwrap(); + let mut header = Header::default(); + for num in 0..=2 { + header.number = num; + header_writer.append_header(&header, &hash(num)).unwrap(); + } + header_writer.commit().unwrap(); + + // Writes the next header to disk without committing it, as persistence does before the + // database commit. + header.number = 3; + header_writer.append_header(&header, &hash(3)).unwrap(); + header_writer.sync_all().unwrap(); + + // Reloads the jar from disk meanwhile, as every static file deletion by the pruner does. + sf_rw.initialize_index().unwrap(); + + assert_eq!(sf_rw.block_hash(2).unwrap(), Some(hash(2))); + assert_eq!(sf_rw.block_hash(3).unwrap(), None); + } } diff --git a/crates/storage/provider/src/providers/static_file/writer.rs b/crates/storage/provider/src/providers/static_file/writer.rs index 0d33110d4f9..65c943ce404 100644 --- a/crates/storage/provider/src/providers/static_file/writer.rs +++ b/crates/storage/provider/src/providers/static_file/writer.rs @@ -775,6 +775,32 @@ impl StaticFileProviderRW { Ok(()) } + /// Anchors an empty non-header segment so the next append starts at `block + 1`. + /// Preserves the file's expected range, since reopening derives the file name from it; + /// refuses segments containing blocks or rows. + pub fn initialize_pruned_anchor(&mut self, block: BlockNumber) -> ProviderResult<()> { + let header = self.user_header(); + if header.segment().is_headers() || + header.block_range().is_some() || + self.writer.rows() != 0 + { + return Err(ProviderError::other(StaticFileWriterError::new( + "pruned anchor requires a fresh non-header segment", + ))) + } + if !header.expected_block_range().contains(block) { + return Err(ProviderError::other(StaticFileWriterError::new( + "pruned anchor is outside the static file range", + ))) + } + self.writer.user_header_mut().set_block_range(block, block); + if self.user_header().segment().is_change_based() { + self.writer.user_header_mut().set_changeset_offsets_len(1); + self.current_changeset_offset = Some(ChangesetOffset::new(0, 0)); + } + Ok(()) + } + /// Allows to increment the [`SegmentHeader`] end block. It will commit the current static file, /// and create the next one if we are past the end range. pub fn increment_block(&mut self, expected_block_number: BlockNumber) -> ProviderResult<()> { @@ -891,6 +917,8 @@ impl StaticFileProviderRW { return Ok(()) } + self.ensure_above_pruned_anchor(last_block)?; + // Navigate to the correct file if the target block is in a previous file let mut expected_block_start = self.writer.user_header().expected_block_start(); while last_block < expected_block_start && expected_block_start > 0 { @@ -898,12 +926,12 @@ impl StaticFileProviderRW { expected_block_start = self.writer.user_header().expected_block_start(); } + // An anchor can start inside the file's fixed range, shifting the sidecar indices. + let block_start = self.writer.user_header().block_start().unwrap_or(expected_block_start); + // Find the number of rows to keep (up to and including last_block) - let blocks_to_keep = if last_block >= expected_block_start { - last_block - expected_block_start + 1 - } else { - 0 - }; + let blocks_to_keep = + if last_block >= block_start { last_block - block_start + 1 } else { 0 }; // Read changeset offsets from sidecar file to find where to truncate let csoff_path = self.data_path.with_extension("csoff"); @@ -951,7 +979,7 @@ impl StaticFileProviderRW { } // Update the block range - self.writer.user_header_mut().set_block_range(expected_block_start, last_block); + self.writer.user_header_mut().set_block_range(block_start, last_block); // Sync changeset offsets to match the new block range self.writer.user_header_mut().sync_changeset_offsets(); @@ -978,6 +1006,9 @@ impl StaticFileProviderRW { /// # Note /// Commits to the configuration file at the end. fn truncate(&mut self, num_rows: u64, last_block: Option) -> ProviderResult<()> { + if let Some(last_block) = last_block { + self.ensure_above_pruned_anchor(last_block)?; + } let mut remaining_rows = num_rows; let segment = self.writer.user_header().segment(); while remaining_rows > 0 { @@ -1032,7 +1063,10 @@ impl StaticFileProviderRW { expected_block_start = self.writer.user_header().expected_block_start(); } } - self.writer.user_header_mut().set_block_range(expected_block_start, last_block); + // Preserve the anchor; the fixed file range may start before any retained history. + let block_start = + self.writer.user_header().block_start().unwrap_or(expected_block_start); + self.writer.user_header_mut().set_block_range(block_start, last_block); } // Commits new changes to disk. @@ -1081,6 +1115,24 @@ impl StaticFileProviderRW { Ok(()) } + /// Errors if the segment is anchored above `last_block`, since nothing below the anchor is + /// stored and the unwind cannot be applied. Must run before the unwind deletes any file. + fn ensure_above_pruned_anchor(&self, last_block: BlockNumber) -> ProviderResult<()> { + let header = self.writer.user_header(); + let lowest = self.reader().get_lowest_range_start(header.segment()); + // Before the first commit, the current file is the only source of the anchor, + // including when it lies on a file boundary. + let current = header + .block_start() + .filter(|start| lowest.is_none() || *start > header.expected_block_start()); + if current.into_iter().chain(lowest).any(|start| last_block < start) { + return Err(ProviderError::other(StaticFileWriterError::new( + "cannot unwind below the pruned anchor", + ))) + } + Ok(()) + } + /// Appends column to static file. fn append_column(&mut self, column: T) -> ProviderResult<()> { self.buf.clear(); @@ -1541,6 +1593,17 @@ impl StaticFileProviderRW { /// Adds an instruction to prune elements during commit using the specified strategy. fn queue_prune(&mut self, strategy: PruneStrategy) -> ProviderResult<()> { self.ensure_no_queued_prune()?; + // Unwinds commit MDBX before static files, so reject invalid targets before queuing them. + match strategy { + PruneStrategy::Transactions { last_block, .. } | + PruneStrategy::Receipts { last_block, .. } | + PruneStrategy::TransactionSenders { last_block, .. } | + PruneStrategy::AccountChangeSets { last_block } | + PruneStrategy::StorageChangeSets { last_block } => { + self.ensure_above_pruned_anchor(last_block)?; + } + PruneStrategy::Headers { .. } => {} + } self.prune_on_commit = Some(strategy); Ok(()) } diff --git a/crates/storage/provider/src/providers/static_file/writer_tests.rs b/crates/storage/provider/src/providers/static_file/writer_tests.rs index f85201df2cd..056bd950dcd 100644 --- a/crates/storage/provider/src/providers/static_file/writer_tests.rs +++ b/crates/storage/provider/src/providers/static_file/writer_tests.rs @@ -15,11 +15,15 @@ mod tests { use reth_static_file_types::{ChangesetOffset, ChangesetOffsetReader, StaticFileSegment}; use std::{fs::OpenOptions, io::Write as _, path::PathBuf}; - use crate::providers::{ - static_file::manager::{StaticFileProviderBuilder, StaticFileWriter}, - StaticFileProvider, + use crate::{ + providers::{ + static_file::manager::{StaticFileProviderBuilder, StaticFileWriter}, + StaticFileProvider, + }, + ChangeSetReader, ReceiptProvider, }; use reth_chain_state::EthPrimitives; + use reth_ethereum_primitives::Receipt; // ==================== HELPER FUNCTIONS ==================== @@ -907,4 +911,248 @@ mod tests { Some(250) ); } + + #[test] + fn test_unwind_within_anchored_changesets() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 100); + let segment = StaticFileSegment::AccountChangeSets; + + { + let mut writer = provider.get_writer(10, segment).unwrap(); + writer.initialize_pruned_anchor(10).unwrap(); + for block in 11..=15 { + writer.append_account_changeset(generate_test_changeset(block, 2), block).unwrap(); + } + writer.commit().unwrap(); + } + { + let mut writer = provider.latest_writer(segment).unwrap(); + writer.prune_account_changesets(12).unwrap(); + writer.commit().unwrap(); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 100); + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((10..=12).into())); + assert_eq!(header.changeset_offsets_len(), 3); + assert_eq!(get_nippy_row_count(&provider, 10), 4); + for block in 11..=12 { + assert_eq!(provider.account_block_changeset(block).unwrap().len(), 2, "{block}"); + } + assert!(provider.account_block_changeset(13).unwrap().is_empty()); + } + + #[test] + fn test_unwind_below_anchor_errors() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 100); + let segment = StaticFileSegment::AccountChangeSets; + + { + let mut writer = provider.get_writer(10, segment).unwrap(); + writer.initialize_pruned_anchor(10).unwrap(); + for block in 11..=12 { + writer.append_account_changeset(generate_test_changeset(block, 2), block).unwrap(); + } + writer.commit().unwrap(); + } + { + let mut writer = provider.latest_writer(segment).unwrap(); + assert!(writer.prune_account_changesets(9).is_err()); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 100); + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((10..=12).into())); + assert_eq!(get_nippy_row_count(&provider, 10), 4); + } + + #[test] + fn test_anchor_outside_file_range_errors() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 100); + + let mut writer = provider.get_writer(10, StaticFileSegment::AccountChangeSets).unwrap(); + assert!(writer.initialize_pruned_anchor(150).is_err()); + assert_eq!(writer.user_header().block_range(), None); + } + + #[test] + fn test_unwind_within_anchored_receipts() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 100); + let segment = StaticFileSegment::Receipts; + + { + let mut writer = provider.get_writer(10, segment).unwrap(); + writer.initialize_pruned_anchor(10).unwrap(); + for (block, txs) in [(11, 100..102), (12, 102..103)] { + writer.increment_block(block).unwrap(); + for tx in txs { + writer.append_receipt(tx, &Receipt::default()).unwrap(); + } + } + writer.commit().unwrap(); + } + { + let mut writer = provider.latest_writer(segment).unwrap(); + writer.prune_receipts(1, 11).unwrap(); + writer.commit().unwrap(); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 100); + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((10..=11).into())); + assert_eq!(header.tx_range(), Some((100..=101).into())); + } + + #[test] + fn test_unwind_within_anchor_in_later_file() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 500); + let segment = StaticFileSegment::AccountChangeSets; + + { + let mut writer = provider.get_writer(750, segment).unwrap(); + writer.initialize_pruned_anchor(750).unwrap(); + for block in 751..=753 { + writer.append_account_changeset(generate_test_changeset(block, 2), block).unwrap(); + } + writer.commit().unwrap(); + } + { + let mut writer = provider.latest_writer(segment).unwrap(); + writer.prune_account_changesets(751).unwrap(); + writer.commit().unwrap(); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 500); + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((750..=751).into())); + assert_eq!(provider.account_block_changeset(751).unwrap().len(), 2); + assert!(!provider.directory().join(segment.filename(&(0..=499).into())).exists()); + } + + #[test] + fn test_unwind_below_anchor_in_earlier_file_errors() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 500); + let segment = StaticFileSegment::AccountChangeSets; + + { + let mut writer = provider.get_writer(750, segment).unwrap(); + writer.initialize_pruned_anchor(750).unwrap(); + for block in 751..=753 { + writer.append_account_changeset(generate_test_changeset(block, 2), block).unwrap(); + } + writer.commit().unwrap(); + } + { + let mut writer = provider.latest_writer(segment).unwrap(); + assert!(writer.prune_account_changesets(499).is_err()); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 500); + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((750..=753).into())); + assert!(!provider.directory().join(segment.filename(&(0..=499).into())).exists()); + } + + #[test] + fn test_unwind_below_anchor_from_later_file_errors() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 500); + let segment = StaticFileSegment::Receipts; + + { + let mut writer = provider.get_writer(750, segment).unwrap(); + writer.initialize_pruned_anchor(750).unwrap(); + for block in 751..=1001 { + writer.increment_block(block).unwrap(); + } + writer.append_receipt(0, &Receipt::default()).unwrap(); + writer.commit().unwrap(); + } + { + let mut writer = provider.latest_writer(segment).unwrap(); + assert!(writer.prune_receipts(1, 749).is_err()); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 500); + assert_eq!(provider.get_lowest_range(segment), Some((750..=999).into())); + assert_eq!(provider.get_highest_static_file_block(segment), Some(1001)); + } + + #[test] + fn test_unwind_below_boundary_anchor_errors() { + for committed in [false, true] { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 500); + + for segment in [StaticFileSegment::AccountChangeSets, StaticFileSegment::Receipts] { + let mut writer = provider.get_writer(500, segment).unwrap(); + writer.initialize_pruned_anchor(500).unwrap(); + if segment.is_change_based() { + writer.append_account_changeset(generate_test_changeset(501, 2), 501).unwrap(); + } else { + writer.increment_block(501).unwrap(); + writer.append_receipt(100, &Receipt::default()).unwrap(); + } + if committed { + writer.commit().unwrap(); + } + + let queued = if segment.is_change_based() { + writer.prune_account_changesets(499) + } else { + writer.prune_receipts(1, 499) + }; + assert!(queued.is_err(), "{segment}, committed={committed}"); + // A rejected unwind must leave pending data available to commit. + writer.commit().unwrap(); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 500); + assert_eq!(provider.account_block_changeset(501).unwrap().len(), 2); + assert_eq!(provider.receipt(100).unwrap(), Some(Receipt::default())); + for segment in [StaticFileSegment::AccountChangeSets, StaticFileSegment::Receipts] { + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((500..=501).into()), "{segment}"); + } + } + } + + #[test] + fn test_unwind_within_anchor_before_first_commit() { + let (static_dir, _) = create_test_static_files_dir(); + let provider = setup_test_provider(&static_dir, 100); + let segment = StaticFileSegment::AccountChangeSets; + + { + let mut writer = provider.get_writer(10, segment).unwrap(); + writer.initialize_pruned_anchor(10).unwrap(); + for block in 11..=12 { + writer.append_account_changeset(generate_test_changeset(block, 2), block).unwrap(); + } + writer.prune_account_changesets(11).unwrap(); + writer.commit().unwrap(); + } + drop(provider); + + let provider = setup_test_provider(&static_dir, 100); + let header = provider.latest_writer(segment).unwrap().user_header().clone(); + assert_eq!(header.block_range(), Some((10..=11).into())); + assert_eq!(header.changeset_offsets_len(), 2); + assert_eq!(get_nippy_row_count(&provider, 10), 2); + assert_eq!(provider.account_block_changeset(11).unwrap(), generate_test_changeset(11, 2)); + assert!(provider.account_block_changeset(12).unwrap().is_empty()); + } } diff --git a/crates/storage/provider/src/test_utils/blocks.rs b/crates/storage/provider/src/test_utils/blocks.rs index d6185d77a17..d558386b373 100644 --- a/crates/storage/provider/src/test_utils/blocks.rs +++ b/crates/storage/provider/src/test_utils/blocks.rs @@ -1,8 +1,9 @@ //! Dummy blocks and data for tests + use crate::{DBProvider, DatabaseProviderRW, ExecutionOutcome}; use alloy_consensus::{TxLegacy, EMPTY_OMMER_ROOT_HASH}; use alloy_primitives::{ - b256, hex_literal::hex, map::HashMap, Address, BlockNumber, Bytes, Log, TxKind, B256, U256, + address, b256, map::HashMap, Address, BlockNumber, Bytes, Log, TxKind, B256, U256, }; use alloy_consensus::Header; @@ -61,24 +62,20 @@ pub(crate) static TEST_BLOCK: LazyLock SealedBlock { SealedBlock::from_sealed_parts( SealedHeader::new( - Header { number: 0, difficulty: U256::from(1), ..Default::default() }, + Header { number: 0, difficulty: U256::ONE, ..Default::default() }, B256::ZERO, ), Default::default(), @@ -207,7 +204,7 @@ fn block1( success: true, cumulative_gas_used: 300, logs: vec![Log::new_unchecked( - Address::new([0x60; 20]), + Address::repeat_byte(0x60), vec![B256::with_last_byte(1), B256::with_last_byte(2)], Bytes::default(), )], @@ -229,7 +226,7 @@ fn block1( header.parent_hash = B256::ZERO; let block = SealedBlock::seal_parts(header, body); - (RecoveredBlock::new_sealed(block, vec![Address::new([0x30; 20])]), execution_outcome) + (RecoveredBlock::new_sealed(block, vec![Address::repeat_byte(0x30)]), execution_outcome) } /// Block two that points to block 1 @@ -261,7 +258,7 @@ fn block2( success: false, cumulative_gas_used: 400, logs: vec![Log::new_unchecked( - Address::new([0x61; 20]), + Address::repeat_byte(0x61), vec![B256::with_last_byte(3), B256::with_last_byte(4)], Bytes::default(), )], @@ -287,7 +284,7 @@ fn block2( header.parent_hash = parent_hash; let block = SealedBlock::seal_parts(header, body); - (RecoveredBlock::new_sealed(block, vec![Address::new([0x31; 20])]), execution_outcome) + (RecoveredBlock::new_sealed(block, vec![Address::repeat_byte(0x31)]), execution_outcome) } /// Block three that points to block 2 @@ -324,7 +321,7 @@ fn block3( success: true, cumulative_gas_used: 400, logs: vec![Log::new_unchecked( - Address::new([0x61; 20]), + Address::repeat_byte(0x61), vec![B256::with_last_byte(3), B256::with_last_byte(4)], Bytes::default(), )], @@ -345,7 +342,7 @@ fn block3( header.parent_hash = parent_hash; let block = SealedBlock::seal_parts(header, body); - (RecoveredBlock::new_sealed(block, vec![Address::new([0x31; 20])]), execution_outcome) + (RecoveredBlock::new_sealed(block, vec![Address::repeat_byte(0x31)]), execution_outcome) } /// Block four that points to block 3 @@ -407,7 +404,7 @@ fn block4( success: true, cumulative_gas_used: 400, logs: vec![Log::new_unchecked( - Address::new([0x61; 20]), + Address::repeat_byte(0x61), vec![B256::with_last_byte(3), B256::with_last_byte(4)], Bytes::default(), )], @@ -428,7 +425,7 @@ fn block4( header.parent_hash = parent_hash; let block = SealedBlock::seal_parts(header, body); - (RecoveredBlock::new_sealed(block, vec![Address::new([0x31; 20])]), execution_outcome) + (RecoveredBlock::new_sealed(block, vec![Address::repeat_byte(0x31)]), execution_outcome) } /// Block five that points to block 4 @@ -487,7 +484,7 @@ fn block5( success: true, cumulative_gas_used: 400, logs: vec![Log::new_unchecked( - Address::new([0x61; 20]), + Address::repeat_byte(0x61), vec![B256::with_last_byte(3), B256::with_last_byte(4)], Bytes::default(), )], @@ -508,5 +505,5 @@ fn block5( header.parent_hash = parent_hash; let block = SealedBlock::seal_parts(header, body); - (RecoveredBlock::new_sealed(block, vec![Address::new([0x31; 20])]), execution_outcome) + (RecoveredBlock::new_sealed(block, vec![Address::repeat_byte(0x31)]), execution_outcome) } diff --git a/crates/storage/provider/src/test_utils/mock.rs b/crates/storage/provider/src/test_utils/mock.rs index d370fda62a9..49f93bb6843 100644 --- a/crates/storage/provider/src/test_utils/mock.rs +++ b/crates/storage/provider/src/test_utils/mock.rs @@ -496,6 +496,16 @@ impl ExtendedAccount { self.storage.extend(storage); self } + + /// Sets the account extension. + #[cfg(feature = "account-ext")] + pub fn with_extension( + mut self, + extension: impl Into, + ) -> Self { + self.account.extension = extension.into(); + self + } } impl DatabaseProviderFactory @@ -575,7 +585,7 @@ impl DBProvider } } -impl HeaderProvider +impl HeaderProvider for MockEthProvider { type Header = ::Header; @@ -627,7 +637,7 @@ impl HeaderP impl ChainSpecProvider for MockEthProvider where T: NodePrimitives, - ChainSpec: EthChainSpec + 'static + Debug + Send + Sync, + ChainSpec: EthChainSpec + 'static, { type ChainSpec = ChainSpec; @@ -895,7 +905,7 @@ impl BlockNumReader } } -impl BlockIdReader +impl BlockIdReader for MockEthProvider { fn pending_block_num_hash(&self) -> ProviderResult> { @@ -912,7 +922,7 @@ impl BlockId } //look -impl BlockReader +impl BlockReader for MockEthProvider { type Block = T::Block; @@ -997,7 +1007,7 @@ impl BlockRe impl BlockReaderIdExt for MockEthProvider where - ChainSpec: EthChainSpec + Send + Sync + 'static, + ChainSpec: EthChainSpec + 'static, T: NodePrimitives, { fn block_by_id(&self, id: BlockId) -> ProviderResult> { @@ -1150,8 +1160,8 @@ where fn multiproof_v2( &self, - _input: TrieInput, _targets: reth_trie::MultiProofTargetsV2, + _requires_masks: bool, ) -> ProviderResult { Ok(reth_trie::DecodedMultiProofV2::default()) } @@ -1180,7 +1190,7 @@ impl HashedPostStateProvid impl StateProvider for MockEthProvider where T: NodePrimitives, - ChainSpec: EthChainSpec + Send + Sync + 'static, + ChainSpec: EthChainSpec + 'static, { fn storage( &self, @@ -1220,7 +1230,7 @@ impl StorageSettingsCache fn set_storage_settings_cache(&self, _settings: StorageSettings) {} } -impl StateProviderFactory +impl StateProviderFactory for MockEthProvider { type Primitives = T; diff --git a/crates/storage/provider/src/traits/full.rs b/crates/storage/provider/src/traits/full.rs index 8277d54bd86..24193486380 100644 --- a/crates/storage/provider/src/traits/full.rs +++ b/crates/storage/provider/src/traits/full.rs @@ -12,7 +12,6 @@ use reth_node_types::{BlockTy, HeaderTy, NodeTypesWithDB, ReceiptTy, TxTy}; use reth_storage_api::{ HistoryReader, NodePrimitivesProvider, StorageChangeSetReader, StorageSettingsCache, }; -use std::fmt::Debug; /// Helper trait to unify all provider traits for simplicity. pub trait FullProvider: @@ -47,7 +46,6 @@ pub trait FullProvider: + StageCheckpointReader + PruneCheckpointReader + Clone - + Debug + Unpin + 'static { @@ -85,7 +83,6 @@ impl FullProvider for T where + StageCheckpointReader + PruneCheckpointReader + Clone - + Debug + Unpin + 'static { diff --git a/crates/storage/rpc-provider/Cargo.toml b/crates/storage/rpc-provider/Cargo.toml index 540a56f521a..da0db3c6a91 100644 --- a/crates/storage/rpc-provider/Cargo.toml +++ b/crates/storage/rpc-provider/Cargo.toml @@ -34,6 +34,7 @@ alloy-primitives.workspace = true alloy-consensus.workspace = true alloy-rpc-types.workspace = true alloy-rpc-types-engine.workspace = true +alloy-rpc-types-eth.workspace = true alloy-eips.workspace = true # async @@ -48,3 +49,13 @@ revm.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros"] } + +[features] +account-ext = [ + "alloy-rpc-types-eth/account-ext", + "reth-primitives-traits/account-ext", + "reth-provider/account-ext", + "reth-stages-types/account-ext", + "reth-trie/account-ext", + "revm/account-ext", +] diff --git a/crates/storage/rpc-provider/src/lib.rs b/crates/storage/rpc-provider/src/lib.rs index 91449d0f9a6..2f3126a9b35 100644 --- a/crates/storage/rpc-provider/src/lib.rs +++ b/crates/storage/rpc-provider/src/lib.rs @@ -25,12 +25,13 @@ #![cfg_attr(docsrs, feature(doc_cfg))] use alloy_consensus::{constants::KECCAK_EMPTY, transaction::TransactionMeta, BlockHeader}; -use alloy_eips::{BlockHashOrNumber, BlockNumberOrTag}; +use alloy_eips::BlockHashOrNumber; use alloy_network::{primitives::HeaderResponse, BlockResponse}; use alloy_primitives::{Address, BlockHash, BlockNumber, StorageKey, TxHash, TxNumber, B256, U256}; use alloy_provider::{ext::DebugApi, network::Network, Provider}; -use alloy_rpc_types::{AccountInfo, BlockId}; +use alloy_rpc_types::BlockId; use alloy_rpc_types_engine::ForkchoiceState; +use alloy_rpc_types_eth::AccountInfo; use dashmap::DashMap; use reth_chain_state::ExecutedBlock; use reth_chainspec::{ChainInfo, ChainSpecProvider}; @@ -306,7 +307,7 @@ where self.block_on_async(async { let block = self .provider - .get_block(BlockId::Number(BlockNumberOrTag::Latest)) + .get_block(BlockId::latest()) .await .map_err(ProviderError::other)? .ok_or(ProviderError::HeaderNotFound(0.into()))?; @@ -1031,11 +1032,19 @@ impl RpcBlockchainStateProvider { } /// Get account information from RPC + #[allow(clippy::needless_update)] fn get_account(&self, address: Address) -> Result, ProviderError> where P: Provider + Clone + 'static, N: Network, { + // Only `eth_getAccountInfo` returns account extensions. + if Account::EXTENSIONS_ENABLED && !self.reth_rpc_support { + return Err(ProviderError::other(std::io::Error::other( + "account extensions require an eth_getAccountInfo endpoint (reth_rpc_support)", + ))); + } + let account_info = self.block_on_async(async { // Get account info in a single RPC call using `eth_getAccountInfo` if self.reth_rpc_support { @@ -1057,6 +1066,7 @@ impl RpcBlockchainStateProvider { balance: balance.map_err(ProviderError::other)?, nonce: nonce.map_err(ProviderError::other)?, code: code.map_err(ProviderError::other)?, + ..Default::default() }; let code_hash = account_info.code_hash(); @@ -1068,19 +1078,17 @@ impl RpcBlockchainStateProvider { Ok(account_info) })?; - // Only return account if it exists (has balance, nonce, or code) - if account_info.balance.is_zero() && account_info.nonce == 0 && account_info.code.is_empty() - { + // Only return accounts that exist. + if account_info.is_empty() { Ok(None) } else { let bytecode_hash = if account_info.code.is_empty() { None } else { Some(account_info.code_hash()) }; - Ok(Some(Account { - balance: account_info.balance, - nonce: account_info.nonce, - bytecode_hash, - })) + let account = Account::new(account_info.nonce, account_info.balance, bytecode_hash); + #[cfg(feature = "account-ext")] + let account = account.with_extension(account_info.extension); + Ok(Some(account)) } } } @@ -1315,8 +1323,8 @@ where fn multiproof_v2( &self, - _input: TrieInput, _targets: reth_trie::MultiProofTargetsV2, + _requires_masks: bool, ) -> Result { Err(ProviderError::UnsupportedProvider) } @@ -1804,7 +1812,7 @@ where impl StateProviderFactory for RpcBlockchainStateProvider where P: Provider + Clone + 'static + Send + Sync, - Node: NodeTypes + 'static, + Node: NodeTypes, Node::ChainSpec: Send + Sync, N: Network, Self: Clone + 'static, diff --git a/crates/storage/storage-api/src/bal.rs b/crates/storage/storage-api/src/bal.rs index ca2cd456d27..b84c1f056c2 100644 --- a/crates/storage/storage-api/src/bal.rs +++ b/crates/storage/storage-api/src/bal.rs @@ -296,24 +296,6 @@ mod tests { assert_eq!(store.prune(10).unwrap(), 0); } - #[test] - fn noop_provider_returns_empty_results() { - let provider = crate::noop::NoopProvider::default(); - let hashes = [B256::random(), B256::random()]; - - assert_eq!(provider.get_bals_by_hashes(&hashes).unwrap(), vec![None, None]); - assert_eq!( - provider - .get_bals_by_hashes_with_limit( - &hashes, - GetBlockAccessListLimit::ResponseSizeSoftLimit(0), - ) - .unwrap(), - vec![None] - ); - assert!(provider.get_bal_by_hash(B256::random()).unwrap().is_none()); - } - #[test] fn noop_store_flush_is_noop() { let store = BalStoreHandle::default(); diff --git a/crates/storage/storage-api/src/block.rs b/crates/storage/storage-api/src/block.rs index 020d6752305..9d3b6597c3e 100644 --- a/crates/storage/storage-api/src/block.rs +++ b/crates/storage/storage-api/src/block.rs @@ -1,6 +1,6 @@ use crate::{ - BlockBodyIndicesProvider, BlockNumReader, HeaderProvider, ReceiptProvider, - ReceiptProviderIdExt, TransactionVariant, TransactionsProvider, + BlockBodyIndicesProvider, HeaderProvider, ReceiptProvider, ReceiptProviderIdExt, + TransactionVariant, TransactionsProvider, }; use alloc::{sync::Arc, vec::Vec}; use alloy_eips::{BlockHashOrNumber, BlockId, BlockNumberOrTag}; @@ -49,12 +49,7 @@ pub type ProviderBlock

=

::Block; /// If not requested otherwise, implementers of this trait should prioritize fetching blocks from /// the database. pub trait BlockReader: - BlockNumReader - + HeaderProvider - + BlockBodyIndicesProvider - + TransactionsProvider - + ReceiptProvider - + Send + HeaderProvider + BlockBodyIndicesProvider + TransactionsProvider + ReceiptProvider { /// The block type this provider reads. type Block: reth_primitives_traits::Block< @@ -166,7 +161,7 @@ pub trait BlockReader: fn block_by_transaction_id(&self, id: TxNumber) -> ProviderResult>; } -impl BlockReader for Arc { +impl BlockReader for Arc { type Block = T::Block; fn find_block_by_hash( @@ -234,7 +229,7 @@ impl BlockReader for Arc { } } -impl BlockReader for &T { +impl BlockReader for &T { type Block = T::Block; fn find_block_by_hash( @@ -325,7 +320,7 @@ pub trait BlockReaderIdExt: BlockReader + ReceiptProviderIdExt { /// Note: This returns a [`SealedHeader`] because it's expected that this is sealed by the /// provider and the caller does not know the hash. fn pending_header(&self) -> ProviderResult>> { - self.sealed_header_by_id(BlockNumberOrTag::Pending.into()) + self.sealed_header_by_id(BlockId::pending()) } /// Returns the latest block header if available @@ -333,7 +328,7 @@ pub trait BlockReaderIdExt: BlockReader + ReceiptProviderIdExt { /// Note: This returns a [`SealedHeader`] because it's expected that this is sealed by the /// provider and the caller does not know the hash. fn latest_header(&self) -> ProviderResult>> { - self.sealed_header_by_id(BlockNumberOrTag::Latest.into()) + self.sealed_header_by_id(BlockId::latest()) } /// Returns the safe block header if available @@ -341,7 +336,7 @@ pub trait BlockReaderIdExt: BlockReader + ReceiptProviderIdExt { /// Note: This returns a [`SealedHeader`] because it's expected that this is sealed by the /// provider and the caller does not know the hash. fn safe_header(&self) -> ProviderResult>> { - self.sealed_header_by_id(BlockNumberOrTag::Safe.into()) + self.sealed_header_by_id(BlockId::safe()) } /// Returns the finalized block header if available @@ -349,7 +344,7 @@ pub trait BlockReaderIdExt: BlockReader + ReceiptProviderIdExt { /// Note: This returns a [`SealedHeader`] because it's expected that this is sealed by the /// provider and the caller does not know the hash. fn finalized_header(&self) -> ProviderResult>> { - self.sealed_header_by_id(BlockNumberOrTag::Finalized.into()) + self.sealed_header_by_id(BlockId::finalized()) } /// Returns the block with the matching [`BlockId`] from the database. diff --git a/crates/storage/storage-api/src/block_id.rs b/crates/storage/storage-api/src/block_id.rs index ae0a35701eb..0c3c782ce1c 100644 --- a/crates/storage/storage-api/src/block_id.rs +++ b/crates/storage/storage-api/src/block_id.rs @@ -56,7 +56,7 @@ pub trait BlockNumReader: BlockHashReader + Send { /// The resulting block numbers can be converted to hashes using the underlying [`BlockNumReader`] /// methods, and vice versa. #[auto_impl::auto_impl(&, Arc)] -pub trait BlockIdReader: BlockNumReader + Send + Sync { +pub trait BlockIdReader: BlockNumReader + Sync { /// Converts the `BlockNumberOrTag` variants to a block number. fn convert_block_number(&self, num: BlockNumberOrTag) -> ProviderResult> { let num = match num { diff --git a/crates/storage/storage-api/src/block_indices.rs b/crates/storage/storage-api/src/block_indices.rs index 6122872cf3e..5b9472128ee 100644 --- a/crates/storage/storage-api/src/block_indices.rs +++ b/crates/storage/storage-api/src/block_indices.rs @@ -1,8 +1,8 @@ use alloc::vec::Vec; -use alloy_primitives::BlockNumber; +use alloy_primitives::{BlockNumber, TxNumber}; use core::ops::RangeInclusive; use reth_db_models::StoredBlockBodyIndices; -use reth_storage_errors::provider::ProviderResult; +use reth_storage_errors::provider::{ProviderError, ProviderResult}; /// Client trait for fetching block body indices related data. #[auto_impl::auto_impl(&, Arc)] @@ -17,4 +17,18 @@ pub trait BlockBodyIndicesProvider: Send { &self, range: RangeInclusive, ) -> ProviderResult>; + + /// Returns the first transaction number after `block`, including when its body indices were + /// never written, e.g. at a snap sync pivot. + fn next_tx_num_after_block(&self, block: BlockNumber) -> ProviderResult { + if let Some(indices) = self.block_body_indices(block)? { + return Ok(indices.next_tx_num()) + } + if let Some(next) = block.checked_add(1) && + let Some(indices) = self.block_body_indices(next)? + { + return Ok(indices.first_tx_num()) + } + Err(ProviderError::BlockBodyIndicesNotFound(block)) + } } diff --git a/crates/storage/storage-api/src/block_writer.rs b/crates/storage/storage-api/src/block_writer.rs index bf4adae93ad..8acbfc778e8 100644 --- a/crates/storage/storage-api/src/block_writer.rs +++ b/crates/storage/storage-api/src/block_writer.rs @@ -4,7 +4,7 @@ use alloy_primitives::BlockNumber; use reth_db_models::StoredBlockBodyIndices; use reth_execution_types::{Chain, ExecutionOutcome}; use reth_primitives_traits::{Block, NodePrimitives, RecoveredBlock}; -use reth_storage_errors::provider::ProviderResult; +use reth_storage_errors::provider::{ProviderError, ProviderResult}; use reth_trie_common::HashedPostStateSorted; /// `BlockExecution` Writer @@ -113,4 +113,11 @@ pub trait BlockWriter { execution_outcome: &ExecutionOutcome, hashed_state: HashedPostStateSorted, ) -> ProviderResult<()>; + + /// Clears the active transaction lookup index. `RocksDB` changes take effect immediately. + /// + /// Errors by default, so writers without a lookup index don't have to implement it. + fn clear_transaction_lookup(&self) -> ProviderResult<()> { + Err(ProviderError::UnsupportedProvider) + } } diff --git a/crates/storage/storage-api/src/database_provider.rs b/crates/storage/storage-api/src/database_provider.rs index 9098cc60873..09a9e4baef5 100644 --- a/crates/storage/storage-api/src/database_provider.rs +++ b/crates/storage/storage-api/src/database_provider.rs @@ -15,7 +15,7 @@ use reth_storage_errors::provider::ProviderResult; #[auto_impl::auto_impl(&)] pub trait DbTxProvider { /// Underlying database transaction held by the provider. - type Tx: DbTx; + type Tx: DbTx + 'static; /// Returns the underlying database transaction. fn tx(&self) -> &Self::Tx; diff --git a/crates/storage/storage-api/src/evm.rs b/crates/storage/storage-api/src/evm.rs index 550a6fa1bc4..c96ffc457ff 100644 --- a/crates/storage/storage-api/src/evm.rs +++ b/crates/storage/storage-api/src/evm.rs @@ -63,49 +63,3 @@ impl EvmStateProvider for EvmStateProviderAdapter

{ self.0.storage(account, storage_key) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{noop::NoopProvider, StateProviderBox}; - use alloc::sync::Arc; - - #[test] - fn converts_owned_borrowed_and_boxed_state_providers() { - let provider = NoopProvider::default(); - assert_reads((&provider).into_evm_state_provider()); - assert_reads((&provider as &dyn StateProvider).into_evm_state_provider()); - assert_reads(provider.into_evm_state_provider()); - - let boxed = Box::new(NoopProvider::default()) as StateProviderBox; - assert_reads(boxed.into_evm_state_provider()); - assert_reads(Arc::new(NoopProvider::default()).into_evm_state_provider()); - } - - #[test] - fn delegates_execution_only_references_and_boxes() { - let provider = NoopProvider::default(); - let adapter = (&provider).into_evm_state_provider(); - assert_reads(&adapter as &dyn EvmStateProvider); - assert_reads(Box::new(adapter) as Box); - - let boxed = Box::new(provider.into_evm_state_provider()) as EvmStateProviderBox; - assert_reads(boxed); - assert_reads(Arc::new(NoopProvider::default().into_evm_state_provider())); - } - - #[test] - fn exposes_inner_state_provider() { - let provider = NoopProvider::default(); - let adapter = (&provider).into_evm_state_provider(); - assert_eq!(adapter.account_balance(&Address::ZERO).unwrap(), None); - assert!(core::ptr::eq(*adapter, &raw const provider)); - } - - fn assert_reads(provider: impl EvmStateProvider) { - assert_eq!(provider.basic_account(&Address::ZERO).unwrap(), None); - assert_eq!(provider.block_hash(1).unwrap(), None); - assert_eq!(provider.bytecode_by_hash(&B256::ZERO).unwrap(), None); - assert_eq!(provider.storage(Address::ZERO, B256::ZERO).unwrap(), None); - } -} diff --git a/crates/storage/storage-api/src/full.rs b/crates/storage/storage-api/src/full.rs index 8ac79ce2d35..f4c26b65ec9 100644 --- a/crates/storage/storage-api/src/full.rs +++ b/crates/storage/storage-api/src/full.rs @@ -2,10 +2,7 @@ use reth_chainspec::{ChainSpecProvider, EthereumHardforks}; -use crate::{ - BlockReaderIdExt, HeaderProvider, StageCheckpointReader, StateProviderFactory, - TransactionsProvider, -}; +use crate::{BlockReaderIdExt, StageCheckpointReader, StateProviderFactory}; /// Helper trait to unify all provider traits required to support `eth` RPC server behaviour, for /// simplicity. @@ -13,8 +10,6 @@ pub trait FullRpcProvider: StateProviderFactory + ChainSpecProvider + BlockReaderIdExt - + HeaderProvider - + TransactionsProvider + StageCheckpointReader + Clone + Unpin @@ -26,8 +21,6 @@ impl FullRpcProvider for T where T: StateProviderFactory + ChainSpecProvider + BlockReaderIdExt - + HeaderProvider - + TransactionsProvider + StageCheckpointReader + Clone + Unpin diff --git a/crates/storage/storage-api/src/macros.rs b/crates/storage/storage-api/src/macros.rs index 961e34d1435..9ca23e10e9f 100644 --- a/crates/storage/storage-api/src/macros.rs +++ b/crates/storage/storage-api/src/macros.rs @@ -59,7 +59,7 @@ macro_rules! delegate_provider_impls { StateProofProvider $(where [$($generics)*])? { fn proof(&self, input: reth_trie::TrieInput, address: alloy_primitives::Address, slots: &[alloy_primitives::B256]) -> reth_storage_api::errors::provider::ProviderResult; fn multiproof(&self, input: reth_trie::TrieInput, targets: reth_trie::MultiProofTargets) -> reth_storage_api::errors::provider::ProviderResult; - fn multiproof_v2(&self, input: reth_trie::TrieInput, targets: reth_trie::MultiProofTargetsV2) -> reth_storage_api::errors::provider::ProviderResult; + fn multiproof_v2(&self, targets: reth_trie::MultiProofTargetsV2, requires_masks: bool) -> reth_storage_api::errors::provider::ProviderResult; fn witness(&self, input: reth_trie::TrieInput, target: reth_trie::HashedPostState, mode: reth_trie::ExecutionWitnessMode) -> reth_storage_api::errors::provider::ProviderResult>; } HashedPostStateProvider $(where [$($generics)*])? { diff --git a/crates/storage/storage-api/src/noop.rs b/crates/storage/storage-api/src/noop.rs index 2a6c0d74cf0..e4d9d1008a0 100644 --- a/crates/storage/storage-api/src/noop.rs +++ b/crates/storage/storage-api/src/noop.rs @@ -556,8 +556,8 @@ impl StateProofProvider for NoopProvider ProviderResult { Ok(DecodedMultiProofV2::default()) } diff --git a/crates/storage/storage-api/src/state.rs b/crates/storage/storage-api/src/state.rs index f5eb61ebb69..80e89381872 100644 --- a/crates/storage/storage-api/src/state.rs +++ b/crates/storage/storage-api/src/state.rs @@ -149,7 +149,7 @@ pub trait BytecodeReader { /// the parent block. For example, in order to trace block `n`, the state after block `n - 1` needs /// to be used, since block `n` was executed on its parent block's state. #[auto_impl(&, Box, Arc)] -pub trait StateProviderFactory: BlockIdReader + Send { +pub trait StateProviderFactory: BlockIdReader { /// The node primitive types. type Primitives: NodePrimitives; diff --git a/crates/storage/storage-api/src/transactions.rs b/crates/storage/storage-api/src/transactions.rs index 354504d9667..31cd6b28f8f 100644 --- a/crates/storage/storage-api/src/transactions.rs +++ b/crates/storage/storage-api/src/transactions.rs @@ -22,7 +22,7 @@ pub enum TransactionVariant { /// Client trait for fetching transactions related data. #[auto_impl::auto_impl(&, Arc)] -pub trait TransactionsProvider: BlockNumReader + Send { +pub trait TransactionsProvider: BlockNumReader { /// The transaction type this provider reads. type Transaction: Send + Sync + SignedTransaction; diff --git a/crates/storage/storage-api/src/trie.rs b/crates/storage/storage-api/src/trie.rs index ffff6e09de1..d34daef8e44 100644 --- a/crates/storage/storage-api/src/trie.rs +++ b/crates/storage/storage-api/src/trie.rs @@ -162,11 +162,19 @@ pub trait StateProofProvider { targets: MultiProofTargets, ) -> ProviderResult; - /// Generate a V2 decoded multiproof for target hashed accounts and storage slots. + /// Generate a V2 decoded multiproof for target hashed accounts and storage slots against the + /// provider's state. + /// + /// A storage address with no slot targets requests its storage root node. Empty targets return + /// an empty proof. + /// + /// Set `requires_masks` when the hash and tree masks of returned branch nodes must match the + /// stored trie, as required to produce trie updates from them. Otherwise node hashes are still + /// exact, but masks are not guaranteed to match. fn multiproof_v2( &self, - input: TrieInput, targets: MultiProofTargetsV2, + requires_masks: bool, ) -> ProviderResult; /// Get trie witness for provided state using the given witness generation mode. diff --git a/crates/storage/storage-overlay/Cargo.toml b/crates/storage/storage-overlay/Cargo.toml index 0fc018be217..3392d433f50 100644 --- a/crates/storage/storage-overlay/Cargo.toml +++ b/crates/storage/storage-overlay/Cargo.toml @@ -51,4 +51,6 @@ reth-trie = { workspace = true, features = ["test-utils"] } alloy-consensus.workspace = true [features] +default = ["metrics"] +metrics = ["reth-trie/metrics", "reth-trie-db/metrics"] rayon = ["dep:rayon", "dep:reth-tasks"] diff --git a/crates/storage/storage-overlay/src/builder.rs b/crates/storage/storage-overlay/src/builder.rs index 615c730e1c6..26bff22aedf 100644 --- a/crates/storage/storage-overlay/src/builder.rs +++ b/crates/storage/storage-overlay/src/builder.rs @@ -172,28 +172,6 @@ impl ExecutionOverlay { } } - #[cfg(test)] - fn extend_overlay(&mut self, other: &Self) { - self.block_hashes.extend_from_slice(&other.block_hashes); - self.accounts.extend( - other - .accounts - .iter() - .map(|(address, info)| (*address, Self::normalized_account_info(info.clone()))), - ); - for address in &other.storage_wipes { - self.storage.remove(address); - } - for (address, slots) in &other.storage { - self.storage - .entry(*address) - .or_default() - .extend(slots.iter().map(|(slot, value)| (*slot, *value))); - } - self.storage_wipes.extend(other.storage_wipes.iter().copied()); - self.code_hashes.extend(other.code_hashes.iter().map(|(hash, code)| (*hash, code.clone()))); - } - /// Removes the database-local account lookup hint before caching account state. /// /// `account_id` indexes the database or BAL context that produced the [`AccountInfo`]. A later @@ -395,10 +373,7 @@ impl OverlayBuilder { /// Builds the effective state trie overlay for the given provider. /// - /// Set `trie_changesets` only for consumers that produce [`TrieUpdates`], such as - /// [`StateRootProvider::state_root_with_updates`]. Other consumers, including roots, proofs, - /// multiproofs, and witnesses, should leave it false: complete the cached trie at Finish and - /// invalidate hashed-state revert prefixes instead of querying trie changesets. + /// See [`Self::build_state_trie_overlay_at_frontiers`] for the `trie_changesets` contract. #[cfg(test)] #[instrument(level = "debug", target = "storage::overlay", skip_all)] fn build_state_trie_overlay( @@ -427,6 +402,12 @@ impl OverlayBuilder { /// Builds the effective state trie overlay using frontiers already read from the provider. /// /// This is useful for callers that key an overlay cache by the durable frontiers. + /// + /// With `trie_changesets` set, reverted trie nodes are exact, including their hash and tree + /// masks, as required to produce trie updates. Without it, trie changesets are not read; the + /// overlay's prefix sets instead invalidate every key changed by reverts or by in-memory blocks + /// replayed on top of the anchor, so roots and proof hashes are exact but masks may not match + /// the stored trie. #[instrument( level = "debug", target = "storage::overlay", @@ -521,12 +502,6 @@ impl OverlayBuilder { res }; - let prefix_sets = if trie_changesets { - Default::default() - } else { - hashed_state_reverts.construct_prefix_sets() - }; - // Resolve overlays and extend reverts with them. If reverts are empty, use overlays // directly to avoid cloning. let (overlay_trie, overlay_state) = @@ -551,6 +526,16 @@ impl OverlayBuilder { Arc::new(hashed_state_reverts) }; + // Without trie changesets the trie nodes still describe Finish, so every key whose + // value differs from Finish must be invalidated: both the reverted keys and the + // keys changed by the in-memory blocks replayed on top of the anchor. The overlay + // trie nodes are only valid relative to the anchor's trie, not Finish's. + let prefix_sets = if trie_changesets { + Default::default() + } else { + hashed_state_updates.construct_prefix_sets() + }; + trie_updates_total_len = trie_updates.total_len(); hashed_state_updates_total_len = hashed_state_updates.total_len(); @@ -841,7 +826,7 @@ enum AnchorForParent { #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{map::HashMap, Address, U256}; + use alloy_primitives::{keccak256, map::HashMap, Address, U256}; use reth_chain_state::{ test_utils::TestBlockBuilder, CanonicalInMemoryState, ExecutedBlock, NewCanonicalChain, }; @@ -857,7 +842,7 @@ mod tests { }; use reth_stages_types::{FinishCheckpoint, StageCheckpoint}; use reth_storage_api::StageCheckpointWriter; - use reth_trie::{BranchNodeCompact, ComputedTrieData, HashedPostState, HashedStorage, Nibbles}; + use reth_trie::{BranchNodeCompact, HashedPostState, HashedStorage, Nibbles, SortedTrieData}; use revm::{ bytecode::Bytecode, database::{AccountStatus, BundleAccount, BundleState}, @@ -906,7 +891,7 @@ mod tests { ExecutedBlock::new( Arc::clone(&block.recovered_block), Arc::new(execution_output), - ComputedTrieData::new(Arc::new(hashed_state), Arc::new(trie_updates)), + SortedTrieData::new(Arc::new(hashed_state), Arc::new(trie_updates)), ) } @@ -968,7 +953,6 @@ mod tests { } #[test] - #[allow(clippy::needless_update)] fn execution_overlay_extends_bundle_state_without_account_ids() { let address = Address::with_last_byte(1); let slot = U256::from(2); @@ -976,12 +960,8 @@ mod tests { let code = Bytecode::new_raw(vec![0x60, 0x00].into()); let code_hash = code.hash_slow(); let account = AccountInfo { - nonce: 4, - balance: U256::from(5), - code_hash, - code: Some(code.clone()), account_id: AccountId::new(6), - ..Default::default() + ..AccountInfo::new(U256::from(5), 4, code_hash, code.clone()) }; let state = BundleState::builder(0..=0) .state_present_account_info(address, account.clone()) @@ -1025,49 +1005,6 @@ mod tests { assert_eq!(overlay.storage_value(address, U256::ZERO), Some(U256::ZERO)); } - #[test] - fn execution_overlay_composition_uses_later_values_and_normalizes_accounts() { - let address = Address::with_last_byte(1); - let retained_address = Address::with_last_byte(2); - let slot = U256::from(3); - let retained_slot = U256::from(4); - let first_code_hash = B256::with_last_byte(5); - let later_code_hash = B256::with_last_byte(6); - let first_block = BlockNumHash::new(1, B256::with_last_byte(7)); - let later_block = BlockNumHash::new(2, B256::with_last_byte(8)); - - let mut overlay = ExecutionOverlay::default(); - overlay.block_hashes.push(first_block); - overlay.accounts.insert(address, Some(AccountInfo { nonce: 1, ..Default::default() })); - overlay.accounts.insert(retained_address, Some(AccountInfo::default())); - overlay.storage.entry(address).or_default().insert(slot, U256::from(9)); - overlay.storage.entry(address).or_default().insert(retained_slot, U256::from(10)); - overlay.code_hashes.insert(first_code_hash, Bytecode::new_raw(vec![1].into())); - - let mut later = ExecutionOverlay::default(); - later.block_hashes.push(later_block); - later.accounts.insert( - address, - Some(AccountInfo { nonce: 11, account_id: AccountId::new(12), ..Default::default() }), - ); - later.storage.entry(address).or_default().insert(slot, U256::from(13)); - later.storage_wipes.insert(address); - later.code_hashes.insert(later_code_hash, Bytecode::new_raw(vec![2].into())); - - overlay.extend_overlay(&later); - - assert!(later.accounts[&address].as_ref().unwrap().account_id.is_some()); - assert_eq!(overlay.block_hashes, vec![first_block, later_block]); - assert_eq!(overlay.accounts[&address].as_ref().unwrap().nonce, 11); - assert_eq!(overlay.accounts[&address].as_ref().unwrap().account_id, None); - assert!(overlay.accounts.contains_key(&retained_address)); - assert_eq!(overlay.storage[&address][&slot], U256::from(13)); - assert!(!overlay.storage[&address].contains_key(&retained_slot)); - assert_eq!(overlay.storage_value(address, U256::from(14)), Some(U256::ZERO)); - assert!(overlay.code_hashes.contains_key(&first_code_hash)); - assert!(overlay.code_hashes.contains_key(&later_code_hash)); - } - #[test] fn overlay_builder_for_state_matches_hash_lookup() { // The state trie frontier sits at block 1 while Finish is at block 3, so the in-memory @@ -1213,7 +1150,7 @@ mod tests { } #[test] - fn state_trie_overlay_uses_revert_prefix_sets_without_trie_changesets() { + fn state_trie_overlay_invalidates_reverts_and_fork_without_trie_changesets() { let (factory, blocks) = setup_frontiers(3, 3); let provider_rw = factory.provider_rw().unwrap(); provider_rw @@ -1228,14 +1165,27 @@ mod tests { .unwrap(); provider_rw.commit().unwrap(); + // The fork changes keys distinct from those reverted on disk. + let fork = TestBlockBuilder::eth() + .get_executed_block_with_number(2, blocks[1].recovered_block().hash()); + let fork = with_unique_trie_data(&fork, 2); let provider = factory.provider().unwrap(); let overlay = OverlayManager::::default() .overlay_builder(blocks[1].recovered_block().hash()) + .with_appended_block(fork) .build_state_trie_overlay(&provider, false) .unwrap(); - assert!(overlay.input().nodes.is_empty()); - assert!(!overlay.input().prefix_sets.is_empty()); + let mut prefix_sets = overlay.input().prefix_sets.clone().freeze(); + assert!(prefix_sets + .account_prefix_set + .contains(&Nibbles::unpack(keccak256(Address::with_last_byte(1))))); + assert!(prefix_sets.account_prefix_set.contains(&Nibbles::unpack(B256::with_last_byte(2)))); + assert!(prefix_sets + .storage_prefix_sets + .get_mut(&B256::with_last_byte(2)) + .unwrap() + .contains(&Nibbles::unpack(B256::with_last_byte(34)))); } #[test] @@ -1325,7 +1275,7 @@ mod tests { fn execution_overlay_marks_historical_fallback_for_managed_fork() { let (factory, blocks) = setup_frontiers(1, 3); let address = Address::with_last_byte(1); - let slot = U256::from(1); + let slot = U256::ONE; let provider_rw = factory.provider_rw().unwrap(); for (block_number, balance, storage_value) in [(2u64, 10u64, 10u64), (3u64, 20u64, 15u64)] { provider_rw @@ -1377,9 +1327,9 @@ mod tests { assert_eq!(fallback_block_number, Some(2)); - assert_eq!(overlay.accounts[&address].as_ref().unwrap().balance, U256::from(1)); + assert_eq!(overlay.accounts[&address].as_ref().unwrap().balance, U256::ONE); assert_eq!(overlay.accounts[&address].as_ref().unwrap().account_id, None); - assert_eq!(overlay.storage[&address][&slot], U256::from(1)); + assert_eq!(overlay.storage[&address][&slot], U256::ONE); assert_eq!( overlay.block_hashes, [side_block_two, side_block_three] @@ -1389,25 +1339,6 @@ mod tests { ); } - #[test] - fn execution_overlay_no_revert_path_discards_account_ids() { - let (factory, blocks) = setup_frontiers(1, 1); - let manager = OverlayManager::default(); - for block in &blocks[2..=3] { - manager.insert_block(block.clone()); - } - let provider = factory.provider().unwrap(); - - let (overlay, fallback_block_number) = manager - .overlay_builder(blocks[3].recovered_block().hash()) - .execution_overlay(&provider) - .unwrap(); - - assert_eq!(fallback_block_number, None); - assert_eq!(overlay.accounts.len(), 2); - assert!(overlay.accounts.values().flatten().all(|account| account.account_id.is_none())); - } - #[test] fn managed_overlay_uses_persisted_parent_even_if_retained() { let (factory, blocks) = setup_frontiers(2, 3); @@ -1467,19 +1398,6 @@ mod tests { ); } - #[test] - fn managed_overlay_errors_if_parent_is_not_persisted_or_managed_across_frontiers() { - let (factory, blocks) = setup_frontiers(1, 3); - let provider = factory.provider().unwrap(); - let parent_hash = blocks[3].recovered_block().hash(); - let error = OverlayManager::::default() - .overlay_builder(parent_hash) - .build_state_trie_overlay(&provider, true) - .unwrap_err(); - - assert!(error.to_string().contains("is after partial state trie frontier")); - } - #[test] fn managed_overlay_skips_manager_for_persisted_parent() { let parent_hash = B256::with_last_byte(1); diff --git a/crates/storage/storage-overlay/src/changeset_cache.rs b/crates/storage/storage-overlay/src/changeset_cache.rs index 69191d65ae9..87598aa0978 100644 --- a/crates/storage/storage-overlay/src/changeset_cache.rs +++ b/crates/storage/storage-overlay/src/changeset_cache.rs @@ -15,7 +15,7 @@ use reth_metrics::{ metrics::{Counter, Gauge}, Metrics, }; -use reth_primitives_traits::{FastInstant as Instant, NodePrimitives}; +use reth_primitives_traits::{AlloyBlockHeader, FastInstant as Instant, NodePrimitives}; use reth_storage_api::{ BlockNumReader, ChangeSetReader, DBProvider, PruneCheckpointReader, StageCheckpointReader, StorageChangeSetReader, StorageSettingsCache, @@ -404,6 +404,16 @@ impl ChangesetCache { .overlay_builder(finish.hash) .with_no_reverts() .build_state_trie_overlay_at_frontiers(provider, partial_state_trie, finish, true)?; + + // Forward updates are a best-effort optimization to speed up compute_range_trie_changesets, + // missing blocks do not affect correctness. + let mut forward_updates = overlay_manager + .parent_chain(finish.hash) + .take_while(|block| block.recovered_block().number() >= start_block) + .map(|block| (block.recovered_block().number(), block.trie_updates())) + .collect::>(); + forward_updates.reverse(); + let state_trie_provider = OverlayStateProvider::<&P, N>::new_with_state_trie( provider, overlay, @@ -413,6 +423,7 @@ impl ChangesetCache { let accumulated_reverts = Arc::new(reth_trie_db::compute_range_trie_changesets( provider, &state_trie_provider, + &forward_updates, start_block..=end_block, finish.number, )?); @@ -624,6 +635,7 @@ mod tests { map::{B256Map, HashMap}, Address, U256, }; + use reth_chain_state::{test_utils::TestBlockBuilder, ExecutedBlock}; use reth_db::{ models::{AccountBeforeTx, BlockNumberAddress}, tables, @@ -631,12 +643,12 @@ mod tests { }; use reth_primitives_traits::{Account, StorageEntry}; use reth_provider::{ - test_utils::create_test_provider_factory, StaticFileProviderFactory, StaticFileSegment, - StaticFileWriter, + test_utils::create_test_provider_factory, BlockWriter, StaticFileProviderFactory, + StaticFileSegment, StaticFileWriter, }; - use reth_stages_types::{StageCheckpoint, StageId}; + use reth_stages_types::{FinishCheckpoint, StageCheckpoint, StageId}; use reth_storage_api::{StageCheckpointWriter, TrieWriter}; - use reth_trie::{BranchNodeCompact, Nibbles, StateRoot}; + use reth_trie::{BranchNodeCompact, Nibbles, SortedTrieData, StateRoot}; // Helper function to create empty TrieUpdatesSorted for testing fn create_test_changesets() -> Arc { @@ -793,6 +805,292 @@ mod tests { provider.write_trie_updates(trie_updates).unwrap(); } + #[test] + fn consumers_restore_trie_values_masked_by_deferred_blocks() { + let manager = OverlayManager::::default(); + let factory = create_test_provider_factory(); + let blocks = TestBlockBuilder::eth().get_executed_blocks(0..11).collect::>(); + let provider = factory.provider_rw().unwrap(); + for block in &blocks { + provider.insert_block(block.recovered_block()).unwrap(); + } + provider + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(10) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: Some(5) }), + ) + .unwrap(); + let path = Nibbles::from_nibbles([1]); + let updates = |mask| { + Arc::new(TrieUpdatesSorted::new( + vec![(path, Some(BranchNodeCompact::new(mask, 0, 0, vec![], None)))], + B256Map::default(), + )) + }; + let block4 = updates(1); + let block9 = updates(2); + // Block 9 masks block 4's write, so the persisted trie does not contain the block-5 value. + let masked = TrieUpdatesSorted::disjointed_merge_batch(&[&block4], &[&block9]); + assert!(masked.is_empty()); + provider.write_trie_updates_sorted(&masked).unwrap(); + provider.commit().unwrap(); + let provider = factory.provider_rw().unwrap(); + for (start, end, before) in [ + (4, 4, Arc::new(TrieUpdatesSorted::new(vec![(path, None)], B256Map::default()))), + (5, 10, Arc::clone(&block4)), + (5, 5, Arc::default()), + ] { + manager.changeset_cache().inner.write().insert( + ChangesetRangeKey::new(start, end, blocks[end as usize].recovered_block().hash()), + before, + ); + } + let (frontier, finish) = database_state_frontiers(&*provider).unwrap(); + let overlay = manager + .overlay_builder(blocks[4].recovered_block().hash()) + .build_state_trie_overlay_at_frontiers(&*provider, frontier, finish, true) + .unwrap(); + assert_eq!(overlay.input().nodes, block4); + assert_eq!(manager.compute_block_trie_updates(&*provider, 4).unwrap(), *block4); + } + + #[test] + fn calculated_reverts_preserve_deletions_across_frontiers() { + let factory = create_test_provider_factory(); + let blocks = TestBlockBuilder::eth().get_executed_blocks(0..4).collect::>(); + let address = Address::with_last_byte(1); + let hashed_address = keccak256(address); + let removed = Nibbles::from_nibbles([1]); + let replaced = Nibbles::from_nibbles([2]); + let node = BranchNodeCompact::new(0b0001, 0, 0, vec![], None); + let storage_updates = |storage_nodes| { + TrieUpdatesSorted::new( + vec![], + B256Map::from_iter([(hashed_address, StorageTrieUpdatesSorted { storage_nodes })]), + ) + }; + let finish_updates = + Arc::new(storage_updates(vec![(removed, None), (replaced, Some(node.clone()))])); + let provider_rw = factory.provider_rw().unwrap(); + for block in &blocks { + provider_rw.insert_block(block.recovered_block()).unwrap(); + } + provider_rw + .write_trie_updates_sorted(&storage_updates(vec![(removed, Some(node.clone()))])) + .unwrap(); + // Reverting the account's creation deletes every storage node visible at Finish. + provider_rw + .tx_ref() + .put::(1, AccountBeforeTx { address, info: None }) + .unwrap(); + provider_rw + .save_stage_checkpoint( + StageId::Finish, + StageCheckpoint::new(2) + .with_finish_stage_checkpoint(FinishCheckpoint { partial_state_trie: Some(1) }), + ) + .unwrap(); + provider_rw.commit().unwrap(); + + let manager = OverlayManager::::default(); + for (block, updates) in [ + (&blocks[1], Arc::new(storage_updates(vec![(removed, Some(node))]))), + (&blocks[2], Arc::clone(&finish_updates)), + (&blocks[3], Arc::default()), + ] { + manager.insert_block(ExecutedBlock::new( + Arc::clone(&block.recovered_block), + Arc::clone(&block.execution_output), + SortedTrieData::new(Arc::default(), updates), + )); + } + let before = factory.provider().unwrap(); + let cache = manager.changeset_cache(); + let (frontier, finish) = database_state_frontiers(&before).unwrap(); + let result = + cache.get_or_compute_range(&manager, &before, 1..=2, frontier, finish).unwrap(); + assert_eq!( + result.storage_tries_ref()[&hashed_address].storage_nodes_ref(), + &[(removed, None), (replaced, None)], + ); + + let provider_rw = factory.provider_rw().unwrap(); + provider_rw.write_trie_updates_sorted(&finish_updates).unwrap(); + provider_rw.save_stage_checkpoint(StageId::Finish, StageCheckpoint::new(3)).unwrap(); + provider_rw.commit().unwrap(); + let after = factory.provider().unwrap(); + let (frontier, finish) = database_state_frontiers(&after).unwrap(); + let advanced = + cache.get_or_compute_range(&manager, &after, 1..=2, frontier, finish).unwrap(); + assert!(Arc::ptr_eq(&advanced, &result)); + + // A cold calculation at the newer frontier must also serve the older reader. + cache.evict(2); + let advanced = + cache.get_or_compute_range(&manager, &after, 1..=2, frontier, finish).unwrap(); + assert_eq!(advanced, result); + + // An older reader must still receive the deletion after the frontier advances. + let cached = manager.get_or_compute_cached_changesets_range(&before, 1..=2).unwrap(); + assert!(Arc::ptr_eq(&cached, &advanced)); + } + + #[test] + fn calculated_historical_range_excludes_tail_reverts() { + let factory = create_test_provider_factory(); + let provider = factory.provider_rw().unwrap(); + let address = Address::with_last_byte(1); + let hashed_address = keccak256(address); + let path = Nibbles::from_nibbles([1]); + let base_nodes = Arc::new(TrieUpdatesSorted::new( + vec![], + B256Map::from_iter([( + hashed_address, + StorageTrieUpdatesSorted { + storage_nodes: vec![( + path, + Some(BranchNodeCompact::new(0b0001, 0, 0, vec![], None)), + )], + }, + )]), + )); + // Rewinding block 3 deletes a node introduced by the input overlay. + provider + .tx_ref() + .put::(3, AccountBeforeTx { address, info: None }) + .unwrap(); + let state_trie_provider = + OverlayStateProvider::<&_, reth_ethereum_primitives::EthPrimitives>::new_with_state_trie( + &*provider, + StateTrieOverlay::new(TrieInputSorted::new( + Arc::clone(&base_nodes), + Arc::default(), + Default::default(), + )), + provider.cached_storage_settings().is_v2(), + ); + + let end = 2; + for range in [1..=end, end + 1..=end] { + let result = reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &[(3, Arc::clone(&base_nodes))], + range, + 3, + ) + .unwrap(); + assert!(result.is_empty()); + } + } + + #[test] + fn persisted_range_retains_transient_nodes_without_executed_blocks() { + let factory = create_test_provider_factory(); + seed_headers(&factory, 2); + let provider = factory.provider_rw().unwrap(); + provider.save_stage_checkpoint(StageId::Finish, StageCheckpoint::new(2)).unwrap(); + let address = Address::with_last_byte(1); + let hashed_address = keccak256(address); + provider + .tx_ref() + .put::(1, AccountBeforeTx { address, info: None }) + .unwrap(); + provider + .tx_ref() + .put::( + 2, + AccountBeforeTx { address, info: Some(test_account(1)) }, + ) + .unwrap(); + // The account and its storage exist only at block 1. Enough slots to branch below the first + // nibble ensure the trie stores non-root nodes. + for slot in 0..257 { + provider + .tx_ref() + .put::( + BlockNumberAddress((1, address)), + test_storage(slot, 0), + ) + .unwrap(); + provider + .tx_ref() + .put::( + BlockNumberAddress((2, address)), + test_storage(slot, 1), + ) + .unwrap(); + } + let state_trie_provider = + OverlayStateProvider::<&_, reth_ethereum_primitives::EthPrimitives>::new_with_state_trie( + &*provider, + empty_overlay(), + provider.cached_storage_settings().is_v2(), + ); + let result = reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &[], + 1..=2, + 2, + ) + .unwrap(); + let nodes = result.storage_tries_ref()[&hashed_address].storage_nodes_ref(); + assert!(!nodes.is_empty()); + assert!(nodes.iter().all(|(_, node)| node.is_none())); + + let first = reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &[], + 1..=1, + 2, + ) + .unwrap(); + let second = reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &[], + 2..=2, + 2, + ) + .unwrap(); + let mut merged = second.clone(); + merged.extend_ref_and_sort(&first); + assert_eq!(result, merged); + let forward = [(1, Arc::new(second.clone())), (2, Arc::new(first.clone()))]; + assert_eq!( + reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &forward, + 1..=2, + 2 + ) + .unwrap(), + result, + ); + let manager = OverlayManager::::default(); + assert_eq!(manager.compute_block_trie_updates(&*provider, 1).unwrap(), second); + assert_eq!(manager.compute_block_trie_updates(&*provider, 2).unwrap(), first); + + // Recompute block 1 against its own tip instead of a later tip. Its changeset is identical. + let at_first = OverlayStateProvider::<&_, reth_ethereum_primitives::EthPrimitives>::new_with_state_trie( + &*provider, + StateTrieOverlay::new(TrieInputSorted::new( + Arc::new(second), + Arc::new(HashedPostStateSorted::from_reverts(&*provider, 2..=2).unwrap()), + Default::default(), + )), + provider.cached_storage_settings().is_v2(), + ); + let at_first = + reth_trie_db::compute_range_trie_changesets(&*provider, &at_first, &[], 1..=1, 1) + .unwrap(); + assert_eq!(first, at_first); + } + #[test] fn cached_range_merge_keeps_oldest_revert_values() { let factory = create_test_provider_factory(); @@ -844,8 +1142,8 @@ mod tests { let provider = factory.provider_rw().unwrap(); let address = Address::with_last_byte(1); let hashed_address = keccak256(address); - let slot1 = B256::from(U256::from(1)); - let slot2 = B256::from(U256::from(2)); + let slot1 = B256::with_last_byte(1); + let slot2 = B256::with_last_byte(2); let account1 = test_account(10); let account2 = test_account(20); let account3 = test_account(30); @@ -912,9 +1210,14 @@ mod tests { overlay, provider.cached_storage_settings().is_v2(), ); - let actual = - reth_trie_db::compute_range_trie_changesets(&*provider, &state_trie_provider, 1..=3, 3) - .unwrap(); + let actual = reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &[], + 1..=3, + 3, + ) + .unwrap(); assert!(actual.storage_tries_ref().get(&hashed_address).is_none()); let cache = ChangesetCache::new(); @@ -940,8 +1243,8 @@ mod tests { let provider = factory.provider_rw().unwrap(); let address = Address::with_last_byte(1); - let slot1 = B256::from(U256::from(1)); - let slot2 = B256::from(U256::from(2)); + let slot1 = B256::with_last_byte(1); + let slot2 = B256::with_last_byte(2); let account1 = test_account(10); let account2 = test_account(20); @@ -999,45 +1302,17 @@ mod tests { overlay, provider.cached_storage_settings().is_v2(), ); - let actual = - reth_trie_db::compute_range_trie_changesets(&*provider, &state_trie_provider, 2..=3, 3) - .unwrap(); + let actual = reth_trie_db::compute_range_trie_changesets( + &*provider, + &state_trie_provider, + &[], + 2..=3, + 3, + ) + .unwrap(); assert_eq!(actual, expected); } - #[test] - fn test_insert_and_retrieve_single_entry() { - let mut cache = ChangesetCacheInner::new(); - let hash = B256::random(); - let changesets = create_test_changesets(); - - insert_test_changesets(&mut cache, hash, 100, Arc::clone(&changesets)); - - // Should be able to retrieve it - let retrieved = get_test_changesets(&cache, hash, 100); - assert!(retrieved.is_some()); - assert_eq!(cache.entries.len(), 1); - } - - #[test] - fn test_insert_multiple_entries() { - let mut cache = ChangesetCacheInner::new(); - - // Insert 10 blocks - let mut hashes = Vec::new(); - for i in 0..10 { - let hash = B256::random(); - insert_test_changesets(&mut cache, hash, 100 + i, create_test_changesets()); - hashes.push((100 + i, hash)); - } - - // Should be able to retrieve all - assert_eq!(cache.entries.len(), 10); - for (block_number, hash) in hashes { - assert!(get_test_changesets(&cache, hash, block_number).is_some()); - } - } - #[test] fn test_eviction_when_explicitly_called() { let mut cache = ChangesetCacheInner::new(); @@ -1139,22 +1414,6 @@ mod tests { assert!(get_test_changesets(&cache, hash_15, 15).is_some(), "Block 15 should be present"); } - #[test] - fn test_multiple_blocks_same_number() { - let mut cache = ChangesetCacheInner::new(); - - // Insert multiple blocks with same number (side chains) - let hash_1a = B256::random(); - let hash_1b = B256::random(); - insert_test_changesets(&mut cache, hash_1a, 100, create_test_changesets()); - insert_test_changesets(&mut cache, hash_1b, 100, create_test_changesets()); - - // Both should be retrievable - assert!(get_test_changesets(&cache, hash_1a, 100).is_some()); - assert!(get_test_changesets(&cache, hash_1b, 100).is_some()); - assert_eq!(cache.entries.len(), 2); - } - #[test] fn test_ranges_with_same_numbers_and_different_end_hashes_are_distinct() { let mut cache = ChangesetCacheInner::new(); diff --git a/crates/storage/storage-overlay/src/lib.rs b/crates/storage/storage-overlay/src/lib.rs index 2594c8c3e37..51bafd6f18b 100644 --- a/crates/storage/storage-overlay/src/lib.rs +++ b/crates/storage/storage-overlay/src/lib.rs @@ -13,5 +13,7 @@ pub use manager::*; mod manager_metrics; +mod multiproof_calculator; + mod provider; pub use provider::*; diff --git a/crates/storage/storage-overlay/src/manager.rs b/crates/storage/storage-overlay/src/manager.rs index 4b1349fcfdf..dd5fedee7c7 100644 --- a/crates/storage/storage-overlay/src/manager.rs +++ b/crates/storage/storage-overlay/src/manager.rs @@ -124,7 +124,8 @@ impl OverlayManager { &self.changeset_cache } - /// Gets or computes cached changesets for an inclusive block range. + /// Gets complete trie before-values for an inclusive block range, oldest values taking + /// precedence. No updates from outside the requested range are included. pub fn get_or_compute_cached_changesets_range

( &self, provider: &P, @@ -508,7 +509,7 @@ impl OverlayManager { return match entry { OverlayCacheEntry::Ready(input) => Ok(Some(input)), OverlayCacheEntry::Computing(_) if cache_config.precompute => Ok(None), - OverlayCacheEntry::Computing(waiter) => Ok(Some(waiter.wait())), + OverlayCacheEntry::Computing(waiter) => Ok(Some(waiter.wait(metrics))), } } span.record("cache_reused", false); @@ -570,7 +571,7 @@ impl OverlayManager { match action { CacheAction::Ready(input) => Ok(Some(input)), - CacheAction::Wait(waiter) => Ok(Some(waiter.wait())), + CacheAction::Wait(waiter) => Ok(Some(waiter.wait(metrics))), CacheAction::Compute(waiter) => { let parent_input = blocks.first().and_then(|block| { let parent_hash = block.recovered_block().parent_hash(); @@ -631,7 +632,10 @@ impl OverlayManager { } /// Returns every in-memory block in the chain whose tip is `parent_hash`. - fn parent_chain(&self, parent_hash: B256) -> impl Iterator> + '_ { + pub(crate) fn parent_chain( + &self, + parent_hash: B256, + ) -> impl Iterator> + '_ { let mut hash = parent_hash; std::iter::from_fn(move || { let block = self.blocks.get(&hash)?; @@ -825,8 +829,11 @@ impl OverlayWaiter { Self { input: OnceLock::new() } } - fn wait(&self) -> Arc { - Arc::clone(self.input.wait()) + fn wait(&self, metrics: &impl OverlayCacheMetrics) -> Arc { + let start = Instant::now(); + let input = self.input.wait(); + metrics.record_wait_duration(start.elapsed()); + Arc::clone(input) } fn finish(&self, computed: Arc) { @@ -878,8 +885,8 @@ fn compute_overlay( let mut parent_input = parent_input; extend_overlay( Arc::make_mut(&mut parent_input), - &trie_data.sorted.hashed_state, - &trie_data.sorted.trie_updates, + &trie_data.hashed_state, + &trie_data.trie_updates, ); Arc::try_unwrap(parent_input).expect("Arc::make_mut leaves the child overlay unique") } @@ -908,23 +915,21 @@ fn merge_blocks(blocks: Vec>) -> TrieInputSo let (nodes, state) = rayon::join( || { TrieUpdatesSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.sorted.trie_updates)), + trie_data.iter().map(|data| Arc::clone(&data.trie_updates)), ) }, || { HashedPostStateSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.sorted.hashed_state)), + trie_data.iter().map(|data| Arc::clone(&data.hashed_state)), ) }, ); #[cfg(not(feature = "rayon"))] let (nodes, state) = ( - TrieUpdatesSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.sorted.trie_updates)), - ), + TrieUpdatesSorted::merge_batch(trie_data.iter().map(|data| Arc::clone(&data.trie_updates))), HashedPostStateSorted::merge_batch( - trie_data.iter().map(|data| Arc::clone(&data.sorted.hashed_state)), + trie_data.iter().map(|data| Arc::clone(&data.hashed_state)), ), ); @@ -1011,12 +1016,12 @@ fn compute_execution_overlay_inner( mod tests { use super::*; use alloy_primitives::{map::HashMap, Address, U256}; - use reth_chain_state::{test_utils::TestBlockBuilder, ExecutedBlock, SparseTrie}; + use reth_chain_state::{test_utils::TestBlockBuilder, ExecutedBlock}; use reth_ethereum_primitives::EthPrimitives; use reth_primitives_traits::Account; #[cfg(feature = "rayon")] use reth_tasks::WorkerPool; - use reth_trie::{updates::TrieUpdatesSorted, ComputedTrieData, HashedPostState, HashedStorage}; + use reth_trie::{updates::TrieUpdatesSorted, HashedPostState, HashedStorage, SortedTrieData}; use revm::{ bytecode::Bytecode, database::BundleState, @@ -1063,7 +1068,7 @@ mod tests { ExecutedBlock::new( Arc::clone(&block.recovered_block), Arc::new(execution_output), - ComputedTrieData::new(Arc::new(hashed_state), Arc::new(TrieUpdatesSorted::default())), + SortedTrieData::new(Arc::new(hashed_state), Arc::new(TrieUpdatesSorted::default())), ) } @@ -1106,18 +1111,6 @@ mod tests { manager.overlay_for_parent(&parent_state, anchor_hash, OverlayCacheConfig::default()) } - #[test] - fn errors_for_unknown_parent() { - let manager = OverlayManager::::default(); - let parent = B256::random(); - let anchor = B256::random(); - - let err = overlay_for_parent(&manager, parent, anchor).unwrap_err(); - - assert_eq!(err.tip_hash, parent); - assert_eq!(err.anchor_hash, anchor); - } - #[test] fn builds_managed_overlay_for_inserted_blocks() { let manager = OverlayManager::default(); @@ -1210,19 +1203,6 @@ mod tests { assert!(!manager.execution_overlays.entries.contains_key(&first_key)); } - #[test] - fn execution_overlay_for_parent_at_anchor_is_empty() { - let manager = OverlayManager::::default(); - let anchor_hash = B256::with_last_byte(1); - - let overlay = manager.execution_overlay_for_parent(anchor_hash, anchor_hash).unwrap(); - - assert!(overlay.accounts().is_empty()); - assert!(overlay.storage().is_empty()); - assert!(overlay.code_hashes().is_empty()); - assert!(overlay.block_hashes().is_empty()); - } - #[test] fn promotes_ready_parent_overlays_to_the_child() { let manager = OverlayManager::default(); @@ -1519,26 +1499,6 @@ mod tests { assert!(!manager.contains_hash(parent_hash, anchor_hash, anchor_hash)); } - #[test] - fn taking_sparse_trie_removes_it() { - let manager = OverlayManager::::default(); - let state_root = B256::with_last_byte(1); - let other_state_root = B256::with_last_byte(2); - let anchor_hash = B256::with_last_byte(3); - - manager.store_sparse_trie(PreservedSparseTrie::anchored( - SparseTrie::default(), - state_root, - anchor_hash, - )); - - let preserved = manager.take_sparse_trie().expect("preserved trie should be available"); - assert_eq!(preserved.state_root(), state_root); - assert_eq!(preserved.anchor_hash(), anchor_hash); - assert!(preserved.into_trie_for(other_state_root).unwrap().is_none()); - assert!(manager.take_sparse_trie().is_none()); - } - #[test] fn required_lookup_waits_for_in_progress_overlay() { let manager = OverlayManager::::default(); diff --git a/crates/storage/storage-overlay/src/manager_metrics.rs b/crates/storage/storage-overlay/src/manager_metrics.rs index ea0cb2cfbf8..8b0a8b2b398 100644 --- a/crates/storage/storage-overlay/src/manager_metrics.rs +++ b/crates/storage/storage-overlay/src/manager_metrics.rs @@ -2,6 +2,7 @@ use reth_metrics::{ metrics::{Counter, Histogram}, Metrics, }; +use std::time::Duration; /// Metrics for state trie overlay management. #[derive(Clone, Metrics)] @@ -13,6 +14,8 @@ pub(crate) struct StateTrieOverlayMetrics { pub(crate) overlay_cache_reuses: Counter, /// Number of overlay cache entries populated by computing an overlay. pub(crate) overlay_cache_fills: Counter, + /// Time spent waiting for an in-progress overlay computation, in seconds. + pub(crate) overlay_wait_duration_seconds: Histogram, } /// Metrics for execution overlay management. @@ -25,12 +28,16 @@ pub(crate) struct ExecutionOverlayMetrics { pub(crate) overlay_cache_reuses: Counter, /// Number of overlay cache entries populated by computing an overlay. pub(crate) overlay_cache_fills: Counter, + /// Time spent waiting for an in-progress overlay computation, in seconds. + pub(crate) overlay_wait_duration_seconds: Histogram, } pub(crate) trait OverlayCacheMetrics { fn record_cache_reuse(&self); fn record_cache_fill(&self); + + fn record_wait_duration(&self, duration: Duration); } impl OverlayCacheMetrics for StateTrieOverlayMetrics { @@ -41,6 +48,10 @@ impl OverlayCacheMetrics for StateTrieOverlayMetrics { fn record_cache_fill(&self) { self.overlay_cache_fills.increment(1); } + + fn record_wait_duration(&self, duration: Duration) { + self.overlay_wait_duration_seconds.record(duration.as_secs_f64()); + } } impl OverlayCacheMetrics for ExecutionOverlayMetrics { @@ -51,4 +62,8 @@ impl OverlayCacheMetrics for ExecutionOverlayMetrics { fn record_cache_fill(&self) { self.overlay_cache_fills.increment(1); } + + fn record_wait_duration(&self, duration: Duration) { + self.overlay_wait_duration_seconds.record(duration.as_secs_f64()); + } } diff --git a/crates/storage/storage-overlay/src/multiproof_calculator.rs b/crates/storage/storage-overlay/src/multiproof_calculator.rs new file mode 100644 index 00000000000..dcf3d47d07f --- /dev/null +++ b/crates/storage/storage-overlay/src/multiproof_calculator.rs @@ -0,0 +1,131 @@ +//! Multiproof calculators over database trie tables with an immutable in-memory overlay. + +use alloy_primitives::{map::B256Map, B256}; +use reth_db_api::transaction::DbTx; +use reth_errors::ProviderResult; +use reth_trie::{ + hashed_cursor::{HashedPostStateCursorFactory, InstrumentedHashedCursorFactory}, + prefix_set::TriePrefixSets, + proof_v2::{MultiProofCalculator, ProofCursorMetrics}, + trie_cursor::{InMemoryTrieCursorFactory, InstrumentedTrieCursorFactory}, + updates::TrieUpdatesSorted, + DecodedMultiProofV2, HashedPostStateSorted, MultiProofTargetsV2, TrieInputSorted, +}; +use reth_trie_db::{ + DatabaseHashedCursorFactory, DatabaseTrieCursorFactory, LegacyKeyAdapter, PackedKeyAdapter, + TrieTableAdapter, +}; +use std::sync::Arc; + +/// Multiproof calculator for the database's trie table layout. +/// +/// The calculator borrows a transaction and overlay it does not own, so its owner must keep both +/// alive and at stable addresses for as long as the calculator exists. +pub(crate) enum OverlayMultiProofCalculator { + /// Calculator over legacy trie tables. + Legacy(TableMultiProofCalculator), + /// Calculator over packed trie tables. + Packed(TableMultiProofCalculator), +} + +impl OverlayMultiProofCalculator { + /// Creates a calculator over `tx` with `input` layered on top. + /// + /// `is_v2` selects the packed trie tables used by v2 storage settings. + /// + /// # Safety + /// + /// The transaction and the `Arc` allocations in `input` must stay alive and at stable addresses + /// until the calculator drops. Unless the transaction is `Sync`, the calculator must not be + /// sent to another thread separately from the transaction, and the transaction must not be + /// used from another thread while the calculator exists. + pub(crate) unsafe fn new( + tx: &TX, + input: &TrieInputSorted, + is_v2: bool, + metrics: ProofCursorMetrics, + known_storage_roots: Arc>, + ) -> Self { + // SAFETY: The caller keeps these allocations alive and in place until the calculator drops. + let (tx, nodes, state) = unsafe { + (&*(tx as *const TX), &*Arc::as_ptr(&input.nodes), &*Arc::as_ptr(&input.state)) + }; + let prefix_sets = input.prefix_sets.clone().freeze(); + if is_v2 { + Self::Packed(table_multiproof_calculator( + tx, + nodes, + state, + prefix_sets, + metrics, + known_storage_roots, + )) + } else { + Self::Legacy(table_multiproof_calculator( + tx, + nodes, + state, + prefix_sets, + metrics, + known_storage_roots, + )) + } + } + + /// Generates account and storage proofs for `targets`. + pub(crate) fn multiproof( + &mut self, + targets: MultiProofTargetsV2, + ) -> ProviderResult { + Ok(match self { + Self::Legacy(calculator) => calculator.multiproof(targets)?, + Self::Packed(calculator) => calculator.multiproof(targets)?, + }) + } +} + +// SAFETY: `new` requires the calculator to move between threads only together with its +// transaction, or the transaction to be `Sync`, so the borrowed transaction is never shared across +// threads without synchronization. +unsafe impl Send for OverlayMultiProofCalculator {} + +/// Multiproof calculator over trie table layout `A`, whose borrows of the transaction and overlay +/// are extended to `'static` by [`OverlayMultiProofCalculator::new`]. +type TableMultiProofCalculator = MultiProofCalculator< + 'static, + InstrumentedTrieCursorFactory< + InMemoryTrieCursorFactory< + DatabaseTrieCursorFactory<&'static TX, A>, + &'static TrieUpdatesSorted, + >, + >, + InstrumentedHashedCursorFactory< + HashedPostStateCursorFactory< + DatabaseHashedCursorFactory<&'static TX>, + &'static HashedPostStateSorted, + >, + >, +>; + +/// Creates a calculator for trie table layout `A`. +fn table_multiproof_calculator( + tx: &'static TX, + nodes: &'static TrieUpdatesSorted, + state: &'static HashedPostStateSorted, + prefix_sets: TriePrefixSets, + metrics: ProofCursorMetrics, + known_storage_roots: Arc>, +) -> TableMultiProofCalculator { + MultiProofCalculator::new( + metrics.instrument_trie_factory(InMemoryTrieCursorFactory::new( + DatabaseTrieCursorFactory::new(tx), + nodes, + )), + metrics.instrument_hashed_factory(HashedPostStateCursorFactory::new( + DatabaseHashedCursorFactory::new(tx), + state, + )), + prefix_sets, + known_storage_roots, + ) +} diff --git a/crates/storage/storage-overlay/src/provider.rs b/crates/storage/storage-overlay/src/provider.rs index 0999d125b45..617becc7d8d 100644 --- a/crates/storage/storage-overlay/src/provider.rs +++ b/crates/storage/storage-overlay/src/provider.rs @@ -1,5 +1,8 @@ -use crate::{database_state_frontiers, ExecutionOverlay, OverlayBuilder, StateTrieOverlay}; -use alloy_primitives::{Address, BlockHash, BlockNumber, B256, U256}; +use crate::{ + database_state_frontiers, multiproof_calculator::OverlayMultiProofCalculator, ExecutionOverlay, + OverlayBuilder, StateTrieOverlay, +}; +use alloy_primitives::{map::B256Map, Address, BlockHash, BlockNumber, B256, U256}; use metrics::{Counter, Histogram}; use reth_db_api::{cursor::DbDupCursorRO, tables, transaction::DbTx, DatabaseError}; use reth_errors::{ProviderError, ProviderResult}; @@ -22,6 +25,7 @@ use reth_trie::{ zero_destroyed_account_storage, HashedCursorFactory, HashedPostStateCursorFactory, }, proof::{Proof, StorageProof as TrieStorageProof}, + proof_v2::ProofCursorMetrics, trie_cursor::{ InMemoryTrieCursor, InMemoryTrieCursorFactory, TrieCursor, TrieCursorFactory, TrieStorageCursor, @@ -39,7 +43,15 @@ use reth_trie_db::{ DatabaseTrieCursorFactory, LegacyKeyAdapter, PackedAccountsTrie, PackedKeyAdapter, PackedStoragesTrie, }; -use std::{cell::OnceCell, fmt, ops::Deref, sync::Arc, time::Instant}; +use std::{ + cell::{OnceCell, RefCell}, + fmt, + marker::PhantomPinned, + ops::Deref, + pin::Pin, + sync::Arc, + time::Instant, +}; use tracing::instrument; /// Factory for creating overlay state providers with optional reverts and overlays. @@ -59,6 +71,10 @@ pub struct OverlayStateProviderFactory { state_trie_overlay_cache: StateTrieOverlayCache, /// Metrics for provider factory operations. metrics: OverlayStateProviderFactoryMetrics, + /// Cursor metric handles shared by the multiproof calculators of every provider. + proof_cursor_metrics: ProofCursorMetrics, + /// Revealed storage roots from the reused sparse trie, shared across proof workers. + known_storage_roots: Arc>, } impl OverlayStateProviderFactory { @@ -69,15 +85,32 @@ impl OverlayStateProviderFactory { overlay_builder, state_trie_overlay_cache: Default::default(), metrics: Default::default(), + proof_cursor_metrics: Default::default(), + known_storage_roots: Default::default(), } } /// Skips managed overlay construction when this factory is used by a task that reused a sparse /// trie covering both durable frontiers through the parent. - pub fn with_skip_overlay_for_reused_sparse_trie(mut self, anchor_hash: B256) -> Self { + /// + /// Trie and hashed cursors then read durable database state only. V2 account proofs use the + /// supplied storage roots, which is valid solely for revealing nodes into that reused sparse + /// trie. Methods that layer the overlay into their input, such as state + /// and storage roots, account and storage proofs, V1 multiproofs, witnesses and + /// [`HashedPostStateProvider::hashed_post_state`], return + /// [`ProviderError::UnsupportedProvider`]. + /// + /// `known_storage_roots` must contain the revealed storage roots at this factory's parent, + /// keyed by hashed account address. Account proofs reuse them to avoid root calculation. + pub fn with_skip_overlay_for_reused_sparse_trie( + mut self, + anchor_hash: B256, + known_storage_roots: B256Map, + ) -> Self { self.overlay_builder = self.overlay_builder.with_skip_overlay_for_reused_sparse_trie(anchor_hash); self.state_trie_overlay_cache = Default::default(); + self.known_storage_roots = Arc::new(known_storage_roots); self } } @@ -112,102 +145,101 @@ where let is_v2 = provider.cached_storage_settings().is_v2(); self.metrics.database_provider_ro_duration.record(overall_start.elapsed()); - Ok(OverlayStateProvider::new_with_caches( - provider, - self.overlay_builder.clone(), + Ok(OverlayStateProvider::from_parts( + OwnedProvider(provider), + Some(self.overlay_builder.clone()), Arc::clone(&self.state_trie_overlay_cache), self.metrics.clone(), + self.proof_cursor_metrics.clone(), + self.known_storage_roots.clone(), is_v2, )) } } /// State provider with lazily resolved state trie and execution overlays. -pub struct OverlayStateProvider { - provider: Provider, +pub struct OverlayStateProvider +where + Provider: Deref, + Provider::Target: DBProvider, +{ + /// Multiproof calculator reused across requests with the same `requires_masks` flag. + /// + /// It borrows `provider`'s transaction and the state trie overlays, so `Drop` releases it + /// first. + multiproof_calculator: + RefCell::Tx>>>, + /// The `!Unpin` pointee preserves cached transaction borrows when the owning box moves. + provider: Pin>, overlay_builder: Option>, state_trie_overlay_cache: StateTrieOverlayCache, metrics: OverlayStateProviderFactoryMetrics, + proof_cursor_metrics: ProofCursorMetrics, + /// Revealed storage roots from the reused sparse trie, shared across proof workers. + known_storage_roots: Arc>, state_trie_overlay: OnceCell, state_trie_overlay_with_trie_changesets: OnceCell, execution_overlay: OnceCell, is_v2: bool, } -impl OverlayStateProvider, N> { +impl OverlayStateProvider, N> { /// Creates an overlay state provider over an already-open database provider. pub fn new(provider: Provider, overlay_builder: OverlayBuilder) -> Self where Provider: StorageSettingsCache, { let is_v2 = provider.cached_storage_settings().is_v2(); - Self::new_with_caches( - provider, - overlay_builder, + Self::from_parts( + OwnedProvider(provider), + Some(overlay_builder), + Default::default(), + Default::default(), Default::default(), Default::default(), is_v2, ) } - const fn new_with_caches( - provider: Provider, - overlay_builder: OverlayBuilder, - state_trie_overlay_cache: StateTrieOverlayCache, - metrics: OverlayStateProviderFactoryMetrics, - is_v2: bool, - ) -> Self { - Self { - provider: OwnedProvider(provider), - overlay_builder: Some(overlay_builder), - state_trie_overlay_cache, - metrics, - state_trie_overlay: OnceCell::new(), - state_trie_overlay_with_trie_changesets: OnceCell::new(), - execution_overlay: OnceCell::new(), - is_v2, - } - } - #[cfg(test)] fn new_with_execution( provider: Provider, execution_overlay: Arc, is_v2: bool, ) -> Self { - Self { - provider: OwnedProvider(provider), - overlay_builder: None, - state_trie_overlay_cache: Default::default(), - metrics: Default::default(), - state_trie_overlay: OnceCell::new(), - state_trie_overlay_with_trie_changesets: OnceCell::new(), - execution_overlay: OnceCell::from(CachedExecutionOverlay { - overlay: execution_overlay, - historical_fallback: None, - }), + let mut provider = Self::from_parts( + OwnedProvider(provider), + None, + Default::default(), + Default::default(), + Default::default(), + Default::default(), is_v2, - } + ); + provider.execution_overlay = OnceCell::from(CachedExecutionOverlay { + overlay: execution_overlay, + historical_fallback: None, + }); + provider } } -impl<'a, Provider, N: NodePrimitives> OverlayStateProvider<&'a Provider, N> { +impl<'a, Provider: DBProvider, N: NodePrimitives> OverlayStateProvider<&'a Provider, N> { /// Creates an overlay state provider over a borrowed database provider. pub fn new_ref(provider: &'a Provider, overlay_builder: OverlayBuilder) -> Self where Provider: StorageSettingsCache, { let is_v2 = provider.cached_storage_settings().is_v2(); - Self { + Self::from_parts( provider, - overlay_builder: Some(overlay_builder), - state_trie_overlay_cache: Default::default(), - metrics: Default::default(), - state_trie_overlay: OnceCell::new(), - state_trie_overlay_with_trie_changesets: OnceCell::new(), - execution_overlay: OnceCell::new(), + Some(overlay_builder), + Default::default(), + Default::default(), + Default::default(), + Default::default(), is_v2, - } + ) } pub(crate) fn new_with_state_trie( @@ -215,26 +247,54 @@ impl<'a, Provider, N: NodePrimitives> OverlayStateProvider<&'a Provider, N> { state_trie_overlay: StateTrieOverlay, is_v2: bool, ) -> Self { - Self { + let mut provider = Self::from_parts( provider, - overlay_builder: None, - state_trie_overlay_cache: Default::default(), - metrics: Default::default(), - state_trie_overlay: OnceCell::from(state_trie_overlay.clone()), - state_trie_overlay_with_trie_changesets: OnceCell::from(state_trie_overlay), - execution_overlay: OnceCell::new(), + None, + Default::default(), + Default::default(), + Default::default(), + Default::default(), is_v2, - } + ); + provider.state_trie_overlay = OnceCell::from(state_trie_overlay.clone()); + provider.state_trie_overlay_with_trie_changesets = OnceCell::from(state_trie_overlay); + provider } } impl OverlayStateProvider where Provider: Deref, - Provider::Target: Sized, + Provider::Target: DBProvider, { + /// Creates a provider whose overlays resolve lazily and that has no cached multiproof + /// calculator. + fn from_parts( + provider: Provider, + overlay_builder: Option>, + state_trie_overlay_cache: StateTrieOverlayCache, + metrics: OverlayStateProviderFactoryMetrics, + proof_cursor_metrics: ProofCursorMetrics, + known_storage_roots: Arc>, + is_v2: bool, + ) -> Self { + Self { + multiproof_calculator: RefCell::new(None), + provider: Box::pin((provider, PhantomPinned)), + overlay_builder, + state_trie_overlay_cache, + metrics, + proof_cursor_metrics, + known_storage_roots, + state_trie_overlay: OnceCell::new(), + state_trie_overlay_with_trie_changesets: OnceCell::new(), + execution_overlay: OnceCell::new(), + is_v2, + } + } + fn provider(&self) -> &Provider::Target { - &self.provider + &self.provider.as_ref().get_ref().0 } fn state_trie_overlay(&self, trie_changesets: bool) -> ProviderResult<&StateTrieOverlay> @@ -382,10 +442,21 @@ where } } +impl Drop for OverlayStateProvider +where + Provider: Deref, + Provider::Target: DBProvider, +{ + fn drop(&mut self) { + // The calculator borrows the transaction and overlays, so release it before the fields. + self.multiproof_calculator.get_mut().take(); + } +} + impl fmt::Debug for OverlayStateProvider where Provider: Deref, - Provider::Target: fmt::Debug + Sized, + Provider::Target: DBProvider + fmt::Debug, { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("OverlayStateProvider") @@ -462,9 +533,7 @@ where impl BlockHashReader for OverlayStateProvider where Provider: Deref, - Provider::Target: BlockHashReader - + DBProvider - + Sized + Provider::Target: DBProvider + StageCheckpointReader + PruneCheckpointReader + ChangeSetReader @@ -516,7 +585,11 @@ where if let Some(bytecode) = overlay.code_hashes().get(code_hash) { return Ok(Some(reth_primitives_traits::Bytecode(bytecode.clone()))); } - self.provider().tx().get_by_encoded_key::(code_hash).map_err(Into::into) + self.provider() + .tx() + .prefetch(true) + .get_by_encoded_key::(code_hash) + .map_err(Into::into) } } @@ -696,20 +769,38 @@ where fn multiproof_v2( &self, - input: TrieInput, targets: MultiProofTargetsV2, + requires_masks: bool, ) -> ProviderResult { - reth_trie_db::with_adapter!(self.provider(), |A| { - let TrieInputSorted { nodes, state, prefix_sets } = - self.build_overlay(TrieInputSorted::from_unsorted(input), false)?; - let input = TrieInput::new( - Arc::unwrap_or_clone(nodes).into(), - Arc::unwrap_or_clone(state).into(), - prefix_sets, - ); - let proof = as DatabaseProof>::from_tx(self.provider().tx()); - proof.overlay_multiproof_v2(input, targets).map_err(ProviderError::from) - }) + // Avoid resolving the overlay when there is nothing to prove. + if targets.is_empty() { + return Ok(DecodedMultiProofV2::default()) + } + let mut calculator_slot = self.multiproof_calculator.borrow_mut(); + let cached = match &mut *calculator_slot { + Some(cached) if cached.requires_masks == requires_masks => cached, + slot => { + let input = self.state_trie_overlay(requires_masks)?.input(); + // SAFETY: The transaction lives behind `provider`, which is pinned or borrowed for + // the provider's lifetime, and the overlay allocations live in `OnceCell`s that are + // never reset. `Drop` releases the calculator before either, and the calculator + // never leaves this provider, which is not `Sync`. An owned + // transaction therefore only moves between threads together with + // the calculator. A borrowed provider is `Send` only if it is + // `Sync`, in which case its transaction may be shared. + let calculator = unsafe { + OverlayMultiProofCalculator::new( + self.provider().tx(), + input, + self.is_v2, + self.proof_cursor_metrics.clone(), + self.known_storage_roots.clone(), + ) + }; + slot.insert(CachedMultiProofCalculator { requires_masks, calculator }) + } + }; + cached.calculator.multiproof(targets) } fn witness( @@ -787,7 +878,6 @@ where Provider: Deref, Provider::Target: DBProvider + HistoryReader - + BlockHashReader + StorageSettingsCache + StageCheckpointReader + PruneCheckpointReader @@ -997,11 +1087,8 @@ type DbStateRoot<'a, TX, A> = StateRoot, DatabaseHashedCursorFactory<&'a TX>>; type DbStorageRoot<'a, TX, A> = StorageRoot, DatabaseHashedCursorFactory<&'a TX>>; -type DbStorageProof<'a, TX, A> = TrieStorageProof< - 'static, - DatabaseTrieCursorFactory<&'a TX, A>, - DatabaseHashedCursorFactory<&'a TX>, ->; +type DbStorageProof<'a, TX, A> = + TrieStorageProof, DatabaseHashedCursorFactory<&'a TX>>; type DbProof<'a, TX, A> = Proof, DatabaseHashedCursorFactory<&'a TX>>; @@ -1017,6 +1104,13 @@ impl Deref for OwnedProvider { } } +/// Multiproof calculator cached by [`OverlayStateProvider`]. +struct CachedMultiProofCalculator { + /// Whether the calculator returns masks that match the stored trie. + requires_masks: bool, + calculator: OverlayMultiProofCalculator, +} + fn into_database_error(error: ProviderError) -> DatabaseError { match error { ProviderError::Database(error) => error, @@ -1047,8 +1141,8 @@ mod tests { use reth_stages_types::{FinishCheckpoint, StageCheckpoint, StageId}; use reth_storage_api::StageCheckpointWriter; use reth_trie::{ - updates::TrieUpdatesSorted, BranchNodeCompact, ComputedTrieData, HashedPostState, - HashedStorage, Nibbles, + updates::TrieUpdatesSorted, BranchNodeCompact, HashedPostState, HashedStorage, Nibbles, + SortedTrieData, }; use revm::{bytecode::Bytecode as RevmBytecode, state::AccountInfo}; @@ -1076,7 +1170,7 @@ mod tests { ExecutedBlock::new( Arc::clone(&block.recovered_block), Arc::clone(&block.execution_output), - ComputedTrieData::new(Arc::new(hashed_state), Arc::new(trie_updates)), + SortedTrieData::new(Arc::new(hashed_state), Arc::new(trie_updates)), ) } diff --git a/crates/tasks/src/cancel.rs b/crates/tasks/src/cancel.rs index 0d09172958b..60121c2be62 100644 --- a/crates/tasks/src/cancel.rs +++ b/crates/tasks/src/cancel.rs @@ -1,8 +1,11 @@ //! Cooperative cancellation of spawned work. -use std::sync::{ - atomic::{AtomicBool, AtomicU8, Ordering}, - Arc, +use std::{ + cell::RefCell, + sync::{ + atomic::{AtomicBool, AtomicU8, Ordering}, + Arc, + }, }; /// The work is still in progress. @@ -48,6 +51,24 @@ impl CancelOnDrop { pub fn is_finalization_requested(&self) -> bool { self.0.load(Ordering::Relaxed) == FINALIZATION_REQUESTED } + + /// Runs `f` with this as the cancellation state of the current thread, see [`is_cancelled`]. + /// + /// This lets code deep inside `f`, such as a loop over transactions, observe cancellation + /// without threading the [`CancelOnDrop`] through every call. The previous state of the + /// thread is restored once `f` returns or unwinds. Leaving the scope does not cancel. + pub fn scope(&self, f: impl FnOnce() -> R) -> R { + struct Restore(Option>); + + impl Drop for Restore { + fn drop(&mut self) { + CURRENT.set(self.0.take()); + } + } + + let _restore = Restore(CURRENT.replace(Some(self.0.clone()))); + f() + } } impl Drop for CancelOnDrop { @@ -79,6 +100,25 @@ impl ManualCancel { } } +/// Returns `true` if the work running on the current thread was cancelled. +/// +/// This reads the state of the innermost [`CancelOnDrop::scope`] on this thread, for example a +/// blocking RPC call whose request was dropped because the client disconnected. Long running work +/// should check this between units of work, such as transactions, and stop early since nobody +/// waits for the result. +/// +/// Always returns `false` outside of a [`CancelOnDrop::scope`]. +pub fn is_cancelled() -> bool { + CURRENT.with_borrow(|state| { + state.as_ref().is_some_and(|state| state.load(Ordering::Relaxed) == CANCELLED) + }) +} + +thread_local! { + /// Cancellation state of the innermost [`CancelOnDrop::scope`] on this thread. + static CURRENT: RefCell>> = const { RefCell::new(None) }; +} + #[cfg(test)] mod tests { use super::*; @@ -91,15 +131,14 @@ mod tests { } #[test] - fn test_default_cancel_task() { + fn test_set_cancel_task() { let c = ManualCancel::default(); assert!(!c.is_cancelled()); - } - #[test] - fn test_set_cancel_task() { - let c = ManualCancel::default(); + // Dropping a clone must not cancel the original, unlike `CancelOnDrop`. + drop(c.clone()); assert!(!c.is_cancelled()); + let c2 = c.clone(); let c3 = c.clone(); c.cancel(); @@ -107,40 +146,6 @@ mod tests { assert!(c2.is_cancelled()); } - #[test] - fn test_cancel_task_multiple_threads() { - let c = ManualCancel::default(); - let cloned_cancel = c.clone(); - - // we want to make sure that: - // * we can spawn tasks that do things - // * those tasks can run to completion and the flag remains unset unless we call cancel - let mut handles = vec![]; - for _ in 0..10 { - let c = c.clone(); - let handle = std::thread::spawn(move || { - for _ in 0..1000 { - if c.is_cancelled() { - return; - } - } - }); - handles.push(handle); - } - - // wait for all the threads to finish - for handle in handles { - handle.join().unwrap(); - } - - // check that the flag is still unset - assert!(!c.is_cancelled()); - - // cancel and check that the flag is set - c.cancel(); - assert!(cloned_cancel.is_cancelled()); - } - #[test] fn test_cancelondrop_clone_behavior() { let cancel = CancelOnDrop::default(); @@ -200,4 +205,37 @@ mod tests { assert!(clone.is_cancelled()); assert!(!clone.is_finalization_requested()); } + + #[test] + fn test_scope_is_cancelled() { + assert!(!is_cancelled()); + + let outer = CancelOnDrop::default(); + let inner = CancelOnDrop::default(); + outer.scope(|| { + assert!(!is_cancelled()); + inner.scope(|| { + drop(inner.clone()); + assert!(is_cancelled()); + }); + // the outer state is restored + assert!(!is_cancelled()); + outer.request_finalization(); + assert!(!is_cancelled()); + }); + + // leaving the scope does not cancel + assert!(!is_cancelled()); + assert!(!outer.is_cancelled()); + } + + #[test] + fn test_scope_restores_on_panic() { + let cancel = CancelOnDrop::default(); + drop(cancel.clone()); + + let res = std::panic::catch_unwind(|| cancel.scope(|| panic!("scope panicked"))); + assert!(res.is_err()); + assert!(!is_cancelled()); + } } diff --git a/crates/tasks/src/pool.rs b/crates/tasks/src/pool.rs index c6c718fb39c..78c234da5ed 100644 --- a/crates/tasks/src/pool.rs +++ b/crates/tasks/src/pool.rs @@ -216,6 +216,11 @@ impl WorkerPool { self.pool.get().is_some() } + /// Returns the number of threads the pool is configured with, without creating it. + pub const fn num_threads(&self) -> usize { + self.num_threads + } + /// Returns the total number of threads in the underlying rayon pool. pub fn current_num_threads(&self) -> usize { self.pool().current_num_threads() diff --git a/crates/tasks/src/runtime.rs b/crates/tasks/src/runtime.rs index 666a40c6e77..b77bba82871 100644 --- a/crates/tasks/src/runtime.rs +++ b/crates/tasks/src/runtime.rs @@ -15,9 +15,8 @@ use crate::{ PanickedTaskError, TaskEvent, TaskManager, }; use futures_util::{future::select, Future, FutureExt, TryFutureExt}; -#[cfg(feature = "rayon")] -use std::{num::NonZeroUsize, thread::available_parallelism}; use std::{ + num::NonZeroUsize, pin::pin, sync::{ atomic::{AtomicUsize, Ordering}, @@ -32,6 +31,9 @@ use tracing_futures::Instrument; use tokio::runtime::Runtime as TokioRuntime; +#[cfg(feature = "rayon")] +use std::thread::available_parallelism; + /// Default thread keep-alive duration for the tokio runtime. pub const DEFAULT_THREAD_KEEP_ALIVE: Duration = Duration::from_secs(15); @@ -108,10 +110,10 @@ pub struct RayonConfig { /// Maximum number of concurrent blocking tasks for the RPC guard semaphore. pub max_blocking_tasks: usize, /// Number of threads for the proof storage worker pool (trie storage proof workers). - /// If `None`, derived from available parallelism. + /// If `None` or zero, uses four times the resolved CPU pool thread count. pub proof_storage_worker_threads: Option, /// Number of threads for the proof account worker pool (trie account proof workers). - /// If `None`, derived from available parallelism. + /// If `None` or zero, uses four times the resolved CPU pool thread count. pub proof_account_worker_threads: Option, /// Number of threads for the prewarming pool (execution prewarming workers). /// If `None`, derived from available parallelism. @@ -214,6 +216,18 @@ impl RayonConfig { let _ = self.reserved_cpu_cores; self.cpu_threads.unwrap_or_else(|| available_parallelism().map_or(1, NonZeroUsize::get)) } + + /// Returns the explicitly configured proof storage worker thread count, treating zero as + /// unset. + fn proof_storage_worker_threads_override(&self) -> Option { + self.proof_storage_worker_threads.filter(|&threads| threads > 0) + } + + /// Returns the explicitly configured proof account worker thread count, treating zero as + /// unset. + fn proof_account_worker_threads_override(&self) -> Option { + self.proof_account_worker_threads.filter(|&threads| threads > 0) + } } /// Configuration for building a [`Runtime`]. @@ -239,6 +253,20 @@ impl RuntimeConfig { self.rayon = rayon; self } + + /// Override the available CPU count used to size thread pools. + /// + /// Explicit thread counts and externally supplied Tokio runtimes are preserved. + /// Fixed-size pools, such as the storage I/O pool, are not affected. + pub fn with_cpu_cores(mut self, cpu_cores: NonZeroUsize) -> Self { + let cpu_cores = cpu_cores.get(); + if let TokioConfig::Owned { worker_threads, .. } = &mut self.tokio { + worker_threads.get_or_insert(cpu_cores); + } + #[cfg(feature = "rayon")] + self.rayon.cpu_threads.get_or_insert(cpu_cores); + self + } } /// Error returned when [`RuntimeBuilder::build`] fails. @@ -286,6 +314,12 @@ struct RuntimeInner { /// Proof account worker pool (trie account proof computation). #[cfg(feature = "rayon")] proof_account_worker_pool: WorkerPool, + /// Proof storage worker thread count the operator configured explicitly, if any. + #[cfg(feature = "rayon")] + proof_storage_worker_threads_override: Option, + /// Proof account worker thread count the operator configured explicitly, if any. + #[cfg(feature = "rayon")] + proof_account_worker_threads_override: Option, /// Prewarming pool (execution prewarming workers). #[cfg(feature = "rayon")] prewarming_pool: WorkerPool, @@ -386,6 +420,24 @@ impl Runtime { &self.0.proof_account_worker_pool } + /// Returns the proof storage worker thread count the operator configured explicitly, if any. + /// A configured count of zero is treated as unset. + /// + /// Callers that size the worker count per block must use this verbatim instead of scaling it. + #[cfg(feature = "rayon")] + pub fn proof_storage_worker_threads_override(&self) -> Option { + self.0.proof_storage_worker_threads_override + } + + /// Returns the proof account worker thread count the operator configured explicitly, if any. + /// A configured count of zero is treated as unset. + /// + /// Callers that size the worker count per block must use this verbatim instead of scaling it. + #[cfg(feature = "rayon")] + pub fn proof_account_worker_threads_override(&self) -> Option { + self.0.proof_account_worker_threads_override + } + /// Get the prewarming pool. #[cfg(feature = "rayon")] pub fn prewarming_pool(&self) -> &WorkerPool { @@ -945,13 +997,22 @@ impl RuntimeBuilder { let blocking_guard = BlockingTaskGuard::new(config.rayon.max_blocking_tasks); - let proof_storage_worker_threads = - config.rayon.proof_storage_worker_threads.unwrap_or(default_threads * 2); + // `cpu_threads` may be zero, which rayon resolves to the automatic count when the + // cpu pool is built; size the proof pools from that resolved count so a zero never + // reaches the per-block worker budget. + let default_proof_worker_threads = cpu_pool.current_num_threads() * 4; + + let proof_storage_worker_threads = config + .rayon + .proof_storage_worker_threads_override() + .unwrap_or(default_proof_worker_threads); let proof_storage_worker_pool = WorkerPool::new(proof_storage_worker_threads, "proof-strg"); - let proof_account_worker_threads = - config.rayon.proof_account_worker_threads.unwrap_or(default_threads * 2); + let proof_account_worker_threads = config + .rayon + .proof_account_worker_threads_override() + .unwrap_or(default_proof_worker_threads); let proof_account_worker_pool = WorkerPool::new(proof_account_worker_threads, "proof-acct"); @@ -1023,6 +1084,14 @@ impl RuntimeBuilder { #[cfg(feature = "rayon")] proof_account_worker_pool, #[cfg(feature = "rayon")] + proof_storage_worker_threads_override: config + .rayon + .proof_storage_worker_threads_override(), + #[cfg(feature = "rayon")] + proof_account_worker_threads_override: config + .rayon + .proof_account_worker_threads_override(), + #[cfg(feature = "rayon")] prewarming_pool, #[cfg(feature = "rayon")] bal_streaming_pool, @@ -1046,6 +1115,66 @@ mod tests { assert!(matches!(config.tokio, TokioConfig::Owned { .. })); } + #[test] + fn cpu_cores_override_sizes_tokio_workers() { + let config = RuntimeConfig::default().with_cpu_cores(NonZeroUsize::new(8).unwrap()); + assert!(matches!(config.tokio, TokioConfig::Owned { worker_threads: Some(8), .. })); + } + + #[test] + fn cpu_cores_override_preserves_explicit_tokio_workers() { + let config = RuntimeConfig::default() + .with_tokio(TokioConfig::with_worker_threads(2)) + .with_cpu_cores(NonZeroUsize::new(8).unwrap()); + assert!(matches!(config.tokio, TokioConfig::Owned { worker_threads: Some(2), .. })); + } + + #[test] + fn cpu_cores_override_preserves_existing_tokio_runtime() { + let runtime = TokioRuntime::new().unwrap(); + let config = RuntimeConfig::default() + .with_tokio(TokioConfig::existing_handle(runtime.handle().clone())) + .with_cpu_cores(NonZeroUsize::new(8).unwrap()); + assert!(matches!(config.tokio, TokioConfig::ExistingHandle(_))); + } + + #[cfg(feature = "rayon")] + #[test] + fn cpu_cores_override_sizes_cpu_pools() { + let runtime = RuntimeBuilder::new( + RuntimeConfig::default().with_cpu_cores(NonZeroUsize::new(5).unwrap()), + ) + .build() + .unwrap(); + assert_eq!(runtime.handle().metrics().num_workers(), 5); + assert_eq!(runtime.cpu_pool().current_num_threads(), 5); + assert_eq!( + runtime + .handle() + .block_on(runtime.rpc_pool().spawn(rayon::current_num_threads)) + .unwrap(), + 5 + ); + assert_eq!(runtime.prewarming_pool().current_num_threads(), 5); + assert_eq!(runtime.bal_streaming_pool().current_num_threads(), 5); + assert_eq!(runtime.proof_storage_worker_pool().num_threads(), 20); + assert_eq!(runtime.proof_account_worker_pool().num_threads(), 20); + assert_eq!(runtime.storage_pool().current_num_threads(), DEFAULT_STORAGE_POOL_THREADS); + assert_eq!( + runtime.state_trie_overlay_worker_pool().num_threads(), + DEFAULT_STATE_TRIE_OVERLAY_WORKER_THREADS + ); + } + + #[cfg(feature = "rayon")] + #[test] + fn cpu_cores_override_preserves_explicit_cpu_threads() { + let config = RuntimeConfig::default() + .with_rayon(RayonConfig { cpu_threads: Some(2), ..Default::default() }) + .with_cpu_cores(NonZeroUsize::new(8).unwrap()); + assert_eq!(config.rayon.default_thread_count(), 2); + } + #[test] fn test_runtime_config_existing_handle() { let rt = TokioRuntime::new().unwrap(); @@ -1062,6 +1191,57 @@ mod tests { assert!(count >= 1); } + #[cfg(feature = "rayon")] + #[test] + fn zero_cpu_threads_still_size_the_proof_pools() { + let rt = TokioRuntime::new().unwrap(); + let mut config = + Runtime::test_config().with_tokio(TokioConfig::existing_handle(rt.handle().clone())); + config.rayon.cpu_threads = Some(0); + config.rayon.proof_storage_worker_threads = None; + config.rayon.proof_account_worker_threads = None; + let runtime = RuntimeBuilder::new(config).build().unwrap(); + + let resolved = runtime.cpu_pool().current_num_threads(); + assert!(resolved >= 1); + assert_eq!(runtime.proof_storage_worker_pool().current_num_threads(), resolved * 4); + assert_eq!(runtime.proof_account_worker_pool().current_num_threads(), resolved * 4); + } + + #[cfg(feature = "rayon")] + #[test] + fn proof_worker_threads_keep_pinned_counts() { + let rt = TokioRuntime::new().unwrap(); + for configured in [1, 8, 16, 17, 64] { + let mut config = Runtime::test_config() + .with_tokio(TokioConfig::existing_handle(rt.handle().clone())); + config.rayon.proof_storage_worker_threads = Some(configured); + config.rayon.proof_account_worker_threads = Some(configured); + let runtime = RuntimeBuilder::new(config).build().unwrap(); + assert_eq!(runtime.proof_storage_worker_pool().current_num_threads(), configured); + assert_eq!(runtime.proof_account_worker_pool().current_num_threads(), configured); + assert_eq!(runtime.proof_storage_worker_threads_override(), Some(configured)); + assert_eq!(runtime.proof_account_worker_threads_override(), Some(configured)); + } + } + + #[cfg(feature = "rayon")] + #[test] + fn zero_proof_worker_threads_use_the_default() { + let rt = TokioRuntime::new().unwrap(); + let mut config = + Runtime::test_config().with_tokio(TokioConfig::existing_handle(rt.handle().clone())); + config.rayon.proof_storage_worker_threads = Some(0); + config.rayon.proof_account_worker_threads = Some(0); + let runtime = RuntimeBuilder::new(config).build().unwrap(); + + let default = runtime.cpu_pool().current_num_threads() * 4; + assert_eq!(runtime.proof_storage_worker_pool().current_num_threads(), default); + assert_eq!(runtime.proof_account_worker_pool().current_num_threads(), default); + assert_eq!(runtime.proof_storage_worker_threads_override(), None); + assert_eq!(runtime.proof_account_worker_threads_override(), None); + } + #[test] fn test_runtime_builder() { let rt = TokioRuntime::new().unwrap(); diff --git a/crates/tasks/src/worker_map.rs b/crates/tasks/src/worker_map.rs index e54fbf0a691..51b30738973 100644 --- a/crates/tasks/src/worker_map.rs +++ b/crates/tasks/src/worker_map.rs @@ -68,9 +68,12 @@ impl WorkerThread { let task: BoxedTask = Box::new(move || { let started_at = Instant::now(); metrics.record_queue_wait(started_at.saturating_duration_since(queued_at)); - let _decrement_pending = DecrementPendingOnDrop(pending); - let _record_task_duration = RecordTaskDurationOnDrop::new(metrics, started_at); - let _ = result_tx.send(f()); + let result = { + let _decrement_pending = DecrementPendingOnDrop(pending); + let _record_task_duration = RecordTaskDurationOnDrop::new(metrics, started_at); + f() + }; + let _ = result_tx.send(result); }); if self.tx.send(task).is_err() { @@ -96,9 +99,12 @@ impl WorkerThread { let task: BoxedTask = Box::new(move || { let started_at = Instant::now(); metrics.record_queue_wait(started_at.saturating_duration_since(queued_at)); - let _decrement_pending = DecrementPendingOnDrop(pending); - let _record_task_duration = RecordTaskDurationOnDrop::new(metrics, started_at); - let _ = result_tx.send(f()); + let result = { + let _decrement_pending = DecrementPendingOnDrop(pending); + let _record_task_duration = RecordTaskDurationOnDrop::new(metrics, started_at); + f() + }; + let _ = result_tx.send(result); }); if self.tx.send(task).is_err() { @@ -193,7 +199,11 @@ impl Drop for WorkerMap { for (_, mut w) in std::mem::take(&mut self.workers) { // Drop sender so the thread's recv loop exits, then join. drop(w.tx); - if let Some(handle) = w.handle.take() { + // A task can own the last reference to this map and drop it on its own worker, which + // cannot join itself. That thread exits on its own once the task returns. + if let Some(handle) = w.handle.take() && + handle.thread().id() != thread::current().id() + { let _ = handle.join(); } } @@ -294,4 +304,21 @@ mod tests { let third = map.try_spawn_on("busy-worker", || 3).expect("worker should be idle"); assert_eq!(third.await.unwrap(), 3); } + + #[test] + fn worker_map_last_owner_dropped_on_worker() { + let map = Arc::new(WorkerMap::new()); + let owner = Arc::clone(&map); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + + let done = map.spawn_on("last-owner", move || { + release_rx.recv().unwrap(); + // Drops the last reference to the map on the worker it would join. + drop(owner); + }); + + drop(map); + release_tx.send(()).unwrap(); + done.blocking_recv().expect("dropping the map on its own worker should not panic"); + } } diff --git a/crates/tracing-otlp/Cargo.toml b/crates/tracing-otlp/Cargo.toml index 026b96bafda..d59c57da520 100644 --- a/crates/tracing-otlp/Cargo.toml +++ b/crates/tracing-otlp/Cargo.toml @@ -20,7 +20,7 @@ tracing-subscriber.workspace = true tracing.workspace = true # misc -base64 = "0.22" +base64 = "0.23" clap = { workspace = true, features = ["derive"] } eyre.workspace = true url.workspace = true diff --git a/crates/transaction-pool/src/blobstore/mem.rs b/crates/transaction-pool/src/blobstore/mem.rs index 634c3afe1b2..c689112d42c 100644 --- a/crates/transaction-pool/src/blobstore/mem.rs +++ b/crates/transaction-pool/src/blobstore/mem.rs @@ -316,7 +316,7 @@ mod tests { fn eip4844_single_blob_sidecar() -> (BlobTransactionSidecarVariant, B256) { let blob = Blob::default(); - let commitment = Bytes48::from([1u8; 48]); + let commitment = Bytes48::repeat_byte(1u8); let proof = Bytes48::default(); let versioned_hash = kzg_to_versioned_hash(commitment.as_slice()); let sidecar = BlobTransactionSidecar { diff --git a/crates/transaction-pool/src/blobstore/mod.rs b/crates/transaction-pool/src/blobstore/mod.rs index caad2a9854f..32723b40b5e 100644 --- a/crates/transaction-pool/src/blobstore/mod.rs +++ b/crates/transaction-pool/src/blobstore/mod.rs @@ -329,25 +329,12 @@ pub struct BlobStoreCleanupStat { #[cfg(test)] mod tests { use super::*; - use alloy_eips::{eip4844::BlobTransactionSidecar, eip7594::BlobTransactionSidecarEip7594}; #[expect(dead_code)] struct DynStore { store: Box, } - #[test] - fn pooled_blob_sidecar_defaults_to_full_availability() { - let sidecars = [ - BlobTransactionSidecarVariant::Eip4844(BlobTransactionSidecar::default()), - BlobTransactionSidecarVariant::Eip7594(BlobTransactionSidecarEip7594::default()), - ]; - - for sidecar in sidecars { - assert!(PooledBlobSidecar::from(sidecar).availability().is_full()); - } - } - #[test] fn blob_cell_availability_uses_cell_index_bit_order() { let availability = diff --git a/crates/transaction-pool/src/config.rs b/crates/transaction-pool/src/config.rs index c3938f7fa0f..b6951097a19 100644 --- a/crates/transaction-pool/src/config.rs +++ b/crates/transaction-pool/src/config.rs @@ -448,7 +448,7 @@ mod tests { #[test] fn test_contains_local_address() { - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let mut local_addresses = AddressSet::default(); local_addresses.insert(address); @@ -458,12 +458,12 @@ mod tests { assert!(config.contains_local_address(&address)); // Should not contain another random address - assert!(!config.contains_local_address(&Address::new([2; 20]))); + assert!(!config.contains_local_address(&Address::repeat_byte(2))); } #[test] fn test_is_local_with_no_exemptions() { - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let config = LocalTransactionConfig { no_exemptions: true, local_addresses: AddressSet::default(), @@ -476,7 +476,7 @@ mod tests { #[test] fn test_is_local_without_no_exemptions() { - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let mut local_addresses = AddressSet::default(); local_addresses.insert(address); @@ -484,13 +484,13 @@ mod tests { LocalTransactionConfig { no_exemptions: false, local_addresses, ..Default::default() }; // Should return true as the transaction origin is local - assert!(config.is_local(TransactionOrigin::Local, &Address::new([2; 20]))); + assert!(config.is_local(TransactionOrigin::Local, &Address::repeat_byte(2))); assert!(config.is_local(TransactionOrigin::Local, &address)); // Should return true as the address is in the local_addresses set assert!(config.is_local(TransactionOrigin::External, &address)); // Should return false as the address is not in the local_addresses set - assert!(!config.is_local(TransactionOrigin::External, &Address::new([2; 20]))); + assert!(!config.is_local(TransactionOrigin::External, &Address::repeat_byte(2))); } #[test] diff --git a/crates/transaction-pool/src/error.rs b/crates/transaction-pool/src/error.rs index b580f66d9af..9f74d9d725b 100644 --- a/crates/transaction-pool/src/error.rs +++ b/crates/transaction-pool/src/error.rs @@ -503,7 +503,7 @@ impl InvalidPoolTransactionError { /// Returns true if the this type is a [`InvalidPoolTransactionError::Other`] of that error /// type. Returns false otherwise. pub fn is_other(&self) -> bool { - self.as_other().map(|err| err.as_any().is::()).unwrap_or(false) + self.as_other().is_some_and(|err| err.as_any().is::()) } } diff --git a/crates/transaction-pool/src/identifier.rs b/crates/transaction-pool/src/identifier.rs index 169952841a1..f7aafb6d680 100644 --- a/crates/transaction-pool/src/identifier.rs +++ b/crates/transaction-pool/src/identifier.rs @@ -216,7 +216,7 @@ mod tests { #[test] fn test_address_retrieval() { let mut identifiers = SenderIdentifiers::default(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let id = identifiers.sender_id_or_create(address); assert_eq!(identifiers.address(&id), Some(&address)); } @@ -224,7 +224,7 @@ mod tests { #[test] fn test_sender_id_retrieval() { let mut identifiers = SenderIdentifiers::default(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let id = identifiers.sender_id_or_create(address); assert_eq!(identifiers.sender_id(&address), Some(id)); } @@ -232,7 +232,7 @@ mod tests { #[test] fn test_sender_id_or_create_existing() { let mut identifiers = SenderIdentifiers::default(); - let address = Address::new([1; 20]); + let address = Address::repeat_byte(1); let id1 = identifiers.sender_id_or_create(address); let id2 = identifiers.sender_id_or_create(address); assert_eq!(id1, id2); @@ -241,8 +241,8 @@ mod tests { #[test] fn test_sender_id_or_create_new() { let mut identifiers = SenderIdentifiers::default(); - let address1 = Address::new([1; 20]); - let address2 = Address::new([2; 20]); + let address1 = Address::repeat_byte(1); + let address2 = Address::repeat_byte(2); let id1 = identifiers.sender_id_or_create(address1); let id2 = identifiers.sender_id_or_create(address2); assert_ne!(id1, id2); diff --git a/crates/transaction-pool/src/maintain.rs b/crates/transaction-pool/src/maintain.rs index a7a26f52b83..54353a22007 100644 --- a/crates/transaction-pool/src/maintain.rs +++ b/crates/transaction-pool/src/maintain.rs @@ -832,7 +832,7 @@ pub async fn backup_local_transactions_task

( pool: P, config: LocalTransactionBackupConfig, ) where - P: TransactionPool> + Clone, + P: TransactionPool>, { let Some(transactions_path) = config.transactions_path else { // nothing to do @@ -858,8 +858,8 @@ mod tests { blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, CoinbaseTipOrdering, EthPooledTransaction, Pool, TransactionOrigin, }; - use alloy_eips::eip2718::Decodable2718; - use alloy_primitives::{hex, U256}; + use alloy_eips::{eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M, eip2718::Decodable2718}; + use alloy_primitives::{address, hex, U256}; use reth_ethereum_primitives::PooledTransactionVariant; use reth_evm_ethereum::EthEvmConfig; use reth_fs_util as fs; @@ -888,10 +888,11 @@ mod tests { let provider = MockEthProvider::default().with_genesis_block(); let transaction = EthPooledTransaction::from_pooled(tx.try_into_recovered().unwrap()); let tx_to_cmp = transaction.clone(); - let sender = hex!("1f9090aaE28b8a3dCeaDf281B0F12828e676c326").into(); + let sender = address!("1f9090aaE28b8a3dCeaDf281B0F12828e676c326"); provider.add_account(sender, ExtendedAccount::new(42, U256::MAX)); let blob_store = InMemoryBlobStore::default(); let validator = EthTransactionValidatorBuilder::new(provider, EthEvmConfig::mainnet()) + .set_block_gas_limit(ETHEREUM_BLOCK_GAS_LIMIT_30M) .build(blob_store.clone()); let txpool = Pool::new( diff --git a/crates/transaction-pool/src/metrics.rs b/crates/transaction-pool/src/metrics.rs index 0ba69ac2f4b..65f792d72f5 100644 --- a/crates/transaction-pool/src/metrics.rs +++ b/crates/transaction-pool/src/metrics.rs @@ -163,6 +163,9 @@ pub struct TxPoolValidationMetrics { /// Number of stateless validation attempts rejected due to a gas limit below the intrinsic or /// floor gas cost. pub rejected_intrinsic_gas_too_low: Counter, + /// Number of stateless validation attempts rejected due to intrinsic regular gas or floor gas + /// above the EIP-8037 regular gas cap. + pub rejected_intrinsic_gas_too_high: Counter, /// Number of validation attempts rejected by blob count or blob sidecar checks. pub invalid_4844: Counter, } diff --git a/crates/transaction-pool/src/pool/mod.rs b/crates/transaction-pool/src/pool/mod.rs index 10ec0143a2a..1ee4cb2ae90 100644 --- a/crates/transaction-pool/src/pool/mod.rs +++ b/crates/transaction-pool/src/pool/mod.rs @@ -1741,10 +1741,13 @@ mod tests { TransactionValidationOutcome, ValidPoolTransaction, U256, }; use alloy_consensus::Transaction; - use alloy_eips::{eip4844::BlobTransactionSidecar, eip7594::BlobTransactionSidecarVariant}; + use alloy_eips::{ + eip4844::{Blob, BlobTransactionSidecar, Bytes48}, + eip7594::BlobTransactionSidecarVariant, + }; use alloy_primitives::{Address, B256}; use futures_util::{FutureExt, StreamExt}; - use std::{fs, path::PathBuf, sync::Arc}; + use std::sync::Arc; use tokio::sync::mpsc::error::TryRecvError; /// Inserts a transaction with an explicit validator snapshot, without running maintenance. @@ -1752,7 +1755,7 @@ mod tests { let transaction = MockTransaction::eip1559() .with_sender(Address::with_last_byte(1)) .with_nonce(nonce) - .with_hash(B256::from([nonce as u8; 32])); + .with_hash(B256::repeat_byte(nonce as u8)); pool.pool .add_transactions( TransactionOrigin::External, @@ -1850,7 +1853,7 @@ mod tests { assert_eq!(transaction_nonces(&txs.pending), [1, 2]); assert_eq!(transaction_nonces(&txs.queued), [4]); - let expected = vec![B256::from([1; 32]), B256::from([2; 32])]; + let expected = vec![B256::repeat_byte(1), B256::repeat_byte(2)]; for (kind, listener) in [("network", &mut network), ("all", &mut all)] { let mut received = Vec::new(); while let Ok(hash) = listener.try_recv() { @@ -1869,9 +1872,9 @@ mod tests { } received.sort_unstable_by_key(|(hash, _)| *hash); let expected = vec![ - (B256::from([1; 32]), SubPool::Pending), - (B256::from([2; 32]), SubPool::Pending), - (B256::from([4; 32]), SubPool::Queued), + (B256::repeat_byte(1), SubPool::Pending), + (B256::repeat_byte(2), SubPool::Pending), + (B256::repeat_byte(4), SubPool::Queued), ]; assert_eq!(received, expected, "{kind} full transaction notifications"); } @@ -1887,7 +1890,7 @@ mod tests { } pending_events.sort_unstable(); assert_eq!(pending_events, expected); - assert_eq!(queued_events, vec![B256::from([4; 32])]); + assert_eq!(queued_events, vec![B256::repeat_byte(4)]); // A later insertion observing the same state must not notify those promotions again. insert_with_state_nonce(&test_pool, 5, 1, true); @@ -1895,13 +1898,13 @@ mod tests { assert_eq!(all.try_recv(), Err(TryRecvError::Empty)); for listener in [&mut full_network, &mut full_all] { let event = listener.try_recv().unwrap(); - assert_eq!(*event.transaction.hash(), B256::from([5; 32])); + assert_eq!(*event.transaction.hash(), B256::repeat_byte(5)); assert_eq!(event.subpool, SubPool::Queued); assert!(matches!(listener.try_recv(), Err(TryRecvError::Empty))); } assert!(matches!( events.next().now_or_never(), - Some(Some(FullTransactionEvent::Queued(hash, _))) if hash == B256::from([5; 32]) + Some(Some(FullTransactionEvent::Queued(hash, _))) if hash == B256::repeat_byte(5) )); assert!(events.next().now_or_never().is_none()); } @@ -1920,29 +1923,29 @@ mod tests { let mut full_all = pool.add_new_transaction_listener(TransactionListenerKind::All); insert_with_state_nonce(&test_pool, 4, 1, true); - assert_eq!(network.try_recv().unwrap(), B256::from([2; 32])); + assert_eq!(network.try_recv().unwrap(), B256::repeat_byte(2)); assert_eq!(network.try_recv(), Err(TryRecvError::Empty)); let mut received = Vec::new(); while let Ok(hash) = all.try_recv() { received.push(hash); } received.sort_unstable(); - assert_eq!(received, [B256::from([1; 32]), B256::from([2; 32])]); + assert_eq!(received, [B256::repeat_byte(1), B256::repeat_byte(2)]); for (listener, expected) in [ ( &mut full_network, vec![ - (B256::from([2; 32]), SubPool::Pending), - (B256::from([4; 32]), SubPool::Queued), + (B256::repeat_byte(2), SubPool::Pending), + (B256::repeat_byte(4), SubPool::Queued), ], ), ( &mut full_all, vec![ - (B256::from([1; 32]), SubPool::Pending), - (B256::from([2; 32]), SubPool::Pending), - (B256::from([4; 32]), SubPool::Queued), + (B256::repeat_byte(1), SubPool::Pending), + (B256::repeat_byte(2), SubPool::Pending), + (B256::repeat_byte(4), SubPool::Queued), ], ), ] { @@ -1975,39 +1978,24 @@ mod tests { received.push(hash); } received.sort_unstable(); - assert_eq!(received, [B256::from([1; 32]), B256::from([2; 32]), B256::from([3; 32])]); + assert_eq!( + received, + [B256::repeat_byte(1), B256::repeat_byte(2), B256::repeat_byte(3)] + ); } } #[test] fn test_discard_blobs_on_blob_tx_eviction() { - let blobs = { - // Read the contents of the JSON file into a string. - let json_content = fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data/blob1.json"), - ) - .expect("Failed to read the blob data file"); - - // Parse the JSON contents into a serde_json::Value. - let json_value: serde_json::Value = - serde_json::from_str(&json_content).expect("Failed to deserialize JSON"); - - // Extract blob data from JSON and convert it to Blob. - vec![ - // Extract the "data" field from the JSON and parse it as a string. - json_value - .get("data") - .unwrap() - .as_str() - .expect("Data is not a valid string") - .to_string(), - ] - }; - - // Generate a BlobTransactionSidecar from the blobs. - let sidecar = BlobTransactionSidecarVariant::Eip4844( - BlobTransactionSidecar::try_from_blobs_hex(blobs).unwrap(), - ); + // A zero blob, whose commitment and proof are the point at infinity, is a valid sidecar + // that doesn't need the KZG trusted setup. + let mut point_at_infinity = [0; 48]; + point_at_infinity[0] = 0xc0; + let sidecar = BlobTransactionSidecarVariant::Eip4844(BlobTransactionSidecar::new( + vec![Blob::default()], + vec![Bytes48::from(point_at_infinity)], + vec![Bytes48::from(point_at_infinity)], + )); // Define the maximum limit for blobs in the sub-pool. let blob_limit = SubPoolLimit::new(1000, usize::MAX); @@ -2069,7 +2057,7 @@ mod tests { // Create a test pool with default configuration. let test_pool = &TestPoolBuilder::default().with_config(Default::default()).pool; - let auth = Address::new([1; 20]); + let auth = Address::repeat_byte(1); let tx = MockTransaction::eip7702(); test_pool.add_transactions( @@ -2091,7 +2079,7 @@ mod tests { #[test] fn sender_queries_do_not_allocate_ids_for_unknown_addresses() { let test_pool = &TestPoolBuilder::default().with_config(Default::default()).pool; - let sender = Address::new([9; 20]); + let sender = Address::repeat_byte(9); assert_eq!(test_pool.sender_id(&sender), None); assert!(test_pool.get_transactions_by_sender(sender).is_empty()); diff --git a/crates/transaction-pool/src/pool/parked.rs b/crates/transaction-pool/src/pool/parked.rs index 7bd524fcde5..30c8e757ac5 100644 --- a/crates/transaction-pool/src/pool/parked.rs +++ b/crates/transaction-pool/src/pool/parked.rs @@ -537,10 +537,11 @@ impl Ord for QueuedOrd { #[cfg(test)] mod tests { + use super::*; use crate::test_utils::{MockTransaction, MockTransactionFactory, MockTransactionSet}; use alloy_consensus::{Transaction, TxType}; - use alloy_primitives::address; + use alloy_primitives::Address; use std::collections::HashSet; #[test] @@ -614,10 +615,10 @@ mod tests { let mut f = MockTransactionFactory::default(); let mut pool = ParkedPool::>::default(); - let a_sender = address!("0x000000000000000000000000000000000000000a"); - let b_sender = address!("0x000000000000000000000000000000000000000b"); - let c_sender = address!("0x000000000000000000000000000000000000000c"); - let d_sender = address!("0x000000000000000000000000000000000000000d"); + let a_sender = Address::with_last_byte(0x0a); + let b_sender = Address::with_last_byte(0x0b); + let c_sender = Address::with_last_byte(0x0c); + let d_sender = Address::with_last_byte(0x0d); // create a chain of transactions by sender A, B, C let mut tx_set = MockTransactionSet::dependent(a_sender, 0, 4, TxType::Eip1559); @@ -691,7 +692,7 @@ mod tests { // create a chain of transactions by sender A // make sure they are all one over half the limit - let a_sender = address!("0x000000000000000000000000000000000000000a"); + let a_sender = Address::with_last_byte(0x0a); // 2 txs, that should put the pool over the size limit but not max txs let a_txs = MockTransactionSet::dependent(a_sender, 0, 2, TxType::Eip1559) @@ -718,10 +719,10 @@ mod tests { let mut f = MockTransactionFactory::default(); let mut pool = ParkedPool::>::default(); - let a_sender = address!("0x000000000000000000000000000000000000000a"); - let b_sender = address!("0x000000000000000000000000000000000000000b"); - let c_sender = address!("0x000000000000000000000000000000000000000c"); - let d_sender = address!("0x000000000000000000000000000000000000000d"); + let a_sender = Address::with_last_byte(0x0a); + let b_sender = Address::with_last_byte(0x0b); + let c_sender = Address::with_last_byte(0x0c); + let d_sender = Address::with_last_byte(0x0d); // create a chain of transactions by sender A, B, C let mut tx_set = MockTransactionSet::dependent(a_sender, 0, 4, TxType::Eip1559); @@ -1090,8 +1091,8 @@ mod tests { let mut pool = ParkedPool::>::default(); // Add multiple transactions across different fee ranges - let sender_a = address!("0x000000000000000000000000000000000000000a"); - let sender_b = address!("0x000000000000000000000000000000000000000b"); + let sender_a = Address::with_last_byte(0x0a); + let sender_b = Address::with_last_byte(0x0b); // Add transactions where nonce ordering allows proper processing: // Sender A: both transactions can afford basefee (500 >= 400, 600 >= 400) diff --git a/crates/transaction-pool/src/pool/pending.rs b/crates/transaction-pool/src/pool/pending.rs index 272ede0546f..59be47f7c82 100644 --- a/crates/transaction-pool/src/pool/pending.rs +++ b/crates/transaction-pool/src/pool/pending.rs @@ -751,13 +751,14 @@ impl Ord for PendingTransaction { #[cfg(test)] mod tests { + use super::*; use crate::{ test_utils::{MockOrdering, MockTransaction, MockTransactionFactory, MockTransactionSet}, PoolTransaction, }; use alloy_consensus::{Transaction, TxType}; - use alloy_primitives::address; + use alloy_primitives::Address; use std::collections::HashSet; #[test] @@ -900,10 +901,10 @@ mod tests { let mut f = MockTransactionFactory::default(); let mut pool = PendingPool::new(MockOrdering::default()); - let a_sender = address!("0x000000000000000000000000000000000000000a"); - let b_sender = address!("0x000000000000000000000000000000000000000b"); - let c_sender = address!("0x000000000000000000000000000000000000000c"); - let d_sender = address!("0x000000000000000000000000000000000000000d"); + let a_sender = Address::with_last_byte(0x0a); + let b_sender = Address::with_last_byte(0x0b); + let c_sender = Address::with_last_byte(0x0c); + let d_sender = Address::with_last_byte(0x0d); // create a chain of transactions by sender A, B, C let mut tx_set = MockTransactionSet::dependent(a_sender, 0, 4, TxType::Eip1559); @@ -949,10 +950,10 @@ mod tests { let mut pool = PendingPool::new(MockOrdering::default()); // Addresses for simulated senders A, B, C, and D. - let a = address!("0x000000000000000000000000000000000000000a"); - let b = address!("0x000000000000000000000000000000000000000b"); - let c = address!("0x000000000000000000000000000000000000000c"); - let d = address!("0x000000000000000000000000000000000000000d"); + let a = Address::with_last_byte(0x0a); + let b = Address::with_last_byte(0x0b); + let c = Address::with_last_byte(0x0c); + let d = Address::with_last_byte(0x0d); // Create transaction chains for senders A, B, C, and D. let a_txs = MockTransactionSet::sequential_transactions_by_sender(a, 4, TxType::Eip1559); @@ -1177,9 +1178,9 @@ mod tests { let mut pool = PendingPool::new(MockOrdering::default()); // Addresses for simulated senders A, B, C - let a = address!("0x000000000000000000000000000000000000000a"); - let b = address!("0x000000000000000000000000000000000000000b"); - let c = address!("0x000000000000000000000000000000000000000c"); + let a = Address::with_last_byte(0x0a); + let b = Address::with_last_byte(0x0b); + let c = Address::with_last_byte(0x0c); // sender A (local) - 11+ transactions (large enough to keep limit exceeded) // sender B (external) - 2 transactions @@ -1224,7 +1225,7 @@ mod tests { fn test_remove_non_highest_keeps_highest() { let mut f = MockTransactionFactory::default(); let mut pool = PendingPool::new(MockOrdering::default()); - let sender = address!("0x00000000000000000000000000000000000000aa"); + let sender = Address::with_last_byte(0xaa); let txs = MockTransactionSet::dependent(sender, 0, 3, TxType::Eip1559).into_vec(); for tx in txs { pool.add_transaction(f.validated_arc(tx), 0); @@ -1242,7 +1243,7 @@ mod tests { fn test_cascade_removal_recomputes_highest() { let mut f = MockTransactionFactory::default(); let mut pool = PendingPool::new(MockOrdering::default()); - let sender = address!("0x00000000000000000000000000000000000000bb"); + let sender = Address::with_last_byte(0xbb); let txs = MockTransactionSet::dependent(sender, 0, 4, TxType::Eip1559).into_vec(); for tx in txs { pool.add_transaction(f.validated_arc(tx), 0); @@ -1264,7 +1265,7 @@ mod tests { fn test_remove_only_tx_clears_highest() { let mut f = MockTransactionFactory::default(); let mut pool = PendingPool::new(MockOrdering::default()); - let sender = address!("0x00000000000000000000000000000000000000cc"); + let sender = Address::with_last_byte(0xcc); let txs = MockTransactionSet::dependent(sender, 0, 1, TxType::Eip1559).into_vec(); for tx in txs { pool.add_transaction(f.validated_arc(tx), 0); diff --git a/crates/transaction-pool/src/pool/state.rs b/crates/transaction-pool/src/pool/state.rs index 20351a7a0e9..5f5515d415e 100644 --- a/crates/transaction-pool/src/pool/state.rs +++ b/crates/transaction-pool/src/pool/state.rs @@ -5,13 +5,13 @@ bitflags::bitflags! { /// /// This mirrors [erigon's ephemeral state field](https://github.com/ledgerwatch/erigon/wiki/Transaction-Pool-Design#ordering-function). /// - /// The [SubPool] the transaction belongs to is derived from its state and determined by the following sequential checks: + /// The [`SubPool`] the transaction belongs to is derived from its state and determined by the following sequential checks: /// - /// - If it satisfies the [TxState::PENDING_POOL_BITS] it belongs in the pending sub-pool: [SubPool::Pending]. - /// - If it is an EIP-4844 blob transaction it belongs in the blob sub-pool: [SubPool::Blob]. - /// - If it satisfies the [TxState::BASE_FEE_POOL_BITS] it belongs in the base fee sub-pool: [SubPool::BaseFee]. + /// - If it satisfies the [`TxState::PENDING_POOL_BITS`] it belongs in the pending sub-pool: [`SubPool::Pending`]. + /// - If it is an EIP-4844 blob transaction it belongs in the blob sub-pool: [`SubPool::Blob`]. + /// - If it satisfies the [`TxState::BASE_FEE_POOL_BITS`] it belongs in the base fee sub-pool: [`SubPool::BaseFee`]. /// - /// Otherwise, it belongs in the queued sub-pool: [SubPool::Queued]. + /// Otherwise, it belongs in the queued sub-pool: [`SubPool::Queued`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord)] pub(crate) struct TxState: u8 { /// Set to `1` if all ancestor transactions are pending. diff --git a/crates/transaction-pool/src/pool/txpool.rs b/crates/transaction-pool/src/pool/txpool.rs index 493aca1eef1..fc6d085a230 100644 --- a/crates/transaction-pool/src/pool/txpool.rs +++ b/crates/transaction-pool/src/pool/txpool.rs @@ -284,7 +284,7 @@ impl TxPool { tx_meta.subpool }; - if subpool == SubPool::Pending { + if subpool.is_pending() { on_promoted(&tx); } @@ -305,7 +305,7 @@ impl TxPool { tx_meta.subpool }; - if subpool == SubPool::Pending { + if subpool.is_pending() { on_promoted(&tx); } @@ -366,7 +366,7 @@ impl TxPool { meta.subpool }; - if subpool == SubPool::Pending { + if subpool.is_pending() { on_promoted(&tx); } @@ -620,7 +620,7 @@ impl TxPool { ) -> Option>> { self.all_transactions .txs_iter(sender) - .find(|(id, tx)| id.nonce == nonce && tx.subpool == SubPool::Pending) + .find(|(id, tx)| id.nonce == nonce && tx.subpool.is_pending()) .map(|(_, tx)| Arc::clone(&tx.transaction)) } @@ -1052,7 +1052,7 @@ impl TxPool { Destination::Pool(move_to) => { debug_assert_ne!(&move_to, ¤t, "destination must be different"); let moved = self.move_transaction(current, move_to, &id); - if matches!(move_to, SubPool::Pending) && + if move_to.is_pending() && let Some(tx) = moved { trace!(target: "txpool", hash=%tx.transaction.hash(), "Promoted transaction to pending"); @@ -3577,47 +3577,12 @@ mod tests { assert!(pool.should_update_all_senders(FULL_UPDATE_MIN_SENDERS + 1)); } - #[test] - fn update_only_visits_changed_senders_when_fees_are_unchanged() { - let mut f = MockTransactionFactory::default(); - let mut pool = TxPool::new(MockOrdering::default(), Default::default()); - - // two senders, each with one transaction - let a = f.validated(MockTransaction::eip1559().inc_price_by(10)); - let b = f.validated(MockTransaction::eip1559().inc_price_by(10)); - let (a_id, b_id) = (*a.id(), *b.id()); - assert_ne!(a_id.sender, b_id.sender); - pool.add_transaction(a, U256::from(1_000_000), 0, None).unwrap(); - pool.add_transaction(b, U256::from(1_000_000), 0, None).unwrap(); - - // a full update applies the current fees to every transaction and records them - pool.all_transactions.update(&Default::default()); - assert_eq!(pool.all_transactions.last_full_update_fees, pool.all_transactions.pending_fees); - - // sender `a` moved past its transaction on chain, the fees did not move. Only `a` should - // be evaluated, and `b` must be left exactly as it was. - let b_state_before = pool.all_transactions.txs.get(&b_id).unwrap().state; - let mut changed = FxHashMap::default(); - changed.insert(a_id.sender, SenderInfo { state_nonce: 1, balance: U256::from(1_000_000) }); - - let produced = pool.all_transactions.update(&changed); - - assert_eq!(produced.len(), 1, "expected exactly the changed sender's update"); - assert_eq!(produced[0].id, a_id); - assert!(matches!(produced[0].destination, Destination::Discard)); - assert_eq!( - pool.all_transactions.txs.get(&b_id).unwrap().state, - b_state_before, - "unchanged sender was modified" - ); - } - #[test] fn changed_sender_update_matches_full_update() { let senders = [ - address!("0x000000000000000000000000000000000000000a"), - address!("0x000000000000000000000000000000000000000b"), - address!("0x000000000000000000000000000000000000000c"), + Address::with_last_byte(0x0a), + Address::with_last_byte(0x0b), + Address::with_last_byte(0x0c), ]; let starting_nonces = [5, 11, 17]; @@ -3736,7 +3701,7 @@ mod tests { fn gap_fill_rechecks_descendant_fee_eligibility() { let mut f = MockTransactionFactory::default(); let mut pool = AllTransactions::default(); - let sender = address!("0x000000000000000000000000000000000000000d"); + let sender = Address::with_last_byte(0x0d); let balance = U256::MAX; pool.pending_fees.base_fee = 100; @@ -3779,7 +3744,7 @@ mod tests { fn base_fee_update_unparks_all_descendants() { let mut f = MockTransactionFactory::default(); let mut pool = AllTransactions::default(); - let sender = address!("0x000000000000000000000000000000000000000e"); + let sender = Address::with_last_byte(0x0e); let mut ids = Vec::new(); pool.pending_fees.base_fee = 200; @@ -3808,7 +3773,7 @@ mod tests { fn fee_update_keeps_descendants_of_underpriced_transaction_parked() { let mut f = MockTransactionFactory::default(); let mut pool = AllTransactions::default(); - let sender = address!("0x0000000000000000000000000000000000000010"); + let sender = Address::with_last_byte(0x10); let fee_caps = [150, 50, 150]; let mut ids = Vec::new(); @@ -3838,7 +3803,7 @@ mod tests { fn blob_fee_update_unparks_all_descendants() { let mut f = MockTransactionFactory::default(); let mut pool = TxPool::new(MockOrdering::default(), Default::default()); - let sender = address!("0x000000000000000000000000000000000000000f"); + let sender = Address::with_last_byte(0x0f); let mut block_info = pool.block_info(); block_info.pending_blob_fee = Some(200); pool.set_block_info(block_info); @@ -3913,15 +3878,14 @@ mod tests { #[test] fn basefee_decrease_promotes_affordable_and_keeps_unaffordable() { - use alloy_primitives::address; let mut f = MockTransactionFactory::default(); let mut pool = TxPool::new(MockOrdering::default(), Default::default()); // Create transactions that will be in basefee pool (can't afford initial high fee) // Use different senders to avoid nonce gap issues - let sender_a = address!("0x000000000000000000000000000000000000000a"); - let sender_b = address!("0x000000000000000000000000000000000000000b"); - let sender_c = address!("0x000000000000000000000000000000000000000c"); + let sender_a = Address::with_last_byte(0x0a); + let sender_b = Address::with_last_byte(0x0b); + let sender_c = Address::with_last_byte(0x0c); let tx1 = MockTransaction::eip1559() .set_sender(sender_a) @@ -4044,42 +4008,6 @@ mod tests { assert!(tx_meta.state.contains(TxState::ENOUGH_FEE_CAP_BLOCK)); } - #[test] - fn update_blob_fee_parks_pending_when_base_fee_falls_in_the_same_block() { - let mut f = MockTransactionFactory::default(); - let mut pool = TxPool::new(MockOrdering::default(), Default::default()); - - let initial_blob_fee = pool.all_transactions.pending_fees.blob_fee; - let initial_base_fee = 100u64; - pool.all_transactions.pending_fees.base_fee = initial_base_fee; - - // comfortably satisfies both fees, so it starts out pending - let tx = MockTransaction::eip4844() - .with_max_fee(500) - .with_priority_fee(1) - .with_blob_fee(initial_blob_fee + 100); - let validated = f.validated(tx.clone()); - let id = *validated.id(); - pool.add_transaction(validated, U256::from(1_000_000), 0, None).unwrap(); - assert_eq!(pool.pending_pool.len(), 1); - - // the blob fee rises past its cap while the base fee falls, which is what a blob heavy - // but gas light block produces. The rise still has to park it. - let raised_blob_fee = tx.max_fee_per_blob_gas().unwrap() + 1; - pool.all_transactions.pending_fees.base_fee = initial_base_fee - 1; - pool.update_blob_fee(raised_blob_fee, Ordering::Less, |_| {}); - - assert!(pool.pending_pool.is_empty(), "transaction was left in the pending pool"); - assert_eq!(pool.blob_pool.len(), 1); - - let tx_meta = pool.all_transactions.txs.get(&id).unwrap(); - assert!( - !tx_meta.state.contains(TxState::ENOUGH_BLOB_FEE_CAP_BLOCK), - "blob fee cap flag was not cleared" - ); - assert_eq!(tx_meta.subpool, SubPool::Blob); - } - #[test] fn update_blob_fee_demotes_and_promotes_when_base_fee_falls() { let mut f = MockTransactionFactory::default(); @@ -4536,7 +4464,7 @@ mod tests { // create a chain of transactions by sender A // make sure they are all one over half the limit - let a_sender = address!("0x000000000000000000000000000000000000000a"); + let a_sender = Address::with_last_byte(0x0a); // set the base fee of the pool let mut block_info = pool.block_info(); @@ -4578,7 +4506,7 @@ mod tests { // create a chain of transactions by sender A // make sure they are all one over half the limit - let a_sender = address!("0x000000000000000000000000000000000000000a"); + let a_sender = Address::with_last_byte(0x0a); // set the base fee of the pool let pool_base_fee = 100; @@ -4768,7 +4696,7 @@ mod tests { assert!(pool.queued_transactions().is_empty()); // Simulate new block arrival - and chain balance decrease. - updated_accounts.entry(v0.sender_id()).and_modify(|v| v.balance = U256::from(1)); + updated_accounts.entry(v0.sender_id()).and_modify(|v| v.balance = U256::ONE); pool.update_accounts(updated_accounts); assert!(pool.pending_transactions().is_empty()); @@ -5394,13 +5322,13 @@ mod tests { pool.set_block_info(block_info); let eip4844_tx = MockTransaction::eip4844() - .with_sender(address!("0x000000000000000000000000000000000000000a")) + .with_sender(Address::with_last_byte(0x0a)) .with_max_fee(200) .with_blob_fee(150) // Less than block blob fee (160) .inc_limit(); let non_4844_tx = MockTransaction::eip1559() - .with_sender(address!("0x000000000000000000000000000000000000000b")) + .with_sender(Address::with_last_byte(0x0b)) .set_max_fee(200) .inc_limit(); @@ -5429,7 +5357,7 @@ mod tests { // Create non-4844 transaction with fee that initially can't afford high basefee let non_4844_tx = MockTransaction::eip1559() - .with_sender(address!("0x000000000000000000000000000000000000000a")) + .with_sender(Address::with_last_byte(0x0a)) .set_max_fee(500) // Can't afford basefee of 600 .inc_limit(); @@ -5485,7 +5413,7 @@ mod tests { // tx0: nonce 0, cheap — will go straight to pending let tx0 = MockTransaction::eip1559().with_sender(sender).set_gas_price(100).inc_limit(); // tx1: nonce 1, very expensive — cumulative cost (tx0 + tx1) will exceed balance - let tx1 = tx0.next().inc_limit().with_value(U256::from(on_chain_balance)); + let tx1 = tx0.next().inc_limit().with_value(on_chain_balance); // tx2: nonce 2 let tx2 = tx1.next().inc_limit().with_value(U256::ZERO); @@ -5583,7 +5511,7 @@ mod tests { // tx0: nonce 0, cheap let tx0 = MockTransaction::eip1559().with_sender(sender).set_gas_price(100).inc_limit(); // tx1: nonce 1, very expensive — will exceed balance - let tx1 = tx0.next().inc_limit().with_value(U256::from(low_balance)); + let tx1 = tx0.next().inc_limit().with_value(low_balance); // tx2: nonce 2 let tx2 = tx1.next().inc_limit().with_value(U256::ZERO); // tx3: nonce 3 diff --git a/crates/transaction-pool/src/test_utils/mock.rs b/crates/transaction-pool/src/test_utils/mock.rs index 68d06548583..03393522b0e 100644 --- a/crates/transaction-pool/src/test_utils/mock.rs +++ b/crates/transaction-pool/src/test_utils/mock.rs @@ -14,7 +14,7 @@ use alloy_consensus::{ LEGACY_TX_TYPE_ID, }, EthereumTxEnvelope, Signed, TxEip1559, TxEip2930, TxEip4844, TxEip4844Variant, TxEip7702, - TxLegacy, TxType, Typed2718, + TxEnvelope, TxLegacy, TxType, Typed2718, }; use alloy_eips::{ eip1559::{calc_effective_gas_price, MIN_PROTOCOL_BASE_FEE}, @@ -612,7 +612,7 @@ impl MockTransaction { /// Returns a new transaction with a higher value pub fn inc_value(&self) -> Self { - self.clone().with_value(self.get_value().checked_add(U256::from(1)).unwrap()) + self.clone().with_value(self.get_value().checked_add(U256::ONE).unwrap()) } /// Returns a new transaction with a higher gas limit @@ -1106,14 +1106,10 @@ impl TryFrom> for MockTransaction { } } -impl TryFrom>>> - for MockTransaction -{ +impl TryFrom> for MockTransaction { type Error = TryFromRecoveredTransactionError; - fn try_from( - tx: Recovered>>, - ) -> Result { + fn try_from(tx: Recovered) -> Result { let (transaction, sender) = tx.into_parts(); let hash = *transaction.tx_hash(); let size = transaction.size(); diff --git a/crates/transaction-pool/src/traits.rs b/crates/transaction-pool/src/traits.rs index ef4d74953fb..d964d68fc26 100644 --- a/crates/transaction-pool/src/traits.rs +++ b/crates/transaction-pool/src/traits.rs @@ -2049,7 +2049,7 @@ impl Stream for NewSubpoolTransactionStream { #[cfg(test)] mod tests { use super::*; - use crate::{blobstore::BlobCellAvailability, test_utils::MockTransaction}; + use crate::blobstore::BlobCellAvailability; use alloy_consensus::{ EthereumTxEnvelope, SignableTransaction, TxEip1559, TxEip2930, TxEip4844, TxEip7702, TxEnvelope, TxLegacy, @@ -2057,16 +2057,6 @@ mod tests { use alloy_eips::eip4844::DATA_GAS_PER_BLOB; use alloy_primitives::Signature; - #[test] - fn test_mock_consensus_encoding() { - let transaction = MockTransaction::legacy(); - - assert_eq!( - transaction.encoded_2718_consensus(), - transaction.into_consensus().encoded_2718() - ); - } - #[test] fn test_pool_size_invariants() { let pool_size = PoolSize { diff --git a/crates/transaction-pool/src/validate/eth.rs b/crates/transaction-pool/src/validate/eth.rs index 21c9065d270..61aa5be4448 100644 --- a/crates/transaction-pool/src/validate/eth.rs +++ b/crates/transaction-pool/src/validate/eth.rs @@ -21,8 +21,7 @@ use alloy_consensus::{ BlockHeader, }; use alloy_eips::{ - eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M, eip4844::env_settings::EnvKzgSettings, - eip7840::BlobParams, merge::SLOT_DURATION_SECS, BlockId, + eip4844::env_settings::EnvKzgSettings, eip7840::BlobParams, merge::SLOT_DURATION_SECS, BlockId, }; use alloy_primitives::U256; use alloy_rlp::Encodable; @@ -101,6 +100,8 @@ pub struct EthTransactionValidator { /// The current tx fee cap limit in wei locally submitted into the pool. tx_fee_cap: Option, /// Minimum priority fee to enforce for acceptance into the pool. + /// + /// For legacy and EIP-2930 transactions the gas price is used as the priority fee. minimum_priority_fee: Option, /// Stores the setup and parameters needed for validating KZG proofs. kzg_settings: EnvKzgSettings, @@ -576,16 +577,15 @@ where } } - // Drop dynamic fee transactions with a fee lower than the configured fee for acceptance - // into the pool. - if transaction.is_dynamic_fee() && - transaction.max_priority_fee_per_gas() < self.minimum_priority_fee + // Drop transactions with a priority fee lower than the configured minimum for acceptance + // into the pool. This applies to all transaction types: legacy and EIP-2930 transactions + // use their gas price as the priority fee. + if let Some(minimum_priority_fee) = self.minimum_priority_fee && + transaction.priority_fee_or_price() < minimum_priority_fee { self.validation_metrics.rejected_priority_fee_below_minimum.increment(1); return Err(InvalidPoolTransactionError::PriorityFeeBelowMinimum { - minimum_priority_fee: self - .minimum_priority_fee - .expect("minimum priority fee is expected inside if statement"), + minimum_priority_fee, }) } @@ -607,8 +607,12 @@ where } } - ensure_intrinsic_gas(transaction, &self.fork_tracker).inspect_err(|_| { - self.validation_metrics.rejected_intrinsic_gas_too_low.increment(1); + ensure_intrinsic_gas(transaction, &self.fork_tracker).inspect_err(|err| { + if matches!(err, InvalidPoolTransactionError::IntrinsicGasTooLow) { + self.validation_metrics.rejected_intrinsic_gas_too_low.increment(1); + } else { + self.validation_metrics.rejected_intrinsic_gas_too_high.increment(1); + } })?; // light blob tx pre-checks @@ -875,11 +879,16 @@ where Ok(maybe_blob_sidecar) } - /// Returns the recovered authorities for the given transaction + /// Returns recovered authorities from authorizations for this chain or all chains. fn recover_authorities(&self, transaction: &Tx) -> std::option::Option> { - transaction - .authorization_list() - .map(|auths| auths.iter().flat_map(|auth| auth.recover_authority()).collect::>()) + transaction.authorization_list().map(|auths| { + let chain_id = U256::from(self.chain_id()); + auths + .iter() + .filter(|auth| auth.chain_id().is_zero() || auth.chain_id() == &chain_id) + .flat_map(|auth| auth.recover_authority()) + .collect() + }) } /// Validates all given transactions. @@ -956,17 +965,13 @@ where self.fork_tracker .max_initcode_size .store(evm_env.cfg_env.max_initcode_size(), std::sync::atomic::Ordering::Relaxed); - // EIP-8037: When state gas is enabled, `tx.gas` can exceed the per-tx gas limit cap - // because the cap only applies to regular gas (state gas uses a reservoir). - // Store 0 to disable the txpool-level check. - let tx_gas_limit_cap = if evm_env.cfg_env.is_amsterdam_eip8037_enabled() { - 0 - } else { - evm_env.cfg_env.tx_gas_limit_cap() - }; + let (tx_gas_limit_cap, tx_regular_gas_cap) = tx_gas_caps(&evm_env.cfg_env); self.fork_tracker .tx_gas_limit_cap .store(tx_gas_limit_cap, std::sync::atomic::Ordering::Relaxed); + self.fork_tracker + .tx_regular_gas_cap + .store(tx_regular_gas_cap, std::sync::atomic::Ordering::Relaxed); } fn max_gas_limit(&self) -> u64 { @@ -1071,6 +1076,8 @@ pub struct EthTransactionValidatorBuilder { /// The current tx fee cap limit in wei locally submitted into the pool. tx_fee_cap: Option, /// Minimum priority fee to enforce for acceptance into the pool. + /// + /// For legacy and EIP-2930 transactions the gas price is used as the priority fee. minimum_priority_fee: Option, /// Determines how many additional tasks to spawn /// @@ -1093,6 +1100,8 @@ pub struct EthTransactionValidatorBuilder { max_initcode_size: usize, /// Cached transaction gas limit cap from EVM config (0 = no cap) tx_gas_limit_cap: u64, + /// Cached EIP-8037 intrinsic regular gas cap from EVM config (0 = no cap). + tx_regular_gas_cap: u64, /// Whether EIP-7594 blob sidecars are accepted. /// When false, EIP-7594 (v1) sidecars are always rejected and EIP-4844 (v0) sidecars /// are always accepted, regardless of Osaka fork activation. @@ -1122,9 +1131,10 @@ impl EthTransactionValidatorBuilder { .expect("latest header is not found"); let evm_env = evm_config.evm_env(&tip).expect("evm_env should not fail for existing blocks"); + let (tx_gas_limit_cap, tx_regular_gas_cap) = tx_gas_caps(&evm_env.cfg_env); Self { - block_gas_limit: ETHEREUM_BLOCK_GAS_LIMIT_30M.into(), + block_gas_limit: tip.gas_limit().into(), client, chain_id: chain_spec.chain().id(), evm_config, @@ -1160,12 +1170,8 @@ impl EthTransactionValidatorBuilder { // no custom transaction types by default other_tx_types: U256::ZERO, - // EIP-8037: When state gas is enabled, tx.gas can exceed the per-tx cap - tx_gas_limit_cap: if evm_env.cfg_env.is_amsterdam_eip8037_enabled() { - 0 - } else { - evm_env.cfg_env.tx_gas_limit_cap() - }, + tx_gas_limit_cap, + tx_regular_gas_cap, max_initcode_size: evm_env.cfg_env.max_initcode_size(), // EIP-7594 sidecars are accepted by default (standard Ethereum behavior) @@ -1307,6 +1313,9 @@ impl EthTransactionValidatorBuilder { } /// Sets a minimum priority fee that's enforced for acceptance into the pool. + /// + /// This applies to all transaction types. For legacy and EIP-2930 transactions the gas price + /// is used as the priority fee. pub const fn with_minimum_priority_fee(mut self, minimum_priority_fee: Option) -> Self { self.minimum_priority_fee = minimum_priority_fee; self @@ -1390,6 +1399,7 @@ impl EthTransactionValidatorBuilder { other_tx_types, max_initcode_size, tx_gas_limit_cap, + tx_regular_gas_cap, eip7594, } = self; @@ -1403,6 +1413,7 @@ impl EthTransactionValidatorBuilder { max_blob_count: AtomicU64::new(max_blob_count), max_initcode_size: AtomicUsize::new(max_initcode_size), tx_gas_limit_cap: AtomicU64::new(tx_gas_limit_cap), + tx_regular_gas_cap: AtomicU64::new(tx_regular_gas_cap), }; EthTransactionValidator { @@ -1473,6 +1484,8 @@ pub struct ForkTracker { pub max_initcode_size: AtomicUsize, /// Cached transaction gas limit cap from EVM config (0 = no cap) pub tx_gas_limit_cap: AtomicU64, + /// Cached EIP-8037 intrinsic regular gas cap from EVM config (0 = no cap). + pub tx_regular_gas_cap: AtomicU64, } impl ForkTracker { @@ -1512,7 +1525,8 @@ impl ForkTracker { } } -/// Ensures that gas limit of the transaction exceeds the intrinsic gas of the transaction. +/// Ensures that gas limit of the transaction exceeds the intrinsic gas of the transaction, and +/// that its intrinsic regular gas fits the EIP-8037 regular gas cap. /// /// Caution: This only checks past the Merge hardfork. pub fn ensure_intrinsic_gas( @@ -1551,13 +1565,37 @@ pub fn ensure_intrinsic_gas( ); let gas_limit = transaction.gas_limit(); + // EIP-8037: `tx.gas` may exceed the cap, but intrinsic regular gas and the calldata floor may + // not, otherwise the transaction can never be included. + let regular_gas_cap = + fork_tracker.tx_regular_gas_cap.load(std::sync::atomic::Ordering::Relaxed); if gas_limit < gas.initial_total_gas() || gas_limit < gas.floor_gas() { Err(InvalidPoolTransactionError::IntrinsicGasTooLow) + } else if regular_gas_cap > 0 && + gas.initial_regular_gas().max(gas.floor_gas()) > regular_gas_cap + { + // `GasTooHigh` is not a bad transaction, so peers that still relay these, such as nodes + // without this check, are not penalized. + Err(InvalidTransactionError::GasTooHigh.into()) } else { Ok(()) } } +/// Returns the pool's `(tx_gas_limit_cap, tx_regular_gas_cap)` for the given EVM config, where 0 +/// disables the respective check. +/// +/// EIP-8037 lets `tx.gas` exceed the EIP-7825 cap because state gas is drawn from a reservoir +/// above it, but the cap still bounds intrinsic regular gas and the calldata floor. +fn tx_gas_caps(cfg: &impl Cfg) -> (u64, u64) { + let cap = cfg.tx_gas_limit_cap(); + if cfg.is_amsterdam_eip8037_enabled() { + (0, cap) + } else { + (cap, 0) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1567,10 +1605,13 @@ mod tests { }; use alloy_consensus::Transaction; use alloy_eips::{ + eip1559::ETHEREUM_BLOCK_GAS_LIMIT_30M, eip2718::{Decodable2718, Encodable2718}, eip2930::{AccessList, AccessListItem}, + eip7702::Authorization, }; - use alloy_primitives::{hex, Address, Bytes, B256, U256}; + use alloy_primitives::{hex, Address, Bytes, Signature, B256, U256}; + use reth_chainspec::{ChainSpecBuilder, MAINNET}; use reth_ethereum_primitives::PooledTransactionVariant; use reth_evm_ethereum::EthEvmConfig; use reth_primitives_traits::SignedTransaction; @@ -1581,6 +1622,20 @@ mod tests { EthEvmConfig::mainnet() } + /// Mainnet with a 30M genesis gas limit instead of 5,000, since the validator takes its block + /// gas limit from the latest header. + fn mock_provider() -> MockEthProvider { + mock_provider_with_gas_limit(ETHEREUM_BLOCK_GAS_LIMIT_30M) + } + + fn mock_provider_with_gas_limit(gas_limit: u64) -> MockEthProvider { + let mut genesis = MAINNET.genesis.clone(); + genesis.gas_limit = gas_limit; + MockEthProvider::default() + .with_chain_spec(ChainSpecBuilder::mainnet().genesis(genesis).build()) + .with_genesis_block() + } + fn get_transaction() -> EthPooledTransaction { let raw = "0x02f914950181ad84b2d05e0085117553845b830f7df88080b9143a6040608081523462000414576200133a803803806200001e8162000419565b9283398101608082820312620004145781516001600160401b03908181116200041457826200004f9185016200043f565b92602092838201519083821162000414576200006d9183016200043f565b8186015190946001600160a01b03821692909183900362000414576060015190805193808511620003145760038054956001938488811c9816801562000409575b89891014620003f3578190601f988981116200039d575b50899089831160011462000336576000926200032a575b505060001982841b1c191690841b1781555b8751918211620003145760049788548481811c9116801562000309575b89821014620002f457878111620002a9575b5087908784116001146200023e5793839491849260009562000232575b50501b92600019911b1c19161785555b6005556007805460ff60a01b19169055600880546001600160a01b0319169190911790553015620001f3575060025469d3c21bcecceda100000092838201809211620001de57506000917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9160025530835282815284832084815401905584519384523093a351610e889081620004b28239f35b601190634e487b7160e01b6000525260246000fd5b90606493519262461bcd60e51b845283015260248201527f45524332303a206d696e7420746f20746865207a65726f2061646472657373006044820152fd5b0151935038806200013a565b9190601f198416928a600052848a6000209460005b8c8983831062000291575050501062000276575b50505050811b0185556200014a565b01519060f884600019921b161c191690553880808062000267565b86860151895590970196948501948893500162000253565b89600052886000208880860160051c8201928b8710620002ea575b0160051c019085905b828110620002dd5750506200011d565b60008155018590620002cd565b92508192620002c4565b60228a634e487b7160e01b6000525260246000fd5b90607f16906200010b565b634e487b7160e01b600052604160045260246000fd5b015190503880620000dc565b90869350601f19831691856000528b6000209260005b8d8282106200038657505084116200036d575b505050811b018155620000ee565b015160001983861b60f8161c191690553880806200035f565b8385015186558a979095019493840193016200034c565b90915083600052896000208980850160051c8201928c8610620003e9575b918891869594930160051c01915b828110620003d9575050620000c5565b60008155859450889101620003c9565b92508192620003bb565b634e487b7160e01b600052602260045260246000fd5b97607f1697620000ae565b600080fd5b6040519190601f01601f191682016001600160401b038111838210176200031457604052565b919080601f84011215620004145782516001600160401b038111620003145760209062000475601f8201601f1916830162000419565b92818452828287010111620004145760005b8181106200049d57508260009394955001015290565b85810183015184820184015282016200048756fe608060408181526004918236101561001657600080fd5b600092833560e01c91826306fdde0314610a1c57508163095ea7b3146109f257816318160ddd146109d35781631b4c84d2146109ac57816323b872dd14610833578163313ce5671461081757816339509351146107c357816370a082311461078c578163715018a6146107685781638124f7ac146107495781638da5cb5b1461072057816395d89b411461061d578163a457c2d714610575578163a9059cbb146104e4578163c9567bf914610120575063dd62ed3e146100d557600080fd5b3461011c578060031936011261011c57806020926100f1610b5a565b6100f9610b75565b6001600160a01b0391821683526001865283832091168252845220549051908152f35b5080fd5b905082600319360112610338576008546001600160a01b039190821633036104975760079283549160ff8360a01c1661045557737a250d5630b4cf539739df2c5dacb4c659f2488d92836bffffffffffffffffffffffff60a01b8092161786553087526020938785528388205430156104065730895260018652848920828a52865280858a205584519081527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925863092a38554835163c45a015560e01b815290861685828581845afa9182156103dd57849187918b946103e7575b5086516315ab88c960e31b815292839182905afa9081156103dd576044879289928c916103c0575b508b83895196879586946364e329cb60e11b8652308c870152166024850152165af19081156103b6579086918991610389575b50169060065416176006558385541660604730895288865260c4858a20548860085416928751958694859363f305d71960e01b8552308a86015260248501528d60448501528d606485015260848401524260a48401525af1801561037f579084929161034c575b50604485600654169587541691888551978894859363095ea7b360e01b855284015260001960248401525af1908115610343575061030c575b5050805460ff60a01b1916600160a01b17905580f35b81813d831161033c575b6103208183610b8b565b8101031261033857518015150361011c5738806102f6565b8280fd5b503d610316565b513d86823e3d90fd5b6060809293503d8111610378575b6103648183610b8b565b81010312610374578290386102bd565b8580fd5b503d61035a565b83513d89823e3d90fd5b6103a99150863d88116103af575b6103a18183610b8b565b810190610e33565b38610256565b503d610397565b84513d8a823e3d90fd5b6103d79150843d86116103af576103a18183610b8b565b38610223565b85513d8b823e3d90fd5b6103ff919450823d84116103af576103a18183610b8b565b92386101fb565b845162461bcd60e51b81528085018790526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b6064820152608490fd5b6020606492519162461bcd60e51b8352820152601760248201527f74726164696e6720697320616c7265616479206f70656e0000000000000000006044820152fd5b608490602084519162461bcd60e51b8352820152602160248201527f4f6e6c79206f776e65722063616e2063616c6c20746869732066756e6374696f6044820152603760f91b6064820152fd5b9050346103385781600319360112610338576104fe610b5a565b9060243593303303610520575b602084610519878633610bc3565b5160018152f35b600594919454808302908382041483151715610562576127109004820391821161054f5750925080602061050b565b634e487b7160e01b815260118552602490fd5b634e487b7160e01b825260118652602482fd5b9050823461061a578260031936011261061a57610590610b5a565b918360243592338152600160205281812060018060a01b03861682526020522054908282106105c9576020856105198585038733610d31565b608490602086519162461bcd60e51b8352820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f77604482015264207a65726f60d81b6064820152fd5b80fd5b83833461011c578160031936011261011c57805191809380549160019083821c92828516948515610716575b6020958686108114610703578589529081156106df5750600114610687575b6106838787610679828c0383610b8b565b5191829182610b11565b0390f35b81529295507f8a35acfbc15ff81a39ae7d344fd709f28e8600b4aa8c65c6b64bfe7fe36bd19b5b8284106106cc57505050826106839461067992820101948680610668565b80548685018801529286019281016106ae565b60ff19168887015250505050151560051b8301019250610679826106838680610668565b634e487b7160e01b845260228352602484fd5b93607f1693610649565b50503461011c578160031936011261011c5760085490516001600160a01b039091168152602090f35b50503461011c578160031936011261011c576020906005549051908152f35b833461061a578060031936011261061a57600880546001600160a01b031916905580f35b50503461011c57602036600319011261011c5760209181906001600160a01b036107b4610b5a565b16815280845220549051908152f35b82843461061a578160031936011261061a576107dd610b5a565b338252600160209081528383206001600160a01b038316845290528282205460243581019290831061054f57602084610519858533610d31565b50503461011c578160031936011261011c576020905160128152f35b83833461011c57606036600319011261011c5761084e610b5a565b610856610b75565b6044359160018060a01b0381169485815260209560018752858220338352875285822054976000198903610893575b505050906105199291610bc3565b85891061096957811561091a5733156108cc5750948481979861051997845260018a528284203385528a52039120558594938780610885565b865162461bcd60e51b8152908101889052602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b6064820152608490fd5b865162461bcd60e51b81529081018890526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b6064820152608490fd5b865162461bcd60e51b8152908101889052601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e63650000006044820152606490fd5b50503461011c578160031936011261011c5760209060ff60075460a01c1690519015158152f35b50503461011c578160031936011261011c576020906002549051908152f35b50503461011c578060031936011261011c57602090610519610a12610b5a565b6024359033610d31565b92915034610b0d5783600319360112610b0d57600354600181811c9186908281168015610b03575b6020958686108214610af05750848852908115610ace5750600114610a75575b6106838686610679828b0383610b8b565b929550600383527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b5b828410610abb575050508261068394610679928201019438610a64565b8054868501880152928601928101610a9e565b60ff191687860152505050151560051b83010192506106798261068338610a64565b634e487b7160e01b845260229052602483fd5b93607f1693610a44565b8380fd5b6020808252825181830181905290939260005b828110610b4657505060409293506000838284010152601f8019910116010190565b818101860151848201604001528501610b24565b600435906001600160a01b0382168203610b7057565b600080fd5b602435906001600160a01b0382168203610b7057565b90601f8019910116810190811067ffffffffffffffff821117610bad57604052565b634e487b7160e01b600052604160045260246000fd5b6001600160a01b03908116918215610cde5716918215610c8d57600082815280602052604081205491808310610c3957604082827fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef958760209652828652038282205586815220818154019055604051908152a3565b60405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e7420657863656564732062604482015265616c616e636560d01b6064820152608490fd5b60405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201526265737360e81b6064820152608490fd5b60405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f206164604482015264647265737360d81b6064820152608490fd5b6001600160a01b03908116918215610de25716918215610d925760207f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925918360005260018252604060002085600052825280604060002055604051908152a3565b60405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f206164647265604482015261737360f01b6064820152608490fd5b60405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f206164646044820152637265737360e01b6064820152608490fd5b90816020910312610b7057516001600160a01b0381168103610b70579056fea2646970667358221220285c200b3978b10818ff576bb83f2dc4a2a7c98dfb6a36ea01170de792aa652764736f6c63430008140033000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000d3fd4f95820a9aa848ce716d6c200eaefb9a2e4900000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000003543131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000035431310000000000000000000000000000000000000000000000000000000000c001a04e551c75810ffdfe6caff57da9f5a8732449f42f0f4c57f935b05250a76db3b6a046cd47e6d01914270c1ec0d9ac7fae7dfb240ec9a8b6ec7898c4d6aa174388f2"; @@ -1606,6 +1661,10 @@ mod tests { value: U256::from(value), ..Default::default() }; + pooled_eip1559_tx(tx, sender) + } + + fn pooled_eip1559_tx(tx: alloy_consensus::TxEip1559, sender: Address) -> EthPooledTransaction { let signed = reth_ethereum_primitives::TransactionSigned::new_unhashed( tx.into(), alloy_primitives::Signature::test_signature(), @@ -1616,15 +1675,8 @@ mod tests { ) } - /// EIP-2780 replaces the flat 21k intrinsic base with a decomposed one: 12k base, plus a cold - /// account access for `tx.to` and a transfer charge when `tx.value` is non-zero, with a - /// carve-out for self-transfers. - #[test] - fn intrinsic_gas_eip2780() { - let sender = Address::repeat_byte(1); - let recipient = Address::repeat_byte(2); - - let amsterdam = || ForkTracker { + fn amsterdam_fork_tracker() -> ForkTracker { + ForkTracker { shanghai: true.into(), cancun: true.into(), prague: true.into(), @@ -1634,7 +1686,19 @@ mod tests { max_blob_count: 0.into(), max_initcode_size: AtomicUsize::new(MAX_INITCODE_SIZE), tx_gas_limit_cap: AtomicU64::new(0), - }; + tx_regular_gas_cap: AtomicU64::new(0), + } + } + + /// EIP-2780 replaces the flat 21k intrinsic base with a decomposed one: 12k base, plus a cold + /// account access for `tx.to` and a transfer charge when `tx.value` is non-zero, with a + /// carve-out for self-transfers. + #[test] + fn intrinsic_gas_eip2780() { + let sender = Address::repeat_byte(1); + let recipient = Address::repeat_byte(2); + + let amsterdam = amsterdam_fork_tracker; let pre_amsterdam = || ForkTracker { amsterdam: false.into(), ..amsterdam() }; // Self-transfer: base cost only (12k), where pre-Amsterdam it pays the flat 21k. @@ -1658,6 +1722,68 @@ mod tests { ); } + /// EIP-8037: `tx.gas` may exceed the cap, but intrinsic regular gas and the calldata floor may + /// not. + #[test] + fn intrinsic_regular_gas_cap_eip8037() { + let cap = revm::primitives::eip7825::TX_GAS_LIMIT_CAP; + let fork_tracker = + ForkTracker { tx_regular_gas_cap: AtomicU64::new(cap), ..amsterdam_fork_tracker() }; + let is_too_high = |res| { + matches!( + res, + Err(InvalidPoolTransactionError::Consensus(InvalidTransactionError::GasTooHigh)) + ) + }; + let tx = |tx| { + pooled_eip1559_tx( + alloy_consensus::TxEip1559 { + chain_id: 1, + gas_limit: 2 * cap, + max_fee_per_gas: 1, + to: Address::repeat_byte(2).into(), + ..tx + }, + Address::repeat_byte(1), + ) + }; + + // Zero-value call with `addresses` access list entries: 15,000 + 4,180 intrinsic regular + // gas per address. + let access_list_tx = |addresses: u64| { + tx(alloy_consensus::TxEip1559 { + access_list: AccessList( + (0..addresses) + .map(|i| AccessListItem { + address: Address::left_padding_from(&i.to_be_bytes()), + storage_keys: vec![], + }) + .collect(), + ), + ..Default::default() + }) + }; + // 16,776,800 intrinsic regular gas fits the cap. + assert!(ensure_intrinsic_gas(&access_list_tx(4_010), &fork_tracker).is_ok()); + // 16,780,980 exceeds it. + assert!(is_too_high(ensure_intrinsic_gas(&access_list_tx(4_011), &fork_tracker))); + + // Zero-value call with `len` non-zero calldata bytes: the calldata floor (15,000 + 64 per + // byte) exceeds the cap while intrinsic regular gas (15,000 + 16 per byte) stays below it. + let calldata_tx = |len: usize| { + tx(alloy_consensus::TxEip1559 { input: vec![1; len].into(), ..Default::default() }) + }; + // 16,777,176 floor gas fits the cap. + assert!(ensure_intrinsic_gas(&calldata_tx(261_909), &fork_tracker).is_ok()); + // 16,777,240 exceeds it. + assert!(is_too_high(ensure_intrinsic_gas(&calldata_tx(261_910), &fork_tracker))); + + // Without the regular gas cap (pre EIP-8037), only the gas limit is checked. + let no_cap = amsterdam_fork_tracker(); + assert!(ensure_intrinsic_gas(&access_list_tx(4_011), &no_cap).is_ok()); + assert!(ensure_intrinsic_gas(&calldata_tx(261_910), &no_cap).is_ok()); + } + // #[tokio::test] async fn validate_transaction() { @@ -1672,6 +1798,7 @@ mod tests { max_blob_count: 0.into(), max_initcode_size: AtomicUsize::new(MAX_INITCODE_SIZE), tx_gas_limit_cap: AtomicU64::new(0), + tx_regular_gas_cap: AtomicU64::new(0), }; let res = ensure_intrinsic_gas(&transaction, &fork_tracker); @@ -1681,7 +1808,7 @@ mod tests { let res = ensure_intrinsic_gas(&transaction, &fork_tracker); assert!(res.is_ok()); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1706,7 +1833,7 @@ mod tests { #[test] fn accepts_sender_with_empty_bytecode() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX).with_bytecode(Bytes::new()), @@ -1721,7 +1848,7 @@ mod tests { #[test] fn validates_nonce_bound() { - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); let validator = EthTransactionValidatorBuilder::new(provider, test_evm_config()) .build(InMemoryBlobStore::default()); let transaction = |nonce| { @@ -1749,7 +1876,7 @@ mod tests { #[test] fn validates_configured_chain_id() { - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); let validator = EthTransactionValidatorBuilder::new(provider, test_evm_config()) .build(InMemoryBlobStore::default()); let transaction = |chain_id| { @@ -1777,6 +1904,36 @@ mod tests { )); } + #[test] + fn only_recovers_authorities_for_matching_chain_id() { + let validator = EthTransactionValidatorBuilder::new(mock_provider(), test_evm_config()) + .build(InMemoryBlobStore::default()); + let auth = |chain_id, address| { + Authorization { chain_id, address, nonce: 0 }.into_signed(Signature::test_signature()) + }; + let universal = auth(U256::ZERO, Address::repeat_byte(1)); + let local = auth(U256::from(validator.chain_id()), Address::repeat_byte(2)); + let foreign = auth(U256::from(validator.chain_id() + 1), Address::repeat_byte(3)); + let oversized = auth(U256::from(u64::MAX) + U256::from(1), Address::repeat_byte(4)); + let expected = + vec![universal.recover_authority().unwrap(), local.recover_authority().unwrap()]; + let tx = alloy_consensus::TxEip7702 { + chain_id: validator.chain_id(), + authorization_list: vec![universal, foreign, local, oversized], + ..Default::default() + }; + let signed = reth_ethereum_primitives::TransactionSigned::new_unhashed( + tx.into(), + Signature::test_signature(), + ); + let transaction = EthPooledTransaction::new( + alloy_consensus::transaction::Recovered::new_unchecked(signed, Address::ZERO), + 200, + ); + + assert_eq!(validator.recover_authorities(&transaction), Some(expected)); + } + // #[tokio::test] async fn invalid_on_gas_limit_too_high() { @@ -1815,7 +1972,7 @@ mod tests { #[tokio::test] async fn invalid_on_fee_cap_exceeded() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1852,7 +2009,7 @@ mod tests { #[tokio::test] async fn valid_on_zero_fee_cap() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1870,7 +2027,7 @@ mod tests { #[tokio::test] async fn valid_on_normal_fee_cap() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1888,7 +2045,7 @@ mod tests { #[tokio::test] async fn invalid_on_max_tx_gas_limit_exceeded() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1920,7 +2077,7 @@ mod tests { #[tokio::test] async fn valid_on_max_tx_gas_limit_disabled() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1938,7 +2095,7 @@ mod tests { #[tokio::test] async fn valid_on_max_tx_gas_limit_within_limit() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -1956,7 +2113,7 @@ mod tests { // Helper function to set up common test infrastructure for priority fee tests fn setup_priority_fee_test() -> (EthPooledTransaction, MockEthProvider) { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); provider.add_account( transaction.sender(), ExtendedAccount::new(transaction.nonce(), U256::MAX), @@ -2117,6 +2274,103 @@ mod tests { assert!(outcome.is_invalid()); // Still invalid because sender not in whitelist } + /// Returns a legacy and an EIP-2930 transaction with the given gas price, and a provider + /// holding a funded sender account for both. + fn setup_gas_price_txs(gas_price: u128) -> ([EthPooledTransaction; 2], MockEthProvider) { + let sender = Address::repeat_byte(1); + let to = Address::repeat_byte(2); + + let legacy = alloy_consensus::TxLegacy { + chain_id: Some(1), + gas_price, + gas_limit: 21_000, + to: to.into(), + ..Default::default() + }; + let eip2930 = alloy_consensus::TxEip2930 { + chain_id: 1, + gas_price, + gas_limit: 21_000, + to: to.into(), + ..Default::default() + }; + + let txs = [legacy.into(), eip2930.into()].map(|tx: alloy_consensus::TypedTransaction| { + let signed = reth_ethereum_primitives::TransactionSigned::new_unhashed( + tx.into(), + alloy_primitives::Signature::test_signature(), + ); + EthPooledTransaction::new( + alloy_consensus::transaction::Recovered::new_unchecked(signed, sender), + 200, + ) + }); + + let provider = mock_provider(); + provider.add_account(sender, ExtendedAccount::new(0, U256::MAX)); + + (txs, provider) + } + + #[tokio::test] + async fn invalid_on_gas_price_lower_than_configured_minimum() { + let gas_price = 1_000_000_000; + let minimum_priority_fee = gas_price + 1; + let (txs, provider) = setup_gas_price_txs(gas_price); + let validator = + create_validator_with_minimum_fee(provider, Some(minimum_priority_fee), None); + + // Legacy and EIP-2930 transactions use their gas price as the priority fee. + for transaction in txs { + assert!(!transaction.is_dynamic_fee()); + + for origin in + [TransactionOrigin::External, TransactionOrigin::Local, TransactionOrigin::Private] + { + let outcome = validator.validate_one(origin, transaction.clone()); + assert!(matches!( + outcome, + TransactionValidationOutcome::Invalid( + _, + InvalidPoolTransactionError::PriorityFeeBelowMinimum { + minimum_priority_fee: min_fee + } + ) if min_fee == minimum_priority_fee + )); + } + } + } + + #[tokio::test] + async fn valid_on_gas_price_at_or_above_minimum() { + let gas_price = 1_000_000_000; + let (txs, provider) = setup_gas_price_txs(gas_price); + + for minimum_priority_fee in [gas_price, gas_price / 2] { + let validator = create_validator_with_minimum_fee( + provider.clone(), + Some(minimum_priority_fee), + None, + ); + + for transaction in txs.clone() { + let outcome = validator.validate_one(TransactionOrigin::External, transaction); + assert!(outcome.is_valid()); + } + } + } + + #[tokio::test] + async fn valid_on_gas_price_with_minimum_priority_fee_disabled() { + let (txs, provider) = setup_gas_price_txs(1); + let validator = create_validator_with_minimum_fee(provider, None, None); + + for transaction in txs { + let outcome = validator.validate_one(TransactionOrigin::External, transaction); + assert!(outcome.is_valid()); + } + } + #[test] fn reject_oversized_tx() { let mut transaction = get_transaction(); @@ -2171,7 +2425,7 @@ mod tests { #[tokio::test] async fn valid_with_disabled_balance_check() { let transaction = get_transaction(); - let provider = MockEthProvider::default().with_genesis_block(); + let provider = mock_provider(); // Set account with 0 balance provider.add_account( @@ -2190,7 +2444,7 @@ mod tests { assert!(matches!( err, InvalidPoolTransactionError::Consensus(InvalidTransactionError::InsufficientFunds(ref funds_err)) - if funds_err.got == alloy_primitives::U256::ZERO && funds_err.expected == expected_cost + if funds_err.got.is_zero() && funds_err.expected == expected_cost )); } else { panic!("Expected Invalid outcome with InsufficientFunds error"); @@ -2204,4 +2458,35 @@ mod tests { let outcome = validator.validate_one(TransactionOrigin::External, transaction); assert!(outcome.is_valid()); // Should be valid because balance check is disabled } + + #[test] + fn block_gas_limit_from_latest_header() { + let gas_limit = 60_000_000; + let validator = EthTransactionValidatorBuilder::new( + mock_provider_with_gas_limit(gas_limit), + test_evm_config(), + ) + .build(InMemoryBlobStore::default()); + let transaction = |gas_limit| { + EthPooledTransaction::try_from_consensus( + TransactionBuilder::default() + .chain_id(validator.chain_id()) + .gas_limit(gas_limit) + .to(Address::ZERO) + .into_eip1559() + .try_into_recovered() + .unwrap(), + ) + .unwrap() + }; + + assert_eq!(validator.block_gas_limit(), gas_limit); + assert!(validator + .validate_stateless(TransactionOrigin::External, &transaction(gas_limit)) + .is_ok()); + assert!(matches!( + validator.validate_stateless(TransactionOrigin::External, &transaction(gas_limit + 1)), + Err(InvalidPoolTransactionError::ExceedsGasLimit(60_000_001, 60_000_000)) + )); + } } diff --git a/crates/transaction-pool/src/validate/task.rs b/crates/transaction-pool/src/validate/task.rs index d71150b8549..c6b571ba4b1 100644 --- a/crates/transaction-pool/src/validate/task.rs +++ b/crates/transaction-pool/src/validate/task.rs @@ -460,7 +460,7 @@ mod tests { tokio::spawn(task.run()); let tx = MockTransaction::legacy(); let out = executor.validate_transaction(TransactionOrigin::External, tx).await; - assert!(matches!(out, TransactionValidationOutcome::Valid { .. })); + assert!(out.is_valid()); } #[tokio::test] @@ -474,14 +474,30 @@ mod tests { ]; let out = executor.validate_transactions(txs).await; assert_eq!(out.len(), 2); - assert!(out.iter().all(|o| matches!(o, TransactionValidationOutcome::Valid { .. }))); + assert!(out.iter().all(|o| o.is_valid())); } #[tokio::test] async fn cloned_executors_share_bounded_queue() { let (executor, task) = TransactionValidationTaskExecutor::new(NoopValidator); let mut submissions = tokio::task::JoinSet::new(); - for index in 0..64 { + + let first_executor = executor.clone(); + let mut first_submission = Box::pin(async move { + first_executor + .validate_transaction(TransactionOrigin::Local, MockTransaction::legacy()) + .await + }); + // Poll once to fill the bounded queue before starting workers. + assert!(futures_util::poll!(first_submission.as_mut()).is_pending()); + assert_eq!(executor.to_validation_task.tx.capacity(), 0); + + submissions.spawn(async move { + let outcome = first_submission.await; + assert!(outcome.is_valid()); + }); + + for index in 1..64 { let executor = executor.clone(); submissions.spawn(async move { let transaction = MockTransaction::legacy(); @@ -504,16 +520,10 @@ mod tests { } }; assert_eq!(outcomes.len(), 1); - assert!(matches!(outcomes[0], TransactionValidationOutcome::Valid { .. })); + assert!(outcomes[0].is_valid()); }); } - // No worker has started: one job can be queued and the other producers - // must remain pending on the same bounded channel. - tokio::task::yield_now().await; - assert_eq!(executor.to_validation_task.tx.capacity(), 0); - assert!(submissions.try_join_next().is_none()); - let first_worker = tokio::spawn(task.clone().run()); let second_worker = tokio::spawn(task.run()); tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -541,7 +551,7 @@ mod tests { ) .await .expect("configured validation workers must process jobs"); - assert!(matches!(outcome, TransactionValidationOutcome::Valid { .. })); + assert!(outcome.is_valid()); } } @@ -572,7 +582,7 @@ mod tests { state_nonce: 0, bytecode_hash: None, transaction: ValidTransaction::Valid(transaction), - propagate: matches!(origin, TransactionOrigin::Local), + propagate: origin.is_local(), authorities: None, }) .collect() diff --git a/crates/transaction-pool/test_data/blob1.json b/crates/transaction-pool/test_data/blob1.json deleted file mode 100644 index 399c33a9490..00000000000 --- a/crates/transaction-pool/test_data/blob1.json +++ /dev/null @@ -1 +0,0 @@ -{"commitment":"aa816d8ab5a585cf13033b253653444643401b30528bcfc805af1cda4257c848763ff1b2be5d4208620fb34b42a606ba","data":"001611aa000000028166ff00ff0000feedaf734c0cd3ee79a45f8504d7faba00004ada77284253631a06551a2057ee9cbdc02788132d5644b2eb9a6c3dcb908c00a2bac8380f118ebf0c95e1e542eb72fbf27cc4846c89525cbdcecd1a87b71500b753a78bc1cc14b39c692b1a61af5fd3a822c38327b04e282a372712d1890500f2d400423c9def0be81484fab3269919bb304ba71afefcdc153a18fdfe1d0400171a2fa3725dda295b967d2d8dc73248fb700b5817e4660b83c5874181e010007f6f46b01f4761f1bfd25f0bb205820c169eaa54bd3d406c1b69135bc2e24d00d3d73b8520bb71767df024661a24a4b16afe86d83dcd631d026eb1e02d0d1c002f1a5091dba32da107533e33fd2d90a46ed0e73d13c6af051e3c1399f62ff700427e47a3eb0c871a878eabe10eece7c25fb6b0f9754df1f64316b6077e9083002707eba5889294259eab2cef062fb664a0a5763574824b0e49446354bef76300949df1ee354ce9fbe1896543e5d14424deb7b6f29b08b50523a9736d6b42a400da05bf0574e9f77a25dd2ab827b05f8aad21dedd0bef3423cc164f5d1e6ed10023a767fb6361eea181fc38ce739404111842d3dacdef58630a1c2dc8740fca00ba51c97040091d8ceaeb17599e8ced20247cb9c05a5699054a5c8fecf0cf7300899ba202dafcb2c746dd4f2f3ce236cd4357f9b333f16eb902fc4d5ad739af00d5113b90e1ca8632b761836026c014b93fc0952c92c47a5048437406eb63fb000cbd51b109e75fde5197982a607368c28e44ba60bfa53a6c53cc0e3a1f6db1005860149314d9301501897c1a99e680f291d8c1993b081a5cfc771f2af6217700fd670c04ff6f7e15b387c64d302b10a1399c06ce4b3d4493f78bc4f1c90a3b00a1e7e48eb1567d07b802716bae3056c27b86a2e8cafa4303eb3bd8e328b61f0048d4e98d9592414bf3ff0e1282a471857ac82789b40f8113e39f0a549c2487001b1074e049ad752f410ae130ff2938841f4ff1e64580cb316232f1ca4e74d200d28bbca77c6cd9a532782c7c9b1c2943dfe56f3b7993f233d8339168e8ec630063b08c5be4eb547977159d0cd0447885547fd3df999ea78b70aa88c57d263a0009149e75866d542756cfa1fdba3b0f9b5ff4d0ad9aafb0382b3513af97b2200080ae28e471d65876e304ac620a8bb4e51b4e492d392fd4d05f318c8e21bebb00d8a08326ebda99ad97c502d0b693196ac26f84d6cbe4935ec9ca130991e546006ecbe401521305bfa93d578b3c84346158623d175de46a6ef1b48180cf1f1d00c9a4eca7f285856f9b950458b4374d7f0eaec7fa7f2aa0d465b1e7de1fc6a400aa6267692b7ee87146364c47e813562199c3be00654230f148c9b4ed7751c300c8d9244c2144ef852672c094642c278db5fc760334202e3e9c9035ca1f57f00008cce55f2c2a0a229662faa37d891972cf75e3eee7107b18ca6f9bbe7239650002eb57de408d0472dbafbb2dba2d9d5f562381cb31461660d65bd1c06443c600fab28624189ee84c4bfef30d3637558a934476ad43b77bea13766842f81f0b00bb05c83f5c41b8c53733c121920582c87f6a95ca5e984ec19e55d315325f4b000072d0bc32966bdae9dd62655e15b7c4e5fb527e8b1077f590c584ede0ad54008451b0cb679e6ba1c493db227cf473049a4589c2b594b4678968928a73183c00e403957e494455c309ffad6ae2955c8ff3777aa15eca10b08c75deb78d7e85003a23ee1ca4237d5ac6af44219004b0255f99ad76cc34b6a8bd7dc54864107600b26186dba9e8edce27e2ccab7189847f9599f27570ed0814cf28280ce7db8500e310a5588b5fc12bd74e906436a8d06b5d24d10b59b6f9aa2517e952745a16007907aef4a563e691ac1f2e2ea8d57cb83e8be9176f2338d6abd2273cc1c40b0010b86515ed3be8f72a7fa96494acd242318b6a787b1e368c7eb9d9230cc06b003e9086eb74d5a0d922d2071a7ed993db20db39fe7241c208e8640c907b755c003477fdbd06e73ae27526ee4982d7cfe8a0d5c0d9458925906027a60c43e491001f49781294f12836a8cd6b12d5a1d7fe3c31205ff6d2df2c2ba85f4f93977600242c700fc686938c6f15e1eb5060e668dac23996d01997359df874e6d41c6800dc707dc44a30531cb264750cd9d66a02cc8c3999cf2d3e2b59b850c1ac16d0003ae8c6016c5997a1b1b4da80a14042268d4a890846a46ab78c1e1e832135db0052807d802ce54f1814152b3b2ea62d64b96693cd8a9b8cb3f0f8f14244e92a00a37efa4f125b9174aef343e5042a03c745d562bb77eb712dddebb86fdd947c00bd6557893af34d8a3c468414652f244c0af531e21731f8e2f67a5301c3709700654c770cdf35bfb0b383471bcc6ca3b6f1ca8590b67916bc8c67c7367219c5002b63b2530c71cfa56edd2ec9954a577521f54d4d8dd111ed5b77adae66f76c000b8bc39c3d12d419309d07a73ee28034e32ec998ada0fbce02704b06400a410009b7e44bdb5b51fc17cfa832d5bca0a0a628d1b25e030ed4f00ed9d3b8aee6009965280023742074ed23a9d242db9b97896883617f6016a69879c914aa167200aa2bf56a9a5475fa8b75a317ee0b803ff0d44e032967422567e8c930723dd40067898f5e6a60fb225aa550e2bd323d7b704b234f6b9463b2416828911922a900c2d39b3035d0cf6ea59309be77582d8745274fa107a23d5e9cc831a4d5906e00877af6c84140780d0673aaa0743aeb4f276c8c83379e4246b272c0c0629ac5001f649ada132c9cc55469a01c1c811da0621005b9787d0989127dd08cc532aa00887ab939efcfa51ce3fad266bd91ce0bdbf3a542cbb75bc98b8df82d7d045700f914dfa4625e570bf9dd77f9900ddabf7cdf8e36c640e2f1246bb38b8bf83c006b3b392231fe1d008fcf4970666db3609e8cfac4410dea331582a81f86b51000c7ac5b6661c7e2bded4b7573165fa9a6e49fcc2be6e45c280d18125b75523c00aad5ba968df4d24dfebf9502192c87ede38dfee56eec366171465b0421ddd80008636ae8d65bb11688334cc4285a5bcfa1e8b6f7e6b978e7e1083da68bf474002538ace262432dd66dbd1e494eee645e71e91cca241c7d3d43f171a9d04aa60094d43327dbaf04604d6954fb99e2ee09a70475f2f9c4ea5e2fc760bb6d263a00e2b7138b8860ae25716dbaf55a5a6b188b29d78787df6bed20ec159f9dd1580021d7068124cfcab1be2665cd79bd5d0644973239c19d6d7e55f0950edb433300cc402a2b0abab56f302b995bd7af562ecebe89c41ac4f5aad5049ca056a6e70063664cd751a615cfb3cafce838b7e09708eaa4f945fec7f8132830768a8df1003eb0219105f0bbe82b8e706b7cfafbff5d8b16fd46d7e82a1640bcc72137be000ec10937633fdfd92a9e63fb2ceb75adb68d1d931de8a57b85ce3ebdbd06ed00f033a9c7d831b9ee2e2341a5e0b3c1d6d335b2f0bbbd670df6c838f156bc1e0048c52cba1e9f8560f5f7cf97a6154e9ea8408a4578154516043c0f0a952a36008b56f33eecc8209e60867c0b212ddc2cfe63f6618be4ec479b4270f4670d32000433135aebd13fe3d2e1b5b35e0a509a607cd7c987a7a2918e591f5bc1cd3d0009b631c5caf2fa7819557d910b99db9435849a126e234008fc3d28243853d100337e2080a1639bed7515c5858fbfdf7e2ae274f13a3a32ab831a7a30e203f600bf8438d5f7c0a9f7f4b962f37bc4c5dd759085f1892e5bb945d69f494e79cf0065c97d1654423ca8831d0acfe574392e7d15977c3a2092437c907a2857ec0700ccebf7134f4b4f01dc82fa54ccb0263a65d37eb8e97442f25e9166bbe3f41d00089b499337cee315d69ffc1891be8f4fc72a947584625d8bb301dceeeaa918009cc76fbc0ec316d6af6fad97286a846fb4444701c5d702e879ab685738fc5f00db45f725541c8f8b9525bd13199ab4d99d4187737f9d31c2286111167c188700dbebb675a7d0755756e2637fc4fd0f26a9382a231edaf991d63a027614d76c0037a13e1295a23faf58bc4b2ebccc99ee0c8dbfe00ee53ec7a78f5c074f915800c96be29b2a60eb958f9fb2988dd74f1d95ec25fd3c9c3427267d7160256c0200f4eef61258ddf4a0325f7ad2a9281989fba826e45852fad54b30d00d4b94e5008adc8635b697d58cd909f9c06e753d1270dd0db283ec04183a53b93479e4a2005c6a611d7b2e92396f500488d00fb8bbd39e1854bb58780c2e412a2c3b13ab0024e7a8e3a5fc31c3e6b105c1ca959b25902ac168d567c0797c2cc4eddac961003454b50dc72fba130aa2b2b1a0ee937d0676d63b79741decdebba3e4fc91ac007a7709129ada4c2a415a3aeac976fde47bfb72e224e3347b9cda8f518e1e6f001142dc8be4cab03240e67b0bf4874a51b5fee1e9743d82b40d9efc87ec159c0008ebc0b8a21f76719cfbff9c9bc30a0ef1540a07c0bbbf8778fb89269d7e9f007c9f56068836b98b1c546363b9233d1652e7403458620df0e9919d460222d200efc3dab03b458366a986c1aaf5aed184a513289fa9f8729357cfef2942fad10012e83c7b3dc5511fa471cf4441d57c372cbde36c456f385fd0672ac926278a008a564af6008dfde89d363f8e08c44e0202b852e538f02000ea452676480f1200443189b172f7d4e23266debbc153c85ed5db85e9c27620016045195acdcc01003ef7a69b178b3b1bc94bca399f2534f07eedf1aa9ce761edf5f3f5c8434aab004c0048f0e7f38183317cc224fbd3e1dd3ddb8ebf21bc977315266c8a09ffb100cf6cfcab3e6ed409be1fd008b33399ac2115edc0f7b2f36ce48429eabd441b00251d169f695a5ba9dffc6ce2199539da0f2d4dcc8202f7e9d9b5b697ca616f00b832ac1889f9d113d399814ec92cb312421d9f4ef6f1da0c9e0c65a5a2ffd700fbf2cb2eebfe9656f39afd63f0ef224de64c43ed1aaff9d27712dfdd502bbb004c17ba3091ae902eae2003f7cca5a8ddf164e0bde75ec562d9c9ee885a5448006aa043a1ba4449b949eabdceb0962736f66eb3e20bf4871bd9117a209ba13700c247de5e7c077d9a06b5a2a5c348ecfae4395c98efc0fc15cc32019dfad9fb002747000bb55fdeba0330c6544cb688e88cd65aeddf0ea0c7247af57274fb5200e8f38b0d36916e2b5ff4312b27ff248ef4dd08730a9721b270a01853b1e1bb000e94cbf9b072a902bc23867cbf9d1787b5a044ff2eb6e433f1adc2c0542d8100f833aacfcf0292f695fedaf3c44251b623188a7f0a2d75f04eaf1732935a4100771d46bd8bd800943238d7202c2a9c4d47e1de3bf2e082b421b7163594ef4f0035fb715b73709c9145c0db349f625b7aaffd36efce04580d82835c327b104a00270e0cf1c20f4fcd921247df7c71a3c6d30539f6b2e6391689112dd4009e6900472b87fe2169dd5c67670a770c3867563ac88599e77a945058814490b7eb0800096f49ef903798604fc21dd0e7577738faa847a02476026795e3bf25178917003b1e6743ecc1d0767a90a48cac6a61d9b433e28089035b5a8f5dc3f5349783005c357ab64614b997ffd93cbdeaa704d87be85b5863f8ae174988fb77d0e77f00830854fb6d2e7a3c84dd8cfec43f99043c6ae58853b55427c54f8dd3536a7e00112ca69e4699a71e22289055b0e2d80c791377e01d247e9685738ef23de9a6009e77b28dde3cc74ac79ea0dfda0698fc4b11c7ed23148879c6a233d7f64116001c45d1625a83eedab5d50f512882b905b0ffedf92c3c726659da06bdc6d2560077617b6a55c0055b4aba6f7421f048eb6f4ab1433383d5a82f096f2d69c5da006be848a9c1cbfbc79c8f1dadd39a4992658e866e6655d50794d5c4d347c95c003279902e15efe46b7f6a717537f29b77a4ed03e4c457cf1b1c4a8fe601bdee005b338e0cad166c41a8f69ab8610f5a5923a40c63849b24c667b853267a39cb001bc8db6852a3c249731e328ceefb2c4f55363b69d9463b284512ca351845a400586a0acfc0bdf1887a340af99f3dac17ffa7bee04aaff89f35eae7bc56511c0068b77c6c5dcf3aa37be8434483fb460b92df0d1ee16b81c49f7224f0233159005b2151a2aaccd8576558199f7c44501fb554b4caf73147f1b06bab9d6c8e3a0047ae5e6a942092a37698fedbdb5dbab4ab170d0d6511e2eb1da096e945fb97005fe151804b87cc38435cb61d63265e96b6c1d5655d84527fd2295e37c7b5c100e2875ed9ea93349ddff613c0ea203cd4ec65711701a9837dee9f878fd47dfd00dc4a30733343c22db01114473488b1e266bbe91293980324937ded7db75ed1009d64d47062397c8bb963544f2c83cbac72a5d9df58f9ae195883d32b62a62c00cb895832ce778dd16fdd42896ac584ea1caac29860e0745fd4689e4535e78700c4631b52db012798ee6f63214bcfa3dfa4deb824e7ba2327a1f6e6004f0f2b0018b63924729ba433ce249b9317db4cb443bc293e3ddfbd7628eccb2ede32440011c5b9e77518bfa8dd7fda0c8fbc34dddd498e3e4b60349248b7305b2beaab00ebe65379256351282e79d7b123ff36ce693fe46ad7d5204edfc3414dcfbc8b0087ba1a3e707472f1c2e07c6a55e83897df5223d6f7efe87871dcbfce69016000cd18c2985d8b116c4b94d5be9047f0bdf8c98d55f3de7f33cc5ba16fc031ec00ce30b6e8c255fcc2017243814a0efcc1226df44ae72576293d97e1cb87c14000ae461eb655a831b16bd263dd76a18e7f98afa0935963d66e63d83e61e0b91000322358dc55cd87f48cea90d2d7eeddc6f7ef476f88686a8d070bb81d11d34700b7f27643d6925dc5944f47844564b8893b5c0e533f38b7010723119c2acc06004ee72a8bebd97c53f65969dc5c61438757c6e0e57ad91bfa459ccb13b461d80087f6a0f7c5712d3f28e1da3cef26eaf21363ed45ac86343ba152ffc053570b0076e743c366eb717683268760a42ca05d4a04013f5d8739ede040fde8265b0a0014d9fc3b4194b9c9c5a9e9e2f17480c4305e64494f069be9ca9693da1758f900a6f35be735ce868e569dce8e522a527b6b07253ff8cb1212b270c19ad2c1e300b777a21ff0bff30fe51b01eb5a739934d6e079daaa9438f5367f06974eef6f00168a973a252e55e9f89b1f513a605c2f71a00025e6ebac46cdd58ef61306470006d701de5fd9b67a65b599314f51dbe447c00bc4a522cfe1c9c04dfe62c982000e4f490a65981a3ae08f5bd22ef062d3f2c72f53c7df99622d157c1674a55d0047a7e20a9fa354446a92023fbfedbd62961ebc255d57f280fdc96aa6abd0c400a332d7d0b7e1eba8069de580474563235aec22376d85a16fd81d81a796d51100b9dfb3866a7cf21fa951210106f799849327395e329504a54f8843f3784b26005bf85f52ebf0eba971a8acc3806a49b0e87daba12343755181462844290752004fcbf5eb11373a1bac3757faf954d2273e9f17d46c2eb0f255adba5bbfaea300ddbc31f2ce8ccd3f24d6460d5210d1bfe6385da92778c61a97e3c847799c7500fd0040c97d110cc20706fd7c9365f89c04c2609cc2fdf7c63fb468d8b021240012f15b7a2eb5262b3ee2076f2bab438c5d8f067749cd4b59faa6d3811568bf00559bdc8d6930be4b75ae6982e262ebd443bf8e1776234534a58960dc9534410038434a4aa2cc889c279e115953c65bb5920a419d99a10698fc4af1ee4e213a004ecadad2cc92578e34da10ed506d0a9f4612d5a88396822a0baa3ac45ec3f9005179efe9f7aa53216588ef3b5338ab25d912dac30a6c03b4577a6a423f8f6d00304bb334c252b59e79d0878aff654410fa1b2ed216277523effd0c405a115800806572e79f958f5211bd674c8adc660f9df8dcf6be8caa849e2ad0d0b8ba7f00ea908644000830a2cc88ec387ab4b39074e3ae04bf23199d00f64fb2452b9400c78de35af686c636d77408c926b8ccaeae1206782495af67d71d8853df2c680025425aa6879f18b5d438a9575024f7b134a666f77938a51d6f80ad4b7c341000b5618c9817ceb34a6a81a14182a33f152149fc646bcc796a0c90fc76b7e94a00f655cd967252bf39c6742dc918ff80e06cdaa4ef15e063bccf79dbb604d91200d08cc6076e7f1fff15c268bb0ede83dc9a6cff0f4ca4f8caeb628b638c085f009263213226e18d5f8b9dd77d42d08b6ecbb7566207ef85b7eaf9afd3b8eefc0078b4ba49bcdd982c69a5bd0736e46999f67c95ec167caa28b1dcc8a3863eb6004f983f3c44b2c18a86f291f557856f975af341a0f6e693249125e86f5c1444008cc95032ae19b1d674dba430551824b962a7a15e02fb48666158ee2d933c0d006635d15e0ae3e4c327f3150dd1a5aff770929dc14fe34851aa6a664f8de6a4007442f2b608de3876250c3809b7ecd5dcf6367c329010edb69000b70e4795e300d2d1060d693cfe0b7b89285cce28d48de524e253cf0b2b319f9210b0889e0d001cf6cf4ee943c17ea0f7e02a945d2d1ba9772f70d1261220bd984b0c57a76f00cdb69247b362e2e2872296e6683860dd6d226cea4c56f30f328bfc1b3328ae009cea74d6f9b660ed81f79c08f3d081544896667cb6b4fad6cae73c3052606e00726d1e0bf9fe361b2c450e7600a375157e968e95742a4f0a2a7c3c496d3235006655cf947250c301ba2f4db3bdeea333bd80ec64a7f1b2d8a602be97f97f02009ae3b07e244249002955ae7d13f1fd83ceb3b2eda99c6465334ed7e4c2081f0004ce08f936fe7b714e98e8cbeab33da6a174eccca2dff2be4eb73b013b65680051d258e493a8bebf904407fdf576c754b39b5fbe5345a27ee574d2392c8b8e001f9772a327946c67da5ac2a9e5f7a119d588a73738f00dfc3b611e98fa2352009aff68fa92a4b764271f5c0166622941cef8881b5e57f65c1f60540f0e33bc00a7b15e03ca7a407379662643104ffdaf86e56c3ea7a7766fcfee9f79b01f71003fcf7436cf1ada54a9210c5a1391f27d814730417ee1b6cc337379bb8744b300f7833908f274555f4cb07e446c25630dd07fb683845482c37785f0771fe171007f1ade5e3302110baefe995e3906a2a12f31ab5c4df02c29cff03ef4f392c600ffb8c7f41029b4d5f4e565de0f3fd22dd75ca7a5c17d594da7509289c2cf260016a1896b88c9f516e40c05efb68a2dc06e243149c392558cb3ede245defb1800c5c135043724b9d8e0db6735bfa26d416cb9e96c3014972cea055b53309c23009eebfc8d0219e83df157d0bf3da02da63ca1e177dc433f8220eafe7dc5f2b2000d881cc6415cc2c8699bb3c2c7c09e07ad06b6f5b8dfb11bed64a40d7f56460048917fa9401158c9f7080c02ce6dc68598d3ad8393d2adc4340deda65559cd00640423d9ce2e7ec9b8dce5f169ceca056fe8fb6c11d595b6a41ef5c0969803009f9777ead4c7c1fb3e8096716814a87416e653ac669489b929c970a8501bc4004699a9f2c7fe81bb2268f0ae7ba55673f644cc86918b41501960b35c672ebd00f3ea2bde40ea5120b9e6612d806152ddc1d4a13f98f97e7055f1ad7e6530c10007574fd7fffb2679a9bc4d9983246dad371b176abe5839899d1824af4391b0008bc1cda20a77cdc3a9e4c6c131b2a2b97a3e2ce66fe019f813dbf51002f323004a5fb370d85b916789e25ebd12c2ed06d6679aefbd8d567d41591d2cab0e0400f17012317979bc2bc5a0c48c749cf7d56b7775b94e182072813bc204e4451200da9f79d4325ffd060fcd222b28d958b4ca6fa3b2db027de30618861c5e4f6700035ad7a065e6a1de8a142bec1c1134ccff7d925cff02ae15118fb5415edb7700dd423c06a53e70fbbc01b53648dea26798bf58f4c830028ad9fa9ba12e681d00753442e691b48cd021a028d47b2d9535c8834cb742f7805a3f5dfbbdd13961007eaf0b1c63595044aba0ea862682924d573d904c7ac10f89a48abfc83b0b1f0064eb81a4b2355068690ba8744a57230e635923f64a8056713c00a92442238600e46706e0e6ce08f7951e7410e8c77be46721a4fbfdcecd8d7996207d7669fa00581e7a6a71d2205d5fc824f5ade07355366dbe95c8c44927dc4047462787f600fbb5d7c311a0c98eb671116a889d5820b8e29496e9db0b3a851e457f4058c90000a3790ab795da05b5539dd8851bcba7e1fc026e80d0771dae72eba0252a590057c956d70ebc7da117fd21de1097fce2c77acb5355d9111e1494a014372e8a00d2ba2e5bef81cf095e7b7a772d3ce61c6485fb3c1d21503a89f711c60526e900687a31a87b3cf9dd613c5680c63b08aa0d974939ef13394e0377be3e108726001829a61758d307b34a6dadc780919cf0941a01ae955d3568174284d5304e1600b8d460b374e1b695c9d17b83295fbbcaa80c2d58f0094164065f56348fd1440024a41597bb23293b235e1b4833d89530d6baa6ce12fa2d1663da3ecca251ea009bb7a68f63190822b7b6c258c67d4c8cfef331855cc979c82628791d0a00df0038cd95545131c73db1c566b97aa0be7c5037c88b2df155c1547681141ea654005708fe4fb1dd91a5b46d772b78a1438d76dc653ff19e776639f6d06c318a9500b63246f0a050a535fe50a5da45eb028f68e43d00d9f868acabf44379546799002479199bd820387c5b649af5c82f1f9b979530de620340356d0877c9042d2200774cb3d0afbe3f32dcab671f35b27870b972092955c7464bfbbf04edf1d0bb00e50e99acaf8201cb2fb63b3c257ad367f2da0fa00038dc3284d70fd78cc83300dc04419cff810c192c5de176957b4b4a8f119da952fd98eb96b3f781bf387200fb5170decd84d1509443f635984b3e3c098960875dca380ff1ea0330e3a9a800b24d33af363409f579a364b351dd119dee19d28a5d1ab39e337cde5c9e2ed8005a47cbe3a182401992d3a97fdc0f33696e14716c06b065eeaf5b974b3b0ed200fa775e41388e3dfb0561c6c07683af805c7440ec0ac8ffcf3c68fe0c715e4e00fbfdbcea332edd2ab4bf6e32ad5abff7658a2b77560afbcaf9c4456f143f6a003a97639ed7132d6c1827f105094a3ece2ec11e324082ae69a1cdc947bf7a8600224d0f5ac2bc09b7f59eec0414656ae7eb559ddf68c5ab7f27eefe5ac92c2e00ce6145b407fc9665977d3f0f2af2fa4a18f49be212f5a310ab6bb6a491ec1800f261e6c67929f575da995f1767d40a4edce25cbf7116beb8078b1ad315fe8b006febbffb0a4be84694f99709f8b0ef0e7df8af82456d2c308e3ef14188b5fc004958daf149d74d36629688dcf12d39c575fee2366d4ff3496d33021580acab005476d00114fc3cfb64bc8234e27f3529d3e2351ffc292b1305188be7a9e40300f0bd2ecbc8f54e9fe5e313a1e6da73f79a34871bf8f10c251a251727f61ca700ead5f1054e1b7184230462088d45f858930d4ec3adeb941cbdf36d5d536043002ac29f39b02cc512b5b81da151d8a63bf7421fe9bc70aa43fee41cc076cc3100cd460080dfa7834653229ef52a40241588ff32cdb0cf686333aa634247e18c009b7609a5a659e5f169b45d81342dcac7abee8a305818a4d6ee15f03d0845ac000e8f07279aec69186ec53b18b585db9f4d3733bbfc3250bea211b2d019894a00e95cc837585accb60d89855bb13f165cc45ea6db9ebfcadd3e164fc19182b10096085f93bc707cfeea25eb56301b893d30a0c1e41cbe6abe9bfe31cc2b05e00064ebfc061657fab804ba1f36a5fe85650645f660a0aa8ddf4b71e5955c083700d71db241bb567941f28eee05010ff511adde098c08b3ee4b792045db25b070002819e9a35b89d1704363f805cee9321e27c09ce6c858070f406653378d1b8f00b75447cc2132024cd6c743617b49a0dbe9311423f77b198f2f3502033fe91e00afc2ab94a09768c5eeddb98d7a12b1f5e1ac63e804043b4d8f3107d5cbc87a0024b327a9271323a91ce27ee1fcf4a477494180ce011b475ef3ce8fb240aec8000ef932de18cc928a22dcbc65d32f2aeefce4a12185bc2ddfd4714a339cceba007387b510229540d16188393981e91f450382877587218610c6408d5d4357a600e75493a182e5eb87f1e1e9a487e6c0e143be78a01becbc03572d2aa152f2b40059340d779d8c38f132e07903edc11f21e41e5997781de629e3a413522f7d51005e3c2c184f4b615d04682742b65586496dac4d298cf565a5a7c40ee89a001e00c46caf87e061e75b9264397abfe4687f522b2770a313440477bab727a53d500036ea5bb299bcc46f2626f5fb9397f8196cb565948a60d57783dde6ae9dd7d100175b95e3a43e904dfcd8191257688dd3c95eaafb8c5b0b4c9055305b7ea7c20025cf6214c2beaa92deeea29dce95a28a1a30487cb97c50dfc2ccb2f3d77e0600a4f09b595c0afb94807f37b317d6c39f2c340e3ace426c4996f7edf9d5ad9d001e5f1e0384acb8828a9af48cf2d9c8002c54b42dea692647485ff384a072e40084dcd76bbb50c31ee726d90fd367624c63353ba1e0eb879fddf7650fb04adf00641fc5d0a3df5a065f3c5a07a35dc01703b9146a1c78da12c6672a3b4cdac300aee9d4fcf00654e916cb07cb9908f358f4ffa2e5787813efe3cecb0cd7200100e6e7485db78777673885c63e664db744ccbee91d6f1c4ee9f47ac01be878710071343d9e802af67ad2cdcc3bc6deab36f7f7622cbdcb9b155899ec6e9da796008c794935ca20ac53876d45c6d87501dd6b1d09dc9bf022d4872e457f89cebc0048e6b1cacb12eb0b48536ce16fb6aa6c02ec0c8f704e6d73393ccb2a9b053300808f6e9e465a9be002759d2cfb87aa7d0e1f9544e10f97509d2cae871d7a3f009484ff4b38d41b12913aad1e0a3c83b717e701d77f18a14cdbd4e7f34b1adc000774df2c86ade9d7c2c16503d15ff79f648f25364521091d95d93ecc973c5100d94ceea9ad21cdcf7b6ada0197a2b3d5fe68e677e0713f1c57491feadf68be002ab5ce72bafdf72bdc3d1d7efb06a577aeba9529295a6238d166be91bbf7b600493a30d466ddaa129c9851461dc0a375df18d3d0d41de4102b882fb553d085007809c646a64009651651cfd9fb6bf79890142751674073940af8bbb4de5f5e00b5f7fd3c2e3c9ae3b2b8789afadeda9bad7630fc05edddb1940b3cfd90fd98008f99ca9d46dbcd2b31eafe2980dd9018e974265da23946cccb9ef789a1663100ef3e50fd70281febf5bf32b05a29427bb290affdcc72bcec39ca29972b2cff00975e24c0732405fdcde452a12f8147e1d0a9ce31198d87356d6854f299fd0900d4bf348a174bcfe5214b68d1c87b78c2213ad30d363e0fbb736fcc1c9cc0d100ac3d54072afec77dfbe2ebcfa8cc42d31a49d4812d9ffdfed0c58b8ba488fa00ed4e63d69f9172c6c29994f73c37126be6896bbfe96e8b9a82810227cfc7bb00cc44fa910a298bf89cc73660e5cf9e9f222feeedf23c3b4d3eed742767d778003e51c5781c2d8fcdf7c76b8e633b676b22e85ba71b40ce4b730b3d887859450084b2aa41f222d67ac3668716220fbf7141e7d80be65ebbd15b20fa14be47c400a153159cb1e92314a3a9faf798cd4292f88466a8839b7545da03d2786444c400268923ac0826aa636f6b98ae8bc0b14d12715ef9ced2db8631b29d1c5ba23200f265646a4c050a5dc1e17f5dc994da9494bf884a199f070556b02cbdbefab80030de121492aaaaab6d8bedb037dccb19ddbcc84fb361b171101cf0deb5693900166992aaac9fb4d5f78ba5ede6802162912b65aa239b6ce375c618241e8d5d00709b87e1d64c665d5e56270a490941e4bb3402167c2e77275cda2d97755b69009b2237fb38e595cb4c12dd09d21e9d511b9f0fe1a2771fcf4bb550f958c37500494c59e4f708c31f3603f6a1c4910e88bea3c4d224cef81d701be1f337ecc70047ebfbea3056d2d6e71a2f508ca32dbfc9e31dcf52b0cfb9d81c9cdb0c518a009418bf92f7228c9eaeb8f48af3d27fe7f8609c6d353a30b0df2b5835b20fd5005a9173f5ede82bc78709b1755ef228c14ad2eb13fa96fc6519a27ec55ca26200100096ad49d8d7f87f7ec77e55d80e2d8755f00e62763d82da1e4a86da40d700ac5195656bf033cc07187c53d1f74fa35d4d0dc554f166c308184968e02731003d120a68dd76529ef9cdcb2dff5d0b6620c153757e6f540994e07877b330e70099323fd1e357f83ac713cd133e7c7b7d13e047c49b089c8163b28cc178bc46007ef52ea681f610b93851fa8d267a984879663d50d373a4f205f32f7ccd3d3d007126fd25a16fda3f9005da02a9ff1b8a993b3354dcd687bf8bec75805d3b3700c11082f5bd3124460aa2b7c5e179c1052a4649bb23dd54eb2d0fb5898f56a9000e3764c19cc62e87acbe1abbfa5ca11e446a7b8ab7f168b90a6eac8fdf5eb900e3c9e903e73d846a6d472a7ec9ae3f186c5cf11f15200c34f870897e5952ad009530b8c3f65e7cb36a6d9dbca1b1ed8951f85e29ab25cff2121307858b2589002d2fbdbd0e1cf15f4f1b72196ed8466ca67221a7c68ecf2b53f0111e2344f40029c8aac1358e5137566f44507704f107523acffbaddd62b6fae0a02b166e0e00ebe838e77a5b250c7968d170e9c6e6d667757611476d9f4923150138e68e0a006f7b836c58e32021ccf4c271b7e3855a25605f0b89b947a3abe36d74d3d84a0079c8d4ac9f6ff34390c3f5f127eed3e6015fc855e12ff63c8317f9ce2f295100b1e1d4c64776b15968acabf36788f6a5970d7ba8448c54d15f79fb4e82eb5e00685937868baaf71c8c059e2b4a2a1aeb607ad783a42c9e4eb5589514b8c5e400349f4c9695d9a6f8cb366e6b72ccfd727681973b28a3a796a4d313c4208b5f006269ef7585946768ec0ddfe05455e127d94b5e3e0334116e318b514e6752da00def348be36343ccbfb1be4808abe73e2f1f3c0baf6ca11d85b0963b7234858006f58c1d8f3330da3fec21bad979ff4e83fc489da62a7aefa626bf0cbc8e9bc008e6c067e374c2b2d6e258bc3c331a785a5ba8760aa8c4812bf101f50eec34000fdd3916183f52117a7396adb9763720d3ca3ef19054707a0f850429d397d8e00d7b253dd932e77744deb826583e6d08ed1c79632b3b9baf4306ec84fc7aca00056e843bea5ade14c2e8951f5f077f92ca179b071385e7274ed07d529daea9800c0f993a7c9635c611e42fe17d740ab64a4a953c0f6a8f8712c8b4db788c29100a4de0acc009f7a4510895b76270dab432a9dbb853faa61e7fbf06e6d5c86ed00483b6738aabb8202b3ebdb0810a2a067686b15db6c524e376e8c52f8f4a78700549af9be02819b2d50d3135f6022c91cb3b8b57dc48664b934789f86a2d71e006e96256a875741c71f024138c14cbb465738dbc7408a22d3822d1e9211b6fd00aef19dc52e6f047540ccb09c7356588ee80769b4c5bde24a34055ed40886c900ef1e4a4a23cbbf160949e5dc90f1a7f5ca9e21e2d6e2f701e36d1e15e20d450081307722df201f5a0351ad2bac897a81fb707815d3d4b7f548b6b90e41de9b0081abd4e5fd161cea9118c258eeecfd92f46d5aa1c7ad69039c9cc98bb6096300ef65173e1007fe7a9b06f12d46bad9731ea7d4915342eb11b0b818153a1d7d0065ddadbae0fb21a821c927ea6798e89f1a24f213b5fab3a46bffb61192e62000c9c40a2654e12d7901b8441a876a2640c0d86690b5c39864a8916029042cd6003983724520fd85932fad11a66fe6502aed966027c590bade7a522e8a961f1a001c119a2948627ec25e9eab6958ee574c7f537d35b03eb7bb1e9b600ed41e9500ed916b8749955cc923ccaf9b0a0b9b020957425b797e2cf87711333bc3c25500867189e886c462ada4d8a7c4757367c020cdafcf8ad64376d51468def3797800ad8dc9e33104cba95650d226e192c02533637d5fba792364f522f6be13e4b800295721c488cf90fceea80823f497e4968a9288af1086621cc3f0487748f4dd00cf5328ea7297753e83ecbf5473566e03ab704bcaf682c7d55c26ce8aaba2690088b848fee110b2d706efe6cdcd33bee86b39f1a1ff1ac59f28f9d36fc9648300194ea963092b6989ab651a437587611cbec20042cef142638d5d4e3c4911d4008d91da613e80a6dd1578e9e34a2b70c405c7e29bcaa11f005c5b63413a343700a4579f7acda70c6e2c7744637cf4c04bf6be5f38249c7e5c550ac5496a86870029fe319907bcae29b7b64e6ce019c42ef96345ce5da87c9d5eb383551d40b6006d388aeff917ec10bb397bc9ae4d8649abac57d97113f7f21ebf7c5b69edd5003d729491e9e50da967495d44e4a5a626227b7b549c52abeb324a9484a67227003519a3cd6e465c176564baab31c3bd741cf27b5e9422c073676432e31bd7f4007ab62eaa32c25bee535996354a95dd0742e000c68bd2772b46b738363d4d4300c19b44aa22f19f8ea1bdce0807c8c7a478a22c56bce57f19fa5aa3a882507e005e156096fd9332360468dae169050bbc76b3f9899a9c6ca9a4a12ca4e43c7f0012de60f669d40db99d521c701271845fe35b76a738947cd66d7c0d0db0d85c00c5b832b4b1567a0a3b622be8fa77c8baea7f06671f703987bfd5299cd116ee006b75b4d01d61b3c36387b58a61d434940b3d3715179be411fe8fd64e5bba3900b22a6ffb8f1cf5bb8f4ad22e7cbf504de2a392eab64878045c4421b4e5d4a8002d55b3fb4a29f3a5d6712a36bb12d1fe63833079eba8f28d52020262f786fd00eda552e51e44f5b4626ec380363b7d25c5fce972239b60bc716577fa65128000cf6702febb69db46bb3ac6cfbec7eeea380ef5bf57221d660acad8924c14e90018a506144993356b460dd7242ec41248942f7a4ca0f4f4dc45b9d4470dd6e100fd70921b269159a28a3ae2a8ae7bea78a25a6eb287356216e82ed95396e60100af3e3a59b7ccaab72ccd03f91683773662f974244338d37b540042fd975d29008aab9b0563b06fa06c123ce2f1ac4cb95b880596b1cb857ea51da38febaf7f00d60c5d6c241700d03f0cf865aac0bc0acd94dde0ad68b02bc82593c02bf7f000cbbc494b34f9a02d232e494b7ff87548692905f0c6ac99c375fe556bed516e00a76b52a81572eaacdbe8c7fcdc22ea688129fc3652287077a2d5aa5336d77b00144f7439698930fec5fcd49e61b0d468220ce45c83006718c6c5300c375d85000777b63f53054766894a7c60b0356a37ac6117c6c3367ff6db99b404494b4100eaea754d2e16fc47a50b7c3d785c104b86bd6385f8534de9a3c799a2d6384700b43d04a084f8994c89b2e7386a1292eff545071b6a9287653f3075dc7b507e0041467bd7d2dd4f20484c0d6741f511ecf43acef2b503082cca783939ad27d900d8394f320c515a7989efce34981518847f036146d24cff19ec9fa95717307800708c705418d947fbfee45f9c3b5d2cf37a11485d41015e045c0b52acac6ea40075354317a6293082bff06921643055a267f8829560f1033de6fafc674aebc9001ae2d1a4e30e5c0e2f34a06dbbb1d8dfc280cd3bfc6337a0287f0f4ddd9c77001240e02145a88ac15d6057f4f3cbe078c3d8623b7cb41ab6fb5269481e126500af18f658a779e11aef6817560276deb576d82643739ae3224e3c140f307e8700c8ffbdb28f36ccaefb74157164c886e4f97ae18e8ed0249e534bfb8aa8d09900e2af013a0685d91566f7b57b22e8f66588d19275331346af1c95e4605c21f900b3089938a956be6ff556ad78806bb8284a1cf5828eb01338d0d0a33d7691d500e7be48b0d95dd6484117e930304568c27b606c821263fee05bd56af7a556c600f1ec5744719512672319a0f297dd8e858267cae8870bf7ea720f0c7d16640a004900a254ed71c74a5d7ba024879c2e0400e669c88a3954bee5cb0cd244f20700fb4b26e3ecaa1fe931a506d5940526ac8d4b780b3cbb40af79e4e4e184a37f009e454d72f19b0789b8cb5604d67da7927381151a3bdb02e0972f57909561c0005fae7d1d017bc7282ecbd5d8d3fafdaa0256204f5dedc5d463e9bd2802cd850025c36d7868c8197e432e2e9e1c20c1fdef145176ac8f92233fedb36e7a3b2900233561f342e5929cb5e12be19d17ed60f6d345b4dfdc66ddbfc5ff946d9ce400a1ee34e5ef3ee5582be436bce122b3f97632d2e3c321e595846c378e476766008a85e14544d1e2abf69e3ed91ba26c24e27891019828b94f9a11f76c883769000ab77aaeefc41ff3b3e1cb7cfdc9e3c9ca1783476966fceaeb8413e0845514006def74f84d56c78942ae181b1bfce1e66be3ac80101616082a4dd0979826f400da17f90ce74de86d7bcdd8c155f3dbcd1e0c4a08ee898dc255847e998b832d00b977dee85545ababdaaafb12f6628c192fbd48170f6509da0baa1a3eb61d150057b40066e96b876c749db5b27d6097a49ae191ce6d2856db6af76500faf61f00efdb5604179c686e4cac44e9649a7877090f1f4a452afa2216b6f228d4aa0f000d9cebc24656a421540eafbaa5bc3a48704e274a3331e4e8d1fccb5827a30e006798c1caa781303afd95f555abf7d1991eca213ded971029b85319ccd5580b00b070ebe3789dbbbeb925a5e277301c7683a761c7fb07d75b351b2fcc9238de0008db091e8bb897ba292eca1e6ad2d973058e37507008dc1a8ce309fdc85378003a69d4211d3acc51ec984f241d8cf7f3846a34746bff7980602ee1b8589073001becfe5439897b5a6f4628a358a321563fc59263a2f12d075a557a3a55eca9000f40e3972766f2478498139c267ba7e2fa1ac36ed318a3362e5e6ec619359600aeef2f09a6396520a8943326bd62f51022142d77da1823f25ebb56f0c3931a0073cee8ca2cb5ca03bef0a133f434d028b428b2282e2a48f1001e98939aa9e2009bd8e82a7f7451ac5ac734d8e69f0896ee00d1a76416c14bee331fbede79dd005c2305fa4c2c753ab44f0aa4e436cdce94e8a63035cb5018d34675d06441f000107c77d903a1e8593928e896e25690d18fbcee0e733ee1ceec11ba8e67a9db00a17f8897f8bf91d53ce8ac3b7b8537bee526e117697255cc0cfe0d5ad579e600e03f8a5ffb8635dc8b3f5a17dd6bacc46bb3c0030be47d43aac0edb25158e1004428d387b6252adad1e601ac5b95676c6220619a2e90578ee1b798f42d55600088924b1fed605ba4403272aeb9aabbe24d2ffa12a5160d9799d1d19c67320d00663c681bd6564a010e8eb33f38f629fe9c6b611f0e0d25af44d95bf894bd4d0053752001b63eec6fc3b0f4b694819f443dc16042e9339963e3c0763317f86300ec126a9c74c93f5873941f0a41e50567b526de8f0646fdb9e09a42de8105d400247492aae22d0a0706a31f5efad8bbcc92df6e3c82a669af8df4cee9bb96b3005654d23241a610d92d1d2c3c12a041e7b21c0e45d44f5d386bc3766f66b6df00dfd74ae8636e8fca69eb91ff491a22265d2f338bfad11b704cb8e706b3123b003bf23f62b78eb1756778f54148f38908baed0aee0b2338308d4172f005ee7f0044224a57519e5ae2051b434cabc8dd84c2140791fe5804e8bdf4033f28e25700c784bcaa751ae1e2c343c126fe78a37ac21ceea0beba593453456d8eee596300f5cbad75d82dbe296316a105f038f9b7493efb5de55cd24adccc743ee7da7600d0f2b60b7bcb12ebd33cff9ba587cda5cc1ecbea820e7feb9a0977d549d4010018496e4c8082b6bf3dfb848f1e54abd6b149a68d57298d6e1b8e4c48113424001ac987da2d20dc2210c8861bdf7bdcda9f30fd5081340c35d5665ae5dbfde700c62e49b37790c7bf94dcd6b33d45d9d49016b1406ea893aef23109ae87bf7c00edf44db20e9a3dca57e77505ff073c130c52a90b3d93935c3e9ae9a86f9bb100a8f08b648e918d9ced72f64f9fa5be932bbcc1f148fa08aee5178fd28bd02a004ab63b3dae4e74b1191f17260108547fd5b8bee199e09ad96e94ad483481c4005d115d2f58491106d92bca4d2d4613c4ab41b9cc2abb8e466000a88f758c27007f580a74b248463bf0a5325a39dd56ef741e41efdddb545123c2742134ef7a00f45786e97c0b0d38cc21cc743f8f145cba0c4ad1a63b7e2ebbe23310bff085004b8d26530b0367b76978df3b41a85899e5e538b792c91e8e9de19b8c7105940086d06aa0143c818544f2a2a4f95b27954483df5e0bdbcbf31956d5cc596f0c00d3ca94560f210552cd52af0ed577ed5726b5c275f8425387e94c580c691c89000240f8c16eaf88f6be9473c144e0ef9373f9a6f93cb3b997be69286369e968000d679cfcd2152e71a6408ea3c20801763a9f3d803e70b6cd84a87948b005960014f5801688182e0d30deb3b7a822878129a513aaa7365e7c85df555a69ac98007c9a0eda03537afc439c220b8def8be629a7e8dce510203b6b62e5821c149400531bdd51e4053ba9dfe70678a286849babdd37f73def328b67154a473883f00026cdc20603d47c587b3f63a999b4887087e8d67a61bfa6825d203c390070ff00120f3348520acfc23c369aa8ad369e65e5321ad9476d11e1980e70b3a909d8005606bf61f48881d9db3271ebfb3160492a3f5e599f64c98c0aea9924aa33e00016b4a553ff27174fb5c7d622b6ff7bc0bab5328688eed7cac6fad6e276fb3a00675971edde76075d20f5fc0f63a221346317b06ed0fcfe419a907988e3b3c400e186be89206491d2dc2cc98961c91056fbc92011d2901a61602b676e9522430097f5d574254f3dc79ba198905325a9360113ca49cdb6f41b5ef8b6cb41a51900dfe3aba25b031dc11b2ae5b1af590e9e29236f1e6798472e4dc191f4899f2f003ba883c722a2fc79a2d590e96b062d8a3c1998154c85ccd3c4563ad174e2ad0055d06796a7c710ca16621160689df124736cdf653e3c09125dab9bd22c4414003e389bccc1d8c8f5294c78c09151821f9abae3a19bb29f6e88334d1f65968f00102ca98e5e5b9743081646d045b7ebdc529c71fe8fd6838d84da4a0ddafe6c009932c15535ef65ef6eccecf9ef984c72d5d7928bab8a498fc1cdbf89449aa40054bac0ffce8e273b1604320a9aed4fc97a4b7615339de89aa7bdd94612585700f076408ce1e39fafe9900ac7a639a1e673f0f638ae8772108e9e0453dbb16500b96c1095dee06d100091b31aa2f21f162b189f55c3c263dcd3681ee26438410038741230e12467c3431fa3a7efb913c301202bff1aa05ca3803c7fa4cbf2c000a3b49932af603bcaf799af44810fd074d73ee0b013d9d4f861a1d5aa0f4ca100a64e167df1988daa421bcee676323791e9bf05cb7bdf34a1eb1094b0e4b2f100f86d5c5f3d853a2326c746faeb856485edd7aa24431bd1ea3f82a92569e612001a7da16e1e04698647829512049158b163058440715a304e3d1358259932ad00e1c08423e26da5393acea6956fa72ed2cb62fa5e8d3d8a5ff7de253def16c5009437c0d706ce0f2f4dc99a7e771f5b014f4e5d7314256ea7c16e262561b5f4000dbb0ba75b36dcec226143d07f4311ec33d78a3e6ecd86e4f6c6dd3165ac0e005b4b17a3a28d285e71c5b33930107ba154fba6242e4da2805447440648dd54003017ffea4a7798e2a11024945cddf1b8e0b327ba4b77e3d63bfa91d538d0f300f60cdf3f65d562d97d8c75e60a6b62edebf092d21f5d9b3d4622c43bc3ec1f00c1416bfdc738a9897772fa463b4692b79a5714c02c0dd307b5d0813ec177f500a4c4c2ab3f44928fce9a550e7bce36d9f071b2aba636398e3e1109f4caa6dd008475976486566e1012b1a60525762383d47f563f970ca65f2092936d31b37800099ff8e19201085711a83731bea437bea349823acea50fed40667293a7491000c36437279ac55584bad91bae0482d8eea314d5a55f997fa49426f2f01ca3a200ef792da585aa91d2c1ed5645ba3c49fb4cbcfc67c406fa22ec4106fbcda49200eae057cd4ab22c0303a325bc818007ba7f2029d820969ef7f62b6ae95bd8ee00d032001805863853d25bec7d2fb9af769256919389e3c85b7a02aa5da9b9af00cc70c95f728c50138e0e1aaa93e4f7d96d440e3e37de1ba110446bbccaeca200fb892a0bf224b9f795c8acda8a17d70ef2a8ed6765e60e5bec7e50165bab24006eafb5f5120885cbef481ffedd5c66854bba619f80408c5515012060f8dee100b57feae196a02eb7adb351352c163fc18bcc4ccad05d64725e55024380e2f100877d06e8802dd99025aef76d3ade7f135bb7bdab05264d525621f6c31c118e00f80f7a5c4b63b71bc5afbc6836fd91657b0bc334fccdc3c2681bd948c42edc0044f0607cd1d7c6e8f4b1b18d0523a58597be6fd3615c9b400742a17aaac57e003e44111fe2f409b1ad04430888ab5f68735bd9f6d3c63b2d78f48816482bbc005755798bedf0435c6919525a29f2f851be387555342b501ef90f959b9cd57000b251802920a3249df444da9f9b98fd0c7eac4a3f870e31803f603347ff7b93000e30683a2b4aaecc63a330abe1e1ec50764491014a90e619a183679a3aefb5003bbe9c79a5d65c658e5b0007639081adb53c9d2cd175ed1f9a379d5b72594c00748ac48df258b91c950477384ee95668da00e79a11cb8500ddef117fd807e70067f03c6bb5d8358190d91e1bb6e745dadcadb9739b0140bff1d5cc30e84c3b00a4dea11879f15ece832f7f7aace61f00758af22a99448e9263450d6ddf010200fa85eda0e8cdb3d881e6f9c573c3813305d7170f0c762260a6acfade7d5fc1008b39adcc50b6bc16d0cfb35e3a4b0e5cb08dd291c5e66e61463f8b9e70520a0034dd58abf2610f8ece67b9c15c9a9f59ce168d975c9ea7fa755bc79ec434bd00fa8f48d84e229a29cd19b05705a54c0748eaad286fb4c7e5adb839e7fb834c00445450af79dc883cc747e55b9ec91412bb9d8b76bb628de64faa2ad8a7659200cfd7c5591de9b4dd58c263847383172b1abfcc9baea88498107614273e7ee40063054a43efef1312f2436be9c591891bcfdd7c973feafc2b4a7074728858b0002df2bca8ab6143affed08db83f73e3068c68f2ea1560e43318d03a8d4cf622008abe07c85fb97a36d13ac21e1d70b8d38b57c3979463f55d4fa844ad8ad31700368c757adf391a609f395f334be2eeab8df6326c890809bfc548bb4b687e0400d9a1914291a2d11caf97719055eaf7f5d3239be0f1bffe70d7d008846d3935001b1a60c57c437c306aa7a8b277ff580d54beb20d7c7c4853d9496acccf90cf000251ef32096b4fcd83849031c31e7926ce04070b465d2b4dfd800296ca33fb004a517d800e117d36d73aabfab70a761afca347b14c6f1dd93c9fcf393c1503002c34703f906cbaac509dfa8d49f12fe14b475f6ceaba448d93337d7a148c2a007418e8aa1e17a72c998116fc13ff515635aaf741032ffe9c827b29b5692a4b001515dc2b3d02cc63bbcd1916de397b9f46585265c96b63886d81af3af076af002ee2f2970e888c2997c76e5077c1257e5709c84103f018505e7ceae49d201e003b8660c7addabab332b3a56dd0f96105e47584b7dfce45751e12ac950eaa03004219a4e579fa4d4f9d0396e6e5e32692ce76b30679722dc5cfe9982bb094c800895fd6f1f3f4983884fe227ea18760b11d71e656cdaab61ca1a8dde8c1e2d400c07e36764eb147050d46b099eed57b6b6c950c6dce1b76ce23450dafc65b8500145a715944735826f539c5ff9005b5e8d005f82bf98d809f42e98a79e1ddf600e85e5bec99fa66048af228c0f0ffc50748f3ffca4b486589239a9fae9f8839006dd4724c1e744edcff17407cb5b46cf8aa32ce3b899e6aca2f9d1d2bc5165100598fbed06a729476fff9223e9b25a8a7c0b9042e9dc6d064ad185c0086b367000f34e449bdc76fb419efd9f64a300270de106ee1680d85e1a04240a724575c00faec4b38aab6c898fa29e5dd70a28a71eaa565347718183ed7662aa57a686c00a49d9aac765d3cb971319970c315c534c6b8a2c0d86172f9e9396a438c192a002d86c6eb3ee12a6ed75c414e298d606df386f402a406ecd7396983722b27a600530b726cee51954aefb11dd2005bac9098204800b886124263702dc5208d7f00d4c9d3b3f2058163eb0777dce570444c97e6481e232adb9aaa06679d4c925f0049ff11286f8564f620aec33b7dea12fdec0946f5ac8f70adc4ce8bc5e007ab0024b4725f63fdcd1c2e59d4a8649041de294b06a5583bfbb4928fb170ebd5e500047bbbb3e81bf7904c34bd2868d438c4af0aeebfb84ec56944539b37cc69d400e824c8926cb2737c52c9be62004ce666e734605e016f990563ba2f6923480c00f969b491da786a0eb50e18f1fa2df7701e8f970e5781296d4cd96931eebc0f00c492e6024ab83a936c2fe9ef933c99970a7743214edcc12f4a203c14fa74d0003036e51eb5a1ef27771d397cce266b609eb0fab46e46de49cf4e6853ca7b2800676b80c9f30e42ea60fb104d3ca8eb1dc377bb0b2d1382e3089cfa4f3648ee00faa12f9f1f591e4147377127e83e96a15f4306c14fd5f0edb1d15d73705c36001dd19f52a8e76abc0de7cae8e83fca95d779558b7e5f1a2dac59fd1e9dd295005900c3eed447e431ccf29324a541296d759e127aaabf61ba16e31347a4e57200634ee8c35908711b731c05e9538d37e63cfdf9e5e3fa1d58f1c437f8ccc92700906bea684e439e97e114a727b2a53266cad5fdfa14fee05d7d81ff6f4ef5a4001882cd4bd965659930d5781f9dc7c75f9f71a9e08012154bf87596f9d9a56d0015f188f89696dd32ee16db9197ec0d8f08d37b83ab2f45b84f96e968dbc4840078a6fa73c8bf6a550107c86ebe3113b083e084b75e60e314f5c4990cc5b31100055bb4c35c6a181fd024fb51f3e7cbdcf41a3dca901ea6e1c42b0b3fd95d5400f7dcf12d788c59ea066c033a0d80ec06e6bb769e467ce9bd739b7b23f4b15c005600e4ad3888dc50b4fbdd5aa34ab10c4028b80f08bbfc1acbd8a3199f314800f8539588cbbb8980c3e85ea665f766469aab0ca22cd4c325a6f84af657fbd800971accf8f7e48bbea72c112341941a05b91ca13a8ef8d61a6f6a05e267cba000c79b393088fc9a56b4a85227b8fa73a45fe52a9f183641a9422ea5a22e487b00cb3b59c62fd802587d9161908cf0a244499c570b3fef4035177615a9d77dd000e107b56b2f40c048f3a0c7a98bf0116b59fae78b31dbbcf05edc35d6429cf70031f0cd588d79edd6222dfe279d08085941fa0e9b6fb0ff8ab38d3aafe8daf5001f651eb37bb23678046acf9d5f0b84d6bc44739485e36750ea94842fc85b8b004c881bf25fa8ba6a5dc9b1c01f6312d2b86d2a02e250e01226e2cea82409be004ad609d3422f3aa7f38d623a047d13e2a325d3eb3f789df377915d8604e99c008860bce363e9127e9f6d405ae5824ada06e1391f1ebde4d6d05ee35036b00a006b70d7a6e7509276f6da4d56c78acd1d428fee640ed51601c3d56f1484043300b777f72f6d7f3fb8b2f447e1d995d9c25bc1215e26d52ac9bfc5f29c0bfb96006d23ea7c23f85de91c2bdf273d9233245ceb9e0271a5083d4081824e820ceb00120c062c64fe459ca9b4fce5fb119d377a273b1cf3d98b8e9c0793d13bacd6009698ffaddeb5ca16b9b8932730da53b8a5a176a7bba806e2f121093f5eea0e001ebad8db7e790dc417f10b8f1ee7714f4e80ebd7bea64349070ba32ccc1c1d0074d3d89715b20c7ff1733c65287653f509907b7cedf78446c8b0e346c71053003e0f13368562fdabe68a16c0addf7e617f4673f86e672c0cc11bca7d267ee4004fc2dfcb78d9282cb547a507d48c7ee172160db9bc52b603a8768154a5bc9800ad5149d0fca112c77273d5822f377bff9eb4b52b29dfad2cd73c7f40ae3bfa00a12cb5668502f000273cd2516854ee5013034acadc47783811fd288d8c048c001ae8b56a4d3e7dc58aedc183d1660d3bff35c641dfc1af429facc9b679963d001489e9f5b1666b4144a391018e5ea97617b175761b923709390fbc9c162345004d577cb26b97b4f5fc2a0aaf146621e098b8e9886aaa91b05603e4d5001967003e7fb24013ad66d1f5eb83facc47c72591e18e092b5a5551a80387bb7ed87000611bd85079246e33bd642ba113c173d5df3358f0e2eecec7928da7cdf1f7a500461d5c5f2ee48d364a1456622535d038eeb04d799019ada6605417e94c667900e57ebfcb5b7144fd5c8c9947c225b549742cf2707f67062955072f3426c3d30041f020e895538240c90653bea091bf743004ee2166f58eb93e79500ffcf5f400b93f9b00b2a7dea112ee4f17405066104870e4e9b4db84494ac0b738880cee00376125e3aee40bb13218a268ac766a54d325c827c22dec3666f34d383628bb00a7c97b15dfbc103cb11d906100d057678c7167abd8b3d660cd1b18d46791190009c1a6451b1d9f37809034a62c375bc76cb78b7e56185c8e22c085adaf986b0052d5d7303fa7ef9a908515c76434df1bffe9ac50452fc7dc23a4adefe3667d006161fc9f7062834e75d0fe1a0e662bf226d47dc0d80201fb37b5e27ee117df0054519cb7a8aa579ae266887b49c6f004b0fbb55423f378d52ca8bdea90576a0015b0c32598a7c193d6118689098fd5127abbca71c3fe12a9238c0468084c3e00aaa082b067a21ec4b45b1948dd8b003d4d1dc7a2c976c2c7501a7d3f17bbea007c744f8c831b7e19e11ed2aed63c49dbcc9d2ae465480f809b9b8af72e410400471c05391ffe7c2b8381228bf7029a17699f8d04783570b2e64164fc928ea700b83d3c1b1749b0e377f72dfb5916d16288095751f811b1a164d17a228d262c005ff5b35240a6270fda7ba06c5acda87cea69f3ac206e17e0e0aa09f031c6ce0096679706edcd9b1b814ad6b1d7fbd3d5e4d49bd39373b3d18b62e0ef4acfcd001477075f7a689f7f61d5ec9d7061466ecdf9f6045a8f8568b5a70481f29916000e319ce78ada7806cda48662948717ca65371d2c9aeeb3627d17e9ad6575fb0065fa80976f9eb61028ee4a84ec2478ec31b5bf9816b3695c6947c55002174b00612c2dae9fa6c9524e5af3ab2c81149e576857874c6de3439edfacb8b0fba3006edff958d93c5784c010827edc8ed6078a56ed34318c5ebb026cf52c29bfa100f6255cff9cf90ebac6bb68470fafe0d1f5e479999f1889be401fa2128eea5a00fb6ba7a910348df552398e42711a88a201301d2bb11adc5b73181782ae32fa00f3848d513453c9ebe1b7e22dad1668e81b05d3638c6bceeddcd1b9a31933e5005457d847d10dc006b11f64cf631dcd56b5cc37b924b74f349b1d609e61b6bb0072c49f93b68e84adf7514ed05b5fff3bd08a89d3a597db979927c5570a49d7003073f6ff8999302428999238f5a4274b03d79f93e4d18fdd94167e93faa0e4003cdf3859aca6ad7ad2a5d06926a7227e5218980e29e7af75746b2f3b49cf3f00d37d15cb57a1a9a6bf3f61ce14bdb8644ca2728107acadbab7200949ace19a0079fee2f3f763d6b9f017c948741963561f308f22ceaedf3242952dd04fd81a005ac54998a212656a855df4d92f5c0d89c30d879a85bd535f6c5ab26d50c90100a216fbb02bb06067ac4d1eb75db0c17ebc4b8b8a2f1247df27ea488269622400d34704b9a71985f9eb09adae068f1efe5a372a5430f4f3f5ecee7cdb3d9d7300e9c6418615c6a478bc832bf962d02ede570f35c89d5a09c39b3e55e76c897900789f4271d471753214ed16a250cbc11e95651707d4a46756fdc964958087a800722abf42a633cfff424dd9b470918417809bf2d832750bfa4596d5a34e14140041c41dc46cf2a635769b3dd87c0c9f47bbeaefc33653d15d860246c7631bbf00e3d094f53255094f2dfc1b8f44401f4487354275caffab32992dc7a160cda3002b565f1783967f8752bbf7a891790e170e6062b71c839ff84d4f07b7d7076500023d44a56676f8be95a07ff9e2d6cc1dab15ec5b4005968823a4146a093e6600c3c9f2aab7224bf549210891942e83851c9fafe2ce20835084992677f92e16008c052983f3f9c4d84703b98668d18714027d38abf8b36245f0d0bf6047d45a00193c962fe91b5d98edf8ce2544366334f5a2df89e285d05c564ba28558c981009eee59ce40fa065945ffa3034edb009a398f59c8b7728bbbadb443fe165cb4008ebc6e13617ec60f396bab225293f4e31c08e53b3353b90e0aceabcb34af1200dac4e33ebc9dce93a3f35f0acfc8d809ab5a9a9c1c9416b3ca259f07ae2c510034ead7f4e966bb7b80fb975aa86206787cc7bf9d7822ef88ddac35641656e70048f23b5c1f542df1197467b06a8b7e0b70450dba11365b0ad66f7af229083f00706746f041376cb648cac78ef774cf9c24605482a785de534e243faddf1c3f0017a1e74d6ed3d66065485090ac3f2add553842340df45546f7964566df47cd00d2fa2c548031a3dfceeb30607ada042cde69eee5267e0c108b612bd7425822009ef4c1657ab3e4db7279b77224f5b3c37f5304681e981902e5d7d1a2abf9ed00c82803011969472ebe2be49545288ab93a8cc6a694af5d220e2ba060b038c700ab6311ca0f54428d8596982115587adb7b9cd607535e471b8e2b8aa39de18600d17ca21a936ba1f3067d890345e05cff8be0b0064ca238f454c426bc396c39004971e43e449f01fd23761adb13435a0ed6bda79a47d895f1e8ba9a13704017009ba41cdca17ad7f60a7edb88a464da4c2296a40151312fde84e69d85e51e3a003136a44d01214812c7e6997792d1ba68096e66a2c30f6f706637a02866104a00cb6041c96743a3e77e04a71a226d9631779075844f1ff14362ee1cc2fa8c3c00416b49d7ab83acf4c9008ef2952fdabd2b73329a0f245ed36cc07e1cdd3f6e00810bc11139a91851affcd483d7afe3cb7cda84bc50cbce5ac244abeb26704a001ef07103b47359f04df89258735c402c6043de97978df6a8c6305763ef2c8f0051d73ee771873af5cf0030f195822b4610ba10db2ef7ae7c07f824434b1c0d00704809167cffe7bb419c04b564d6dd639f79a192ef918e86d1c0739ab745cb001daf45e74d4755e0bf5f4cb664516f534c8c71dc2e5efd885744bc9d90d2a6000230255a8810361bc17a17d05337cb4b4b33b5f82ad7643f86db5fccdcf7e500386205844d840613ee3ce7fa749e55e008bb694f3d848f7b1fab8704088615001718231cc1abae2f74ee964c2c7b610bc85050b9fbe962d6d24092b807328b004eea6e933eafef1979bed68722693565161305bec64e665d75d076c5286423008c27548d5cbab2d78f76093068536a3cbceb60c5976d816d7da0677b78e2d200b3e1d0ff77a6ab8a697c360724eb9922bfd43cf91a8e06373ab7bfd0d5661900ebc2a2f0f9abadee0de46b923c317453f23895c066bc25f52242d64cd4336800acde029da55db0cf45a8ef921807eec82dcc69f000745246fde6d1c3c95be100948233a0e92027ed79d836bc6258e47d124f0d2d51ad41df4ea38c51bb9fba00c868de68e043640018ce61a42719b5f8c2e5fa005c11b8708bbe885b6f382600edfba17d44a149b2b51c4285acd898859d2d0f9e076769313b80678360ade700b21ec3c3c3ecc6f21a687bb8955608b17b69155885100a5b5ee46bc5ac255d0065cbe5448d367daab30b7fbdc2c6f51727914ede397a6b1567b5a14f7646d000cc30e6afb2491d9f8d0d72be3cfd5544f576d0c66405f4478a17dc64061e6b00e45704c5cf19a47cf72782841a5da92c30b6deb8c05eeb9837fa47d10de0100023d030dd4507e39ff2153454283de6e4ac87c8e357d12af03c97e171774e4c00556222cd4d69fa7da512d817321317a0cd03284de4871cfdaa86ef5815bb8f00a3d14436ee3bc4c7a6a4e39bea7c9bc143d3ab533cd3e1cc9e07a576728a0400e2491329700d773289df399770992715ef8e728a6954886a4c1773e96981830095d4e31c3d9a0c731d1ed525e536b8d836c696deeb59c71f3782a92e69b7ca0081869318a7a968b2bffd4625acb8c90f4c24047665856e2e28d85b892af51400dc68a9fbcb0ee6f8a322a0a4de41bd5393b55be9e5efda234f0e21f9ad10040060ba58601fa079ea16d4edec40947d12e3fe9cd27f518d84bdc3417760f03f00ff734c2ec2b3e80da74957be480f4e9c2bb32dd18a7831333752387ac204c7007226ddc54fba698a7ef0b3d025daced264ad01e468fad09a693ba5c3d9bea300a0e98d9daf6f772c139b5ae54e51b1dcc0028cb485af7a751df4fadec636f500cfb4e98c08b093868a7af57a56bc3c085a22acbec5b64b791fb0db75d183b900d1c89bdaf6b86b346e0c823fd07c200656fcd21b2367cbf35fd0a8f25bcb36002ec677ae140c0f42bdfacc2e2da289b2a97b9e9cc0dd405851b4a39b80737100e73a4bf158ea0bfd67d0a508890049a21323f7170cb9a38d54e2672a4188cf0092674733d97e56e39f63d10e8c9127597d1d29f6bb7a6d9b7fd076db5ff6e100bc83a21c00b0befc28406412b9b4d1f9863844e8d0ec8d669ed37fa4f4e6d300e4cc740ed564a378e9821babaf9dba3826e1ac1999ccade6b387b08bd907560014f808dc8d00d1631be440a0a9bd3162cdf83acabd22f1465980dbdd52870800cc301dc737f17ee8ce936c6b00704d81ec4b2f0c99bb073fa25060473bbd6d00314fe755fc963d602f613221bddf5f313ff6a17557ccc17f9c20c4d99827540001fdc940459e389e44fd7cf40abfe9affa47048dcf4f48c1e0158725a5af99007df2607e3af9d56674780080d25d0766ff3bb6da81460fb49c7387fa48b4bb009a2b4ad95bf1c858ae4b8496d2a1488e5a252f06335bec94404405c8011c6c006b1396016b8b19829ed7a45e496b6229731e451deedd3d715378e0cd740c10005c79a1bd96860ad1ed16cd6c6d7d67d0b0b1480efcadcdecdec8a6cb424f8e00a1d12e83fbafb6b6bc826a1982a4a749926f3ce855f05810f3ad217f8fa97500cfb34c55d05f61a696f5394e670fe56afcc5398384e54415b72b6f2ce7021a00d481147586083abf6d86a40e4946b439749f123c47dfe5dbdc59f3e110974200939ca3699f61e078aa9e092576c65c8efba0ccb997855b41ae6edd8e45bad5001ea033e542ecb6f36b2d917b734613ce7c0c5cee2e65830e7630de89b105f200bb40dfa7af8e03e950c36e0613b7ac609e6fbb01cae8e06274c7e8fba6f8020070bb37cccae66f3a11691a6f26af5b2cd9916de435e840a8f79d5d2040c1fe007d2efb2b84f55b5d6c94065a85d6369f0fd0b63fab278f84900be24754ac100030e5a47014c4ae120c9e1150e83c9b0f3780ff587e4950c24579ed544fb70400315392e6bdf9977ceb7fe7c33f79d184b926afe0babc2c28768e0dd9a1c90d001a6ea9c1dc2098b8c8c70c9ee5e61cd284b7b0789fc16c62db05ea05fc128700ba45ca10201d8cc938096c8d1898646e5f08a1b234e49e10f51f73f49ee03c00e92e39a44f8ab89e4148a059264e0fd79d525732e61427bda26e45dd82089e00e5dd6aa54d98161778a265ec105149b732ae892f53a3b9ec1a3a538eb2af0d00c94099e76024d13a4786f06a4b4114cfb4279b22e87fbe3a270cbe0b25e2bb00068d061d6998d936c3d1455c68396a915a5ed1d967a590dd2c138acef8705100a7f009c47ca4c60149a1696b1077fa51c2fcb59d23f7da3706e8664bb78eff009bbb947b8fd9f7a645d298c7a50b5582cd96d6761ef70eaeb527c3c5f6effe007c15049c57c5679a1420dd6f32e380e012e47cad95f461feaa8997112b8348001fd19a8d4789cef1029ad5f07588584f583457bae8171fe38059420e601a7e0019fb2e8003345eb97c8a375b602efbaf99736ab545fa87cac0614f84e5dd4600b45ddb0b2fa6d1f450a2908452b15ee3b135eded9c8646cec939f9262bf1a800bf1cee5d68b68d361ca1160f7df3e7390fd69f8ea5b19e574fcfc17402bff2004f0b41edccc84b65bbcb4727e9b1c71550ef27a2a2ba172a038061bf1d5c9b00092a2dab380a90075826c7859f8694d3becd26d17543c5086f4950f3585f0a0017c17546dc946548aa439254d7752ff3c956d1a74f3cf8fcde82625f1a3fd600f5ccdf341391f8abc25966d7764fe61103c4dbe9f15ab1bbf74c0fd53239aa00107a8d0d99164b12bbbbb13386345e59ee4f7ffc90f4eae5a353bd84d7a691009b404f23dc29dd951f18fcecd93e9fbc294b91f07d921257e9561cf4b41cb700b88741f69e677982be44c69586d08941cb0c77240181f65c9c10dcbabbddca00c762d9008b00b1e0a3d68b6b276174e08c151bdb0eb4302ced02a0586ccfe7004cf774a31e86b9d2394963166a8815f8f298d71d5eca4a12619d25c323c39a0017da59e683b32dde8ec6ffa9afe0e924e710cc3563ffa2336b3260ca0f60d7008b2b3adf622a5ad2fb9a18a357dc4614078caec4f7272236f8162fb844672e00d3f2b1f95ab6ae85afb378f86707dead42bc83bb8ff0cff08b5d2e998698750095ba4b9ad6240a58283ab8df178e9fdf8a68b49e8ea84c26e61f2fcb117287003f43c07a54ec5dfc8885295e4be9cd743b305dc42751b99b1812fe443a7b85007730274c87b0150fadc2280aeae2f69fb9d4d496501eb5a8b50d6a2cba3ee100c96381a299d8c1758a44ded9228dab8f61e62592982b8d8b91a966443d32e8003c70ca9abda157f00b9ede146702023f6124b9e25a6acb84c81caf5a60158900c0d3dfb692fd832562ff770edf2a3e7a365f0ee6b0f5dd348603dc0547d86500a02f071d71ebe0bde0e023ad35fabc6a6a6feebbe0415c086e34b1edeb6ee6005b24f9770122e7aeadb8c29828f3f2bdbd317b32d4cb9c1109b7bbb2456252007a5f84210c25225086df11198b5fd78f3196a6c6a81a25442c80d25a718cc400a73e09a2ee10d72b5703e3cbb956e3b648866d9b1926a8d1057a7eb122685a003f2a1f0e902759ec9f7a6c3051821772275e1d3fd8e723dfdb11c2986c9d9c0084d2e3028d742cf2b3feca10d77d4fc192d43aeec2801a4f22192659db00c700f77d843a8e56692a5bd4a4e9decbbfe8e72cfec441f9d65f38e493e365eb5900595cfb5fb16ae598af9532871fcab46c4fd6d9dd98c18ab857f2be65cf82c600fc04097be45af2205e0600cd1e24d2f699943b3992e950315bb142aa533fdb00df097e14098492fd23ed741a2e020fabd9a0d9c0cdaee7c533610718e52b23003f2c0b76cdbe908eb24720b6db566296dac39c7423d85cf7b1d8879ebe9ad30052d093ee1510aff26684d2be2dbeb9f9e90c37aefbeb4f96afee4e5873554e000e08a1a4989d42838b7670141d9263649e7403fe0319417e43aee412e570a400f15c28f43f4d1357a43e462dfb491b0b208fac9ca9bc698d34c2bd5a1b7d61008a427dde96cc02123b6e7214b244d690392a6bc909f7b44eb20b8cf447d2e300862b19a9a733fa47b312d9c7fafb4805f59e2c49b18417c6940b72371801a3004624792b334995a405e87a91e196bbfbc7d991d6a9441d88c4a4e5c3d423af00f30f1f05d2564e4d5e32c0c2aa8cbd37bf84e3ba8a7ef57e65a6210d36ea49007516f0d1c6d2f09be741285ade0ddf1cc7801dffa68ca7ea12b2799f0efb79001476509fa240bbda1f0bcf10a67ddb6a2cb987ab3a32ab9fff767552e40eae0004a045bb314734ca60c6c7e7b2c78bae5696b10637033cac8c3ae7b44ab6e8004d766c7df6e11801cba6f824224fa2485003d461fa61c019272ad339500ed9001e807626be8bbfd3e1d5210a337c3cfc78244f7d276d8361a3de34ed88268d00c46d0a0d45980c13a41ba73df60ae9ed16c0ff7ba3cacf4175b54beef6275600c6a7867e0fd0617ca45ddfaab3271949fd0497b13c55ced1ee2ada452753bc00231818218d03e51271c76f912d2d79b8c07467cb3e1d0f296d9b37a334854b00a4ecaafbad6fc7c54b00e836fdf6f5b03e771cba7bc617da1048b6e4f3d3b6000b34f77b9ccdafc66c3ff1c015196238cc2f61b5e51a9a185d9717fc99d08600e725e4a021da7e611679604d2172500192b33a39ff24a31bf6d18450a65825007831b58c5e20a2132692169c00e5c55459657a8cf8cecab58dcb9bba2d275900e0a161342e6f14140edc9fe32635712ed58d619c2f07b0a8623aa68beedbee004b04770051af8ab9084428743d7a1008d8bfb53744be188769ecb8b65d431e00033a7b8321ae84740612237aabb1eeacf88e1d38a28cd2f7ff1bae775ea6b9005e9914a27efe4d89c4e38186dc4c3325b004f0984b9e1d71fdec602d454f4c00bb6b303407a8fb53ae348008bdd3db016ebf85144f5eea4aef996803c6762800bd4020c79a361d371953ce8cd437bccf567ab7cd30966caa245cd7cc0fad9c00034a8131dbb952c832e2de43ebac20cdc5fcf182e621905d67e0a3d94ae0ae0019c445e66f37c6d414c8ae9b4cc6b73c2408887913d22706e0a6d632e153c4003fa44d16c43b4a3e9e3e0e3133f53e43b6e3aa69cfa724b0a0df893f7079df00bf49af61eb62fc122ee25453c01ae33e259ae1d0fc980416218163eecf76f0003e4f23783891691ff1bad13424dd9cdad7c189b2ef5bd4a03178ca0a4dd85800f69f4bcd8feab927a877c5c8dd2910c7d62d7eb38a944f50af3b6fb74c6e43007d55fbd1a7ee8de87338fdf42efbf56ccfecef3a38a67d9afc33658480145a004991388f63aaf0bd153750f5ebcee791915aa02bd130ca98d2b6d1d1336c0a000056afe79494d022816840b9331e788cd0332bfe56b2e2e5bafa55d5d229a500bdb2b627023e58df97e0dd09fc0aa670e00389b3f7a76543b412ba2bdc797f00ad0bc6f2a4a0c8e9b4d4f9306a18832c7ca79511dd2c404179161853295b1600f8951bbe3a851c452c30e298066de70cb2afaf61eb4899b27355c6e154b9650022d3d31c5a99652e98a39370b3d96136126a8f0b5be1a2d9c3b5c83cbab3c900ffb948e874cbce1eadef66718a3343aad905e9c2cb78ce722b5bef0ff3326500e44812b9eaa5f02e4910b8f6e0c665702cdf236d8f6c203aaa077adba9bc2200c37e7fe382b6e50f19b61c83bdbb027dc170f8bb62e8dfa00cbd34d2e4d10a00555dfef86dfdd229e90987687c94801beaec01ec158f412c77d5a989f23bf800247b560dde0e4d8a3fab73e8b6f4f83b3681dab299b3e033557f3f0373d57d00fe7ab1d7a089f7e0527ee9ffcedced7c139c9ca1772182feb8361e0b3d79040067f6aab3ebe9580dfa5fbff25b3675fb5ea3f367f6c4a9b68ace2cb81cc1dc007cce3ddc19722e6d994858de13daf9520cdd4f25ee54d0395b8d8f0311b4db00c3f5fff46504452e892773f2a85d2f6ad9cb84ae87a3390762fe39aecb3d2800c59a914d853b8ba56ae772e80e639245aa1e8e93b94b4536c8b742e7f53588001d6ccde0410ca4e88b4f35035d71cf3f43f8e4013572fab0dc0471b849d6d400c53bdc6b3b93de6efc9e032792051f51a7e1c12d7582ff573a623ce45e82d2006a7e15e5b9a20cbd8b90f9a97f73d7cd0530b1a524b61317258855f6bd97aa000920ad4e61e9b4fd2c27e5159ecfebe4876241cd1d984ea568c084082d1eeb000c1aaacf4154254a1c5c3227ccc005d00e266514a6f072a1c8a5ba9dca26d6005331205cf6cfe63d623943ec906ad795e4f8622b7e1d65a2bda280389d463500c7c362d4d82c4053387f8be8408a45e6df42abd1fbcacd573bcd5f02ede79d0011e7485a750501679c2f7e86f9f1fba216bba456ac5b7c84b3a7c3d6d1167000c49b66f679c42fc8740feb9824168a4335ffacb5927219872893abaa411a6300a61d1cd191aefd62e3111ff7c2be199a7fb12891323b7a45e1f08fbbe81d1700ae42e2671517e2eb5029489f1225fb1accc28ee18498eb0a8fb82e5881bd1d002db7c971b819b549b349eb10dd8437d072d61acf1d6b14a9f00a80f059c6c700a9a350770556b13e0eb70b16f6eb91de2fa2c85890473ea1b6de25ccc64c5c005892dbcb2c82c939542d0c6256a33cfe4d56b709d52caaf3de4c10090d153600c37b4fb79911a74571ec72c4d5ecae9ac730136d8adcc9e7a0e64ceb924f1e00ae96f0684eeafd591ff9138a006c998171933336ac5283116e922a36bcd9e90057d7ae03a409fccd56f7f66fc9a185ef322ef3064d3f8b36e3b9663f130856009349c6fe273a3c0cc3539b3517513fc0da48903433931e4d929269cb80873f00c3149e14a2964f398209c30eacf6e0cd108117a1be04242b75706c6d2fbabe00b0e7038c3249cb78fbdcdcfed2887b3bd43a2d1932f838302c2d6ed62d77f3005ce82712687a615d44cdfce03aa88872f8337f13997d6eac5ac02d423ffba3001f73f1994f3029f4f869b9043bb104ec7df9c33bd61cf7abc8d53f273bcd34006233d0036d4933e22ae6bcf8eca4e7eca38b0acceff9b2ef6a8a06b88b40f300547121b0c973d0a96f5e2601f2e012138f3215970d81b041d9d1af6c09c00500ed4f9b6cdb004852d6f94c801e7a72f5cba2ae38a3c880e1cfb9a2b88120cf007894b0716153bdc19ee663df6c24ea051e99b9bab55810473415e46909018400630a50f2b33a79ec8d893d6a7654d6972fd89ad91afcf58b3c8097eaf0f83f0067becac9dec831f418ea9b8496e15550520d12a528009318fb24a7c35e42bd007f6b2255de36dc6fc566004c60a68409252708d7a937c2d7cd40f60ce8cfcb009ef2975dd4a4ba96a64f86cc20e7683750d73414ea02e4f10bcefe17d1a9a500c836fc98b48c31305516f0237bcdbcc33a83ac77c12288ace667da23dc1d0a000be0a13ae8a8cf053e0b992004adb4446705abdad0256ef6bf7c829f6184f800f3d901010f7f714e2bcfdab7acd3ad4dcf5496f519396c6f02f086467e476900f97b0768478b44683639ddf5e61bd0d03c96f567f93acb57d3caf4bc9b084d00b45e7d26e027fe06cd9c552ab8e1742274d47ecd96032b237d39f8438e109b00f4c6fa0730c97a6d395d49ded12e3ee061343df2bc03ba7353451a24b6a5820039ea783ec54b203ca5255e65e7643eb278336e32590279ee135119afa736790011e740f41d5d2888c3f304cad35755332847ba8ce2c457b59f59f1404f647800954a8e226920f8e187c7b56a7d3921aa7ff625e4662a552861cd167caf8631003d85d26db874abe698ac671c2b564f52592292a4da31b11761242477e2e474004fcc245deaf4690453ebaae8f8bfb2864339d8a08cb33ae0d2139f8cc810cd0011309ee24f729dcc8212c02d08d433ade75e50d83aa8e8a43cc4da9ca20966008a87264c24d1e3bf8994f4ae043be25c44df0be15620c202bd1af9572b5d2d0060bdd38db4a5a8e32712a0b4f75a020bec81686dae88ef4854c4079fe81ddb001bfd416010cabc73e68b4fa5b73234c4c9df432cb9667b0b88232fabab5b4000ee2abca0b3b423cc30a2669d33ce1e144ca257858f44c385785f23573a791700e2ba167e54b41a2c768e331a0d5455a712ddccd0237d22bdc6a3d110d75d1b00a50d72b303e7973e379bf17956a06754cfd2454ec203db31212c39e2a2167000321db3bebe8cc75b65e90c1994217af13f51f9e8e0949460f819da4c3b8cea00cb8de54ea92fca8409e961f60cb5f43f74caf6fbfb64566272bd511fc3e1da00b3eb6094bfaeebdee37492c2b14a3be86e22534ca879fd2b2e3bb6c25439a00090432a073d4d2e2ea25cb0a7963b960352390a5bf993fa310548ec1aa08bdc0036e9e66df89b73c72911c3ea0acd585ccf87eb9d80d9416bb7037c84efcf950023f700fbb88b957e1d5a91651f26bc188648d8fe2e03cf154c33c73e1351fa001292db58bb4c8355d17915b1e7ee27d7436f2397d357fc6e82193881d20fb1000f38fbe43e3cbb9b13e342a4d635616f3e750a666ba1a5fd0b5cf1d4f9e0010081433787ffd0dfcc6300202af0ae3ab687055a38c4e0fae6127a341ecef8a300d0bafe7b31db12bc6ab840670f25221281d8c02febac26a1ca4df12d99e7e7001c2e077c64a91ccd6f3a3cdbb2560fb4cd7f9ca6e51d2c7fe157e15499e663009daa71aaad7d802ce6e720b4fb09b70371c2d5d5328c0af6f033b415db315a00c3bf4dbd3502672e10c10cccdcd61eec70dbe42e3d7404d93b01cae45483290025657dccc94de3e547350b1fd2fb4d55a4633a43e880a25a28630bde808f6f00a795ccdec2087973744aaa5b06c8741be3c3bb54bd418b96d540664e62091f00a4ffde1adcb27976ebcd690227f2d66e37a8b7a8a8aa278389cb31e63f481800b6f52d1b484baa2bdb05e5aa368bb52a2e2ea936a781780110b2bae691ec4c00427f9403b6cb0616bf8df720b4b7aee1b7c31e6cd94ece322ecd302e0856fc0068278fe2a84a26b0781154ddf647b9d67cc57f4534e76ad2fe15746a51be0200ed344033d66afa50eb5aaa5bb217891a720ec75596752efecb50f727d685cc00990b34806094b118b15eab15ed0106ab76f9c0a8a6a9fdec56aeae199fe5ef00445a69d746eeb578552d08ff3ad5d3fdffd5cca7fca1dadca1ff93b150dcc20032a16ceddf52c87a72e30ace495fe85c45972973d4961062e48f1e8ac070be001c0f96e1d0e833f4ccbd7e852a6e04cdff2d40b7fae91156ee65ed3513104000fcc4a3163ddc16337082de9cbcd771c8c7cdb6e6bc02f0ac769b5ab7edfaea00c52ed7661db5fb0330cfd421ea28ba5422641e71da5ceea49f59032137a7b60048836169e535f56cc5613ae8d025185a514efba87ec5063fa692ab8a308f4600b29cff7fb7c522d3f96454ea774fa1cc6204a662f5ad5e2f8295dec1c3699700ced4277293313dd1f134552b9bd87195f80253418e8f39222039667e299508004ca36f9be6446658549ad6107888e95ea42b05f8a03c1f0c026e44bdc8b7fe001a9b47cd806bc5d5d9176b8990131e196b495510d06af7a8830e400d353b1100aff5f9ae2f1e02bead0a80fa88d419df24e71051dcc89ae36aa1bfc47fb75800dc3d15517ca587e2ec607a09e9355b727515d1e6bdd99c383d9a5d069b67eb000037ab2e4c4d97d8477d48778aa4bb1fc9a27033ad176833772a9e7b975ac0002decd45294336b09a219bfb13785d6640e4a3e754a3fbea1d78a5cbcce68fe00f4ee53979e2acd5aff6b0c87c153dfa92bbad1be31a122972534979dc39b7b00e25cc7522de23e554197fa98f14e8c98368d21eb4cd0ea58bf250ce2d1720500077e466a686df08090137f85f29e02ad58e7f935a6421a106d6e7b4aa00c6c00e7706a9efb658006cae88ca073c307acd3a7fa7b46fbadba3181737a85cd2a00ae9260cfb8eff7cddcf4393ac41156b81bbbfacc4e44de169924c881885d340071585f97f6e4b6095541d3b7b7be896a0a8b6f465cb1b442d1b7db7e3ac44300ce52a776428d9417c484c7f8577f4a1428b6ef1363e97dc6e7e292334681a400bd8e25967947361a7387197919ac9c18aa31faafbcd3e21f6b4bda650c9fc500b910b0e36e5e4329da70462ece1d4b46b0da5afd77b7cebe6f6a9efc9981ba000fb26f936e299655148f0fdf97e54676f00f975a277bc71075799561df57aa00a904f8eb28436d7f6cf70cc792ef776047a342e0d2cfaf331416e5a78c56a300c941e98c0840dee3cd57faf282aa8df6c2fc95d935d629ab23f1b12fb0dea200b813b6a60aa9c02c631d29f4a498d7fbd4d0a986fe428344a2e03ed14af26c00eba19f7ac7a2f1c72434060e90e7445a35a90f57caac7a22e90c4ce2019e5000708132564a32b91dc3463fcf842999ae0840ed76cef46b6d41bcc2c4c17af50014ae5dc6c3e31179ff691dbbe637e193d0b961076ef2aae8022ad8d8ac80350006eb258a3307eaad80bd4853130d3641e78f9c55f01fa8e91e3b816fc635d3001e5445863686f3b8a312a2d9ae52624bf8d3d8476d5129e71fe5627cc1bad600e9bf5b108896300332529dee28f2270b34c2470886236202ae9b20c81425900083f2cb9b9ef90ac1ee3ac860d33323c79afead354897ad2dab4d9919eb4026006ebde5e4e302d7a28d237c09a01e0a4d0c9e6266fd3fe94e4b0f6a71e8c5df004d98018d824a01d3fda03b5b6ba3f5d51628d0a852515a4ca99c40750bb84800cd171112f759bf5b398242934d481a16aa8b10ef2abae6ae1f4d62f5fea03000d2f3edd47db0cf4977f89e986ae90160db32154db82bd57da2fb940c3d46a200050ce6ee4484c9a8d4e98576ab388cda2652ef8d0f994c5b90a2f37004dbcb001e7546a16e958ce243f660c17642ee071a4bb139358772c113b2fd84c7d18300ec2efae9573902b64ceb2fc2f5d82a8331206f3f89290f18ff3ad1d1e002ac005901b0feb601fbe9d0127f8ba2ff58d6e71217b70ab06777b21b2acf3b117100631c9ce560e15f7a46b4829d9a91b2b0781ec0248cb3cc7296ff08a25c7f0b00c36982daa64c13416d6188927ecc3762d3f7caa999c578e00cabdd2ca1825100632859dc743d5f9ad1f02abf5ebf5f90cea7181063d7bb0d2f0ce42f1f780700f1705a6324c4ca63ebac0b784c772d631f44d4589fd85fe4edfc70ec76dfe9005f6e97a41eaaf230353d48eba1933d4a171b5966a58d666a193f6669a78ada00de771d27e78d25e77d25c3c34f940a67c103d36f60a4c5f9287ba065a01f7200f71f954fb7ab5d9bac80d799590f3868c85eaaad030bcdfc6be64a93d9ef1d00d17aa0c6c8d6934fbc8699d36e9d3be1e355411a6ab4c425ac127f62a9b70000aa24ebb8617a35ad2acd13676631dbb45f6e836f58091e0f92a71b7b7e8c5a00864910ca94fa024285dfbecca28a683c1fc4002dda2424e8441da0c62a84cf00e87f27e19d1b8cd5fbfa5420319e53d10c3bb25bd8224fb4dd05cf37858d5f0071a52b322f71b208f98579a476af954783ebcd8e28b1644cdba948632e986f00c194364eb39d2106edc8d4810d115531c27bfa625fd065b09082f755af18ff00f43c4938229f6d805a15e630b66f4201a6b86cdfa52f8e67a504a357b27890008ec225eefde5767a1c26434903571875dbab894eebc5aa49c7beaa97d6df0a00430befd0767777cc5cd1d7593d07437ec0dbc5d8185fda4c5220889ab463b600a999b7e9436115d8e53b5ed1a358061dc785caa0f68f2606fe435f93a3209900126e6bed6b25fe924d8f9703cefd2a71f78c43bd8e2b842ae4296db075c9d900bc183f8fc456ae40b00dd19b87367182047302988922f7b8a7018e41bd18ad00502bb5ff05dab37fd96999c3b8b8129606b5c607e7541e867a605b1f0ad4a700ee6e1922bddf16d1e42ab0e518af11a0c7b09fb153b78d2c8763c666e6c355003e6cf10ed6ef64939c1852a99329c09a3cb63f7ecbd9f3dc9e8f64194beb230084a15008e72a0a3349caeda6f9171e0dcead6d9ffc8204c55cd870d8f3575500ce1f1b54ef3d8a49ffa4ca44f5b8419de80d30820c5f9b5ae0a52e9530bfc100163f194a52c8cb87a92e147cd57d7fd8719b6936a239f2a46757eb0b514ad100bed61bf1d29b45f12fe4d0c734a48454088a2cf1ca848ba6a683192493e3a60009a37bf796912de94f7d454587e3aff40f013045c5025c12dd331045d952e6008aaebca532f8f9981a51e448de23e89957b34a7fa05683520ab2d06caf9b1300ce791ad69e5df5cbf6ff1efe2a858f337bc6e89b445466a3194b8199c54e9d004a57a5ac800f6bf833d06ad345419c771dd824aaa1aaf87b2d158b00ca73c800fa7b4cf4fe01b243ccc854716d0bb8a9cb9898a7ae730e92a8665976e79e6f0014f888505e885a8f0ddd9558a766e994eb6b18ae9afca7d7509683486ffd81005dcaaa6850f9a667c5eb16a4b091f6e1c3ea02ca9a1aeca600bfbc5f87584800132495193af53ac7876de8a48c7b304c5b5580906e809c88c93c3e3c1e56bd0063e2d4c444a73c315f8149708c0f69d771eb1b7c11ae5c07c91d98769f993a00fd85e9a9ec29ef69fb21d841d2053bacf9f6d83f344846b4187017d3ea360100ed41ca7d63801f91378a6a06a7bb2f93c4ac8969429b38c90d9058db719a300074a8454f95d688d51d534438a6c25eb59b2e60f515163686d01d77bb18eb1000ee118e7b61897a9a8cdec52a42beaf2532910ec133821036e1ef70943b263e005c61079e7dba4b81624b9c14e97e26925f5f7d694dfd6c4da25626bae91d7a001092fe761256ae34908be0b3b90781d9417944e2dd1b6afed5e6bae75137dd00d41691b7669c93b78658c08e283ce9f57bfa39c5f4b6227a711d7492330af600d2efeee7e6e58739595721f3a30a17b11faf783160391934cc39fdd5cc61af00d11d7c0b4f8cea46b1e687d587d626eca7fce4c3b93a4d8e8369a1cf0da719006bec7e844c3954ca488609ce565c59025f8a2db51d0b51bbf490aeceda5cd500826613e0883b091f0f6ee8591694014d622003e36c16cfff507188b74a58d9007f60cbba3d5f5a0b670fc0d0c1e94bd530854e2bd02760989ee6dc83facf4900c9d5630d9815ea213c39b5cdf7948effa21287cd449accc47aeedd4a04d5650009a52ae8dc87de94f8ff54a41ab05d1e9a19dadceef7e3bd03c67de0d436ac00736037380117c63cf8002fad76ef610c0f0246a9c0e2c6d888bbf2875cd9d70073b8499fd9248900c39f0817693fce8e9a541e90e35181087908e9ea91aa5d0059ebb5e316404e92ddb17b186c5c7d1a7d7c7b53c26221af30e80be776d004006c80df6d42d7fe9d425fca3ed58e570b37f7b627c77f93e711ae6952b028e900db99fed5084bf41c856532aee80e8105ccecb8ffbc4ff168631eb19fae924200408034c35549ddc976dfa0f7e38d8b669fa0563243e8254d900acd09dbe84200f771bbfeec02084d9a7e1e54b74d4469cd9d36fccaa526ef2728958b6897f1001b1c9e9f689c5d492f9924490f143f7368d36097e3e3677977a8e4e59b11be006bb0c7b553e37efae6dc5647e9238130be7b3b53662bfabfa4325ef20db7a60031335f2d94e8f8e210b93443086b9f3f6b8b20fab566d841602ddf2967ac0100389b372636b5c655848fe4f9390736adc7e5af7e4923737dd32f94e867fc5d00d2015db52425f9aee7ef76b192d537c5536b1c6d33434283364e7a81f3cdf4005dd0f9df96c8ee185bed0bcedb44c634ac6294900fdc280338d0270185705400e2470599a28a7711aa6f1c9a8f26c9cd225f4a6fc5da7849d366e1d4b3a93700270acf0763d0093c7f8eef8e68f028976412542f889c15b25c6217046ab7690032a4a49aaf9f91c3263b029bfbee883bb6c9769deaa1dbbc5f4e783923042000eb75337ef4ebc12e33ec9742edf7b8e14e966caedb8f3c565bcba5bacde972009ff6205086748d54762bda258be9b12982af87783c4b1d3aa23b9390a51a98004e966cc461d4e300a038166062ea9e3d02b8c0714978d6912b1c50109ec90700c2a9caceea50e8f709667ac3b3014ca2e06628b56765b8a54b5c3d8ddeb77e00268ee728c1bdd0e287526c733d6c2d54547e088033eafa8d05ac433aa37aa8009a791552c5a17dbf794c8584b60012bf712c2a012fce94aff241eac3895ddb00e9ec54a51c3958d4a2ff293f5f05a66a279c3f190612db4fc84d2210636f2300d1a20e80ecab9bac33cc9e7a74225789a6cdb6458ffcbd594c42fe02223fc000b3b19ee7fe8abb291f9f02fad5af4efdb0086b35a1e357a60bde1d02228d7c00629634a4a033ddba3cb63425c050914386ccf3df38463f81dd2417f7cb1f5200fcd8960a3d7b2fa55290eb0a210e39fec23e8a294c1d9b15d892b9d5688f35007f0df218dc5290d42c50271950b2dc8ffc77a5be50266fc9299d9ae4321ec5009ef90b331fe8b227b23a30c576ca903a86a332356b7b2508342ea26ce8909c00ada311b509203b4d7162c7197c538897d1a309b8b9d0b861fc0a23184dd642003b9f52184d6801a02c12b89236b2d53480c74363ce9634ba331b0c44e7cc0d0061e2cd9bca518a63c01bec2de946fce91b60f481cf051454469226ef882d18007f600112fb313bb2186f7261715ff7c0d948204cf7da4ffd9120984f95225300cc65d5b52ca78d094165803a1b12bb5b7bb43dc9a47704267ca755350c0acb0064c3928ae24a86e89d399503ce57ed6bf79d7331e9ef7c79506f45c8a9355d00b66e5798de1dcce1e00982354b2a87e3b6d6a968878930e62b72a968a32cd500392d895defe8640773212714196d9dee32ba25626f1d76eebd162d1515309100777dccc3f07d1e09d0b3cd7423e3dd6f8851aec0e7bb431b8bacc2acb7279700d663661f9e358cb6245d449043bee45a27a73a0bc5c02362127011e0187c6a00f7a5242bbd7b9dca93bc742db6cc98b4e78265275a754cd369f9a6d73c6a00007122f03d7f1bf34f9de55cc6addb93d825c56af2a5d7b1e2b5e0cdd2a10cb6008cd2bb184841d68a37f64e83c17f38347a6f39bd7d9c1b4e2e4bbeb930a9400064d8b1c755338cd7e4fca582435ed974c07c976e2a2d2b8aff4f3348c04aea00a056ca5d07ffbfeaba0d331616f5d75c7c21a56885625c122c03c8b54cda5a006fd642855e40b6109979fb28c1eb3e4c11e36181380eef61f4a410c81ed35300103dc8dd5f15cc38d10759c14e3fc0315347a0755fdb7e54ed826a44275c100063e4600ba41873528106f8c00d5e496fc5d488c8ea8ee1af8871430aad420d00ec3c8fda4cc5774f3c8e5651e0f7b5b22902d22e63f1f967c7d3ff70898fcc00cebfe3841dc2325771a67e02de09e19283512608e273ffa30ccc008103d6df001b5de3620c3245e4bf60579a29607b1f8d06295420069b5e2f23fb1652a891004998c1c715ee677be382b3d4ff6bce135cb2021f64f2b46d0513add814553900ed81e048da48290acbd6c55bde9a760a869fe9fd84345a257d2cf6db46d2d2004c81a1e863bf926ccee1a3acbfdd80dbc1308455d3b7b7cf7d1ab7bfd3edbc001af32999c3b6ee142890a9c3a611e7beee7ab684c9d0c76f961d10802b56c4006b7f3052163d27d43c070fc8a1ea39d447567a4f0711df3111423a9ff413d50033ffe0d4c9b7b806bb93a2e5b4ca75458de4f8a64bb5e6a1856dd708e393360040955915409b362c504bf9567aec58a7b7652221bcac969d2bc76a5acf76950078ec285e99b6390dce65fdb705bfdd30e7a174ca10e78aecb0b4fc38085b2b00300311e8b24c2c58695a30ec23350f11a8bbb93b434597848295a5af03ccc100b9c54677a752a93c4ff71e8844bf31e7bcb6a29840cbea9e2050e4d4bc993b00a4abaae7da376ecc5a1b0804611c4477051e68f0d87308336d004792eb9b2c00e48c6984d9952854e9cca0d2a39ea63f8f7b144473a43069ed7321c582d06900526ca99505533415b28f987bb5c6ed9db99efacacbbfca6451cce819a8ad5f00aa0fda16b71d5e3abe104c38749df6e8ba3a314b437d8aab54666b6fa25f3700ef49a197a56bbb38c26a5c143ae5d9cd0614f15ebd738bda7d8b55b801ec07008dcb5fd5945ee2ebe1e82010c26748579ccb980f7be8119ce25cbeafa575a000663e558c4bee2303e8d3b2afc4acff03420d61bf3bc6d03706ade0de708a6300d50bccd5eaa999afd5b8e3f9b135e53a46e0f7d5b309fb966b2a202dd7d79f001e7d9ddfede2f0db48beee755d897ccf63791a8020dba317e227572694386e00f74b006a8790c0049d5d7611daf2bf9288fc02ac293b16b12cd6fcbd882f5e00bcdf487017d616ea5f4796fd8e1acefe29255980553fd01606d00809eb96b900e8c5325be7aade897b423df707e200d92ea297f6b847837660a82cb79a754f0059662900b5591bd4b71319335354413b5c2ace8dd7c7c9e00a849ed492aa1e00ef062475fd844d8e27ff6e45fad95a6ddc43b1a86272b75ff6bec3a83e983c007ff8cfa46e2531176871a9fb3ecf33c69d958f7053d1ef0ab7e07a272036340014b6f0a5d3a2246c869e06bc407e98f83a98ef98e92df5c6d5290b8a04f818000553192f20b7767874589f00691e0a02f1cbc7d7268fee125dd4da89d2723a000002942276883ac5911c518da0f277dc65f004651ddcb47ed1b60199870630003f9453d65ed582a5b58aed0725c8b4648b0e49463bba7b82ff6b6ae26caaff00991754cfa8093815f49e4aa5082b4df6957d547eed81a8f126e541030b8316000cd9c3cac3d8302063b84f5a71e8789d20e0bfb3e4cb8d292cddfe472b082d00d250fec7c4c12b61b35567e04b67867d9ca1cd0391fa10911edc413ac0506800b93ef3b2a226d8cabddccff75c62eada2e3dcaec6e5927c10c52e461ef3b9e00921b007f4061109517c3cdbe982fc56f78f3af341fedbaf85413f166dba21200a259b640338a43cdef2c4086debc6c9ec18be2b12911698ba5405bf2f9633700318b39f570fc26ec5d004454db299a803bd5800bc25edc80f7adeba7773e2500a133e1fec6ac66a276b3a524f96bb83e4ab63fa5cc1a94f1d024c0a5c081870002602f2db886c94cd8e211179c6e568dfb3ba8c9a254cc65443a0d45ac8a4e00bba5ab66c0dfaf3eecf32f9688c28d8779a52af1517d19c59755cb56154de0001c3a1272ff90d7ca99017341bb9fa6267ee3afe4a017b84bbab41d370bc249007a6677442fc3f32ee22989f4c5eecbbadae4b0531774200c51fa2c200c9506009756b8d26c5928a3c2440d0093faaa48535b1cad0d4dd8dc0bbe47c664d60f00eee61b2a89e7e69c26c391aa3aacf64f977996ebab9c205ccbfc96f24f3aa50040d40dd8341257f5645e699f45b903a0a5fc496a69408b107c2f23373e631100113c9b6b8754820e5fb92c895a519803db66f42b0dee6339a70f901e2a836d0091913c713fc8265adaf43afafecce33e95d8309367e5f73e25903804b521eb0027826809547c5add7e63441e22fc128384c568215756cd61009c7ed450b14d00cf9eea9a0c1ad4a329720ffdc731681892af878aad3fba76584acc8dc5dc6100f99a6b145235a865ad1705edb0b2d9429ceedd2cd096dc4afffd006fa328390042088591a03e041bf6f378e4d52f9b9dd2eab48478a35255b15bd4bcca521000b40d7790cc3c0ac8f6025458d58661861e6f0372eaa81a4efb4f190a0f13cb00c529ec0885cbf442c12cecd01f7a56ea50ef6369cf1b0a3f32c78c80cb6c800002da0c11befd31b878530ddebccedd0adcc773a3eb341c8308a6e5202504e900534cb465c0dc508a5853db1a123686e4bdf2ea0650cdcb7bb35e51e3801e3d0013a0e20188772498872f5e0a81548025adb0405f7970c8453804491ae75eed00b160655eb3fe8128d5ccd0e3104fddce1e144d23c87fcaf4caa5c17c43d0f6008dfe076db570896c1af9251f6621ce94602be03a6fa74b850472b19dbf0f5d00c0247582372674faabb8875b891ec9b150bb6cb0163d4b8548812bb0f7a037004f6577a1bd723121b8fe5a7d1d4303cc6fae079f7697c847f00cbc37c9e00d0021fe0329753d5895e86554daf2fe7c5381c6f37f20f12f31953bdfdd8afc530086cd37b3fd7dc278a5e83517b01695ee248cf5c6027a8aafeb0afca2f44fab00b059a75886cbc651cb5c9b2294245d27008bc14cfa90903aaf9598776e5a0100e45fa9b8468fde830d0ff225ec545179843969997b01594d0881816160a533007b7820ae304003445627d815c6da2bf4d228566703868dd9847f4ae36f86070096d610570e5c0a866a09cdc0770dea8550fcbf1d91ea62e1a2f06b0913c07400d3c3bd6e3c4a8db05ba6c779a08b01dc36af4d79498128e025f1b290da408900dc98d96c5cb9aa211d116f510525be1144f27ff26782b033da9c80ae6aff5000ef47a7a0c2318753d8b881773b9813a9549557e73a897d34307af5c4088808001e80340c4eebd4764cc9907919522fbaf5ff34541b704a9f7ce6b4ef9d2fa600e69d262c6a437ea979a306e9b90c0210609fc689ee4b3bdcff9121345a4b610012fb2db76166233a3e9e6121126cc54a8d8a8bc6f076fbe03f037bab2e393500d430ed005900dced136c7d148b2045f9f9a9b3df5f27f35654160dce78450800bac639a4e1a994017cb0c6e38493b99bf3e7b25543435dde307a866ee4627400dd584287183612df45ca27165001b69483f3d623ade62272dc99d03e231f950056f1c3f732a2ec82bdb432a7eee218d7ebc965d3d0ea5a1dea241de6ec2e60001652ff8a9347035d9d6c1327c9367a3fd37bf1c876929dfb6b80eafbe5f46c000bbd0ee140e77999f73075d95643f60a16dc3db08faa67ab920aca89bdfc590061dff4581ff59988709013587a29c0947db78c136d321303a410087b82b18c00debabb440de3d35f11d81afee03ff564866a74bcc92ce7276444ba2b2575a400d1efe2615e146c6203a14065458c2688c4c7b38267d6cc2c4440b9df1d78a300feda08026db613f4d6a9b7c6c6977cc6c5c04bea3f0827fec32afb17de96bf00bc16fd92095f9a1fd8478f33addc20a36cb99f04fc204373569c92c883cb7f00b43060f24ce5ae2437f47b09c85acb54c334b5a20d27d3eef81c50de3ed7e400606c58ef74f5c2085050aa199c65c46feb52bad8195c858f93dc1a958580ee00c60730e358be9b18964e6bc2cb0ff5265bbdd90dd45d5d1343f6e05db095d000859711cc5d99fc240170a98bf03706768384c0dc45bc44b8c6d31df7edcc7b00de30f8ada4eade89592a7f5e1cf211449cecbb192f51f720ffc9c40eacd3c800ee7174de18eb79a27f79d9c615038b237dfa6c3a702cafef6ef37d470f046000c2a4c40591b67232b6133efaabf0ff31ed683faacd9ec669b846cc4d1b39b1007bd618224422060e5f3199b2786e669acfd74d502c61c94dc9b43c733f9723009c264cfa39df826b3517cfaa5d6e94333d25d8373a499948e32d72dbeec7df00b7f04c6f996a378da3f91d9692c780bb676ddcc3c1f93dc52a5911d8ca1fef000cd6aa8f8568d5b1b63d126f0cd912d532d4b8727664d328462705cd33714c00454bcd0576495daa9e7afdae21f4ad8f0ae9c6060855370007799d8b727bf000f6dcb39cc62f811bc8e39a90c28dd9cf9a2b3fc1c7830f0fc5ba2dc2a615530071ddfed1743708f7676c23ffcd9bb351fc0afcd003c11691e82876f8062b8200847fefc90a09a7be393faa3e991e00dd35b4099fa8d9319184e7cf5337758300bde5d0e6f073882dbcf872c69cbebf0dd074be8a06421876df17e94a168c640064392835e6006df88855856d9492084b4c68962b7399eb7bd72c43554c7b8c00330df1e9b966d0a3dfaefa8af11bf01cb658b75d91f5bcc6249f3740ad64cc00b406ec4a9d32c8a061d00241319605672efee9df86916c8ad061a0d1ee021d004f8518fcfb2148918c35c8a243e0db379cd0a097d787d1740ac228253fdc6d000807d1e65bcf7e752c596e165b5e6d0b68ccf4aea4efc148d7227e49e1095d00b3c67c6c340a96e9d0842bba7a0d16e49e0cf1f33b052bb676357be2b6cef700348f68906f3555f00c04093bce1d58879366b36fe8c8347065fd987f390a1f00b492c321e3fe0984fd8cfa4627648681a0c4245cc0c63795e8cfbe5b3ea32500aea6d8b1f485e37e556f19b6a97add5e2109b85d245ccf5b6f9013f8e14b5e002a117ffdac9c0f65619422aca2d142bb28053b95c6bf74d796975e5eedca8100f3098c32628b28b8055f84f167c7c5475ccb8a9b303f261e076705684f24950056e964cf76a4e68652c586c25ec124bce5235265c57a6850aababf2164381c00494b4f07f08b4eb9ee045d8435be7fc8982f836e5ba24c989e15a8529d629c003eb4ead6d56d71a8011c8122ca72c7d89629b05a4c325923817cfb79e5a58700ec715686cc174d96e681fcf38b56d31f240897ef31c0aa23cc94fdb6ab09ff00d68a2fd39ab3acb8bf0d812d26f35ea2beded9887b3c880124fe2867c2ce21005407a8b7921d1b022e2c2d26a99ae43469614f2fc6f835683ddd174eb7d84c00f7d083f96851e19845efae07ce413005fa408432c3dc44a04feb75927697d40091bf5c0a52115e65a69e4784f5798419909a8c8af63e984033e176fa9315190034479de05254c8cde9c088fc6b1c60fad5973e1947319b3b3f4a8dc890c1c100454aeac868d49eac3e591b496ac686ac23d758b44a59b7fe2f8461948ec22e00bb09351a20aad40ba40c9f00452a69840e6c3084cd2c5beab16c2e0984803b00377fc56a6af72773ab66f561448edb18ced8ede15fa1f1295af2551ef82cdd002b0491c836dec87c429a0f376410e2a88423a5e3b52c4046030752499e0fa40073360ce9cfdffa3222f27823cd10993a02727855ac9431cd40b6e8229297820013d5e2da30610d3343f6feb8b9d1cdd84cdcb32006c5e37ed5edc50ed68243005786fe733d862bda30965f925ef16fc586e3457aa73b6dd5a70be4732c60bb00a8fc7b4f7c598de8bee4cc0de73e61d1ad353ee5a05b03adb1336160f5c25200384ea003bac1a1a7460948afa21636d2712ed9b874d1335545d0302b1a456200b9d998a3c74969ab7f1f6b73dc6ed299bab7fa8af4d6cf16db7424a99902f100caba923181d8284ee97ad2797090f89916e508729d89d4ccd7ca4afa88798400bdc2b39ef43276fab0bba18e99bf357369c3e8a216ff2422cdb204d1e0d13d00b2487ff256b248070dd9b6471ae08bc4762d677ca238987a64409b2563c1e8002d04484ee1d46e8351ff83590f6d8926375c9836b0313c1df722cb4b4502a500074f5e7c2b1b4e97dadfb7f22eb5f4c755df1115f8feaf0f1d91fd99c897fb001490e6e260b8906efe1670e300b98c63abd0c9d758696fd64cc314dbe21128007f12ceef2a7ce9b5270d951c52831b0e1ae31355d3f892c06f98e2f3a830640027bd609b3b5f220bd5ab56582eeebd8f0d8011c9fc916896fca3e2a9e1c14700da4b94e310b83ced896857b649680a52f4a989d6b22ab7cc58f026b7254ac30097468da1570feac539b3c0d29fb024801109f6d47cee6ade4c801d9ee5a2c80022a0656637b734ba8cad2f4037d20ba2f4ee39ad27f9f40d5908efb0c81df00095b888f6a8839e9d25e9c54a025dcdb60bcf824e3cb6d5610dd9185fbe9527005d0c301ec1e88d5f11f4b5601b8b8b3923e6ef5107cdcebdc0ff3de0a8aac8003b1dff5d195ceae6a940a8c255881893dc9b30f4d00c136f2b472d765c9ced0055263c8baf313959d08c0493355f134b4204a90256174e6ca9158de620450900a1048ac6de990807f0912352e06c3cb320e7854562449e1119513f8be10ece00a417dcf497094b8425237365562bbc03c890aadc6963d2531b1142e076c14f009b086ded25fe86fc4d75710e0d152d9943a6d013f772b485e445e6395c8c2100b8ec9c65766db5f3740f0bb5fa2a2350273d2f048fc5ebef31d5af06f6ab2400a6371e6a049a19beda53d167bfc21106315699d5d857a443b7b37a4c4374db00c02226702b9cd5f7fe5a04c7084c397e40cc70b8fa8288a70b9051ff1e93b400132723e3e43e688a9cae00eeeffc8d17b895ea7efcc892d15653d0dd715dbe00f068b1e8accae6c2306c5bf81f3e9b711dc2b9516c8c68a9b55606f3fe4b3500dd7ac797a521beaf5103daca7a98892ea0d3d436231a0ce2c9ede3a42e2bd400aa3ce63be210681c40ddea0d535b189047128906ae160feb643c0766860a60007563e3be8bb4906f0439b6503967bb71c5ae7fdeb0ad277681ea0a39e04f0c007191ddc43e4414456989a6c54b853e0f0cca64912b6dfb9a4e3286dcc99df900d261730b312f9bb3abf6e8967c628c9ca641b8eb52885432ec87f40df31fd800d17af01661ee82753231eb59e80b62aef825df80ee1c4018c132103f3ddb700022e1be62cd6727ba91e47845ee396ae27ca42b51948b155cb8a638915b8cde00bce949b28ffa1453334b4761c548e03e060df71a3e81d76450bfc07da2fbee00257a8964605489b49321bfa2e6de505b461e0e90eccf45e18433bc5a38d23000b427dac9acd524571a346432fc8a76afa6d0f1178dddda7aeee736da668aa000b9db2b3eddefc4e4b2c41b7625402ee0ae2f6465fea9a2259930bcbbd2e7c7009b2131650286b6bc26ec3711a12579704f8f5433dbb5f5404d03f4854072e40095dc5c4b1a41e0eec4b496442fd2ab76ad0fd6e08b01e61ed9cecfa729803e0060b1bdce7196e1725588a4a2fd65e8f531f4b9a673455de589a30e30f115520096ce12425402862698204c4e41bd78e745d8894bd9e0f8a170fe67f341f63d0014cddc950e1f7e0c4982e67074baaa3359eec8bf3950e55f2ce1b5f8be126300397a15177735a65b448f8930f6608ae34e756b57c64b90284fd13fea77f9f300e1cab734f3927dc99d73636238d9420f7c6c214fa00f7443f3578bd029d6f00031ea42730da1714291b09069b7c0086cd2202e1dcb7faae1dad8664f06f83100dbdc1651d1edca967e6427f64b75f57084d803bdc897b8f97f0464fab152030039ae19cdb5324f1dde3773e61121cf59c4e53efd8202293f38bdb3a2b869c400d20b0257e2ca73fd7d2307396d4951414bf695edce85eceebc511e238737ab00a004fba6e40a07a24b6b06f7322f020318a13252ba9f57e61503d39d8433f5008c5a0471ccbadb54e7540e882b1f38404ffd9046527e0aad02e3e46c4c8f4500084105afaea8272a450b97afde3e2bdd1ef4a72933bafedce091bdb5786ed8003a605cfa8beaef15b216a63fb1ab471039f2dacc5457e6e35d283957da16ae00b62383dadfbc56013be3a67d347fed13819d5ab1bdaff292172707f0b6ed3f00742d5d23b3e8b8d8fe4f37dddc4cabc30d081b17cfea56d81b7740a525fdfa00c50c2c565691e57164c1406bd3007d3ab610b338a0af630ad7764d11c6d2b30082e675131c79055d88f8ce450ede89e9c398001bb87e10ebd660a250b7ab6700dd562286e784060b1bffcc4a117d9073eaa1007de4023cc8e98722985532d600c155f047d1b0de656ec981dc97edb79018a3d4102a74185f6dba7b3623935700403e8f72adffd46425bc8ccce3b59e98e4e299c32448aba12b7bcb26f87bed0069df2aed246b6f672a313c2859cfb57c7df20a74d51d0f1de80bace41dba7200f592249c6641b2c6b51c2eaa19234bfff5ef4b27b08b2998939ef0114058fc008fea15603b40ba29758242e03cee5a89e74827a9938c611a849fcb4deb6cb6003dc435d170f01d5cc192bea77857fd7de6410b3a8800e016242d1458aa0023004c13cdba14a18371f8b74196524ccf9e126994029b79f5aa66e327c12c1fc8002c92570a488b9da5eb68e0a3fd41b8c183ce55dcf581353c9cbcf1dc984dc30064dd2da74d8ecb459b844393e384ec5c6284368136c981ebb43aafd16b2320008cfd0f67dbffba558b3dc0271078a97ed1dd88881e2740fd745284e76351aa00ab5b3bb10bc8912afe3ff7fbd97c6c2a7e9859c256d88a2c3bd1f6748c05eb00b6ced8fd569b72417868ab9a5f6343e1c6b6a5d348fc15f26dcb34346a232a00490493eef6f49a7381ec19f5b0bafdd65efc15a8fa016440431c264ef070a800a54b33202dc92d632026f27ba50d8d38bdf6a4e8b7bac6017aa3c190d380b3006e3984ff6d8ae91471898e483d9fe3dd3fc005356a0a5598055d12c8be634c00cba60b57ee3110c4eb77aae0ff4106e29f794a968735c1809922ec6d4840ca0049e6a89910ac9068aa1a71357562a0e927bd5e950e09758789a268b256c92600000c2593a35939dd2987064c06b10bdca6d846afe921b2844662c71d708a5400dbb051ddea513bfc4d6903132b59d8000a7aafe8bb41dd435fb77423067f910004dae8c9840fb5bcde873692cb58cf01507f5f3d654e22b029826dcb9a678b0074a4b619deeca61bb51c2fd8a1afefc1cf7342c29136b6179bd3e9dbe791b60041ea9d31fd4d395cd4752620f62c137af45bda1c1fde935a902868220ee01600c807dc852deecdab653a0f6ae25d1599ae3d8fa7149331e5c7c92ee518fa1700c8b08ae41c74871262121b19f073ef5c72bc9f7e584b714645152f8d9bb5150083f16e6af1bfe6b462702b9b2543222a508fa8499b8a07f9ac8ed4a4b1821600de03a6d0b6b82fbdd56fded53ebb94bce70a50253c7ab32f7f63cb1b5052c800b51ae18e6b36eb185b25117f19ae24a2f0d7c98928535b907f805fe343a289000e9b8e41a5ac0604c0c28220a68ef44182f9e02fb55f7b2da2268bcaf21d38006a3f5751c62a86f087dcbe3af54da5df10f27c6890561c8b209a2027d6138e0001c08e0047893f4e2a6a3616d5ffb800e43250176cfb484e333fb528e2ee080031a398b00028431d81880152037b60b78f6cf8d8886393dd4b6d865843c81200112d65aeb4be11bc0b92d00ffd495a6cbed3b02f06795bf9e12fe47ab761ba00220d6f00a9e671fbcd045fabae6f1a33f494267c67a0d4d2d0c7f79b64e75b000565ddaacd99f837e2f645b0690599192e6b838e389905286ee292a8678839007081700960a0110ad635927f7c789316ff5d2ea6018f74e5b90becafdb0d4e004d7cfc8145012ea7149b2ae12d868afe77c0c2c958c6e70a7d0e11a99e96dd0022c24f6be7857092ae2d9bcde131038c1774c42d36d87290f30db759d44ba100d599007aac475704776eba53834d18feb722015fbdf6fddf4fe227b4e5e374003e937031a83dc027eafa3dcc87317f484aaf4269423c2a3bca3443f0fd8f1b005454dc8bbf49b3e2f87e760c2e143979507aea635b2e0c5f7f9cda83de4ecb00c6a2cdfad1dea42e1d12387047be54938268d7361af764b8746b124d30dc6b004e72aeadb67ebb0db14ba49412e5aaeba77bff2a24d6bac215cee2e02a4ef0006907c688497945f05bde0d646494a89a711aec95d5e442f94744ca61252baf008d8c8a79a8184856dd695c44f214f357ffb06d766f1964a8c2c2d9f96d04d300f92075e8ab7322a3e8739a86c073f1b2e728b0ae953ca26463767983dcdbf700308dbbe6a67159f3b670aade227b11a70b0605c7aa9a13a4392690a9f5267d00cad32f792c9195ac5d6716250b933421ea70259b38b3ac042b89b5f67e40c8000df59f2194c7af699a5eb60882fc84c3c0919f9a0c83a17881882e197af106004a3a829966731cc400bc866a01cf998b01ae0bab1f01f6ee6374151bda7362001e35c9ff989c5b6873d81beba51b328401df90753312df2e576da2d51fb8fd00e677d19fc3eabf59512c5a4d76a79f7b9312daeee950f2f36a3c4dbacc0e47004e424d8070584e1f4220cec03cdf64a2d29663bd71cb8f41b3736b8adc2339005290d6457d4b3817b064453d72245fef1225ad5e9e7122540e86fdc3b5a77d00fe352a5501a8d575f5c9a2992b79134b19d9ccafd3a851f593c0af80019eb70091372e4ed3a89b47eb0cbce2315f5dd6a85c51b6306a0f3f3caa3235579351001d4c7131efc7278ceea73b57471b2553f6140df5139616a16781033f020c5400da3a3780d724d206dd306df8668469948d938526d7e8268aec41b93f761d73003262637ec2c0e0e6d5826dd262d3916091ba019d701d2fc67d9cf7269a594c00bc740651f8bc0dfb7772237131efaee9dafc3dd57e476729367819e484476b0065179dd913579957f5a2765e839e3be57a35075eab08208244182035fab8c2004471610ac1a5efae58b1f9ac1f355dbd6ca3303f5ce55a6bf246c5d8b52d9e003338618456a4480adff95711a645ba1e1997dd5a385df375987ce495da731e00293e2555d1461d23f4c9199e537e96a62798cf77df40ecd056823bc01a8d2b002dfa93b42b735ffe87c691ced1160778d60b5bbe9bf2555d2078a7daf661b300e483f5d2749818425320962a0c70c2f3220041d105450b39f10e7e3ff53b5500ad6802400428c4e1197db863ff4d6fa652186732759d877631f81925b6708b00ef1c8d03c218c87bee537211828c1f1a117c1c0c9c58ef44ca2d2c987b650400b0b0fdb8a5da948b79ea80d89d6b0e0982b63163d13071f91936090245a1080081cc40db82caea4ae96d31afc8ac8b9e5c921b4282a36aa79ca2a0e7868cec00f966702fb70466a9f790454558b6ad227e1128ba43c5e28dd4c3d5b74c97f300aa016255833846e37dbd06710586e6c6cc653848844f7a07d57a959c0c6b2500b58c78edf31c69e832663124364361789001403ddc67a4b4945660432908970086f38a978f8217609360c55d5f9b7169ebd61811d1cbc120e3a17680fbf538005461a2ea609a9b1c8de72193fabb5abc47399064c34365c00b2644247098f100a08aa5e6a6b9683ad64d8b25bfd338c8c32ab49f50a07143275ad7ae16c89200d706e6fe5f73b58477d736e44b0fd1694ca7e26f3d32617e69b6069c7efd1f00271cd806dae303cc9fddd8824bbeafc130d4468fb2a273c1aa8fb968963b9400e45b8e4492a69aa21b975c1916e50e284f8cd9e00dc4f05f104b4bb36b0c8a0041945f2cf9b9b73219edc1c2ccb6e011f825fe6e7ef916e0305a2966c5c8cd0002d268eefdd12da7a4772895092401976cf16a993619169771837d8e89e4a200123d7bda76457d486144135d3f5e0e9c97da6f6a71bd88989a368c7a04971f001202f4815a1d2ab1630c53b1f128f419004eb1cde1e139278baa42f5dd6f2d008b018abaf68644530282230a6d111e3950890b1a3101dbc1ca8a00f932604600c8ffeedeaf20421b82a608701cbccb94125aebfe4157745f21e7d2d50581bd0085c56c3af9d1d5d5b8a9a360e97474d5c198ae1bc335bc6fea9bb9dbc8609e00d03ac7a85556ac9e9baae4b55ea892ae4a88430da5776e3329d427cf038df4007c2ac604f5e17faf183e53135c5ed53d897cc89c68357ae36d5adfb4b713e9000a7dec8ba1612232764bcedce59ecc09d1cf3bdd4152d0abdf343d47c7537700537bdd7c60bc01a91fb875e367d52db885c5874ab28518780b0d9344c71eaf00afefe30581d3925ce139047cd002b9509370a363427d1758668df24cd1d8cb00ab50d0af2e9d58d95c75c32e5568de6088c6cb7a0bcf4e7e4999273bf3415f00ab0eabd13e98eef9440d6241741397c2856ffcce5d8c6e6d238ecb1e01dd4d00c590cf9ec830c5c512b0ef3037f2f8c832d4b60bbe888944dd3a14f70eda26009518c2c00543abbf012f256153c91de7fcf23de44b4fd1c514777a8605875e00a2e9bfb8d312f21e8d008ff79bd396034fef0576a6b6ae02f899dc50b8039b001c9051754fb6e24764cd78c10aad9330bcb714c82926af3d5439d9b660657900435ad1733e84a86ee7ed6b0d64d4dd8277639de1c5bedabefe63bdd5d0c19100ed199cb4be4a9ecf6ee6bbd75df46788a6675f5fbfb74fd90141d7187e5a6900d2bd8ab2cec2e02df70ebab5f8727d7d325fc437a39f88a3f433ad359c0b75006caf06979a3f94578768d9e90ff161391bf94d5be71a55e6757d544a16c888003377e864b65d246bce14bee450720975450776c0e063c67f669c5b11d7971300878bc91a66dc6c766b7a1b69024024b2c13ecc538f3fe0b98862f330e9d0b90052a8089f97d432c30e9cf090ea84120712ed5c8ff9d976e03d25a24bba4be30014d80635836603ef3a821944f8274c6de4bbab1fbb4aaa342497ed475c5065006003d78d4a3c18f3dffb5ce3cf14528e4d30a11ef8dee970122c1b68b4698c0075227643622ed54c029e5e21734e86a5874dc2f487f099d0d93c432c547b75001a8cadd21f378c17139d3909015351ddaee9d65ab5bd1ea4e5d376c5a641db006b58f86ee61f2ee5cec740adbc63a32e49786d55ae82392f74637ff8215777003b35e23a470c9364cd2df5364c0b6f291c5a9ffaf25ec2ad3a3439903e1762006a1599c6da9871935afa8b1b6e2f3438999212043abf3b0ed6f04e202a657b00e31a56e0576533149cc557cf091290e32cc2f80b4b15ac53b573daa7f7afbc00faac6e3f94b4a80cd4449bae71f21857b4df2ab4e02eec73235981f955023700cf6c11e98425e3ed0f7ab471574870036ebeeb83b87c2d1c2640811aeff78b00e9bf1b043aafb622ccfc3037ffcc351b561212a90508d4b085724281aa5d7c00272fe38d3a3b963fd492294c0b617f10a22bdb4922b00b3a40412f781fb31700bd53fab0180d706ed4f3f2dd43bfb83f34ef5915705e0c2a8da4d2095a7e930055c4a3af97338175d0510aab232cefa937b7bfa25571254872b44b6e7bc6b500ca794f65bb67f6b1601b7149b4a9e2f55d2a0497b0e5fdd70fd4f3144d003900889de7e8f6bf4c3b501a4d3d8a530301140ca6f92ad1d677d0e72db4f8887500c36d512ee0b9fa87284d580734d142b7d7be041252e341796d174cd9d23eaf007209112a96a35a6d94b1c834d7d6927370bb2c00e30efafe71fa465f001bb1003508255cc19a49f48afa81e3ab2d903b545431fe539a763444df847df64668000efabd7b7049f8f87089d8336ee13510104c406b88bbf4e4c69b5880a72e3b00d3746f7fcaa2f0967ca33beebecb9f0dc6841be6f8594600f94df23d03b3a3005fefe005a271e660ca96d6cf4127e0d5636a003eabd582ee02e7e84555c01400861423650f8f0e2fdac21fa5aa5c3deb0acc382afcd4f548b3d726be51c1b100863f224ac09332c162021fccb01a720b4c36e3db4ecb381a6a49070eb5d3f10042000b75d0af3a4219090170b81c3b3eb56a887b84a35787cc618aeaed31ed0093e5ed5acd285b3f750882b8a7499ecfa788f8e4e69eab8b2a4082d993387b0025be2b1040bba499e57b25686b0520e31d8c2ed8f85177373506270792fd2400a5855e3f31bf98bcdf04abd0ba69df9714a60f5e24b6666a95f209a50222da0032cc04f3596f06bc92e7b01071d6efe1a953f518606fc8bbf29ba0d67546fe009e56feca41a689906fe34387b5ff1dd214256ec84b77f3f294f6fdde39a7aa00ae79086f932cadd8e396b23d44908176bedd19e9968d0a85d33d853690f11000037ea85712d60de1a0cab7861c23ab39c7c637df74d69174e0584e66b3d07a006ec923959e7ae1195407f8d772c0cd9f325e27361cfa084d8bab413b54b04500ba20863a5159ae8fc4d66f77e3c9efaee273965d0a51c8b190d6ff5d4cc4ce0087200d89447e23ebf12a862b7ba57407691c21507c8e02d42273f668eb5659000301a8a4a7fd1334d3ff2f49b1cf0a4bb07b84daa1f956c9accb14691c77bf00ffbd4e39bcb2495029d1c64c9aea867ff48d8d2aeed888f1c9c1dd7f3f4b0000b0bae6c392e28b6c99101f48dafe69e172464fc0581116b3dcb807546e2213001570f1fc0c0cfd87087bd7d3e61b4b167332d778883295974907232d86723400d287e5ae0c72aa9682d49fded0a04b42cf6909900d48c0ad2c4cd16922bb4e00eedc2a38629fb63e2584169a7ca7b86372c9ce94a0d7f5280df7eaac1e897d0033341962fb20a24fd11126ade493a079050afec069b7bd8d2af1f28dc69a1600da22640b102f3c8bcc6e6fb07a9c78e9d54999afeba9643a7d3ce1e67e078f00d90cbc0bed08e4d60cefd55705b9d896741f276f0b068282d2542b3c4e434000a7f67b408ce620b843060dc22c51a35485e01cde46329239936e503fe10f1b0091d612cc9ef91405e41137b0cfbaceed4e1fc83edcd366c0e9ef6d754a8f13007368bf347b61e0890dc1b88d6d92ce90df09754c974a8ae42072a29caf6f8a001390517dc151d523db5a0661f1eefe5df3a34172a20423ed492c0a24aedb7800d6d1b66be3ba6016fdb517a33fe6a57d99dba2955cf3e90eeb41553b6723d900e8db783fe89e04454e00bda6b040e3a8598e7fc2f682ab2724380b01df908c00e26d14bd9e98a55afbfa01819ff66e4a82c535c158258b9313a6f373b2a211008b42012dbecc3f3245da65c7f636f796fd2a97beae7bb0eaa76f48bf8af9340059b47451324129b96880f44ec3b29ddbd633080d98e59436c720b5c52e50cf00de7325b25a36298d1327688730642b2cadec07aa2d8fbabd978c9660a63cad0054e42ce633c8126b71f33ad6136b45de68b8597cd6c5427777f9b7b57cc4d900765185fc4b30583bc9ceba4c569156fc3df7fba99d8c88ac5da1aacc58c315008d2629d127be361d241f674204f8e7abf3f989720f58ba82cb376b7c1232980047faceadacd45c95307a628a658ec017a3628b38016c995887bf4159759ca700b6d7d9d23de957ff3b186dc29235f119ca961cc1f4d283470e9ff6b7a786b600779908080812be334bcd0e1ad05da66fe542fd47ce288c5c3fafec581a945100f1c65ef348ea78eac010e8f5757b2c5c8d6ce76654b6ef3ee0eef03c0584730017647e2ce66e6fa632c679498f849e534da57cd9f0ab069145c4a7c887370b00a52bfd431e6768f11b641b780b528a7eb89b20bce0fe0b4bf3ff73aa9a7592007963587eddccc982c0a1234319b886b716e2bd23d596a8a7e450401eea37990052d7d3028eead682920b022a977a5475c79dd2d137418a9e82f0bb05d7e582005f0ae9c19d27294da8f0659db0e43101b01d66c7775f7441fa7595ee68cdfc00a58c7c34c5c6f25140a6f86a7fe4c56c2aaff6b74449ae26a123a7a6f459c0004089b41cc54a20340e6d47607651df501a0d55aecf8707d40b259bf1e34abc00de59a195c1636f23814400bba60ed2d01b662dbfdc741a94b1b28bb1f3602100bdcea726d307edeb577517b9faeea0fc522f791d996c5b53b4524bd56139d4003671d60595b51aa325b720211588eac471c3bcc2a6de901e95776bac59cce8008ac29c21ee1ac5559f300d21bdc7e42a245abbaa29a9319115838d9861e7c4008999943da67ad3d290ca68e9941f2efdbb2c22523440bf14136da160486e4e0020bdbcd885d06e720875287a16c69afe40feb7aecc5f8488d4e061702e76d30059870cf92b9cf661a33e4b59997263a0b19dd0c1e0455041a868ea245d75f400524516c5103309b69f04a4fb3e06fef7706c77a8af9bd5d35829fa494359d3006e9a5f46d5b5794ef3d21b1632e9085416ca43519ce3d18724263f7e6bad5200637e0ee0985385869d176251a635142c7a0987fd6d5529ae3fc78bcbb334db00421976713648ba446ae8ae5696e32c6b05cd627eb262e609c951dfd103fbff0015f47d7d9265d73da4e229662aee60726d411d60f91cd562da73fbfc62644900fe7dd2ce2f74f66077da16c05cdfb510448a5db5ef00288d12000f3e16673f0028a0693a7569da38ad0f62e0013b652116b4c447022bf1eb250681dedbd7150089919a33264068db1ea5d6722974288619228168e7c5b3baecddb20a45571c00cb0d012edcbe52c444501fe3fc9a8524aa46cd6ebe544c41d648e1e2c0c98c00bee7be9fffd5bf717722a1a9b147bbe339cc550772cb2f091b807dda763a68003951eec14dadefb220e654e8f2995a7bd730925273e9147d1a147677afe06200924dd78114282fa3612ceda44f49f6e5962322ca75d091ac6e1a091f701da0008350838736664c3aafac05f57fc91dc5a8d0bf3ef270c863d7d75116a50f3700e5a115ede25a49d4754246f48bc1b3d64170ee64e61a5e4b15feaa7d14c908002d50435c62a2a3f0bd550da4ad118ace2c5e48ce078834c41534831bb3bafc000f73a76ecb92982e1b649ee856114afc6fc8c48602c98c46c96bf0860b3584005bd9f440e3fb0de013af0029a0c43f730baac3e221835a3bd7272aab3d57d000512a63e48a5e14e04bde6d094759aed2b2c535da6d20331952feddb17c4b4200ae08aeb44c6da29c27b08274ad68b2984f804b94d854837649a91875bbb8e400ce67bf8f9bd3f2a7fb3435ebaa07e9d265e78a88e2999aa432931879e1e62d0071807bb79f0977c7d9f24340720fe6fc7f2e2f90bf5040af80fc7697a6a96f0043b23953f835d861a2864e10d43e41e42afc5979de00c15100fd05409bbc7300726962e75bd6da346dda5dbff98aaa5906b00ffcd642519fb94e37e755547400d785cf9f6940724489db1dd8609e179088b9483baede544da14a6f6eceefd2003e38eb708129517bc0dc462d2a09a10a84c38695ec778851704ebd2ee658fe009c1447ca2328e8bdd327c7d9b4f537a606da825c9d25cc7b34e7fc9c0627c400b82baf8992ea3dea4e9444998c6f133669543e4854b87ba82b598f981898700020ff2d90c6c865818b4264441768ff36e53d42f5383d23557ba0571264dd470002faabbcb6d026cbebf2d288afb4d061fd6656a76c424fa9480cb9bc96a54c00af3586431ca1911795b4f2ebdb7d83ab3e61901c4f6d00a82cbaa2bccc70d30012cea12f0b92a412d692249a7ed90e9fdd68b5476fa2d94650fc713c60e6550096bb69e40461b383f423e9e72492f69bcaafc0a1a07bdcbc42c1d6399760fb00c8a2697fdcfa2c6fd510572f5cf24e415424bb441d213f74720387d828bbe900e7e4b9453985fa53cfdbc2a54ac3872b17a1adbccc728ed441e3125b4a73a300f5b32fe6c6a77e7042ac335b90f26fb893138c0cb5e6f188a5e99344a7b02a0013b7cc1dc5fa338f20b10d23635ae6082649006c3d6ed0121554a2c1fcc82e004d5089effc4e2edbbca0f06d178a2f078bd2318c0ea36283abd2d98c8bd64300f1a544516d00a85d7ce78cd6c1ead2e5c1866f3b60f4cdaf7f14299a50404900e513d0aa023bbb20fd22b55f759972e0aae97b8f738205781a7af903e6a026002405476c1c80d4ba208f3dcb7e1e40e3f6cd8c915e36d621bbdaeabcda3d98008278eb267b10f95e6d5fb1a960a7bd32f2448aff2483dfa66c726f6fcafcbb0051e653dcd8bce8a0ebd458acd23487fe2b51e7aad34feb33049d52a3b4ac380010ce42b5f6ced6da07942effe9f2760223a9efa7da3f9ef4c745432aa3b299009a4632282e8fd2d16372bc119c1fa28fe6eb87e632320a8a36238c799c36d400dc159cf2273b94c6bbeba2387d133c27933619446f28b8629d3b305c0117c20057385492b29108a745df441d607d1945d09dbd0d528dcd1ebcf87cd9c4107300ed6d2123d0a2a714873a9a7094985ff9cd67eb948bfc8560d5b491088dfd6100675c6dce7e2f96a80ab5a02d16e5b7f50795e2ce9e0c5819edb8e66bfad1ee006ac02b3d10f4de2ea8f49a2a5fa4346af4a5bf40c59a35038f938337d2a3c1002946eb41ef141053288d129d2b5b4b2b5e830256c1753730a5adf7b42b411f00ca4140fe07946f6ee0a416826f9aec4ef6e23e0f7ba366050ca6ddf062ccae00192058addcd89fa5895eb1eee591225f30c28e1b4c097aabe1ae7aeca9cad90037806835d724ee029f7048ecc53bc3e1c04664be7c4e31eeab80d3656d388700c2151d906f5781c8515c35a27e7908ccbbaca84ec4b1d75eef696b1b9842980049cc8204b22c40e15514d3648beca2e77e0e2794b70bb98567f44fbf693b1c007a0fd574fef6ac9ffa0faf8c99a842f94ed209101f83e1e264f42fb0af4d3f00923d9145b912623391648fe8828379c7b0e1209ab0191120007c7309c05fbf00b9e956cc83bb986c9397d1ed0050250e572c16476403b501126e6ceee70a1600a014510960208f108a08f846af88b880b72b9b97a7ff2ab8e3bcde397dd81b0068cbf23d51275345d04a64c2fbaa1e230416c21597592ece5793c0277e5ab100f60be2040d6321973fb637ffea3919268b55e5cea0a9305ef9baeb0bf67d1d00b32f36fc98c8a451c013792e3b000d61794e25c78032e6eae49e2198773c8600a61756838562c6e133c3b2e3c83158c1b320c81930cfc971fa4fafaaa6bbbe0091cf0da4a679bb1f2e7a4b6dca01b2e9f150d7b5c095c1f8001d8891922a3400e2ba1108cdfaab505373da31baa8994df3f48b02b912c7230da36e4424731900190bb83ed872cc6a69ac6762fdfb9a4a531a9714f071ca48a9b27e625c93aa00e52d443aaa498398e11be5be6515f535656583d1b730dded9551e1b77a3cf40083f63f124eaf58b72efdaafbc095ed9571ecccd5cf4d289652add483068dce00e46cc650fb5280f2a2b7f657342433635660ef28179aa1643cb4a7f31a543100641b471b9780ed012a044e0ab50db340b948b30ca499f67087d7350fc9e89e00311e0222ad5f8e0b7c6bb3959dfc9bb2db5c333b5cb4ce626bc2f863fe0ccf007f0ca8b92a3066da22ee52af8a48fd4f6f8ad48e65d6c36cbb02b7e799c1ab005fc64d23f873915ee513813d7f7e1430a876edd80054d582ea1c5f32ad3b700033a8d6de7c33324ed475df423d621185ebcf228b40e309516b8f2c7b3f535b00b76bd41b0acd3b123962100a153ae6b2f3dab3c979b15aca6bdeaaf4a23b1a008c122c3c83d455e23846d734f8b9662ec5de68881cf9fedc0ade9e8fbe298e00a5a2b4ca69b5ea92d067044bfd66546a7dd1818a20a1161fac6efcf417feb90056e8bcd923cec79b14f721e364c46ab8391472980417356d6dd9dce8cc077100e80a5d850d7220711a779e01487586666a5b6ef148b88f8e5e6092f9857ed100487e95b27d56383fa3dc335b0ee2123b5d7190352d7b29e6778b4b3a4bc50800a851f3ba44f9a6aa58cf1164cfe1aa9ac4abaeb5cf338f148ace99e04922d900c8c18c29c6128657e2ac3df44590a2a93a74565642669c36becfef7cb07180003bacab2f0d46d6d283b902693f23dcea620099e9971108b8af3dae340995a100440d262841655c0d24651983c3b33b2f4bc48438d565dcb42445a8fa38842500e7f669de5334671fc418bd7196a14e69c1a184b5a18af87735bcaa175efd810032c8de0c1ec88dfee30529640ba894f0de893a1ee19aa2a2ba7209d6a584b4001293cf698b3f10a0e7a1cad47c22558c4abde9044970bc17b6ba128b07e9d30054321ac3ecc623117f9ecb77bdef24bb9ac3a2e28431d1289a1d1542beebc50012780727cb47d3c7ad582fae56ba8e03f22a67fdd9ee3a7a14d1f001bcb25300a749a20843cf433458c747ea0cf9a70078e7c04b6ddd3d7ad6d6419d7373a900f1312b24c0ab16988ac49bbe54d90ddab9090982abb36638e940d9aee17c3b00174750a79214d9db6a8aba7fe87bb29f6cfa460dbdab3fd225b5517625a7a600a473ece2f84904362036d78a5b2008b969e30457c8c98e7ed41f64d17ecc0f00ffbed3387126769bf563fbf081abdbe462c2474cc4d97fb5aba7675730097000797e13ca7b1083c0e54774c20c967314fb6c095160c6dd500c5cf5f76766de00d468a33e4f9d8de51b57f62a206a98ef83c95b29cbe62a3e574c12d8b679b70097989dc72b3ad6cdbe4687171f6792a8ce55999d51b237a405c900dcba16df009407166c5302155e0fc1ab5f5ffb35c5203e2b0caec8257789451dc93cac0e0021e224c1611c0abdea3b34b5bd2d43c61238f9f4754b1596f98157c8d1394000f32fa6b940526de8f5ae27fcc1a70a86ac349493ea606f45d2b75d9c9f889d005bba10e8f22817eee42c199c20b2c51c69f278ec0344dd96ab4e4e5323bb1d00e7a1a021ccfd9585de2586ac8b91e6ed8cadaca367ce643b9dbde4f4856b7f002c762ced9d5f03e4011b92a6bf5627fa35755f5d31616eedbbff2e1b43c0530037d877a2efabaf1e4f1624a3fa7245f9e04027590cafb0a597e65944e8aa3900f9b9c0cec5aa9338a5ba84519395ba7ebba8558a33f25a059e5271156f5114000f11fd55a6678889d10d931a04c7f29f9f8ad7ce79efdf18fbc92b3fb048c600f96eea6262c526c5a5f64fb336bbe78ff38934cdaf8a2fea88b60c4a26390f002ef9732547100b3d8b9ec7b84ae40830196f53a2b366d88093457b25cf6eb3001d8450a018ed121262d22b5bfa5d437d3b9b023d695888f7b6495aa8651cb000cf2c6203a035bd98e0a9ba86b9b31720a6e78f9f2a3dfe04550d7743e5a0d00022b7eebef425aaee774e164841b5b35b373ed7babcfc27be28c8c6e6326531009795ac7de55c18b474f4122033e65ca7b810f93b672ada4e90b5e787163ab900ecf633f9e4940623002715f803587cc0cddc7e989372b2d137902c788d70ec005e32486a31ab44023fb954a27c1ff49ec21b1a18671b74366fad00540249f500995498db2928f5826156792fee51e108e706a13bffbc47d3ee2c399db42fc400f3aabfa017db1a87ce579d3e3a708928124f3e8cc165e831cbb4020e50581000ab3fd884b99f8f63c7c829096f6392976bbd078b33c27f67d2fa46f318d4d700625b694472b9d49736529ca7736fa3e1267dfa2e7b27b2c0cfbccc040f3efa00ae2dc8f329993d14534b925be507e6bc0459506f933839719a4744b788b582007aeeb7fbd327e537e9f44614d8b8127429e95a2837a2d0ae98035992e2164c00e21b6c6f3d5f8fce5ecc8a05825b3e4cf91f2e7211d35b4059143983650cfd00cc68cb48510f1cfb733088dfffef16650ec888cb1e7b359746a16b34880d7a001da2852c56006c4bce36e7e7278f9d1880b11746c705eeee8c1dca15d0241700ff7431ed6ae672247be278b8d10a701077a504b7dc850c08eca599674bb349008aa960d3de81148d94e7f6e10119dff8f9c76e16821b945b65bee0894fb4e7005ee6593d4fdbee33dd48b9cf8d16a53ab4651d8436c21134d2b3e02f551e160053cb7a7ca41ddd9cd8d511ce7a3a6064ce16eb8a27e55e5d6923a6fb6168f100c0ae9f7bf8f6025212d527c45ebd05138f47ffad1b220030acba6be027616200a3343ff3e61328eeb7296b397abd9f54ab9a6115d52c1e4a64a8495804f64400859899c68b88013a0a29f98b05e64a4ba50ce5f6fad5b5bda74bfe893b50810082738311de93186c80a6f4fb862a809fddc71663642aea08be280837d57b3200fb0e3ec2904b3229d6ca4368574cf304133f0ea1ac3637cac158544fb6424100a87f0424137d69dfc3ae363a5df90c06070d0e1772007e9350664144a1adfc00c7d8021e016d0e27b9638e75feb850c3cbdf3d7236d86a93c72239e7cdea7b001cd0b0278c9fa22e11f344a0d86b1de0ba2f453cdd3dbaf9be2d1cd6932f9400a95d1b11e1efd4c8c09e9e7ef12916b9ffec431628d49aa9490c71b77009c60037868f38a0a47db11608072a066826c44181b421b2cf36c08e62fb6df8ffc20090c3cf8f42717eeedecd3c9177e3266ccc19f6d8ef6bea13faf0fcf57afab70003a1960bad8e5a7c0299f1706425cd933e5d5624ceb453779e941c35e2ab4c007163c400d8bf142a69e1fb9196984c26ef82b887940269eb93046515ec7c7f00ea2d08be6934a76ea9f177223dd954e384e64bc3ca11b6182ee1a64250db1e00bd10deaba55874131b4f910a7e3fb469ad69ba970684e4905771ff8c32882800c487e97f23d7e4b6ff221c6aa886a923b4cd798a3cd0d5adae11d0419e0de8005be27381d8feda7f50a2e73bba70b1f9a944f0d2904973915b62602c8a878a0021717e78faaf5895a81e7c23a8c4c13a74a5adeb8fb3c1ec232b4d5e4c1964000522646531f895046b470b409c9a8a57849d96d1590ef04dfaa40491d1fe9c00849d491f80724c54ea16c191751c2e68c583cbbb6885ff9c12ee7df21c870e00a457db7a9862357862a2145a78b15cb8560af1db7d6137d0ad655c76b0402a008a73aedeeb213c7dd744b88059601e32c5cc3432620da448cce60090f80fb5009a81fb9d0629efe825f5097f49e0496769a57fe78edaa0086878e99530e21400d5e82f78d4dcb8c03745923510bca8a8931e76af92abbd580344c74e8a43a70045dbedabf17dbebb53a38169015f527ea1228ec82521aebb2a551e305af1f70049b3c4a833edbfb990b5c9a8515a1c0fd7055c1bfbd60ce1539efe4ca4e4da00bb28ecd1bbff573653a12c6b98499d3c3fb43eaab4b771d3f13d3ad7fdca1400f6f45d4eda44cee7261850dd322c1a04ddbc71ac75fa0a0600970129e5597a00e8ffbb4163021d3fdac1db9df0770dc73d2e4bfec08e2b68befea4320ce903002f8b2c7ad7110de255012dd48fe49ba0a64e40c7f2132e09cdcbb7ec28ba460039ba15b15edf38882f442c04367aee09dbae4f8bf1f9d887303e1f6f4e6cb4006757742648000d87bebd8a341837321ff5c22f5e3c896b9a111e08fdf5caeb00a6331ecbf73f099b9ad5cdba85b9643a258a2f56651bfdb4e80c67dc7e655300bbaa69b70b3a6fba76481db3fc1a0ea20f9a9d341dea436e312e60982e91d70078bc40accfcb4335f9a1d7872ae89fae86832bfd349546fbc113e6ea58aa7c00cad89e577f8d2ded415e89bd4f21b5deb5af236c0a97b6a5f7750829501dc800ef7ae28e2f4c695a34fd2f7caf84087a3b8e4d410654c03f15593fdac047f1009a1bd027d7ff0ea1aa3470d395b2607128bcd40fef59cfea9155a133956319007bff85e4eaee5368f17f489578872aade523843ddec88b3a1e89e75670eb6e003bd280f0bf0407458537537eab05d707671e8a1cc2a96e80f4acfd0c3fd2ee004e5bf0defeb41aa13c90b57035161d9a5c89e3ea1543fb0b689198a337267100d14799d627ee55d7f303eeefb02af84d311e2e5d5e2704bde2e3ee00e52d140077a0e4825597a43495a591af3d45f68186815715e647fbc187f8daf22ca417005b78b360039d54e421c7b9fcf2f3ccd3b0df2d2a79f4244109fe86e6a088b000b87697f38ec7e4fbb5c16be1c6d83039ed5b7d5d45c4230b5c24020487014100b162ec4eec5a9e3bf3603026cae34743cd76bb2926c2957d92ac8417478dd800ffd5a5c39ef24f0808de33cb9c6876b03cc8ecd4717c3d85313432fa8500e40021c3202a248018583715450fc2022985646c998810e9e14c5d1719d561fe6a002a9825d60240e7a81790f8884cba3b194d74e4bd47d5dab8d54655f7dff6ef00dc56c9f4bdf1500f8b2b4c28013f9e01f49cca0a4686fa78257b11f86a0c840003119ac1fac213c32a4f33ff2b62d6e73d9161417f2ba5d118a590c0bd13a500132b7a480a801816da8c99ced038ef8bc3f9d037728d6c28e4dbcc0a2b64420025ab14d96914c8357560bdfb658d9d3c64fa3f44264fcb5ebec19c33b335f7000265346a93715870c3dfd702a4f8ed51d8424b7f7a49a53c7e347a1e15cb8a004bb262415d2fd12c8a701d4cf5ab17bfb178b9e101dc7f307580018af9e66c00bf0df45393ea5320ac4e428427325a98b4c55d16b79a2f699ea791f2df0c4800ff2c284c32e35775a64358d342c5cec5c8701df0a7915c91ba6aa376f42524008bd9a7f5da6ea8a47d65e37d120ceea53b5a3d9d6151d0cbfec6face33406100126acb92270c649843b244f9662e7a5e4c2cc90f6da778b7d2c157890fff530048d3120a54add13d36cc9bd51b82643feac2ee7e50f26d33605635054fd2e70071fd3acfa2412ecff462ee9192c8f8ee8565d64572378b081bbc56bcb056d300def24670e92168b9f36b61c1c5a9f786c4f337a911c96eab2dcd67476eb5c6008b7d4bcced96c85d1fbbc7fe435d5c4c214df0a74d56352774c2de425ee03100377ce6fe039811c83a440950523f28dd478361105007f8503a36333c3789060010731ec893bd7faa136bf6f44115174cc045afa8ec5324156e85cb6d89de7e0008e8c7105e4bc590d786886d5a88fab3ed9474390f9dcd7c47228d77edc30d0093e537d8525fe12ecb46f5e53bef0afd248652e5b4e9fbe865afd27646a43f0032dc328fc9af70e546ac00550155b43b1cad9a312c0ef8433714b61cd0abaf00f044a7de3fb31093125355c0f0c5f598be6ff966934a93c27a1edaca5fe96b00e9f973d50ed1f6457c97b72299b073374a030f06fce63b236c1215bb7534b7009a4eb15ab60a1839ce86ea329bca424f622d0f41fa270433d89340f22bf0ad00f49c44b576dbecdbbf50abb9ccff1642fab7dc1e4471e7ebcbda82cdb22ab400e4a21f6546912b8b193c47ca3339916938d0e0162b79477d62f185a610117c00a3096a94e11b3482ddc28bb91697a520878c69445528454b134c2b731aa19b006d5e4db1b87e73b6b0c40aa7064ceff8c7715b08d0a5067ff5aa42021fc7cb00f63e0e02d3e25cc3f3492d997f5cbf70f4cc53734185947301fecf2a41f1fc004187bfdba01fe1738f8da5c42920acc667fadeb23fdc13936824bb6c6e150a004cfa22cd8875bfcfafb8d7a2d15cf56cf2e5b4b8cebb50eff0df6f6ff3eb7200faae94c7ef1fa1fbdef09d0f18fa1dd8254f939ddf25a7fadc411243f36ec80048fe85de95ddefaffd84915b2f8c11b36a1c77a5bbffc2cfd46be4aa5b9ced00a39cd25afdd185226d1a0fce9678f6d5db301885a9a38fde7949c72d45aed60079430595f9afe7b5d5565500fb309c91a2857a4b43a5b65b94d7bfbafd2de300f9e973ea646401a50834d3dc4299bffe0a5cc6a05952710ee47a256434308f00b978b91a88278c0a57772ec98e7421869bcba35698b878ed8d6119b132b15100b4807fb263d496887a35fc1220be5fcb298696486f2a802ca0f35cc5de405e006ea37fc1f5364743f1298d782dc7aa6aa41780cda247c66990bf20dfc13a1d004a3c6acae7519281ae3aadd8f684e492d3b7aca382ea19e65475d7bd087d5900375d4f03021e9112d4e7642d2d51ff4a728b3407923a901608efa8a7bd3b490099d6d5f1e67879a27c94754479d179dd79c33b35c996c8aae4e76f4f6c837300b9985f2e47cc88962a37b8e75815b7c2091939853cdfb9b42724c16ca3f6a300088aa64091db3250cc29e27cad4b215374ca9ed68150865ad97bfd703bd3bb009abb9dd3045ca5bf57b887d7e00e4021239759739bf193a8062e8a87a1b386009e354bda5479a55701d30fd7c16a5d74b3ca7bcd3d2f28c201111f0ff54aab00253067a8ed67237e252712054d540032243ff36cf7c2b6773e4edc16522ce700caa07f6606a502bcd9a6c700eeed4728cb342f820655b67d6d1abb099605f7006449a18474e5b77f89bb3a98b94dc399196fbe6547fbacea4a4b32b050b37900f985cb207caeb3bc5c1c7ebc92f096012ea478c4e1f039a28c9b9daab2a26100655b490026e2cb226daff1bd8b7258acc04ac2ae4fd09f897f5ac02d346a89003658e00c3ff96e6f2fbc5532def2eda56e9453b0bf7cb356beb72abd67ca11008ba643c061d078fc058cebf349156192305d6ad1bc2cc88cc29496b2f2969b00ef8989b3efec38700fb89f3c92b2f522c143028da24ebe40df9c0407e9735900f366eb08fe14f53ae98fd23298639a001ee86498e5a89e7964c9735acc440300a6926d344aaaaa7dac622014bd8e268227039c98b20f09f15e346858b44af900c18b5bf8bf6bc35ce80a3083aaf8f3636b376cef1fc0d1504404bb55562e3d00655cb2bfdd2199f5ed5a34039a9aef3a835e278bbf93299833ed29b8244f3b000c5756f7c19e9448d9badc7252d2043483100384674dbc2fb299bb33e35bc400fda26a721b49480e9b6cb82e549d8fc153c08a9ea941fd8ddf007d7bc919c900c011b47096b2ca1121d04b278bfa724ebf8940aeb4949dd56ac61f4a5d2fa900efc89de23c5616e4ad7d4bb5a9c44113c0dac127fdd0c6387127bae419494c0072ae3562f5936b8f2ea4d0224802f6c747f0cc749825ca39da87edaad7ff1b00ee745bc851812ee8edefb9760dedb19ee07b0341b4a3da55e182ad7ec493f700419d6b154dd8ec41ac88eea3c18dd45578c5a7b2d2e6399f08b29af50b4e43003421f09c8f78d59cc199d5f8a4d33af6d10cafb499e66758cc6a8ef687fb7a00b44d0af22bab1c492077512a7fbf4a6c3089c1df1c04528ba5c35512f30980004e97141c76d298a2121ee386648f0c46148cb006f62da1eafca7e5ee982b1e003195b9529dd9f7724dd554c64e7acba6bcc124cb28b996005901136fa3da3200f6d96b2d999c76bab56a36c9fbd5b6feba2e5d5f3ad3446165cc0e86543c4400567401bbc202bece868903560f5714702b7db943e16c4a8181aa60509e94bb006ed060f7dcd34915adc0729d61ad735c14d47fac3b3aa17322f2f26d211ada00cda091d45ecd64f6cbdcb92e5b85850385fd1bb28c431fd2b594c37e38d52c00a70d109e30c131c2ac7b139ac6653f6f0787072310647ba9f364b5161895a300ceaa550715fb9befb044bf3e6b2cfff64e363f1c42d9fcc6f8faa1f074239d00e3e4ca44d17bb35210d8fc33c864cd0c37e3f88552b6df020ba87e5ee33773004c61fd13887c3ed687229dac3290d0184112879927271b0ea7b173b5d4f0ca00110daa4ae3dc811d17c8a2fe0b82aed79a076593ae7caf18659d8156f8e53e00283d97bd6198081e62fd3094b9bf0e931b2f2db7e02b99a8589653bd502cf700e909a6573976f02e37e40927a021497f5650ba4b696bd603a252f88791753e00a9cd5599336d10cc03902fcd7845e400c78cb7bfbda650b67b39876f309bb300c5056274a0dc6a61f5102a4a5176d6492c8557f17dba332f02032c5e5a75c3005e8468124180a90333f257eb3d0293b2c82387dab09363b2a5195e0152c3f6004e56e44fe0a539439c437584b16047862974029a2a610234bb1aeeb6d36a23006cf10f5ab891def75cfa9dc0456783141baee4cd2189a1c16559f43fa9597800132036901fcd8d41e792705623cec730cec17425657e3e36b9f78107d7c052009c5a49b51ac28abc13f3f782de066c4df15910a24f3b0d0a8a6d3468fef88200b2482400c27799ed5c8ad577d30b757f00da3c0f367a0639f2ce777dca88ce00a00017e3d6ee98406b3beb7e1b68b36e5c26b93919abb2f2881939923af22400f124ee0f955105f822bfa623647a156fad897ff0f02d2adaee729d2d129c0a007fd0fd82450062aa2aa3007b600189a05260adad6263df9ff2b035318935b10084009e8e854483d8976cd458bd303d8f49fa80ade18f76294387030af9a6770097b28a564d8a8df28de728f756ed2d21d50375741314180a17b0dfda1f22670023c1299828d99fb990890446929990a051aba6d8e73afe1293f4a8f714fad2001b1dc093c35c02e125786c8e37264752be41a6811b2e293c6e68bba46f188900c255e481c4f45afb57446e64c097768101d57113f8a90baa824a522232a82d00b0064ac1eded322807e1cd6eb7bcf01b20d6779c2ac447e40a09421b283e5d00ca11e5cb1f9a3374708811f8235091cb027d0f259f792a8d5b0d1d4eb6bae00052638b109fe73045ad33500931436d521dcd084490c92d555dc83fa23bc6b9000fd0adab18d845376406df98d059b088c7e42148499d1a2e38db9a3dc8670700f69ccfaf10b38185426917d362bade5ee559338dbdb2744bbbc84db05b3c2500c7570e133343ca710f2e66ff1b0f0d0c65149ab5de418a22e2cb2ed20980ac00fa97ee0e4dd66a9d332b4eeb31da56b73580743ef4b9be564e1466f5c38cc600e4cd89efa4298f013210609023ea360a6564254be3760fe503b99426db32140060ba2975bfa02518524e4dc2d34dff5f4ee4aa4eeb92debc9c659f479de0fc00283bcbbbde08efe1d37154493561104f3c79dce373859075d38d741b352fa600aacce516c94157db447471b90829f33b23f1f38977520d7e1509a291809677002149276f623766cdcfe09ff0e6ad5bb3c1b486cb64d3e75558a956fae934fd009456f95eea9bc6205aa43b3c5534cd1cb6360160d674400274046a52c71662008af37031eb5d8fd19a018c5dfab1eda9c24cf74e5c9cd6f94230daa3248e6f001e79593908b1a648fbb010d0808aac1c59f659b05c4b0f975c28d0bd68343d00ecfccf0ef07e2b0a8b512040914dfca18c86f6fe70d139e73e0c4225c575f1001e1ee7a146e7213dfaaa20f34f6b2c9f1a97400d446560d6f5811f757115bb002426fa5db21d5b728b0cf1d7695acff945d5a6d616f2586087b5abc9c9a8ad004e87a472217808006122b4ed2903e25681bb9ef3d07abbc56a8186bb0bf26c00c9d3d40578dc4dad0f643b4664dc5021e03ac5fce5000ccb6137035063646d00d51fa6a474dbba4dc066d138ff001fe2507a297cedc5582eb2f42a180f612800100faa904fca2c9fe9cca8f35a0c7d8a43e9cad8e0a0f05ada493d0c114974002783e3a8ef5b87c4aa25aa601386c46bfb415acf85762c7c2593b74c8e3e33004d5f9e0398388bc00afdfa248513644411bf8a582e294b5f157dad56100f5b006f84e87aecf53d919ae90b77d79ff8844ec8dfa4929f9835b74496bf8a8dd3001e991577d337781cbdd21c95e04fcfbe79fdf6874421db8eefdf1637654332007e09d4be0ec3cce3a6d32281726e5202aebe48b01f869624fd300ff4e30e7a00d8eb68b3d65218d3712325dfa676539db1010ece9a792ce02813c64f45397a009bab9887cec328ea90e195d93b057e20bdb706598f434453a9de45acc0c1e700dc174580417e482b3c69532b6244a1161b884438579a71ec3bc8f3d51ded57009eaf61118f43b8cdfb84eeef24a1d48e1633d5f60d5c300b0d510aaacc02be007d7ddb39328347c74f259bc851595f02464c22b3c4399c57845885b404fbde001112337f25eb424a9f4760c9da5af17add05854c7e270b75136c8343b9d3e10014dfff7238e0d4cf0366f52b64517a292c23f62036d3043cd4719caa8400e40091e32a2e8918fef6245c675f50e76b5cae2471b23ce2b1beb4768900b355a3001a017942563bfb45a0a0f5229bdc5f012034a1e3b3e35f290c6bba04c6ddc7004f5c459e28f0eb475cf03225e2cf526dd27d43a2b5c0f5e0513a63c84e6d240086149fdc7d64553581c231c6b2b7bdfffaa2e6b6497da4cb8b8e8613fa136a003ce4ef1e820083b993d4bfdd30b6c9d9c9f057b6757b525ced65a420d541c80016d2c27ac79784682b5166e6750ec8f38cd7f2f9483fd0f07ce0eac4304d66002a066514b95a680fb1e8054a2ee1b7c6722d4328c9c0c6961f4300c2972b7900d9b0e4d64eb2cff94e577cb077d228989d6cd34bed119c76f1d7e67ef0ab4c00cb1d83656278ab0091389a4329c783e00bd516f432e3e7cba84b65e8eb3ca60003119189756d04a63098d76018803d6f63d30a688c9fe45233340fd11d579b000b94d753661b45a28648bcc5722688d2dd2f1c6dd1b73987614d6390c9c64a005f04fe7816550a5afc58aa08e89619d23be4493353307a34a3866acfa2b23b00424ebee709dab0bea11d13600457a42ff0095b57e75af6d0cacff2b7a4f59c00d14edbc7e538b2c546c610a66460e1172919393d8d2fc7f7235ba56104c56b00b6485b53a3e88cb8d437bc638c0a6225249198d533fcb8910f3c5af79ffaeb005ba7d5e9d4b32e3101a67e2b8d1d37f1511d7d7a168c5b07fb7b25e21c343b008b77bf8eb140c036471d534e7c09a5e3285a7a413b2efb22f45b3d31e242c0000320a0c8cda70fad968c4bd1105adf951b70d440a078e148e8d147ec76f9c700bd9fd07f6198a36fea034919811f4920bc0937fcce284f47c7b60ffae3bb7e00fb350d72176d9a10b4a0c8033338c2404dce825c936f7f2b46dafc5671034d007d63b60982b3c0614da0564f871d8aff1d7f51bf78032d231ff212a2d1c8d700a26a6248f5b1a1b00bdb102ba36104148ff3ac3517d6c7669438adcdb82f0b0093c13fcc5bd357000d2915423490f980bf648585b88c76c26a200366b48f5f0031e29cc774314711e9cc8d94a62be99e271db728a0b13e348461b301e52f7700578d8c0ec9ff52a78a015ad40284b45ccc88fea263fc0c71a35d68837f948a0034b4f248d3e69f18324778ac844af44a76a92a84d4ec30db6e6601aa0e8ffa00bd54014661fb939d48cd42919644f476e99859b480507ebae2a34505d92e12004320230eb88bb8cb2b9f24508d82cd99abcc3cf8a096918aace73df69752cf005a3c5b5adfbdda0b3c50711067a9cc63a67dac00f48dd159d0ed331cde7c3b00c8cd240806fe39caae5a099433cab68a0c47bf5f6d48eacfd36f97afee143b00c505adcac54d0f5502464271f3f6b048394d0fb779498e05df877e0d8e8e06000e9f39367cb4ab32e34008fc89ced1794dbe5b558892aa7027931825a28ea8008db670e3212d8b267e70c03505f00899a5099db2ba67bfe5f6a24f7fe5caae00e7e6d260dac286363ae6e5dca1b4d5db2859ba6d6b5097d1a8dab4a43feb2500dc1d1a20852f94add21adb7660144bf312f4358761135e03be5fc48297095700f9614a3ead352037f4e053fbbc9efa91e6aadc4dc48bdadcb55f9ed5883912000c40a84d665b205ec9229ae058a188a3319007d997efa0b8d20a6d110cd4e4009543d44394e10401579cc449d4720df6ef3bf7122fce7e2f750f827f73d74e00820192f817aca3c9df2b3ef41f861d53a38d6f5188efec9f39afdcc7124066004db94e5c5375b0d975c9300cb43903b12c7af4a1a7f9bc34f9584cc6ef19e600fd2a9c14c242ecd751d08a76a3d58424998fd99a8a573b12b14b1174ebce5c0071bf97455b06f299dca888f0a19a493b492955163f9e9683bd64a09272c3ee00b3e7700316c5828a49b4b206bd55754713fc3500e7a1d7ade5046f18d1546d0077446d089294faf9a23196f7960f5179733365a84b8977e8bcbd4dbcffe5ed00163516ddcd6740f47e7ff6935404c284ed618faa9342b013370c577f956d01008a8c620b540d2bbb1f195e881739d8ec0603ae96e7dcd7a661917e0cf569880053478b33ae89754cebc7097d52e4b3b177316f1ae08e3e16d6301ee439f6e600036f9362e44d9a6923d600504d804e3a77b423c54b1cf0600d147e33d513dc0088462978509c395eb3ffea2335c7d7c732dab6f76a308c95c26116ca71972e0011257854fdc8f5c9d5d39512e9111f496030e3cacd577ccfde2af241a2b4d1002629720258d14f9bcca701487c74ae92bceb76ab42119d5597d57fc330e079002851d6673d4cb4ee4dfa52345d5298e87f8c9c193743bdbdf10574700a1abe00c1eea7e06a13b1836940ff32f6cc9d5e74b81ea4ac220c8943e9c756fa22430019f4fce8c8825ed9d3141caa8ec03e290feab31f4a81028a40fdd19440c91100229f5246623672685c91ee8682131f6dfa923e2007a487da326d209e0f12da0047b8e5940fea91b69bbea40a031237bca305944a94e41ffa576f3a206c5c4500dd561167242185237fc19abe799132b52d8eb4865579d9dd0ae81a4e76d1d6005721e1305d9c8d645b7a67d88babc1c8540eb603329f07b9187082a1eb97b000b20950fbdc12af74cd01de82cf700e183db025d66557b393c7d071f90a855700fcc3fd59a0402d78af765e7dd4e89683c5dec7901679d773bb26d50d8c326200cd6540b7bcf05a190af8bccbd6af37b43f329ec0bfcd112f4a8a41e3a1317000a2d94dd0cd5de545f1fff7c0aa164437912082c64ba5844ab068093eb05c4b00d2853db2f13d63fcd4ff52216cad0c1298a2b2007e45d19f521acde9c129d500598286b6c41b701c50b146c7264f2fe8692a7da62a6e0f4d06851e81f093ec006a942acc94abd2deeccefa58c87d58bd15fe44bb3d5cc0db3fa5fd72e3e4d700e0d1bf02c58c0802f0843ac2538fffc0abc3b8326a925f2d9a57d0573e485900033123af2d4d378c5b0ecce81983a493f0ed4aa1b09984f7b4523a8ded8c40004c8667c918c5da7cb2239d42788b00c9f0b0196005e850f4138259bbbd1eb4004bae4c09c751d4acd823a8552d61c445c4e9b8a3778d2ef8d4ca6437d7e72700d3ad61340717dc85fc802388d8a3820c88264bba7d8db8981ddcc547911536001b3359e43fc72e254e8314cafb85f483c42f88f0fd1ef180f61d4dc9001a1100b0c94bef36221c0168653d9bebae31b86db51f7a5f20e03ff155d7b6782866006d7151943ac3ad1dbed1fe6902e4e7e481ae16b4986894b81a373f3f66fee8004836c04d2e7b63a94940c7cc371de5ff01cea0b1d7e7a3cedff3edf8e17bd000699e66a00fd40814a6712243f525296afbb53fbff5da8cbd87e1d98af13c660048f7263ea423db511bc64892e48306d5aeb368fd9a22d535c4f7ad534632b700346374352bcc722d726b3fa41eec89b78e0fa37c03afad9ec0fdf33e87b6c10004e3b08c02f537dc986a20a6d9987bf04112a734908d528fb8a11194394d0c00cb36a1c9dc6b51374fc3d39d34c590d4a78db0f9ef70b5b894d4d3d960994100a45422d02b85ecb807f1adc657776d4ffc902cd37c850eeaf0657ad2415043000edd1620f3073bf3c780dda35d6f20c2bc9eb0c6a64885e086693568ad0bd300327d0c6ff962e8a27b6e539a688f14c3f06688ab67a0f1cdaf1fe0f05b0ea90094d63626790c464715c774b816df5fbdded3f33281c18098a40cfa68cdbf6a0092f70c7ec8bf354ea162a2d2674995ebb317c473fcd9044d2c181210d9429a003a8a4e2fc893afdf23efafd152958d4f030ea413f73dd591005a566822821500b062b90f3175f8a0918009274dbba9b79f7e2be80b7008c76286d210099063003df73a8bdd294d043893a8fd567bae93a1cfc5c0c190794a3fc2751b95bf0d002e7e4a4fb2ff5b02faa8a272db15ea1fb13bdd3a83d45f778c6b475fa9538000ed29106266de45f50e09e454ad7afd7224d13a17e4c8e0bc453a0f268aed8200718e9d2064bd04db1fda4d0bfcf3f60ef1e759a779f574bfff416067d6f786006fdbbc8d135efa7d672652713f24f67cf14271cec3a21adcdd8dc7d5c81f57002caf5ab80ec167fa0e1969ac8e70879750edd07486775b8ea9617c07ce18520007f8e238d127ce936cc7848fd8741cefda7763635f8ffb2aec104323a0242900e3df42f8cb16e17efacbb6841495cfc3d43616f20a97655ed79d28ec2dfbfc0096d0dd5fc6df2bbe1395fb0d1593fbc188bcf681b1c5e103d7322c044a80300046a5c0221ff1f7093395742aedf834a326480fcfa67e92cae41c7c2eba84030023022396d6cda275a554542eef576815ee17cbad4049c7b2a86fd8dd65752300d6aea94008cb7b728252cd35932d6c0014cbbc84f51e129bc87e0e6a2b85ca0042891ca80679fd01b54d3cbd16f0c46f9d53e5d2a218784cbef4b57885f02200db1a0d90d03d491fbc14a4ead3ee7a2c253ac536b0e3eb19924a2eff228fbc00e1d2bbc67767deacc8065968c1a5bb4bb2a4e11da2cb769eb2e302ce2f3c520096311ff7a220dc853dd70ceb9a9ea586d457aead103bb650bb0f7bfbd656cd0079c318bd045c029aca4ae0d76de518329dfb707c11f943efa5831991263414009d7c9be43ded20a955a72e4ed5032646f0dfca02042575302a7afd375fcef400cab1611a06ac759ad891b126c47a8ee706c075ff8b870de6047b2f0248bfcf00263ccb53b9ccf60367faa429181467c7b39c88f10ea22527842147a06b599800f25e201985351a205da957e8512f2e81b361834c774cbfc91b374ad0dab3c0009d604835e874e0f6d28867cc4a7e1d9bad3bf518c56be56912aac8b320bf12001f3f51c8ac33bdb1ead79bf044fe53b04c1b1586e149137e5a26fa96c490b00041b5e7c3f51fe705ee374aeb9d14a675b376d4ee78e7c0eb5dd4471a940875001043d38454f903fcdb4ecfcc3d4a11c8759d2e5d2781b7b3fd7fb48034285100c71f7d7e89a1ec92a130c2b8d7b53769d663dc989274bde4dc2fdd0544a13400f5c69affb6aaeb8ab9ea8e4e28b5782234dfb1d1d55b2d6e08ebd4fd4859270088ac98213d48c0b0ddca8026d5ed098ce2e3f9d23b08fe0a6764797a422a4700496063165e8455a7c2bfa77ea1ce3ef63685e2fa63b223b980150566a85f2400bf5d428d2a33a4204234fceb797fed3ef63c5f04fc19232f54c7314f789f020092da6919e6a85ea70a9be05dfe18eb4d1584e7367f2b3afb6913166c875ec800d42913e0c4d90759c12028eca9db489143b1196f66032a48e4ed4d669f515f0027161a4de79a5f7405b34f0eb1d52c47fdf14e1567ddf402a9b16fa40d8d2700a00e047bd0b2c0ef5889b61bafbe941478ab33842aaa3cbed074eb150c2f9500f8ae66922c7ce56c3c5cd435e688a3c8754d5c41480f629e549cb3454058ff00373be0a687cff905e9df16ec92917cde336a655ce3cdcda7e12d00d958b925005658ebfb326bbed3e6a33fb250e2f1fbd18444626df17bce40882a84d0749c003f1d663c6df58674780325e9dd51eb80cfca676628aa254c87dbb42c953d8a000c1dcd1965ba136a3082351e39c6baa337f3259cb1665ca3b78469825c586200762805d6c0b1921462d0fc8b8cf6f325f9c0034064a700f42f283cd3fa357c008b6ac93e1319fff9b461a009ae4ea2fadeb81aa21d919ef112189be05aa29200d30028825c3f4ea5a019c0cc377e914dfb05f0fece497c78d8508a4c02a68f007a7dd82cb65e2400d5cab5e5b3ff8cf35792e400ef698dd54c50facd2c9056005f235f37625e3f091e44e3e8b82474171913d4ded961544ab3ca1d2929e735003ce895f97d6241617a32626f30aaa69cefef20d264dab05cb36c800a149a4000b0f217500942b89e5dc96c1fe2e1f45f66696532614b5bb7b767cd3f5191f7006f5038e437106545822717612c5f876b2200b3d7794362d4a9f43b9eccd96300efe7461be638aed655dcf06fd8849ae76165787546d78e272fea3a88e71c93009a5fc5b79336ddd73ce4f41877b4bacf01e293fc7e0d785e51d8078e68dcb200c8771b54d4655f5dd41856b53b478d1a5168deac2d4b9334f1fa3fbbaaf5b1006b87c2f700600196eaadd41afa6200136c649861b4d7ea68fcba0911fc780d002e14b1f4663fa755251fa36df1904377781e423ea318ad086ccccd999bb5cc0058e00daf31e52e3128cb980909582bff1ffdfa393d1efa202d1b560a37afbe00eeae205fec219dfc6120514ad7045c9c6aaf6e453162f51ccb2b2a1dfc5e2a0081d5b5eff6522616d77e1c0ea7c0332fef5e9566753d19f058ea0f5d02166d00b57d2ef433667878f5b9677847910c8e1a865f1860e7c094d9390882237bf90039676da30c5723b4c704e2fc2ec5537612422eefdeb9231d79d4359ee91a0600460c41d019443b2ab1a40702236cd5ae87698a7a257e99888c766ac26cd6c300f3ce980ebe15fbc45ecc0a34e32a8886923b9b7d76bae56e737088bc729d3d00ac58456e0bccae025f89fc8a0916cde4cd3980eab26929aba3a2d060b0f62c00958c167235a38a4400788625b697ded53d42d4fc3e6c3b8872929d42cf25ea000bbebf784d0da97b98dbc0f515f58f867a094d33ba4509a57cc3d4f84264de0080d38c5cbc9fce34c00cc85b414f726dec5afe5e5dd501e8fed8c624cc7770002e271e4380a117fc3a4f860230a212b774d4d0b17a44f416b977f2bfa179b300ab6957b938f0f6ec66a26080c288c1f3153e49a19924e6a189fcb88abdede90048d9f7123b8b0934049963233148c8eaa65d7b2ebd3bdac61b444289303d5200bbc4dfab4c10975ef12e7d23d9f810de1130b1fdde0e881df740aa00c04fc200016ec77bf7477846067c42ed71f9d5c26e77e72c921bf5e6af866be83a4cd7001fb35f13858bff02b74abb95bc0a3eab9bc12d8c6aaaea04ce24357ff083d300c6cd03663761c17ae16ede156626b591de15351df72d1ffe901b171eb502d700dff879ce8c80b53c9cfc4beac1257af725ecbb5761ea7f07b44d7cf40b41fe0051635b45a5d356028d5159b3e54a61f4454e4002509175d46a8216b916732f005eb9f76e0001158904e48117a10486bf4068336d18339e6b352e602a9952e6007ba518eba23ea72ad154e49136d98b3c10757e9640511c5f0ad6998c4c7ccf00635d2131bc65638531e4262fd65a870d7eccb030049915b4e3a6c6428c9b6c001827b824e0497e362b0141703b2e24993bb0fb3902c899eb6f69b62fee4fe8005a751d71d262a1c4eec6896119f3d9ebbe77761b48b238b7d2d42d134e599400c04d4683e8027c11ceb769ea33d4fb66014313c4fbd290fb60c6e737e2a865009f31ef83839228ea49b1e50560f3ddc9a524b22a6cfab9737eba89eb3bfb30001f5b9b2e4d91ae59c01947807f19384eaa0c32a3e40a062b729b39766c82de0039a6b927fd65fa276bef2c1bd5a09964ea2e13fb1cfac97313984418b8bcf50021250f919a91311f4d1081a7f44f9ae662985b3f065c8e865e15f0f9173d0d0007fe45c995e2e500d2adf52406dc937f012077ce3dc81039ab4353bf5dbc0b00997f28c109d57645799c45b545c42ce606d90c8e3a35fac55dff8ef7681ede000122d6261045a06dab96cf1d7de11d092909202f522945953cb2918296b67c00a7e41ad16719011666d00a1edbbb2bd3d4d4bccaaeb973a7b204f2df82df8f009da97f620a33654cf0fc7f75c3d029f820754751a9e9d0aeb3da39dbfb4c290046647309f831981070b7e55da3669c125a85ce51a5f29281cdce6f04ad610c00d6566d9768fb0f3e02a438208b2d2ad8fc14de0f4f370f8eadcdc94d536e150016fa99f1f16c07df50ac909e4305e0ebac130b331a8de741566457f5bc118c008ed3101d507d32bd09f198d30986b9c1a456bf0c058605d9cd816d3becd22800142cca5f422936ef3e926f09b978eb981d70273f475e425202dbecbdd69bbd0047de4132850af0ba5d7257f06eac563757f150c87c48cd9387d69d16ef103f003da64a4918cbb40b0a66ea807aa001a58f42eb948840f28954e2d260c880a9000bdc5e8f45a3683fc9664bc0a4d451444a034caa11f17a732147a0bfbed7e00088237baaa918eb6792c73a1ab5c4fe88a3f48f8175c7f3a5c0c6ae735be01c0063d05d81ca453d849d0910b711d5eab3e8a4d24e626a9f4026c2b8d84f0630008a032a9c92f9179dd428d73edb8acddc27014258b672b193485ebfd6c94a3100b2b2475771019f1b4feead08eecde5200e7f8e01182cf022310daaca1756d20052fb3b41a831cbf8b463ed7e29a691b1cffc83c0b5950d46384b75a0c3fc5c0066408ccf44858f540c29373a3c8f472ab9cecdf181116792e1025640e4211c004004023d5914b087c2e5bb397c6a17f968b76ffc54952db2cae15eadccfe7b00ce4f1eb283b55a03da1f61de790107605f88ea201ff78627c1a819f5913dfe009d64fda6b3af2b11948535406430e558fd4f3e82db8dfca24ee213d5f4567900463fd574930c9f5045f7176b99c72f2d8a49cae4d559403ac575c0a2efba4c00d95a3fcdd7384975b5cb64b7d647bdf6b83b2fceb5ddfe8d37735a739aaded009b7e976a980b80fc3a6743459c588bd5589736cec6d8ce42804475a6733b46008d165512da8ee02c03182fe847e1903cf7d5edf68fcd556015b6a00a35c57400dbb515062062e3e340fd3970d667a0c8325ae0212ddba530fb0a6249e6621d00ef551033e5ced787766ee907cff541980f1bcbf1defb42655ce60e8351b0a1000e0ca04fc2ae51101e915cfb64c0895cf6de1d61846eb351b1541c27959d5d00969aaab17a882ff2738239383c5640da045adee53866855f408695201fcc0600f536d4dcfa9b77751a33674066cf0dc0c5ea2f345928669b93b7ee18b8480e00d896d6873e20c565d897fe88cbd44c4c2959693f45b567de03c160eba6eb1600b89ee2ab2635c8af4b495afffc5581cb6525feb4067253ac169f0a43f77e6400c58cb48370590ca3727600df8ea30e7c31a0f676df0037f8d58e36f58bf6ee00eb900d669dadb48bb963798c8f85a72af5d43962f680f4006585d29df0ceab00eeed57be05b7e328e68fcdf47f0c36cc5a72915b1ca5b9686a296b808d3c7300d607e1ab30260fccbbcb3f720239b0f2d34f07d9a08598d9ffbd27fec9841d001f8665beedf46944747b0d14b0901dc5fd0970280c29e09e2836f789ad479300c90a65e55beff538587b5279114a3941cbd270fcec37f7b57da16b61699552003789b43400865d2e43d878cabfad042241d7366ab890b34671f77929003b1f007db9d2601a60a05e4cff08ec2bbefe4ce361b40610faf87d21de8b627d1f4a001f5a397ca32743be321f6a9fedecfec5145cd978e194057284145fd0e60eb600935bdf35df407096a7c12fd46135ec43c203df0d72cbef7fa580abb6cd9f66006fa2901d7124c5efe5552f10e716526e9e499d3afe975a603a0010b7b2a511007e9a4a761c441ff30083e662a665728a068d581f4ad8db52287117bfd87bf900efdde0deb38bd7a926d57608272a1efb2ce404971e32d1eff86e7dd0ede43e0011e489626c56a664cd8f67c85a09b7b0b0e9790b79587b1691cea9d47bf23c00fb6fd925731c9fd3a30509be5f95c32cfa677d891219b2112afd4ff11107bf0002ae17d114d0116de71b4294be41e086ccaac66fcf285d0e2d796e67874f060085939b7ab58c014295fde7534f7c8ce16a92f857d57cf2aa9c9a3bba92ec8c009916112514adf21abd53bb7554042a03c3e78210962681dcbb5a42ac84fe3c003eac5020759ffbd9239afc83fc8c1fab7dd04f0ffb83f9784fa965806c4ed5003dd34ab12aa204ce788808bc5abcd645bba307b1d50f5fc8409ad6b2acfe9b00e5a8055c2b3bf543c786f87ba9e4a0fc1d61e1919297044b4db2285db38dc7007f2f051943213207ad28c9762c9d80df9284c9e7b011bd5c757801cbc0f169008f500f8ba0e964dc04e4cfe954ef4995c6c48bd3d567ecf23f44d3a7c097730021a76053230dca0a8d35099795ee367946acfd099fc751d3aa6e47a6221df700f5dc3f691602d55619882932832e7de3f98a09484ec6961908f6d822c70d0300c498c50d46b20a0cb00e1ad6d24f8fcae6112e89474043b464fb2f12e0e640004964cb1331ac5f39e5639029c19bece90cd31f20604c5c2ccc910638868af500765e2a839701eff727ab494ef9638019b8caf0c7b2b1c321d3623590a3159400396fbadb44eb805a32e6af59fb5518bc17c4d14b1b0f17e0c313a9e02ed84d009b1da67d8d10857813ed7f19d82b6e0193d2d9fabbc3be7034692e9ec3089700cd1d010e9f5ac7e70f932d6daae7a5b68db1c5321178e2535f16d5c373025e00424eecbc562c6dc3cdde979eb243ef61c3d8a8700b99908181b0ecfd179338003178408f05bf13cc92cfe4126bee122c57592ea141b3aff5ae31f88c534d6600437aed77aacbd47c0656c58df6943a38b9a271ac6658c3f1ea62b6346d253e00ed1b3b012fbe2dc7d1e65e266a61ed101cbcf3edbd17d5e1579728a5225419005bfb8d714b3674226714788a443391627d8c669ac1eb436b4af9fdb3e08527004a3138667919c1a6c25efcc8bd29ea8b61809b905905cef0766a9dad0f957b0039ac46bfc6366afd267fe125d2a9c2986e43fd80b8c927863374f62b6ae0a600721c8e6fb036172f165f8cb2a3a60567c24219a82d625f1b45c254297a6b9900c4329ca676b17f56115f2761283668bd9179f94f5ba110be6a6442e70713c5006ef53ef071c711d240f38e38e807e98e86ae3fe305fe587597c58fee63413e002b53fdde3ec59b833410d90b4cbbe5233c9e8c4b1db77cde046c190a855abd004403d00536c860a25b8a2533a0cbb95c805f0b7e0181d0a5acbe8fac467d90001c755ee19e0e2ac824583138a2b51f61805a325cb5622b9496d3a88428983f00e0b0538fe5be99b976249dbe9e78818bf613b9aafee804e147f9cb039ef29d00ce2de2bdfc54be698461b7f501486b7b3657b1a91975a4fdbd768e6b3d35de005df45b59e93a172d7c82db49d68100a9838cf2991e53de97743bb480f6ce6c0020e1f985af73cecbfdd3d33befae43c71c59edb467cc69700bb70d80836f740090df8f15c43806bb5a02b75e31bb0005c33e55ec9937ac8d2401663a85c7340029747e3883f795893cb635ff11e07181b21dd543a1d8f658d4caf5551e825700a00a773cf0e981097c7509044657ccc45ab1e80cbada98a9676966801ecad20057749c788d80d3179099914cae6bc5a4c9dbfea70353371e5a89a0252236d100ecb38c29bff6212f7ffda7def4ec5a4020bf5bfd45bf5ee0740f61e586866f00fcd2c3bbe80feb43ea74fde045f3167266d5204a84c92c2622276aa85350760044f4ec6c39d68a39b168ec570562b2b6fa4991c0c9ac5043f17947b6d4edeb0002278e09d70975a307ab8021978b47796bf3b204ef670bfee44e77b493e59200caa7162848fefb18ec70c90abb9821a567977f82532ff5bef8cbe5bbbedd840021d7bd4cb60b0b50203428f5ffba358d485802741fb2227b648a3ac1433ca9005a5d34ec34121ee24f2026dd55c2b415de8b363c6cef61125e8b7fcb3a0203000d3076c9907d7479753e5c84b3f84dbb4c326205f8dbb54786c0e94b5c55d000d0f92cfaadfb05911bc075ee61e863aa9767ffac0bba27be8458a66c1206770001d6b756df80449f1fe89d4d573c3ded7fd2d238712afd0562a318e107905d005a3ff1e83c17ca2afceb4225c12a01ef6966e9909cdde5afd474b8326eddb00075abf3551ba813db70baae56a4f6a420856a6b47c9b6be0df283a0f9e1fdbd00697273c232d579de0b7b08b82b6e84ca38ecd88bcd7031dc951ff673b443cd008288d80ad8f64d47ec53bb01c917cf7a78b2aa43ad680e737b530d58e8adf4007c5befc89c53f8082a1c946e5e84207bc310c2ca74686506ffbd8227b427680048c7cbd21cbd1be47ccd566f7d3f8888cbcc04b7ee4d88b88417351be2aca500283ef421fe8867731e972461a828117aed583e1691e9f4939ae95d57f22503004319160072673ea974e977eff6ff96bdd2dc8c8e3f34419f328b8713b16bdc00e1c233e0eb5431d1a3a3fc2fca860edf75844f8d6a819edd93ae5f1b029ef8003c627fa961561ceb89d2a0818703339b4804d59837da3650c75b634f30240000949d3f70175bf136b1eed210cc36f9d7b28fe5f4f38752614156a4fc2f047000ccb0752af4b15adb14b2db6ae89aa483c18ed1851ae54a29bf7fe761c254ec00fc5f7468cd5663f2491128a371addffdfb2decdfe7ddb2664d20b3128789a4007c269a34e20e400e0cdeedbdc4949cfa43feebc172b1bb308406b623dc7f9d002869a55eb72fe6d40d3302f2adade22cb8e2b06f8c9a9f2f104c03d6389b4400b19238c645f423904e238e029f09cf5030dfbae65bd6a9d51ccb34921f92720074ebad325af138591abd32d3ea16bf7b58d312fce4b094f4df187a48b07b1a00ce3c7dc709d45dae8c23c10d0acd21ca8764fb660ecc510f742a463ffef498004e3722dbe16837c6e2d4363b2389d512821e3ac21280f1892968a41083189b00f9c25978179aaf59c88ee62a7975a3ba5bea5791d15a2a110d7599f39d903b00a2e01cc115d4c702f85c5545c3c5f40cc685d15a0317c4c9883d3de4c41fa500ed40ac82cc3dd5417958e30d1da27da71cde4b20e5cf7d087c985cb0c8042d004345df2fd0b43a5a9c365d11ec2e39c8fd3f03c0309119b71527515de6316000dae9a1d6128e2b37c756b886517b4ba2d4ed7a1fc7204adadbc483257d038b00ca972bbd802df7556312f76083ae62152e442863eadd595cfdd78d69d9c80800866d1cc340100a2fabcd0b0043985bc7e55342ea5743025294dd3697c023d500f96cd1fac0e3c4e8b247dfd67443cd79450e2d2ae6d3aac86d72d90f5c273000dac698dbc62d196ed1d65de81d6ab02ff6d223ef06f3448cfa108a5187dc35008361775f804f49e666b238177bceb428c92dd4569e39fffb4daf719b839deb00514ef2c0390ecb04e3bff8c93213dfa8913444829f5d50922f9bc1fa95880e00fd061a01a623a698a85b9ad81cf63a5625efdf0951693eafd4a081470ae68200786bdb4f14f6d8a1e3295d3da105488d69cbeb2071430cf8a8ed7550b5d5d3006fb23f4e381a3d0b4a7d10b54678104374baaec3daadadbd5f707eee40a31f0098fad303de519b2f4d8b4b690027dc4bb0a348c121c60c0b6ad06de49da21100644ec7806bde19c0ecade108da8dc557568984228dcab6670d464ca954e323006a3f787c1439869df99b6a116d702e2ba540ce9edb02056c28047b119b31aa00f9e6387712a0c7fe0383fe9ab3dac77678398148600b7a5b00a953641787d00058fadc0f931af99350145d30975f97fc60dcb1af8f1658d756fef71f15bdca00784a937037c6116d8a8f18c10e07acc0d9ff8c1df09eb106af885f46221d9200a86190d9c312180de06c43a75c6c8ce31893c5eeb33049152ec7cbc3a59c87008129c4c4f22d4e9049ab8848814fb8f8e8e61bdf47082f9aaec7a5c600807e00c5f85c7262bf265787ec1ccdaa1bf1a42e9f2dd162e439afa9192f06e9156c000a2bd1fd863dad2a609032ecafee781e0c4d0b06adfba3dd8705855a703a89005f13e3aff1c0d83e10d758b03e426c16f9d4c7b6306f5a213f577c786ede6b0075f41305bd8b95cc6c6e9bd814da8592a3f65640a4ec3de23c3ceb089228dd0071e475820bb5fd42f600891f0100d3c5abf9e85a35745985bbddc955e4687500f8f142f86fe4e1e3030c25919e87c8ad3e7aa1987eff3b9486036f90075dca0086030b32d1c7e4ea3fda11601c80f0d0d26b0c2da50f5c58d0bc5054d28ea600ff94f4b303bf0a6589e86a97cf5c6ea131fa7b4b587652f447d36dbe69c6e700d914792ced0fb6c30ab3469df9f77775fbfcdd14f55d2906160695ec985133006569af5539cdc43663dda8965534b807c6fb045517a693e990ad2026f1dabb007b49a4af52adcdd4977ef6a50bc8b30ce78b59f8260928412aff77923563480059b66536ded42e409a0effb39d412849b95416506df816ab123701b9816bf5003f533b5a17111c6c4dc5a7aa1b99de9605cae5add5dcce1c6303cec2b8cda6008c3964107ef142d34633316a03474141f710cd0f3e9df96878d9e7a8c4b97e005f7a6d31aca57ec4f28bc2894951790314ddb50b02891e0b8b05e59fae37d700246a93bf4009c857d6f93ea7d687b4ea35caafaed32334ce3672ba3393e623006916ff59a626f2e010fdce9680c9e0206d11441830348a9b19253d8d70f5030015e37fc952b5a213963569ea97ac6c05100e80159c0696ca02909997b8518b0067131278563779c784cc918ef500a094a22c5fa723350c898df22b886e4ee900733cbf44e9068cfa3aa57fed018b9ffd7e408f3ce9418f455ba037273919b20066ce11b561afdfff683a04a52c1bb98818805e2e0b99f6c9372fcfcac204f80053ad1db7af312eda352abf8b3beb9dbebc1c24a05bd752d6d3ad02d9458f8b001aac7cf4747757bf4b240aeb92fef9631877775beccb53a7ba02b6b0bb92d700a5d634df9fcdcc01ad0e3641bae73ee8f967bc956dfa31ee050005461bda7d00d35075d641109fd342de6cecef21cd277c2c149040b198a1a12d0f2acd15680002fded037da492f8900656e96be52f2d2df980dfa643bbb9e351a4840e970f001957ecfe5c9b274285242baa24884995f18f4f01e0b790ae2a4b112d2b08b6005f0c2eb42cb6530efe0ea7c52c214e243caac892ead393d4e2984c04d5be6000ed35ba87e02d834d30cf469a784b4d6cd5c29219c34ae9c80d9d41113136c0005f24067762383ce5c508f768c5b9eee656d23110cf70cd52a584fd693b45b8006499e411667490987f6d7da3938b1c8c3ed76311eba8de1bee5560372b865700020f790a09df1520a6fd4538d9e6717de0bac234f139317a2211a59bd1c0b5001b2f451e093a2fc09d231111f383c9f988d037e7db4ed0386a680b6fdde3cf009381fc3c9a81c7c18043e152191605cdbc30ece685bc7fe49af4d46ca2f51c00bd6dd9065c5db2827c05ffc56b1364aae44b04953ca42dd255cb062c1f5bf400cbf07166d29fc16abb69b1fe19c5816a1db2991e380c95871e884d227ace690013e32d501abbafcb3f469dbf15b533e44d58aecf326ce0c7c1ff5f6a3a384e002f7fb6e88018b5c252967215cf9441d9fd5f13df0113e144ef3260109094670091fb5410b04d8e9d74dea5567dbd18249a492a0e3348dc13dfc6433c4ce00a00f0e4a84759bc5e179bf8e6f6cd06cdecc0a7db905ae9a7801d6efcdd77dade00b97cce15b36640926235231211990bf2f0b4e38cb0c3eaea7b1d1e9c8739b8001cf718c11a079af7ba7976eec8ea8969d2ea7156c0745e9a9039e65da8073a009ed92ca71ac4f65d322e83ebc08c5ba9f6d9da4deca9a12583015dc62b682800dce82702be463c6b7216d23c2bca19b9a43a2f297df804a3632635af9799f0005099b93196a632c6855deb0f4d8323cc126366a0a0476dd783c978872d1f41000b185f1dfe155b3e25ee48f53d35de880281f0cc1afd066877aceebb042e91000277c11d4eee151ec749bf8e19c16809a836600ede57ac5d7750e465a4fa990055ca95b44870c491a9bee93a3c8ea36a20eaa4e0a7eb50382837918a4e37c300094ed6b9d942892e2b5683520cc4dd6c47b3e12e9096d5faae494282353e0e002cb8363bf63805593d0182ef084c48788dae6eeadbe8d58a37ff2d43aa5089000cb9f8ca84b1ff4fef2dc7afa90d1a3cc2d093b5b99967c0c81e4d15a9edc40015a579fda5afd600f806ac1904cea38b1e99fcb9b5d82b17ab6b6ce95f48e800b7803047bc708054d34181efa7f38fbc2bcffaf89b67fff09fe11d53c65b100016434d0fd30f19f143791f534b69a3eb72f8a8898253144f61e01f4c998b900067f5f91890339503430a8cc64a678f35f08808cf51dfb3863fc18e39e95de3009e6cf0c98baa924ab2f3b547b899b29aa5a6c67d29c78b29e8c28924235cc500568609aed8c22a610abf4e4ac5b9065f9e10d7ae345d5795d1627281be9cdc008e8c974e6c219e4dbf5c209e407b5273a7caff473d083b468b980f973ce93f0039e4dc1ef2ebc1466660554849c0134c69aa5846415d308f2f319e46e0d72200bacd0a7de0e625ab01c23545148bc85d684c2d35dc893f2cb0bd43e533f0e60096f833aaf4472e0ec5cc0b9f9e95ed9a241c0fc5f865feec9b7a6caf2892a500046cd222bde13ace4abbf969075acdbbda626d893044b215442a90880449890058547e2d143d371e3b91275724be237b4e7a74b297413504cf6b60ba5ea73900e781d45cb129fd95a02d3a6a74beb2451a6a20eef5f0a2d849c3177230e28700e35c4002f8485044a41ae03ccfec928dfa68061ede4dc0bdb7435d8002f93b00888759a81e0ac6596b0d13aa0a284b62eccfeb4ba63d6354670fd0f3d72fd600ff755c523bb6b9538c6acefb0b3247f6c4c65a87396e9c09b9553045546b0f00f840ad3595773d6c8520b6d1ef9d008537c76c71edcf4115ef48cc826fcb2d009dc2ec9b5abd3d68dbd5dd338d41cb95fcd0e6b9c266b32c89a9a28e8416a3003f380461d9ad157cd1ef20c2c6008d951652fa4bfad2725e150d408201b6410059a71e4d54a963ae78dd40b8307b8322156d6dd79769a28d7c1cb230a5b111008a0bf6e32c1378cb40586fbf2b67562fed347b5bf4f336ba96272572d788700021622c55bbe5806ba20b6409f20843d3368ddaa7bf5fbd2750dedba978f47100248966611ddd273e4916355e110b9658c2c837e4f2a7a223e74e4f16417b68007a31f923bfefcb81b85d89ac64845ef255267e33aa393254e02f8b317423bc006a8f2cbd3b3a883afaf175ab7c807e10b0be99e46d16d53e98e4323456c05c0023c01b47547338feb15ee53847d14bc4d086dd10edbcca177ba1feb649d87800782f87f002d76e49e11eb3522aad3da9bdec8bbeff62c87a07dd31b107b1390089cc57c0bd329a3337f13d67c49b5647eb9db0d82a9a60d074d2a91fc9760c0043fadb1f5bd7a52e3cc2c2b5208acb85eadfe239a6aa47a51b38e8a908fe99004aef135b634916066f5cd2d2d821d71970687b27d8e0c394a7a31893e2400c009fb55c92ff0da7a69bfb831f782febb3da642ac70bfc9b59f78253ef27b3fd003fa8f6f9216ed91d7b52f07f2c364d407c374910224030400d9c0ad18768dc0070f37e2bd7c25eef366a2a51a2a63173316eb27ac08cf9f85d3d564cb0968500c5ba0f074272fb4e080ba847501224433c8d27b84a5084bef2ca29fafbba3d0048dd22f35774ef75ec3b6d2aa89bece85efe565bd9729bad2e05893f364d6e00d5f3ca4056cab5fe3c5d66d99ccf8845713e6bc7f93300a98d6933941102030087f50d797e5dded04655c46dd92b74419889ac7dfcc3aa9851b0fd52489a8b005fedc06ae083d7ee4fe8daf2203a49da2a64beb7e5d1c2e18f4542438335d800132747c68a7d784721eb0c18fba1daa05d4402568ea47d8b9b1eede1810312008882b11e35cb03a5006ba6cef4fbb56899a8e4c244be41c2180e06aa4d579c00f498dbcbf552360639cb0f1b6ca41af756809e9e8eb37e2aedb39c99cc9459007e5a9a4ceb16b314ce9b9ca18e904551da40c3e324e43b8a74f12eae3b71e300a0570b5a3f1f54a001087f02a8ad77a5f413800243b7b1414e06c0e01fe6dd005c75bf0858c796e816b2990c82527b83a82e91eb0dae185065fa415041e8cc0078eeaad88582f945c227ba5b13aa8c55d8f957e316eedd6197d32a0e7ca60300b73824dadf1791b0170fb634ce571a68d3b484bd0428216bd4e21eee3762cf00e6fa02f8cc36c7f070b0e0bd52a9e11eaeefda4838aa1e84154fdddd2f866700242b4fdec7751cfe0c359bfd16ac8d8a9adeef9a1c0e3289e27ae7c432a9ec00c199b369bed4e1d6e007bf02bba8d5bbcf3ea2ca0fac91485d1b0ac42ac50a002ec7f7d8d483b3dfb207ec57f8f153a2d8a0e8914a963dd16d470d9749ce9e001bd1b54c4a38248c21ebbe518ab14ebd451bc0dab4e6342ad28bff51af655600ae9f8678cb0410f9590860318998a7a07d52b0d6a9f8ff97abfcf9dfa6b0f900945d078fcf2f1c65eae6b38857cef5e21551df13505b53fb8911f864d1b71000bea661d60271bb97b5cb71b2901588caf00573b9c772d53310ee5190d63bcb006c185b2e5846d5429241811d270dcd40e395129e52a65b3f6d7f67ea72009500f362616961694337180ffadc0e8dc47baf20dc548aabc5a1679588a25792db00bf6d0564eb8a04134db724d3bdd4aed73ab97213ceda04ab98f54ccafafced00c92a104a433083fb5410417ab9bcd885a87f78086e0ed2fe27e73e0ab79e5d007ecba88c7fdafe012ec702f1391fcf21b3f851b3cae2b0e64480acd27ff14c003585937ecae375c909bee132cd4854438ed10d8a98cca803c5bd036ba71186009e5068f17fa5512221a028ad073373dd17fc2ae2663185f335d1bb09e23bb000aa9b94788ff4c52c05470c489e35bc256000820ae561e96d825dd3d431862f0032912b6d936119c8cdc770dcd9b2cfc213b653fbf3ebdd4bfa7eb8be51da9500882d597433fd930de548373536222e84c628640a20a619da3650bf797514da00dd8eeef6af50102f10714ae314d548f81ea6a1538a8056d7c296934af18843009a57a977bea5e54f3dd6679b9ba0c6d6252a220014fa2819bf13b98529b32700b848143830c90baaa36808502c63707868aaab0158641d9f7afbd191393d6400baaf7c250f2b74549ba3e108c25d2d8da476b9451706bed00e1db7d9cf699f00896ce051b6eed572a94b71edded0dededf5814a61f83fc19d5f54adb94c9be00e639a20c3b9b2315760dfc94dd2395c268dfb696074c451a4c8999a89a220700dff87ce453f23e283285f86cdc929c6aadc5a8bed2d4cd37b3c74d7306ea310005803af3c6c0ddab32eb139c45b5fdab2527aedd2d4b129856765600452d2400c415d13a6169b66ea62d2bfe3f81ef06e5c739aec18ba896610feaf5b73526005e3455475173d664a7f709ab759c89bb83f6b18d41b7ed57cc9bc3372c893b00a87dabda35f6b2790f220793832a997da1192836d70874194c396b83f428560031066e0a17bba1f9e4718b9f5c55792846110301324ceecd1d1046dd4d578600242e77b5bbc9c2fb53ee9fda0c4dcb81355f21e166e6923ed3d91587daae7e009f29b81d90e28e76c8b3d657234a5b283e69469ce29088f9da978d4aabee2900dad3e85e183ba3d3bfff520c1d507c4c74b0d393bfe6e9ba7570bc1bd3134e005045cdfce4bddb8b573cdd3baadafd445a238d346c10383cc187edcb1bbd3d00ab367b4243f559f9e2c8b8da19e6743dfd253b3dfed6fd9970cbe35c69d0ec00168bdc056a83a0c030ce0f0b31fa3bc4b6235dc185d3b1566982a4571df06800abcff2a1f13199d41556157fe33aa5b4681770044ed031b63385a1caba1c4600162961c5f86ab92e6c34640e9aa6c43bddcab7700de383c482d0de5e359faf00c5f82d4a4826234071d342279ee9bd6b5970a8164236040091f7089ad57d34008d3a032676ec4f47ccadb77d93911b362b73368c70f366ea1f6a971c1d5b2200c4e39ce8131fafb696a29b54b6010d6e2dde1df463cb61415f46292747e6df00e55c3efcc623afd665f1ab2449b23c3835270703af060549f618394e690df00012c502e7e9f38e7df4df7cd6a7b45c78b8d0b449d384b57952b5840503464c00a9588a2cd2c9c174dc25ba3a7bf1dfb258be0a369a9c4a0d8fdfa5e49cc19600cb324af213191ceff259330b609e66b4aad41c085a394edeeb7ebe6c9e6bfc007aca13b35b4abadbc53c351bb534b0faa471d5e585168166d0bf7afede402000cc692d5591c45f03a807a9c0cf22b6e42e0a53ac0a25a79964e39cc897acd5005d950d5f1175fbe8be34623960da3438f38779924c5e3942786574b2406bc200fd764c0ec279d75953b8c14fc01d0e03d7e51db2dac0fbec0e5573a58220ad00e620319f3a4000ce7682260c8b9088074ce544c4624abfec33a2c1ef16d1c00070435e7102080885d90811e4066f27472e373f7d067c3750850607fae3883b005c7940929a0990749dd68a0f2671484a60603054e93bc4048981b87e2330bc00b34844276cab9ac457a915c5d0c835852a637ca624d3855c843299d11ac1f10052f9727b363b12dfda3bc4d9d0afe0d673edcdace9fa79eddbf3a645c7382a00f604ec5c5eed626d834f4993074a7f0f10371d0797f71205219e7aae41c985003903362b24e736a889aa661e1e11d1e0e06c05c86b66c56f148e705fd0a06300ac71ca430b7c6ed8a879cf64d2b06ec8da917478ee9fed4417ef8c214b271400a89232a7ce40dfe70fde5b0a57f4df572ca59e085ebab32ab6f6fc641248af00f05df709ea395c1eb3f423de942209e8d61ad430bcb868e58a20fa5d4a0803003abd7529d81ce3ec5a703d2f78bba02cab89d352f006f31fde0f9191bc3e4d00797cecd2e4ed784eec272d7222465168c23e05594e576469a6fe5a0578ec2f0041ad389dc42c323cff039a8efc8d166e569e35ae216e16b2d04990771cc4f300c321b4ac845da32f5233ad61ae21cca24273aeaf0e3bf5bfac80393e8ac1e50081314756743b369813f26fd3ed44d246acc7488306b780f4fec4236a2e9418001c9a9ff603f22d7841e9269860078a7e8e267ad92e197ce94d712843a0f95900cc4685b432eca9d39903aee32336102dec5449996f9382d126a7673f81fb7b00b6aab8f30ca6d3f25a4f6fbdf5aa5c73e4f8c8da1809c50a7b37d5c84ef8d000ac83dc447fc1ccdf9bc5673d148a03bd1768dbce50676c2eed5b5e470d4b2200b833501ab9a1b29909cfa92f3de3a9bfa0842670ea28c56fe2ff40247d862700099f1cdc581c5ccb0d3c07ffeb809dd7a4d1343841547dd6243a09c524317b007acd3c0ccdbf61b22d53c6955f0133e229db64dc2545fb99cc52b845b79eb0008eb602656c7b40c8ee50ed0a7ee52b5937450cd82f06c0a5f312dc4393a27200ddac41dc5fb2d4c92d06d237a34326e1cddf78bf1771f6e934c870c2e44ddc007c193c8857a114e73d81cb43b1f36376664ede18e768d722c15f3e65c66a9b004cbf0203411a3bf554d56bcf5186fc95771dc60832c09b9ca24d744881f36f003d36f6ba832a89dbae2adb13b49607042e21a029e8fd267ff693f1a06db08b0062f712c316639619880af63f862037f54bc87dce72e8636124f263b29116320024964e8bf20e2911abd7d7ebbdbbc68805beb4b1b5deb5d1c17298f1b31d8e007470597b49eb457313fa1f5f028a7e4c8e37750c7f3a3367c80e14cfaf353900f3ec85e574582614bbe74d062fc1972d667c66f62a2436b2e4f81ded9bd4d900fd968f807edacec7313cbfd4b32cd4f481fb49183f9fe578d9795264fb4c130045b25a34bdaffabe6770e9e230649a542cc8510aab971ecf306fdc54b55de700f1ec0766a406591e3d651f52538d4f262e63a2c44ddbdd08b042bf35f693ce00051f0ece4b42950d21bb561792e3113aa1c4795d44f6aba3dad72a9b6ca35a0001f0bdd182052de2812363649717aea6d4a8d2de3783d50545387c39f2c7d50036ea31256c5b181ab8cfb8aaf08d56b3fe169d32d4e26c9662d6ee429ce75d00e8923434f86c0d7e8688e0d7c5c25bebf9c2011b0e88ec7668e6f60b57301c00a3b36263433ca70cbbde9fd28aafefb6829f7c48615318722361f01b0de7ea0033180882d50f1aa92f687fcb0c130425e022c1e62f89017b1b2334a7373b4f00dd6114b2eb8fc52fa02effec5f55d10f35222fe789799c188810700a2ca9de0017aed05a33abd658dfd85dd0c87407b1e69466da1ed3e9d4d5829c3ea0d2bf00e332c24c21bbc9eeca4bb220c5d3da043f7516271092f1fd749ff749b4ec5f00983d523bc2709352e85d7d6073a88b771979c7956ac878d01ecab08c87cc8b007fe0d3013cc5a113093f26bb7c6cdfefdf2ef1efbdbbd3c792e301275b9f05008da81056bba5890b37c8137ba4b5b41dfeb819e4ce04c0f0666f4d9bc4f48300b5b667ed1afbe3a1c2e6d8011089e0957fa748745df54b07ed5ebe2cea55a3009ffc7cae5bc8117de0f6a681239672845caf15f8ede6c1f54e04d2e72750bb002832a615aaf1446a04d2a3072060348a4aad6357b2f5e1fd43ea7bfae092210054aef8892609a654889c16644a1b541e4b2400112796b333642c38d263dd5800b99d2b49acc55c8e459413bbc1b0cc32e55e9a58790ad69cc952bfe76b8be100abdc835360b38c9ef0dd6fae0a880f08bbe6a6ba078eeae421497c57bc071000f6bfab06c1a7ce7acacfa6ce8c2177eeb8cf1e34c5f13eaf2b6972df029eb0002002f834ac5a9f4b1d9a4bb95b3a7547a3614158728b365dadfc3df917ec50006a6f780944ae0a4d7514f0cb4d62066393e25759a2ece07affff9331a69cd30073565ae8fffda40bad0df7dc1fb0c4adf2f0aa69389bbb1fda3a75a46ddbe40089d181c0a150b932b863576fb6067050a1c86553388b8af06e6c003ba3395f002c53174634cfab9d464d2cc5125c8f8bd71fcb11057629af8bc1c31e3089e000d2cdb6f7be1194cdbd5d5ae776b18f3b60ec8dd465e9191a67897116c16cff000304c8ab5144fb4ac0aade84cbdb0b49aa4819848569f5c152f5392e036686001f571aa84f417d1c6e4fd2e4b06825b5ce6cd73be679a913c0820cfe30c3b600ada96f49ee8f62562e0325c10b9d6c86eefdc169adc9504701d4d70cdec5eb001d937e9377a9ccea6f637e6ad807f52356ac4cb00da29d0e4abb18b7a203da00de3cafdd3a3d6ca49b2f680a600ca6576affd9e97f4028aed8352502ee1eaf0036846e85cd08ccf74ec3363366ab927451f26d75b7385c27c3e3a492a7f9e800c8d31ab8f0b52072f5ecc08ece9515b7d9cef5ffb0f8f3f7187b4c70de41f0001736e0ea61b26819afb343ce1f2db08acac05979d0f59c2d44c17e93857df000e4b8e634b26157e13fb76de82be4023b71e0e8219eaca789380496e4be83de002b94fbb72af69287b696e80e07c819851f2ab2852b3fc4c10f9c9015289f5c00eb02d9296799b7ef16ef35487a586f519c162c8f92fe2d4bbe7a068e07c6e50014e233406a7588cc920865591a16f5ac83323046fc9d909299aa2498ed45ad0028d1665a046f77112607961b7d2c427fe7bdc9fe95652c3006c98ec879ddb900b7672b5d45a16d80e6c24951657eff05a833bc9fd0762ce03544454a52d61b008b4f5222958c131147e31f1003ca681befb89392675e16a96ad164ac8b7e7a004e872d66a05e4ed71f22f64c7a63e6e5ccb9c5f003a17116a2d862aca3202900cc553729f465d9e9b80c0a890bba5f396b3f471ec217c4e657483f8f243abf00380406ae38b6fee39539280cb28c30db87aeedfcc2164decf585c51604292b006b25a7c6438a2fdf960d05cd4d873227ca70fed0db261dcb58beef6c45a43600403eb8b39b9e4fbc3e9f35c397b10f8f0e6dc3ad0caf1cf0e798afc44415cd0099bebec70d9681321489ba365c402960bc5838faad65796cf4dd28f164f3d800826bea7f3252dc9834caa3c067de8f728b85ba9e697aa1b3a6468370388b9000f474f728747dcbed528be0a79ae47978ce7d4c20dc6e0f479e32233e0c869900e237b0189ffa55f64bb6bc5644e555c0d4dc2794f779ae6fbc280b70d28e3500bc2e436d657871d456c77df4f50c2f882606fb9171360e53b974f6e7846669008d3c90488f3b788a0bc10da9430aab64c8883c7a128bf917181730b40b544500d126dcbc8d6295f60443d380dd9fab21dadcec6d163f43e110bd9c34e55a9100ea598bb0ef6870774feeb58e2eae9782549c36e651cfa129feafd7102e75b3002acbede249889fad82c14a1c19423675cc9b8ba6c1d5c8b74839e5d07fe1350068c4ab32b82b288c3658988cfa890e9ce11fb2d28aea9b9226a38d67a68bf2007009051bb9f056dbf083e83aa1cfed5b28aad9c73e16f040f595b125cab11b0035e0074493bac90d9f871e9061c7e9d2d409a4297c5cca619b0bbb80ab5eec0063ce0eee059c52a1b47103f53217fc37bc2ed3c0b871659697f9bd6c7d084d00191300b5de3e79e1a6c097dcf5fe0a6b3206cb2c670262bbce7b24c2f19ec2002ce24585e99b380083bb0b971384c9903ab05e6b5bcf866104e93cdc13e79800c764d0f1db40d4ec6702d939526dcf7a030d17d6792e3acac2e8eba9ee50f90081ab26848bae4fb541233d8c78ecc7723c53c413a29f9bfc65b932c63cf54800b6040f220a5f8ef0c0e81fef8b43bebc9dba8812658d345a448d5d6361aa4700375021cabafbee14a08862d84c07c2c7f8377875b1f65f4c9d20950ec6a2c500430399c854d62a052969a16afaf17222966f3f03db797c15caafe9284d6a3800416304f4125c206840e5f6e6aeaa1e095eded52acd8fa9b4b197bde633316f0061981f27fe67438773105fa098d99cad85ca5b16d675a13cdfd0c07878476b00acaa92006353ecb1c94ed054ee482e0b0f34c26c7cd708c0bed67359ce6b7800f3109eba7f2e17554a4c488e9bc5047708b0bb851d6e56599530ace8fa137b003323496612349104a16ef137fa845944e4f7f6c9124dfa3e1cda4987d5993400ebece77580a418e100944fc8614fcaae1ed6687d3a2cf54a2ab58acc4d44c5007569743e3590c72aa454d0ac51ed585e13001b93c7b1023c20eea2e8e47b25000a64e55fcc95455f182faebca835cabeb82cb16852521accbdb37bd57c078400e845d37e7f9dc1bbd08b0df3997779b8319a92ec27d14758c7de198b799f3f00dc02430f3f447cb9b8e8ff7db534c895256c9336caa1ba806de1951963d5600032e12aae3b0db29d6f74f84158309faa78426fc71e8b64ffffb63715d4681b005883acac84646b6194386753e12c6af0d59ceec603d98def8ec01cab1286d500cc78d69d4aa897c5450c15390a86bb6b2286ade12c130f625757f199e028d3000f6106dc799f0f31d793eb1fe463554c59e5839845a544f2a4c578e5c5f67800f75ad3b05b5871f84d556c7585c316061c5fa5bc2343f6e7b66793ddb44d5e000f95cb6519f9f475d853616e55b16bfa86702ef73c613de31be9101979263a001ecd37cb263be3d50bed44dd497b2e82fb662ec7054af1ebca691c2fb1e973005a243f56e74b97aec3eae45390932a4cfc8c8d013ac1cc157a2de81c1e507d00b3f17ddf3747037486b3e26c591b39f7cb41197b363f62cf6536bdd63d022c009043975f59f5c951fb08ef9a4c691a051e7cc5ca3d1a5134c96b1d9d122c6700d385c40a5306ff330d34e69503e4490678ba2245150deee69254d189c6a09f00889fef0208ee0300bf2ee53ebf2e7f5fe9062429f826664322a4eb992eaf16007fcea680cf7745e85f775234a269ce573de03d27f9924a45ee6aa3f275565000fa85e8fed12dac7660ff7031d594dbc313aca9cc19882bb67b2831bacf3cda00c620ad475054499b851460cb52bfc0184ca9a05a22153088b2acee82c7136600fd0830551f0faf2f6e3fe987267ce71b0b0e99a6949e1dfd3579f11937fbfe008180f4468b2514796964846e7974c0f3461d40581083a4126f56de6cec7b2500013cf3ccf78a4928b76a1581d841c5e2a3e68b47dffc2385d86f8571141a4b005f9dcb027c697c5fd24274df16b091426b1f71dd71a0da4361ea89036c06740033695baae4e4c378845f16907e4cd80ae5466ba1b7f13880e687f8c029556600c2902aa3a196c187372420305c6c061719cfbac8f4a409e157855ef9b9140b00d618ea9b52a22accb734846af6bbf4760f7cfcd8b6d205ab452631f2d6522d00039776bf32885418eb30b9d1066bfccd86e5d1fd0cc7819420bb0f496df8f40036285b6942764750133fbbfda202af94142ab5b29dd137a0b5f73c77becf75000819ebe0109859154011e2a4d20b3adff849dc8222ebded7d594d912c20f7900ed184e97797297d3b9d9557e083733f239b94d6a79a4a69078a024a83aac1300af353994ff4c9c6d1abe9e04491fe0c63e88951131d9492a84853a6f5db75800823935b328a38c03414973d0c58276f834dc389c071268d565679f513a9b3100a5d5931f72d661a3d63e68ac3d9aee7bdf4185ed3ff3384dd4f781cfa1b335006f07dd69ce734930987ab30a5710d4128dd857e0033f99dcc924a8542a13030083e0c5c8e747810fc00efdb11e74c24f12277257c3d2cf77f64bb38489263c00696ca772d4f86bfbf37410f278a4d032f5796fe882abe8c15fe42c9106a721000374bcd2fc3c9677d65cfcb94b44f0eedd08522faf0fb857031d6936a5d880008b87f6740612b58516abdea86f2280f1da1fe70448dd7cc000bc996d09ffd7006ef3f954a0cc7463da493b7335c0adea62f6515274cae57a63126b13d150db005a68a69233d3dcaf66e628ec96debe3b203df0f288e1eb9a221ca34a8da3fb00a7747268de1c2844564555fbf1c703d72441b87e286b2257893a4d6edc9ac3006cfb9460b0db2a104195aa242325e5d697701e767100e7090c4e355508aebd007d697d32f86360e79ed8fa5e89f8e80bed160a30aff8001662d2cc41e0cf6200f241b38ec36f9835052f7dda4346563c53b551026caa922729de873e64ac1a002ec31d6a054be4d13e8820955309c7426ea878b1e9c2c709d8156a6fb8277100b6281f81d61c1c8a7d226d7f30271fa48968d9f4f57e7362734048dcdd8b6900416a664cbc234773f23cbac40fa22fcf260ab7ffcbd5a63246547163bef1bc00da82d44d69eded9dea9163a7626a47ceae421d8f66eddfada5c03995b016670038684e053e37b78ece93a60f15f5948ce1f3760e93b1bed7661c79d2e1db390072a77aa198ce4c746cd3735f9feb88ce57ff3ead6faae7c4fed129ba37b94500ba03e3e1ad3fd49248914b1ffe6585d2f87f22ea1405dd84dfd5d7518023c30037da62c52bfd181a443b4cf2b59bd5ecae89b41fe5e3eb0220755ad057393d00bdb3cf39321195f20cf242c636827c1ece25c6ed4b51e20e2ee44b8b5fad0700dda8928900a6a486700f87c9f516edbf1ca1a55df59eaadba27e6a0ee0336000e4a766fceafe9dfb0252c48dd777c3229f57acebfd58d036d3000d0f6d99820017719f2ffe03f152b29a5eab1e580a83d74ecf9266be3a55f98375a6783c3c00abdc913cf0df2f171d53572e48a183803037ca15bd98ebf6a000366cdf9317003bd09dd76d5a3e4747e9e435777519597dedbd309446eaca0bc920dd7fe1510010bad623245cd17d081bd080b4f5d05409a2716670e961496e06ae742b7476006a4282caa942512e899c6ea5eac5c1f1a11785b552402b61413e7ec40ecfb80053af4519b73c17d37bcb70b40dfd47404a8c4840c5d43d0cf6e557b69a7ff0007c4c1e18dc440791a23149eda7d3d2ec27bf42b559b439a324d5dbe3ce986c0017c3c09e59afb45791d0bb347a55529013c55acb36ef5b47a2bcc66d6d967e007a17d200a4240bc7f20a2631ab03cbe231bd271686a9d3d3c20535dd27c07f0040bdba38085eebaace0c4e1ae388c6c28316524d28df397c594243e46adc5100feba518ac57105faa3df6a1a5182b6ff3b88987f85fc166ca42fcbd365f2e2005693e1a8f503159b6cee537d2e03bf642097be493cfcf630d61ce6b46f6d3b001cc3e90294c94a99d39c246308c34a55ce8819c4b03a943446a685083492f5006b56ffdd490c5ff8dc8dfb686351789fad12df95a26fbd05b73b069162c42b0092359d349c0c956426c401a71ee4cd1136cf3856e0ebb20fb27a1289b326f6003a366e61df0cad2e8ba81b3301e7e7a24e732e7f124c65598c9153949d822100774c48f834637b748673f8225909e2a165b8bdbeacad7384b3b1228c6fa63a00f5cbfe12fb0e7d9389af0c0842d58634dd8f0f734184f5efd10ed1e1189a89004f967e402e976a7598e08585a1c60459eef763f2cecc0bc8d79c5a1fe765180060ea02d862a57a117e21aa4d51ece9dc3deaf29ce3c41b50656174dd2a9cfb00b26992f9a38fc0dbc2a3d15fac9025eecd8b61c2317c17a7139fad8f5c7a4c0043b8ff108e53ecb670fab7f3f92ddfeee2af72c1c0492ac1463559eabeee7a0071a0c825ad23413ec8655e2988a3a30669c9c400eaf4bd629cdcb0147d17fb005514c479c443cc7980b81f31e6eb9b4eda1cd5f8acbc8c4d5310222025e86500ed7ff7ee318dc2f37628406317c38fe6083e9ea5bce5a4c832806ed9a7fbfd0004ccea324e9c477dae16eca029e4ee114e134a4ede3f4e2eb1a867565f8e1900c19f8a940dd4514f9374f3c8984a92cab7266faa104a55f3dc14fe0542ebfc0099e2f1dbaf7e433153491d49b3eddd14e0e5c102a9de665697e901384db0e50013eace7d1488d639e9b4229faf9b8d1ff04a0babe986dc2c88872ff95db28b00512d13e42253cd0abc5714b0b732ccb50ecfcdcb72301e5844b417022ff0ed00418f5197815ecdcc345c64ab602881c9515546a1801874f4bfec25ce7fb9be00ba157c78cd3e9ea978d551ee9b113c51d759bb3d18981d05e20ae0ecb5508b009fe2040c769b14985b0590f3019731c034d9ba3a2e85a729cb72c8062cfb990020715e6037e15be7757f4ff208547bc5134efbea031e15b157276c6ed960c10039e44770d6bd92b5d84cccfd841563ad5aa3d31a0e11aa77f40b36d83d4aa600fb97a87fdf8016eb348ed06f4ab6cda2b233331c4c6fcd74866f89b344bea400ba142a1efc7956d09755c24905bb8c5aba3b9b53e31f666b75f899c75304bf00cb396ab2770c4eb6823c5933b2c31d02889afd5b35d2e5a78058d4254384800027bc4c23f99d8f5c4b97f746b9cd9b49ee01d87146ea0db0f3e3547c44bf3100c4e30061db1a755dd5299e289552cfaf02e9f21ee293a83d060199cf06356a007dbfc6dfa14d7c16ec24d61acaa99f199bca8aa60aab2d1e383c30159c265c00bea1c19571f3f43e1242b6c0253e62f5f83af4874470699ed2e9746d8fb8a1007ef10063dc870ec887c6b47b7a9ffa76b0649c5a8c05f025939ef08625422100846f9422914157c5b11e07cca3943829a97e77c6381f0de96197febbb7ccef00211f1910f1b2ec4c52c7bd2c5007a4a8a4e2de8d9551edb0b9bae573f1bf2f00e3c5692e9067d219fd9e46a4a72410a8c50388612e2133599312d24e626dd60090534ce38cb28b57124edaf23752271c266e6691e96d0d78172613e97a9cea008d17cbe117da9a52c076f20a74cf2a65c8a1dfc97df77bad38027b8e0450830007beb00081269cdcc4df202ce36766811d88e131795f776cf6568ce0b3886e0057858dc690ebe338a804a7b3bdd79ea34521f6fb04de227310dcd292f9ed33007ec6418d49751095a63f5f442376144a9fd8f40065a51c2f74545d48f859b40099e2f55b294ef455334a10d6f993f543e3dba8833a28ebacf1db9322f9b9fd00582df3c0f9f46ee37d7391889aa70926ab35feae08067e7b2d5212920cd4d800251748ebe1ed1d32e20f0f5209aafbf50e9245d1cd9f6fd0235e4f0622cd19008d88b5d05a29e7dc305cab20aa7ec7d0b48c41fe82afd8f2f2cddc97ef1c5d00567f17358db963d0dd3871d7b28326cf8949374457ac3c90ddde392f955f7500562f8de9068085e45e5880f03819b64165c7bcd0181fbbf20f443f0ea297a000cbeccbbfdb26daf2bad516958aefdbd38a55b43a31141dfb10daea1ed2e833005fba80f3d891dd79b961d715a581f17f400d6a497ed339e54392206763683600db2c2150345aac6c09e802ce58ca53f23735708c32370189dbf878451fbd7400b32f5c01829ab656d808f8f8a8a2c1b180c6b2a855bce20526f8f7c05864e500d35ad89b578a391d7ba592b4b9c1ca9c07ae8a732803a13ebd62d5098a99fd00a2226525b397d2c89b7b959918e01c5f45dd797de2acc253185e0afb31090d00225a1faa64076353cc2c55cd00872c014f29d70e7ab4d61abd2e64ed0ebb8200b4cb44e979b8b2dc38e38e6d59f625a0c709f709e9de4d4cadc422314f4c5f009fa49483c69365df740730e7988c151400157e4833989165a6c9166a736823009ce1412d93693417c469ab10a3a40b494dcfceac0ab8dc8483336078b59efc009f38c2a93d8554b54b44a71e96db0b270e2fee4486d774babfb547f1f23c7d0045a9d84c723c7387b355cd0555e6f9cf3db312ab78900ea613baa43f34a34a0086b6b20d18586f3d7564675e76fa623d8fe3620f51cb74f39678287124fd9e0052faaf379aa30fc8daebb041fc83a4a564b62ecd2e6c2169aea7fb18f5e285004e032bf693d3d1bd2c67a65bca90b58388d170be457a99aef89b6b9f9cb926007f3e8ea9747ca65669c6ac230d64360078c60554ea05922592f7b3d17d396c00dbc664cca4fc1c223b5d6110dc30333e0070f2915c3ba34c3bbf2fadbccf2f00711578b19e8e727a2ad0c2c54c20a1a77d82d09b15f6b641d3ea0587239ac7004c33d1e3e6385b440468a2815cca25467c11cb270855adc6c4837af924de800046dc57ef6d1d414fcea13194a49dab6c6a1481601fc66cc8edcd7fe0e059d8001d95f6be5210f4aaa0302f0aa3b0ed8250da058a6ced144e2541778a348eab00c1b55f106777a68e31fc82d14a14a8acdf2c9250c7eafe8e951f62692646d200b7abbf35e00e06abd851b6f336f27c589681076eb012d8ab131ef3da8ff87d0001734a2416ff08ad2681f68a62694b5774b0744c0f2eee0dfe2379229c18e3009f79e34ca611f90dd1024cb14d87bcbd14607bd0bf597ccbee6340d657307f00ca809a5b203eea424c9ce4200e0af1cfe8afdb5699345542c3b0c6b0cb30b6001e6ef3e5e5942df94c2fbaf1e979a9f32b30428a7a9ac8f26c82973b77337600f26c874001c34df79f0bd558f978110b98e02b8cfca8589c080eb2ccf331da005e2853117f0513b6e062b48d843a4b0a24760f0975604b17e2224661a8039c00396b387d91c3bb383fc3b49bcd1d6139a5f21cf2ca4a1a8e9dc87a5cb3c5f90003d5de8260ac08d4166c72d0d883032730cfc07869e6f51761d0ba84a02b250087cc1184547938e4963faa357e1a537d566b196095e1513b2a8df500875fad00d3e75353bde46a1175636f4a7cdbe33e4fd38ef2146dd1ede239066702820400bebb10630d7df82d037f7ad40d622b2e7a5923209616baeece0b43c28bc88d0080470eb576c12445485069308665c62cc698992afdc2b3d21c716c838a6a0700625f5e632886c3a7f7f6061f0c19a14fbf96d4fefef541eaf97ddd951af563005a9c8cd63608c6fb74f203123e64cff78bf6611746fa1563f4dd91c7ff466000d73fa5e48e7fa967065343cff3087a6449786671f62e70f920c8aea34de83a00934f3ed0e13e793201bae4f95bbc45ab395773fa03b972ee10b1f54b38356c00b321cc3ca0f9b9bccee738710d660b4112d15167e520e15288f26e2486f55e006d0ec57a51948664eca0ec352f5661c8e1c829a17468c3ef2e1b3ea9b1fc9300b296b8f29747eef8e95bf741d2e5e5b9ce21def44826b9be570731ea729e92001c627cb1adaf49818076745ab78585741607d41edc477b8049fb643f40fb490091f8d66da84df4f028cdaa631506e5049a465aa551778d93b4177386df0f6e00a260c584b34487ea9518ddb090e70eca1fe78846e2279026d9755ad482c05f005dcedf9fa1b348e0a88f7c201592b10e538d32d1bc985b7020c5abf198773b0086392e00e1baf8ae86c183188c70ab4ee1cb901eed68a3696ff9201860515e00906d6d0a5fc3290576d1b3c80b53c6351ccf9531576749a9b055356bec5a3000139954cd57d17893296d378812265760f8c45307d184bdf5959d8f8eabad61005c17401d6de0df3dc6e864b8548e3af677ebb5b000552bf22f5453322ae800005ea986c67b643a5a5bb4655caa47152bfe679d2fce9ab9dbdf67c29e79820100073c67ce7487ec632f1aa2815068badf9dfd7e4afa460d7aeadc0914840d0b0040a7725b1955d2b2d986762cc00561ead247d97dd7dbe36353731cdcd24f1300d8c4920bc3179829a260d5265266b4eb4c4797e99a94baddd55c02fb741d8a00808963b6246406d8691612d6c308ddb46bb39db5246ab6b1dc8595084661950025c401c9681c7a0e3560a5dcbd3d96a0010146f784094bfa3a2dbcc5f1fa3d0087bc8fbce237e87c42338e6bccb72eb2b45a228a7c9c47b40115b4f667352c0066ccf3912de8c90e47b963531aca1daa11bd8a156618ed88ca826e4c16a98d007408a5c7f56c4566efffc4d117669015a1ca884d301527b27fa4a2339d5ecd00c9b148f0266d8de8b536e87b165b6b58dc564c4bd810c64b535347ef3124d2006bb770b0c6657330dd91bec30a96317510250bcdf6e2e40210d441177cd9ec00c9ca04ffe6f429e5022da68b4379f92861d900bf778bd740cd94d87b0cbcca002d39f22e29dacdfc488a17c40466539752cc2972fabca9af4bad7a3f1d283900fcaa2925ddaf35cca79b93e3e1ee82ed334562aff8c93ba117108fbf7cf17600cddcb4cf6503c24249a695117a8ee0742eb08f1f2e88d40aabcb0e35416a270063a58a44921e259e5bd8d01dff77358081412ab402a5514250f654a24561c600aabf9736546e628f9c2448a913f3a31674970a983d4e164828014ecbae6a43002c85abb8314ef7b06d2e2fdd8be5a1f697bc84d19932aeb42c4c2ed58e7f39006ed8e2ca85326cda63cceeac912e4f5ea12fe5711b950fc33059c19a6482c100b24c6d63165be60e432f948b997283da384ca6bf6bd2c453fd6d2d6b3013b1006d81852a301a7f7815aee031033e7d3bf11be099f4189a4b2749fcda82b75000ab493a4f1d1c5d28f42f96a46f74bf6f6d1f862288684df00ce80d916e5b90001baa02785773ecf3e22ccb55c9d11e97e77a83ee28a5aea89895e9fc2f7ced001c5c49b33854f1420de110f7ae6614abb237847bc001c70dc806319c98d5aa00b1074a64056fd306ac727c143b57903913778317755a69e69b050d9fa64e6c00e518d3be51057bfe17af9a14e43be1809047e643eafc119c9528102c90ac4b00e42bb7516aa9454b28b3a893467c271901fc0dea7bce0217591a9535ff51d000843a75ae444ed1848ba60fb2d990bdbab3bb3a7b8178e4a7dc4a873173ebd700367cfcce3bfe464305ff97a7c4a97dce6153450a57286d883738019c1c8ebf005736ed003e2e15b9fb2c203a7a1763853bba1f37db015fe580dd2b696d5e9200eb15b45b7d157f069269881a6a225eebebcf669fe2f195f3c4683d97a2742100eeefe93a77d8d175b5ea835b502bb040c81abe26ad132e3ca09ab1aded479a00fa769713dc217a038673b53680b51e59872ec2cdad370e74c815a74ee16c9f005557b588b087dee4d3d80c07b055b7b2c0bd258f0e8c3f7b0dd92b14ff3c5c00fc756ed9aa37506e768e2f9de552dab27b81e4ef4c6cb9d5f6281f64addb2900b929e044b0fbc781b30098eb86a711100ba85f116b1f355dfae98ca458e1de00cbda9ffbd4e0a4536d1cafb985eab698756a74e0a268ba5c803a426d569d4b00c1e73a80d6b9a08d892a47187ca8c4b1c248cff39e5930e18115b19ca297a000494ffc8ea51b1e51d31f2282eb0b82239b39e4a9416ed0f9e796a8289067d8003247b53de0b0e89bdc4b4fe286237cd0e08a93a05892a03e2ac14e029f5b2e000a446450f9fcebad8bed9b5a974d152e07911d87996616e548469c68700d5b00028923f316343b8912690b4b658d99b5a1dda79dd3707cbeec4b750ca81df3008043333def3b22a460437974098d60e2777c980455b178b740cc5c95e2219a0075542d5942b01e9486bf71123c095ab2bd33757d9ee92ef0b55adf5287505900fa79ccaa315ad61b631b3981af9eb88fb7685353a4a5f00d1e5b8a6fea81f500b8a249793c1f70ed30c8d4ce6401493470bfe49e19fefc39c77cce6bc61abf002f321fdf638f888f8edab778cd05518d4952683be63533f6a446e078e2928f004484d805de01f74c4cb889432f2d3d5cdac1630287f2855a6ee8067b0696bf0010342e39595f3767140406fffd5f187998ee703a9c3143f54e956cd821097c003ea7a2a06881a31464c82b32b55b86105731500b934f2c3a0f37c65b556305002f97ab5e8debf16d727fc745d425065ab80439e2fdf48632cd762eeb378d8700a64b47adaf0ceca405f8e65b30571465481c5e43f94f4b4703186f600a0844000abcb86ba660ccaf6401784c8429b670150078e782c67c04b054cf5a045ca200ea5a9bd4cf5484250f0161b119a18fbce4feab3829b187e202916785b97ba400fc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"} \ No newline at end of file diff --git a/crates/trie/common/Cargo.toml b/crates/trie/common/Cargo.toml index 7df50c735f6..9c38b5b2fc8 100644 --- a/crates/trie/common/Cargo.toml +++ b/crates/trie/common/Cargo.toml @@ -48,7 +48,7 @@ rayon = { workspace = true, optional = true } [dev-dependencies] reth-primitives-traits = { workspace = true, features = ["serde"] } -reth-codecs.workspace = true +reth-codecs = { workspace = true, features = ["alloy"] } alloy-eips.workspace = true alloy-genesis.workspace = true @@ -104,7 +104,7 @@ serde = [ "revm/serde", "alloy-eips/serde", ] -reth-codec = ["dep:reth-codecs", "dep:bytes", "dep:arrayvec"] +reth-codec = ["dep:reth-codecs", "reth-codecs/alloy", "dep:bytes", "dep:arrayvec"] serde-bincode-compat = [ "serde", "alloy-consensus/serde-bincode-compat", diff --git a/crates/trie/common/src/account.rs b/crates/trie/common/src/account.rs index 382157e4f92..8228a48c57c 100644 --- a/crates/trie/common/src/account.rs +++ b/crates/trie/common/src/account.rs @@ -35,7 +35,7 @@ mod tests { fn test_from_genesis_account_with_values() { // Create a GenesisAccount with specific values let mut storage = BTreeMap::new(); - storage.insert(B256::from([0x01; 32]), B256::from([0x02; 32])); + storage.insert(B256::repeat_byte(0x01), B256::repeat_byte(0x02)); let genesis_account = GenesisAccount { nonce: Some(10), @@ -49,8 +49,8 @@ mod tests { let trie_account: TrieAccount = genesis_account.into(); let expected_storage_root = storage_root_unhashed(BTreeMap::from([( - B256::from([0x01; 32]), - U256::from_be_bytes(*B256::from([0x02; 32])), + B256::repeat_byte(0x01), + U256::from_be_bytes(*B256::repeat_byte(0x02)), )])); // Check that the fields are properly set. @@ -75,7 +75,7 @@ mod tests { #[test] fn test_from_genesis_account_with_zeroed_storage_values() { // Create a GenesisAccount with storage containing zero values - let storage = BTreeMap::from([(B256::from([0x01; 32]), B256::from([0x00; 32]))]); + let storage = BTreeMap::from([(B256::repeat_byte(0x01), B256::ZERO)]); let genesis_account = GenesisAccount { nonce: Some(3), diff --git a/crates/trie/common/src/hash_builder/state.rs b/crates/trie/common/src/hash_builder/state.rs index dc35f7150e4..e31fe8962a1 100644 --- a/crates/trie/common/src/hash_builder/state.rs +++ b/crates/trie/common/src/hash_builder/state.rs @@ -66,7 +66,10 @@ impl reth_codecs::Compact for HashBuilderState { { let mut len = 0; - len += self.key.to_compact(buf); + // Collection codecs return flag bits, not their encoded byte length. + let start = buf.remaining_mut(); + self.key.to_compact(buf); + len += start - buf.remaining_mut(); buf.put_u16(self.stack.len() as u16); len += 2; @@ -76,7 +79,9 @@ impl reth_codecs::Compact for HashBuilderState { len += 2 + item.len(); } - len += self.value.to_compact(buf); + let start = buf.remaining_mut(); + self.value.to_compact(buf); + len += start - buf.remaining_mut(); buf.put_u16(self.groups.len() as u16); len += 2; @@ -162,6 +167,24 @@ mod tests { assert_eq!(state, decoded); } + #[test] + fn hash_builder_state_fixed_slice() { + let mut state = HashBuilderState { key: vec![1, 2, 3], ..Default::default() }; + state.value.set_bytes_owned(vec![42; 128]); + let mut expected = vec![]; + assert_eq!(state.to_compact(&mut expected), expected.len()); + + let mut storage = vec![0; expected.len() + 1]; + let mut remaining = storage.as_mut_slice(); + let len = state.to_compact(&mut remaining); + assert_eq!(len, expected.len()); + assert_eq!(remaining.len(), 1); + assert_eq!(&storage[..len], expected); + let (decoded, rest) = HashBuilderState::from_compact(&storage[..len], len); + assert_eq!(decoded, state); + assert!(rest.is_empty()); + } + // Generate fields directly: the arbitrary interop's fixed byte buffer can run out while // generating variable-length fields, aborting the test before checking the codec. #[cfg(feature = "arbitrary")] @@ -181,6 +204,7 @@ mod tests { }; let mut buf = vec![]; let len = state.to_compact(&mut buf); + prop_assert_eq!(len, buf.len()); let (decoded, rest) = HashBuilderState::from_compact(&buf, len); prop_assert_eq!(state, decoded); prop_assert!(rest.is_empty()); diff --git a/crates/trie/common/src/hashed_state.rs b/crates/trie/common/src/hashed_state.rs index 6a8e3a87f32..ccca8ad4244 100644 --- a/crates/trie/common/src/hashed_state.rs +++ b/crates/trie/common/src/hashed_state.rs @@ -10,8 +10,6 @@ use alloy_primitives::{ Address, B256, U256, }; use itertools::Itertools; -#[cfg(feature = "rayon")] -pub use rayon::*; use reth_primitives_traits::Account; #[cfg(feature = "rayon")] @@ -169,6 +167,10 @@ impl HashedPostState { /// Returns an iterator that yields chunks of the specified size. /// /// See [`ChunkedHashedPostState`] for more information. + /// + /// # Panics + /// + /// Panics if the chunk size is zero. pub fn chunks(self, size: usize) -> ChunkedHashedPostState { ChunkedHashedPostState::new(self, size) } @@ -772,6 +774,7 @@ impl FlattenedHashedPostStateItem { impl ChunkedHashedPostState { fn new(hashed_post_state: HashedPostState, size: usize) -> Self { + assert!(size > 0, "chunk size must be non-zero"); let flattened = hashed_post_state .storages .into_iter() @@ -840,7 +843,7 @@ mod tests { } fn changed_storage(original: U256, present: U256) -> StorageWithOriginalValues { - core::iter::once((U256::from(1), StorageSlot::new_changed(original, present))).collect() + core::iter::once((U256::ONE, StorageSlot::new_changed(original, present))).collect() } #[test] @@ -858,10 +861,8 @@ mod tests { }; let mut storage = StorageWithOriginalValues::default(); - storage.insert( - U256::from(1), - StorageSlot { present_value: U256::from(4), ..Default::default() }, - ); + storage + .insert(U256::ONE, StorageSlot { present_value: U256::from(4), ..Default::default() }); // Create a `BundleAccount` struct to represent the account and its storage. let account = BundleAccount { @@ -890,7 +891,7 @@ mod tests { #[test] fn destroyed_prefunded_account_without_storage_emits_no_storage() { - let original_info = AccountInfo { balance: U256::from(1), ..Default::default() }; + let original_info = AccountInfo { balance: U256::ONE, ..Default::default() }; let account = BundleAccount::new( Some(original_info), None, @@ -906,7 +907,7 @@ mod tests { let existing_contract = AccountInfo { code_hash: B256::repeat_byte(0x01), ..Default::default() }; let legacy_empty_account = AccountInfo::default(); - let prefunded_account = AccountInfo { balance: U256::from(1), ..Default::default() }; + let prefunded_account = AccountInfo { balance: U256::ONE, ..Default::default() }; for original_info in [Some(existing_contract), Some(legacy_empty_account), Some(prefunded_account), None] @@ -919,7 +920,7 @@ mod tests { ); let storage = bundle_hashed_storage(&account).unwrap(); - let hashed_slot = keccak256(B256::from(U256::from(1))); + let hashed_slot = keccak256(B256::with_last_byte(1)); assert_eq!(storage.storage[&hashed_slot], U256::ZERO); } } @@ -944,7 +945,7 @@ mod tests { let new_storage = bundle_hashed_storage(&new_account).unwrap(); let existing_storage = bundle_hashed_storage(&existing_account).unwrap(); - let hashed_slot = keccak256(B256::from(U256::from(1))); + let hashed_slot = keccak256(B256::with_last_byte(1)); assert_eq!(new_storage.storage[&hashed_slot], value); assert_eq!(existing_storage.storage[&hashed_slot], value); } @@ -1018,7 +1019,7 @@ mod tests { let slot1 = B256::random(); let slot2 = B256::random(); storage.storage.insert(slot1, U256::ZERO); - storage.storage.insert(slot2, U256::from(1)); + storage.storage.insert(slot2, U256::ONE); state.storages.insert(addr1, storage); state @@ -1122,7 +1123,7 @@ mod tests { let mut storage = HashedStorage::default(); storage.storage.insert(slot1, U256::ZERO); - storage.storage.insert(slot2, U256::from(1)); + storage.storage.insert(slot2, U256::ONE); state.storages.insert(addr1, storage); let mut excluded_slots = HashSet::default(); @@ -1148,7 +1149,7 @@ mod tests { // don't add the account to state.accounts (simulating unmodified account) // but add storage updates for this account let mut storage = HashedStorage::default(); - storage.storage.insert(slot1, U256::from(1)); + storage.storage.insert(slot1, U256::ONE); storage.storage.insert(slot2, U256::from(2)); state.storages.insert(addr, storage); @@ -1171,19 +1172,19 @@ mod tests { // Test extending accounts let mut state1 = HashedPostStateSorted { accounts: vec![ - (B256::from([1; 32]), Some(Account::default())), - (B256::from([3; 32]), Some(Account::default())), - (B256::from([5; 32]), None), + (B256::repeat_byte(1), Some(Account::default())), + (B256::repeat_byte(3), Some(Account::default())), + (B256::repeat_byte(5), None), ], storages: B256Map::default(), }; let state2 = HashedPostStateSorted { accounts: vec![ - (B256::from([2; 32]), Some(Account::default())), - (B256::from([3; 32]), Some(Account { nonce: 1, ..Default::default() })), /* Override */ - (B256::from([4; 32]), Some(Account::default())), - (B256::from([6; 32]), None), + (B256::repeat_byte(2), Some(Account::default())), + (B256::repeat_byte(3), Some(Account { nonce: 1, ..Default::default() })), /* Override */ + (B256::repeat_byte(4), Some(Account::default())), + (B256::repeat_byte(6), None), ], storages: B256Map::default(), }; @@ -1192,14 +1193,14 @@ mod tests { // Check accounts are merged and sorted assert_eq!(state1.accounts.len(), 6); - assert_eq!(state1.accounts[0].0, B256::from([1; 32])); - assert_eq!(state1.accounts[1].0, B256::from([2; 32])); - assert_eq!(state1.accounts[2].0, B256::from([3; 32])); + assert_eq!(state1.accounts[0].0, B256::repeat_byte(1)); + assert_eq!(state1.accounts[1].0, B256::repeat_byte(2)); + assert_eq!(state1.accounts[2].0, B256::repeat_byte(3)); assert_eq!(state1.accounts[2].1.as_ref().unwrap().nonce, 1); // Should have state2's value - assert_eq!(state1.accounts[3].0, B256::from([4; 32])); - assert_eq!(state1.accounts[4].0, B256::from([5; 32])); + assert_eq!(state1.accounts[3].0, B256::repeat_byte(4)); + assert_eq!(state1.accounts[4].0, B256::repeat_byte(5)); assert_eq!(state1.accounts[4].1, None); - assert_eq!(state1.accounts[5].0, B256::from([6; 32])); + assert_eq!(state1.accounts[5].0, B256::repeat_byte(6)); assert_eq!(state1.accounts[5].1, None); } @@ -1208,35 +1209,35 @@ mod tests { // Test normal extension let mut storage1 = HashedStorageSorted { storage_slots: vec![ - (B256::from([1; 32]), U256::from(10)), - (B256::from([3; 32]), U256::from(30)), - (B256::from([5; 32]), U256::ZERO), + (B256::repeat_byte(1), U256::from(10)), + (B256::repeat_byte(3), U256::from(30)), + (B256::repeat_byte(5), U256::ZERO), ], }; let storage2 = HashedStorageSorted { storage_slots: vec![ - (B256::from([2; 32]), U256::from(20)), - (B256::from([3; 32]), U256::from(300)), // Override - (B256::from([4; 32]), U256::from(40)), - (B256::from([6; 32]), U256::ZERO), + (B256::repeat_byte(2), U256::from(20)), + (B256::repeat_byte(3), U256::from(300)), // Override + (B256::repeat_byte(4), U256::from(40)), + (B256::repeat_byte(6), U256::ZERO), ], }; storage1.extend_ref(&storage2); assert_eq!(storage1.storage_slots.len(), 6); - assert_eq!(storage1.storage_slots[0].0, B256::from([1; 32])); + assert_eq!(storage1.storage_slots[0].0, B256::repeat_byte(1)); assert_eq!(storage1.storage_slots[0].1, U256::from(10)); - assert_eq!(storage1.storage_slots[1].0, B256::from([2; 32])); + assert_eq!(storage1.storage_slots[1].0, B256::repeat_byte(2)); assert_eq!(storage1.storage_slots[1].1, U256::from(20)); - assert_eq!(storage1.storage_slots[2].0, B256::from([3; 32])); + assert_eq!(storage1.storage_slots[2].0, B256::repeat_byte(3)); assert_eq!(storage1.storage_slots[2].1, U256::from(300)); // Should have storage2's value - assert_eq!(storage1.storage_slots[3].0, B256::from([4; 32])); + assert_eq!(storage1.storage_slots[3].0, B256::repeat_byte(4)); assert_eq!(storage1.storage_slots[3].1, U256::from(40)); - assert_eq!(storage1.storage_slots[4].0, B256::from([5; 32])); + assert_eq!(storage1.storage_slots[4].0, B256::repeat_byte(5)); assert_eq!(storage1.storage_slots[4].1, U256::ZERO); - assert_eq!(storage1.storage_slots[5].0, B256::from([6; 32])); + assert_eq!(storage1.storage_slots[5].0, B256::repeat_byte(6)); assert_eq!(storage1.storage_slots[5].1, U256::ZERO); } @@ -1291,7 +1292,7 @@ mod tests { vec![(kept_account, Some(account(1))), (removed_account, Some(account(10)))], B256Map::from_iter([( kept_storage, - HashedStorageSorted { storage_slots: vec![(slot1, U256::from(1))] }, + HashedStorageSorted { storage_slots: vec![(slot1, U256::ONE)] }, )]), ); @@ -1340,7 +1341,7 @@ mod tests { let older = HashedPostStateSorted::new( vec![(address, Some(Account { nonce: 1, ..Default::default() }))], B256Map::from_iter([ - (storage, HashedStorageSorted { storage_slots: vec![(slot, U256::from(1))] }), + (storage, HashedStorageSorted { storage_slots: vec![(slot, U256::ONE)] }), (empty_storage, HashedStorageSorted::default()), ]), ); @@ -1399,7 +1400,7 @@ mod tests { let batch = HashedPostStateSorted::new( vec![(address, Some(account(1))), (deleted_address, None)], B256Map::from_iter([ - (storage, HashedStorageSorted { storage_slots: vec![(slot, U256::from(1))] }), + (storage, HashedStorageSorted { storage_slots: vec![(slot, U256::ONE)] }), ( deleted_storage, HashedStorageSorted { storage_slots: vec![(deleted_slot, U256::ZERO)] }, @@ -1440,7 +1441,7 @@ mod tests { vec![], B256Map::from_iter([( storage, - HashedStorageSorted { storage_slots: vec![(slot, U256::from(1))] }, + HashedStorageSorted { storage_slots: vec![(slot, U256::ONE)] }, )]), ); let mask = HashedPostStateSorted::new( @@ -1452,7 +1453,7 @@ mod tests { assert_eq!( result.storages.get(&storage), - Some(&HashedStorageSorted { storage_slots: vec![(slot, U256::from(1))] }) + Some(&HashedStorageSorted { storage_slots: vec![(slot, U256::ONE)] }) ); } @@ -1478,13 +1479,13 @@ mod tests { #[test] fn test_hashed_post_state_chunking_length() { - let addr1 = B256::from([1; 32]); - let addr2 = B256::from([2; 32]); - let addr3 = B256::from([3; 32]); - let addr4 = B256::from([4; 32]); - let slot1 = B256::from([1; 32]); - let slot2 = B256::from([2; 32]); - let slot3 = B256::from([3; 32]); + let addr1 = B256::repeat_byte(1); + let addr2 = B256::repeat_byte(2); + let addr3 = B256::repeat_byte(3); + let addr4 = B256::repeat_byte(4); + let slot1 = B256::repeat_byte(1); + let slot2 = B256::repeat_byte(2); + let slot3 = B256::repeat_byte(3); let state = HashedPostState { accounts: B256Map::from_iter([(addr1, None), (addr2, None), (addr4, None)]), @@ -1536,12 +1537,12 @@ mod tests { #[test] fn test_clone_into_sorted_equivalence() { - let addr1 = B256::from([1; 32]); - let addr2 = B256::from([2; 32]); - let addr3 = B256::from([3; 32]); - let slot1 = B256::from([1; 32]); - let slot2 = B256::from([2; 32]); - let slot3 = B256::from([3; 32]); + let addr1 = B256::repeat_byte(1); + let addr2 = B256::repeat_byte(2); + let addr3 = B256::repeat_byte(3); + let slot1 = B256::repeat_byte(1); + let slot2 = B256::repeat_byte(2); + let slot3 = B256::repeat_byte(3); let state = HashedPostState { accounts: B256Map::from_iter([ @@ -1576,9 +1577,9 @@ mod tests { #[test] fn test_hashed_storage_clone_into_sorted_equivalence() { - let slot1 = B256::from([1; 32]); - let slot2 = B256::from([2; 32]); - let slot3 = B256::from([3; 32]); + let slot1 = B256::repeat_byte(1); + let slot2 = B256::repeat_byte(2); + let slot3 = B256::repeat_byte(3); let storage = HashedStorage { storage: B256Map::from_iter([ @@ -1893,6 +1894,11 @@ pub mod serde_bincode_compat { #[test] fn test_hashed_post_state_bincode_roundtrip() { + // Bincode cannot delimit an account whose extension is skipped during serialization. + if Account::EXTENSIONS_ENABLED { + return; + } + #[serde_as] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] struct Data { @@ -1930,7 +1936,7 @@ pub mod serde_bincode_compat { let decoded: Data = bincode::deserialize(&encoded).unwrap(); assert_eq!(decoded, data); - data.hashed_storage.storage.insert(B256::random(), U256::from(1)); + data.hashed_storage.storage.insert(B256::random(), U256::ONE); let encoded = bincode::serialize(&data).unwrap(); let decoded: Data = bincode::deserialize(&encoded).unwrap(); assert_eq!(decoded, data); @@ -1938,6 +1944,11 @@ pub mod serde_bincode_compat { #[test] fn test_hashed_post_state_sorted_bincode_roundtrip() { + // Bincode cannot delimit an account whose extension is skipped during serialization. + if Account::EXTENSIONS_ENABLED { + return; + } + #[serde_as] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] struct Data { @@ -1960,7 +1971,7 @@ pub mod serde_bincode_compat { data.hashed_state.storages.insert( B256::random(), - HashedStorageSorted { storage_slots: vec![(B256::from([1; 32]), U256::from(10))] }, + HashedStorageSorted { storage_slots: vec![(B256::repeat_byte(1), U256::from(10))] }, ); let encoded = bincode::serialize(&data).unwrap(); let decoded: Data = bincode::deserialize(&encoded).unwrap(); @@ -1982,7 +1993,7 @@ pub mod serde_bincode_compat { let decoded: Data = bincode::deserialize(&encoded).unwrap(); assert_eq!(decoded, data); - data.hashed_storage.storage_slots.push((B256::random(), U256::from(1))); + data.hashed_storage.storage_slots.push((B256::random(), U256::ONE)); let encoded = bincode::serialize(&data).unwrap(); let decoded: Data = bincode::deserialize(&encoded).unwrap(); assert_eq!(decoded, data); diff --git a/crates/trie/common/src/lib.rs b/crates/trie/common/src/lib.rs index 55e88dca1e7..e74fcc65718 100644 --- a/crates/trie/common/src/lib.rs +++ b/crates/trie/common/src/lib.rs @@ -11,12 +11,15 @@ extern crate alloc; +// Used directly only by the `eip1186` feature and tests. +use alloy_consensus as _; + mod execution_witness; pub use execution_witness::ExecutionWitnessMode; /// Lazy initialization wrapper for trie data. mod trie_data; -pub use trie_data::{ComputedTrieData, LazyTrieData, SortedTrieData}; +pub use trie_data::{LazyTrieData, SortedTrieData}; /// In-memory hashed state. mod hashed_state; diff --git a/crates/trie/common/src/ordered_root.rs b/crates/trie/common/src/ordered_root.rs index 03a30db0e20..70104514c43 100644 --- a/crates/trie/common/src/ordered_root.rs +++ b/crates/trie/common/src/ordered_root.rs @@ -2,7 +2,7 @@ //! //! Ethereum block transaction and receipt roots are ordered trie roots keyed by the RLP encoding of //! each item index. -//! [`OrderedTrieRootEncodedBuilder`](crate::ordered_root::OrderedTrieRootEncodedBuilder) accepts +//! [`OrderedTrieRootEncodedBuilder`] accepts //! items in their final contiguous order, without requiring the final item count until the stream //! ends. //! diff --git a/crates/trie/common/src/prefix_set.rs b/crates/trie/common/src/prefix_set.rs index e3de9ba77bf..930eec38502 100644 --- a/crates/trie/common/src/prefix_set.rs +++ b/crates/trie/common/src/prefix_set.rs @@ -423,15 +423,6 @@ mod tests { assert!(!prefix_set.contains_from(&Nibbles::from_nibbles([5]))); } - #[test] - fn test_from_sorted_b256_iterator() { - let first = B256::with_last_byte(1); - let second = B256::with_last_byte(2); - let prefix_set = PrefixSet::from([first, second].into_iter()); - - assert_eq!(prefix_set.slice(), &[Nibbles::unpack(first), Nibbles::unpack(second)]); - } - #[test] fn test_from_sorted_b256_iterator_with_duplicates() { let first = B256::with_last_byte(1); @@ -502,19 +493,6 @@ mod tests { assert!(prefix_set_mut.all); } - #[test] - fn test_prefix_set_slice_returns_frozen_keys() { - let path_a = Nibbles::from_nibbles([1, 2, 3]); - let path_b = Nibbles::from_nibbles([4, 5, 6]); - let mut prefix_set_mut = PrefixSetMut::default(); - prefix_set_mut.insert(path_b); - prefix_set_mut.insert(path_a); - prefix_set_mut.insert(path_b); - - let prefix_set = prefix_set_mut.freeze(); - assert_eq!(prefix_set.slice(), &[path_a, path_b]); - } - #[test] fn test_trie_prefix_sets_mut_extend_ref() { let account_path = Nibbles::from_nibbles([1, 2]); diff --git a/crates/trie/common/src/proofs.rs b/crates/trie/common/src/proofs.rs index b90ee5b0123..5878611b1b7 100644 --- a/crates/trie/common/src/proofs.rs +++ b/crates/trie/common/src/proofs.rs @@ -4,7 +4,6 @@ use crate::{ BranchNodeMasks, BranchNodeMasksMap, Nibbles, ProofTrieNodeV2, TrieAccount, TrieNodeV2, }; use alloc::{borrow::Cow, collections::VecDeque, vec::Vec}; -use alloy_consensus::constants::KECCAK_EMPTY; use alloy_primitives::{ keccak256, map::{hash_map, B256Map, B256Set}, @@ -95,6 +94,10 @@ impl MultiProofTargets { /// Returns an iterator that yields chunks of the specified size. /// /// See [`ChunkedMultiProofTargets`] for more information. + /// + /// # Panics + /// + /// Panics if the chunk size is zero. pub fn chunks(self, size: usize) -> ChunkedMultiProofTargets { ChunkedMultiProofTargets::new(self, size) } @@ -133,6 +136,7 @@ pub struct ChunkedMultiProofTargets { impl ChunkedMultiProofTargets { fn new(targets: MultiProofTargets, size: usize) -> Self { + assert!(size > 0, "chunk size must be non-zero"); let flattened_targets = targets .into_iter() .flat_map(|(address, slots)| { @@ -246,11 +250,7 @@ impl MultiProof { nibbles.ends_with(&leaf.key) { let account = TrieAccount::decode(&mut &leaf.value[..])?; - break 'info Some(Account { - balance: account.balance, - nonce: account.nonce, - bytecode_hash: (account.code_hash != KECCAK_EMPTY).then_some(account.code_hash), - }) + break 'info Some(Account::from(account)) } None }; @@ -375,11 +375,7 @@ impl DecodedMultiProof { nibbles.ends_with(&leaf.key) { let account = TrieAccount::decode(&mut &leaf.value[..])?; - break 'info Some(Account { - balance: account.balance, - nonce: account.nonce, - bytecode_hash: (account.code_hash != KECCAK_EMPTY).then_some(account.code_hash), - }) + break 'info Some(Account::from(account)) } None }; @@ -484,15 +480,8 @@ impl DecodedMultiProofV2 { nibbles.ends_with(&leaf.key) { let account = TrieAccount::decode(&mut &leaf.value[..])?; - break 'account ( - Some(Account { - balance: account.balance, - nonce: account.nonce, - bytecode_hash: (account.code_hash != KECCAK_EMPTY) - .then_some(account.code_hash), - }), - account.storage_root, - ) + let storage_root = account.storage_root; + break 'account (Some(Account::from(account)), storage_root) } (None, EMPTY_ROOT_HASH) }; @@ -877,6 +866,7 @@ impl AccountProof { slots: Vec, zero_empty_account: bool, ) -> alloy_rpc_types_eth::EIP1186AccountProofResponse { + // The proof nodes carry the complete account RLP, including any account extension. let is_non_existent = self.info.is_none(); let info = self.info.unwrap_or_default(); let (code_hash, storage_hash) = if is_non_existent && zero_empty_account { @@ -907,6 +897,7 @@ impl AccountProof { /// [`AccountProof`]. /// /// This is the inverse of [`Self::into_eip1186_response`] + #[allow(clippy::needless_update)] pub fn from_eip1186_proof(proof: alloy_rpc_types_eth::EIP1186AccountProofResponse) -> Self { let alloy_rpc_types_eth::EIP1186AccountProofResponse { nonce, @@ -920,10 +911,17 @@ impl AccountProof { } = proof; let storage_proofs = storage_proof.into_iter().map(Into::into).collect(); + // EIP-1186's summary fields omit the account extension, so take it from the account leaf + // of an inclusion proof. + let leaf_account = Account::EXTENSIONS_ENABLED + .then(|| inclusion_proof_account(address, &account_proof)) + .flatten(); + let (storage_root, info) = if nonce == 0 && balance.is_zero() && (storage_hash.is_zero() || storage_hash == EMPTY_ROOT_HASH) && - (code_hash == KECCAK_EMPTY || code_hash.is_zero()) + (code_hash == alloy_consensus::constants::KECCAK_EMPTY || code_hash.is_zero()) && + !leaf_account.as_ref().is_some_and(Account::has_extension) { // Account does not exist in state. Return `None` here to prevent proof // verification. @@ -935,7 +933,15 @@ impl AccountProof { // See: https://github.com/ethereum/go-ethereum/issues/28441 (EMPTY_ROOT_HASH, None) } else { - (storage_hash, Some(Account { nonce, balance, bytecode_hash: code_hash.into() })) + ( + storage_hash, + Some(Account { + nonce, + balance, + bytecode_hash: code_hash.into(), + ..leaf_account.unwrap_or_default() + }), + ) }; Self { address, info, proof: account_proof, storage_root, storage_proofs } @@ -949,6 +955,22 @@ impl From for AccountProof { } } +/// Returns the account committed by the leaf of an inclusion proof for `address`. +/// +/// The leaf has to lie on the full path of `address`, so an exclusion proof cannot supply another +/// account. This does not authenticate the proof root; callers still have to verify the proof +/// against a trusted state root. +#[cfg(feature = "eip1186")] +fn inclusion_proof_account(address: Address, proof: &[Bytes]) -> Option { + let root = keccak256(proof.first()?); + let TrieNode::Leaf(leaf) = alloy_rlp::decode_exact::(proof.last()?).ok()? else { + return None + }; + verify_proof(root, Nibbles::unpack(keccak256(address)), Some(leaf.value.clone()), proof) + .ok()?; + alloy_rlp::decode_exact::(&leaf.value).ok().map(Account::from) +} + impl Default for AccountProof { fn default() -> Self { Self::new(Address::default()) @@ -1171,6 +1193,7 @@ pub mod triehash { #[cfg(test)] mod tests { use super::*; + use alloy_consensus::constants::KECCAK_EMPTY; use alloy_trie::{ nodes::{BranchNode, ExtensionNode, LeafNode, RlpNode}, TrieMask, @@ -1216,7 +1239,7 @@ mod tests { let storage_leaf_0 = insert_node( &mut witness, - LeafNode::new(leaf_key, encode_fixed_size(&U256::from(1)).to_vec()), + LeafNode::new(leaf_key, encode_fixed_size(&U256::ONE).to_vec()), ); let storage_leaf_1 = insert_node( &mut witness, @@ -1266,7 +1289,7 @@ mod tests { fn witness_nodes_follow_inline_children() { let leaf_key = Nibbles::from_nibbles([0]); let leaf_0 = - alloy_rlp::encode(LeafNode::new(leaf_key, encode_fixed_size(&U256::from(1)).to_vec())); + alloy_rlp::encode(LeafNode::new(leaf_key, encode_fixed_size(&U256::ONE).to_vec())); let leaf_1 = alloy_rlp::encode(LeafNode::new(leaf_key, encode_fixed_size(&U256::from(2)).to_vec())); assert!(leaf_0.len() < B256::len_bytes()); @@ -1608,6 +1631,56 @@ mod tests { assert_eq!(acc.storage_root, EMPTY_ROOT_HASH); } + #[test] + #[cfg(feature = "eip1186")] + fn from_eip1186_proof_recovers_account_extension() { + if !Account::EXTENSIONS_ENABLED { + return; + } + + // An account that only has an extension, encoded as the fifth field of the trie account. + let extension = [0x82, 0xaa]; + let payload_length = 0u64.length() + + U256::ZERO.length() + + EMPTY_ROOT_HASH.length() + + KECCAK_EMPTY.length() + + extension[..].length(); + let mut value = Vec::new(); + alloy_rlp::Header { list: true, payload_length }.encode(&mut value); + 0u64.encode(&mut value); + U256::ZERO.encode(&mut value); + EMPTY_ROOT_HASH.encode(&mut value); + KECCAK_EMPTY.encode(&mut value); + extension[..].encode(&mut value); + + // A trie holding only this account, so its leaf is the root. + let address = Address::with_last_byte(1); + let leaf = alloy_rlp::encode(LeafNode::new(Nibbles::unpack(keccak256(address)), value)); + let root = keccak256(&leaf); + let response = alloy_rpc_types_eth::EIP1186AccountProofResponse { + address, + balance: U256::ZERO, + code_hash: KECCAK_EMPTY, + nonce: 0, + storage_hash: EMPTY_ROOT_HASH, + account_proof: vec![leaf.into()], + storage_proof: vec![], + }; + + let proof = AccountProof::from_eip1186_proof(response.clone()); + assert!(proof.info.as_ref().is_some_and(Account::has_extension)); + assert_eq!(proof.verify(root), Ok(())); + + // An exclusion proof for another address ends in the same leaf, which is not its account. + let other = + AccountProof::from_eip1186_proof(alloy_rpc_types_eth::EIP1186AccountProofResponse { + address: Address::with_last_byte(2), + ..response + }); + assert_eq!(other.info, None); + assert_eq!(other.verify(root), Ok(())); + } + #[test] #[cfg(feature = "eip1186")] fn from_eip1186_proof_accepts_empty_hashes() { diff --git a/crates/trie/common/src/range_proof.rs b/crates/trie/common/src/range_proof.rs index 11592b7ef41..91c332bbbea 100644 --- a/crates/trie/common/src/range_proof.rs +++ b/crates/trie/common/src/range_proof.rs @@ -37,12 +37,12 @@ impl<'a> RangeProofVerifier<'a> { // Verifies the range by rebuilding its root, making omitted or altered leaves change the // result. - fn verify(mut self, root: B256) -> Result, RangeProofError> { + fn verify(mut self, root: B256) -> Result, RangeProofErrorKind> { self.visit_reference(Nibbles::new(), &RlpNode::word_rlp(&root))?; let got = self.frontier.root()?; if got != root { - return Err(RangeProofError::RootMismatch { expected: root, got }) + return Err(RangeProofErrorKind::RootMismatch { expected: root, got }) } Ok(self.next.as_ref().map(TriePath::lowest_key)) } @@ -53,7 +53,7 @@ impl<'a> RangeProofVerifier<'a> { &mut self, prefix: Nibbles, reference: &RlpNode, - ) -> Result<(), RangeProofError> { + ) -> Result<(), RangeProofErrorKind> { match self.range.subtree_relation(&prefix)? { SubtreeRelation::OutsideLeft => self.add_outside_reference(prefix, reference), SubtreeRelation::OutsideRight => { @@ -69,7 +69,7 @@ impl<'a> RangeProofVerifier<'a> { } // Visits a boundary node to expose the disjoint commitments needed for root reconstruction. - fn visit_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { + fn visit_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofErrorKind> { match node { TrieNode::EmptyRoot => Ok(()), TrieNode::Leaf(leaf) => { @@ -105,7 +105,7 @@ impl<'a> RangeProofVerifier<'a> { &mut self, prefix: Nibbles, reference: &RlpNode, - ) -> Result<(), RangeProofError> { + ) -> Result<(), RangeProofErrorKind> { if let Some(hash) = reference.as_hash() { self.frontier.push_subtree(prefix, hash); return Ok(()) @@ -114,7 +114,11 @@ impl<'a> RangeProofVerifier<'a> { } // Adds an inline outside node by descending until a retainable leaf or hashed child is reached. - fn add_outside_node(&mut self, node: TrieNode, prefix: Nibbles) -> Result<(), RangeProofError> { + fn add_outside_node( + &mut self, + node: TrieNode, + prefix: Nibbles, + ) -> Result<(), RangeProofErrorKind> { match node { TrieNode::EmptyRoot => Ok(()), TrieNode::Leaf(leaf) => { @@ -160,9 +164,9 @@ impl ProofRange { } // Classifies a subtree prefix to avoid resolving subtries that cannot cross a boundary. - fn subtree_relation(&self, prefix: &Nibbles) -> Result { + fn subtree_relation(&self, prefix: &Nibbles) -> Result { if prefix.len() > KEY_NIBBLES { - return Err(RangeProofError::PathTooLong { path: *prefix }) + return Err(RangeProofErrorKind::PathTooLong { path: *prefix }) } let left = self.left.slice(..prefix.len()); let right = self.right.slice(..prefix.len()); @@ -225,11 +229,11 @@ impl<'a> ProofNodes<'a> { } // Resolves inline references directly and requires proof backing for hashed references. - fn resolve(&self, path: Nibbles, reference: &RlpNode) -> Result { + fn resolve(&self, path: Nibbles, reference: &RlpNode) -> Result { let Some(hash) = reference.as_hash() else { return Ok(TrieNode::decode(&mut reference.as_slice())?) }; - let node = self.0.get(&hash).ok_or(RangeProofError::MissingProofNode { path })?; + let node = self.0.get(&hash).ok_or(RangeProofErrorKind::MissingProofNode { path })?; Ok(TrieNode::decode(&mut &node[..])?) } } @@ -240,7 +244,10 @@ struct ProofFrontier(Vec); impl ProofFrontier { // Builds a frontier from validated leaves before HashBuilder enforces ordering with assertions. - fn from_leaves(origin: B256, leaves: I) -> Result<(Self, Option), RangeProofError> + fn from_leaves( + origin: B256, + leaves: I, + ) -> Result<(Self, Option), RangeProofErrorKind> where I: IntoIterator, V: Into>, @@ -251,13 +258,13 @@ impl ProofFrontier { for (key, value) in leaves { let value = value.into(); if key < origin { - return Err(RangeProofError::LeafBeforeOrigin { key, origin }) + return Err(RangeProofErrorKind::LeafBeforeOrigin { key, origin }) } if previous.is_some_and(|previous| key <= previous) { - return Err(RangeProofError::NonMonotonicLeaves) + return Err(RangeProofErrorKind::NonMonotonicLeaves) } if value.is_empty() { - return Err(RangeProofError::EmptyLeafValue { key }) + return Err(RangeProofErrorKind::EmptyLeafValue { key }) } previous = Some(key); frontier.push_leaf(Nibbles::unpack(key), value); @@ -279,7 +286,7 @@ impl ProofFrontier { } // Reconstructs the root after sorting leaves and subtries into HashBuilder's strict path order. - fn root(mut self) -> Result { + fn root(mut self) -> Result { // Outside subtries are disjoint from returned leaves, so sorting produces the strict path // order required by HashBuilder. Reject duplicates before they reach its assertion. self.0.sort_unstable_by_key(FrontierEntry::path); @@ -289,7 +296,7 @@ impl ProofFrontier { for entry in self.0 { let path = entry.path(); if previous.is_some_and(|previous| path <= previous) { - return Err(RangeProofError::DuplicateFrontierPath { path }) + return Err(RangeProofErrorKind::DuplicateFrontierPath { path }) } previous = Some(path); match entry { @@ -321,8 +328,14 @@ impl FrontierEntry { } /// Error returned when a trie range proof is invalid. +/// +/// Its display text describes the validation failure; reconstruction details remain private. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct RangeProofError(#[from] RangeProofErrorKind); + #[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum RangeProofError { +enum RangeProofErrorKind { /// The response leaves are not strictly increasing. #[error("range leaves are not strictly increasing")] NonMonotonicLeaves, @@ -383,10 +396,13 @@ pub enum RangeProofError { Rlp(#[from] alloy_rlp::Error), } -/// Verifies a consecutive leaf range against `root`, from `origin` through `limit`. +/// Verifies a consecutive leaf range against `root`, starting at `origin`. +/// +/// For nonempty `leaves`, the last returned leaf is the verified right boundary and `limit` is +/// ignored. A boundary leaf may therefore exceed `limit`; callers enforcing a response limit +/// must truncate the verified leaves themselves. /// -/// When `leaves` is empty, `limit` supplies the response's right boundary so an empty interval can -/// be authenticated without requiring a leaf past the limit. +/// When `leaves` is empty, `limit` supplies the right boundary of the authenticated empty interval. /// /// Returns a lower bound for the next key, or `None` if the range exhausts the trie. pub fn verify_range_proof( @@ -406,12 +422,14 @@ where if proof.is_empty() { let got = frontier.root()?; if got != root { - return Err(RangeProofError::RootMismatch { expected: root, got }) + return Err(RangeProofErrorKind::RootMismatch { expected: root, got }.into()) } return Ok(None) } - RangeProofVerifier::new(origin, last_key.unwrap_or(limit), proof, frontier).verify(root) + RangeProofVerifier::new(origin, last_key.unwrap_or(limit), proof, frontier) + .verify(root) + .map_err(Into::into) } // Keeps path mutation behind one checked API because external `Nibbles` cannot have inherent @@ -421,16 +439,16 @@ trait TriePath: Sized { fn lowest_key(&self) -> B256; // Requires extensions to consume bounded path space so hostile proofs cannot recurse in place. - fn descend_extension(self, key: &Nibbles) -> Result; + fn descend_extension(self, key: &Nibbles) -> Result; // Rejects branches beyond the key depth before mutating the path. - fn descend_child(self, nibble: u8) -> Result; + fn descend_child(self, nibble: u8) -> Result; // Requires leaves to resolve to one full hashed key before entering the frontier. - fn descend_leaf(self, key: &Nibbles) -> Result; + fn descend_leaf(self, key: &Nibbles) -> Result; // Shares the overflow guard used by extension and leaf descent. - fn join_checked(self, key: &Nibbles) -> Result; + fn join_checked(self, key: &Nibbles) -> Result; } impl TriePath for Nibbles { @@ -440,17 +458,17 @@ impl TriePath for Nibbles { } // Descends an extension while rejecting empty keys that could recurse without consuming space. - fn descend_extension(self, key: &Nibbles) -> Result { + fn descend_extension(self, key: &Nibbles) -> Result { if key.is_empty() { - return Err(RangeProofError::EmptyExtensionKey { path: self }) + return Err(RangeProofErrorKind::EmptyExtensionKey { path: self }) } self.join_checked(key) } // Descends one branch nibble while rejecting nodes below the fixed hashed-key depth. - fn descend_child(self, nibble: u8) -> Result { + fn descend_child(self, nibble: u8) -> Result { if self.len() >= KEY_NIBBLES { - return Err(RangeProofError::PathTooLong { path: self }) + return Err(RangeProofErrorKind::PathTooLong { path: self }) } let mut path = self; path.push(nibble); @@ -458,18 +476,18 @@ impl TriePath for Nibbles { } // Completes a leaf path while rejecting leaves that do not resolve to one full hashed key. - fn descend_leaf(self, key: &Nibbles) -> Result { + fn descend_leaf(self, key: &Nibbles) -> Result { let path = self.join_checked(key)?; if path.len() != KEY_NIBBLES { - return Err(RangeProofError::InvalidLeafPath { path }) + return Err(RangeProofErrorKind::InvalidLeafPath { path }) } Ok(path) } // Joins path segments while rejecting proof nodes that exceed the fixed hashed-key depth. - fn join_checked(self, key: &Nibbles) -> Result { + fn join_checked(self, key: &Nibbles) -> Result { if self.len() + key.len() > KEY_NIBBLES { - return Err(RangeProofError::PathTooLong { path: self }) + return Err(RangeProofErrorKind::PathTooLong { path: self }) } Ok(self.join(key)) } @@ -481,19 +499,19 @@ mod tests { use crate::{proof::ProofRetainer, BranchNode, ExtensionNode, TrieMask, EMPTY_ROOT_HASH}; use alloc::{vec, vec::Vec}; - const MAX_HASH: B256 = B256::new([0xff; B256::len_bytes()]); + const MAX_HASH: B256 = B256::repeat_byte(0xff); fn verify_range_proof( root: B256, origin: B256, leaves: I, proof: &[Bytes], - ) -> Result, RangeProofError> + ) -> Result, RangeProofErrorKind> where I: IntoIterator, V: Into>, { - super::verify_range_proof(root, origin, MAX_HASH, leaves, proof) + super::verify_range_proof(root, origin, MAX_HASH, leaves, proof).map_err(|error| error.0) } fn key(value: u64) -> B256 { @@ -606,7 +624,7 @@ mod tests { assert!(matches!( verify_range_proof(keccak256(&branch), key(1), no_leaves(), &proof), - Err(RangeProofError::PathTooLong { .. }) + Err(RangeProofErrorKind::PathTooLong { .. }) )); // A branch sitting at the full hashed-key depth, which has no room for a child. @@ -620,7 +638,7 @@ mod tests { assert!(matches!( verify_range_proof(keccak256(&reach), key(1), no_leaves(), &proof), - Err(RangeProofError::PathTooLong { .. }) + Err(RangeProofErrorKind::PathTooLong { .. }) )); } @@ -640,7 +658,7 @@ mod tests { assert!(matches!( verify_range_proof(root, key(1), no_leaves(), &proof), - Err(RangeProofError::EmptyExtensionKey { .. }) + Err(RangeProofErrorKind::EmptyExtensionKey { .. }) )); } @@ -653,7 +671,7 @@ mod tests { assert!(matches!( verify_range_proof(root, key(2), leaves[1..3].to_vec(), &unrelated), - Err(RangeProofError::MissingProofNode { .. }) + Err(RangeProofErrorKind::MissingProofNode { .. }) )); } @@ -683,7 +701,7 @@ mod tests { assert!(matches!( verify_range_proof(root, key(2), returned, &proof), - Err(RangeProofError::RootMismatch { .. }) + Err(RangeProofErrorKind::RootMismatch { .. }) )); } @@ -695,7 +713,7 @@ mod tests { assert!(matches!( verify_range_proof(root, key(2), returned, &proof), - Err(RangeProofError::RootMismatch { .. }) + Err(RangeProofErrorKind::RootMismatch { .. }) )); } @@ -718,7 +736,7 @@ mod tests { assert!(matches!( verify_range_proof(root, key(2), core::iter::empty::<(B256, Vec)>(), &proof,), - Err(RangeProofError::RootMismatch { .. }) + Err(RangeProofErrorKind::RootMismatch { .. }) )); } @@ -739,15 +757,15 @@ mod tests { assert_eq!( verify_range_proof(B256::ZERO, B256::ZERO, leaves, &[]), - Err(RangeProofError::NonMonotonicLeaves) + Err(RangeProofErrorKind::NonMonotonicLeaves) ); assert!(matches!( verify_range_proof(B256::ZERO, key(2), [(key(1), value(1))], &[],), - Err(RangeProofError::LeafBeforeOrigin { .. }) + Err(RangeProofErrorKind::LeafBeforeOrigin { .. }) )); assert_eq!( verify_range_proof(B256::ZERO, B256::ZERO, [(key(1), Vec::new())], &[]), - Err(RangeProofError::EmptyLeafValue { key: key(1) }) + Err(RangeProofErrorKind::EmptyLeafValue { key: key(1) }) ); } @@ -765,7 +783,7 @@ mod tests { ); assert!(matches!( verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, [(key(1), value(1))], &[],), - Err(RangeProofError::RootMismatch { .. }) + Err(RangeProofErrorKind::RootMismatch { .. }) )); assert!( verify_range_proof(EMPTY_ROOT_HASH, B256::ZERO, no_leaves(), &[Bytes::new()]).is_err() diff --git a/crates/trie/common/src/target_v2.rs b/crates/trie/common/src/target_v2.rs index 087f5608517..5d607a69aa6 100644 --- a/crates/trie/common/src/target_v2.rs +++ b/crates/trie/common/src/target_v2.rs @@ -112,13 +112,17 @@ impl MultiProofTargetsV2 { } /// Returns an iterator that yields chunks of the specified size. + /// + /// # Panics + /// + /// Panics if the chunk size is zero. pub fn chunks(self, chunk_size: usize) -> impl Iterator { ChunkedMultiProofTargetsV2::new(self, chunk_size) } /// Returns a set of [`MultiProofTargetsV2`] and the total amount of storage targets, based on /// the given state. - pub fn from_state(state: EvmState) -> (Self, usize) { + pub fn from_state(state: &EvmState) -> (Self, usize) { let mut targets = Self::default(); targets.account_targets.reserve(state.len()); targets.storage_targets.reserve(state.len()); @@ -142,13 +146,13 @@ impl MultiProofTargetsV2 { } let mut storage_slots = Vec::with_capacity(account.storage.len()); - for (key, slot) in account.storage { + for (key, slot) in &account.storage { // do nothing if unchanged if !slot.is_changed() { continue } - let hashed_slot = keccak256(B256::new(key.to_be_bytes())); + let hashed_slot = keccak256(B256::from(*key)); storage_slots.push(ProofV2Target::from(hashed_slot)); } @@ -183,7 +187,12 @@ pub struct ChunkedMultiProofTargetsV2 { impl ChunkedMultiProofTargetsV2 { /// Creates a new chunked iterator for the given targets. + /// + /// # Panics + /// + /// Panics if `size` is zero. pub fn new(targets: MultiProofTargetsV2, size: usize) -> Self { + assert!(size > 0, "chunk size must be non-zero"); Self { account_targets: targets.account_targets.into_iter(), storage_targets: targets.storage_targets, diff --git a/crates/trie/common/src/trie_data.rs b/crates/trie/common/src/trie_data.rs index c52c26e629f..7ea6dbaf47a 100644 --- a/crates/trie/common/src/trie_data.rs +++ b/crates/trie/common/src/trie_data.rs @@ -34,30 +34,13 @@ impl SortedTrieData { } } -/// Container for sorted trie data. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ComputedTrieData { - /// Sorted trie data: hashed state and trie updates. - pub sorted: SortedTrieData, -} - -impl ComputedTrieData { - /// Construct sorted trie data for one block. - pub const fn new( - hashed_state: Arc, - trie_updates: Arc, - ) -> Self { - Self { sorted: SortedTrieData::new(hashed_state, trie_updates) } - } -} - /// Lazily initialized trie data containing sorted hashed state and trie updates. /// /// This is a no-std compatible wrapper that supports three modes: /// 1. **Ready mode**: Data is available immediately (created via `ready()`) /// 2. **Deferred mode**: Data is computed on first access (created via `deferred()`) -/// 3. **Pending mode**: Data is computed in background task, callers wait for that result (created -/// via `pending()`). +/// 3. **Pending mode**: Access blocks until the caller-owned producer publishes the data (created +/// via `pending()`). No task is spawned automatically. /// /// In deferred mode, the computation runs on the first call to `get()`, `hashed_state()`, /// or `trie_updates()`, and results are cached for subsequent calls. @@ -65,7 +48,7 @@ impl ComputedTrieData { /// Cloning is cheap (Arc clone) and clones share the cached state. pub struct LazyTrieData { /// Cached sorted trie data, computed on first access. - data: Arc>, + data: Arc>, // /// Optional deferred computation function. // compute: Option SortedTrieData + Send + Sync>>, /// Lazy mode. @@ -96,7 +79,7 @@ impl Eq for LazyTrieData {} impl LazyTrieData { /// Creates a new [`LazyTrieData`] that is already initialized with the given values. - pub fn ready(sorted: ComputedTrieData) -> Self { + pub fn ready(sorted: SortedTrieData) -> Self { Self { data: Arc::new(OnceLock::from(sorted)), mode: LazyTrieDataMode::Ready } } @@ -104,14 +87,18 @@ impl LazyTrieData { /// /// The computation will run on the first call to `get()`, `hashed_state()`, /// or `trie_updates()`. Results are cached for subsequent calls. - pub fn deferred(compute: impl Fn() -> ComputedTrieData + Send + Sync + 'static) -> Self { + pub fn deferred(compute: impl Fn() -> SortedTrieData + Send + Sync + 'static) -> Self { Self { data: Arc::new(OnceLock::new()), mode: LazyTrieDataMode::Deferred(Arc::new(compute)), } } - /// Creates a new [`LazyTrieData`] with a spawned task to compute sorted trie data. + /// Creates a pending [`LazyTrieData`] and its producer without spawning a task. + /// + /// The caller must arrange for `LazyTrieDataProducer::compute_and_publish` to run before or + /// concurrently with access to the handle. Access blocks until publication; dropping the + /// producer without publishing leaves access blocked indefinitely. #[cfg(feature = "std")] pub fn pending( hashed_state: Arc, @@ -124,12 +111,15 @@ impl LazyTrieData { ) } - /// Returns a reference to the sorted trie data, computing or waiting for result if necessary. + /// Returns a reference to the sorted trie data. + /// + /// Deferred mode computes on first access. Pending mode blocks until the producer publishes + /// the result and can block indefinitely if publication never occurs. /// /// # Panics /// /// Panics if in ready state, but value has not been initialized. - pub fn get(&self) -> &ComputedTrieData { + pub fn get(&self) -> &SortedTrieData { match &self.mode { LazyTrieDataMode::Ready => self.data.get().expect("LazyTrieData must be initialized"), LazyTrieDataMode::Deferred(compute) => self.data.get_or_init(|| compute.as_ref()()), @@ -140,23 +130,23 @@ impl LazyTrieData { /// Returns a clone of the hashed state Arc. /// - /// If not initialized, computes from the deferred source or panics. + /// Calls [`Self::get`], computing deferred data or blocking until pending data is published. pub fn hashed_state(&self) -> Arc { - Arc::clone(&self.get().sorted.hashed_state) + Arc::clone(&self.get().hashed_state) } /// Returns a clone of the trie updates Arc. /// - /// If not initialized, computes from the deferred source or panics. + /// Calls [`Self::get`], computing deferred data or blocking until pending data is published. pub fn trie_updates(&self) -> Arc { - Arc::clone(&self.get().sorted.trie_updates) + Arc::clone(&self.get().trie_updates) } /// Returns a clone of the [`SortedTrieData`]. /// - /// If not initialized, computes from the deferred source or panics. + /// Calls [`Self::get`], computing deferred data or blocking until pending data is published. pub fn sorted_trie_data(&self) -> SortedTrieData { - self.get().sorted.clone() + self.get().clone() } } @@ -166,7 +156,7 @@ impl serde::Serialize for LazyTrieData { where S: serde::Serializer, { - self.get().sorted.serialize(serializer) + self.get().serialize(serializer) } } @@ -177,14 +167,14 @@ impl<'de> serde::Deserialize<'de> for LazyTrieData { D: serde::Deserializer<'de>, { let data = SortedTrieData::deserialize(deserializer)?; - Ok(Self::ready(ComputedTrieData::new(data.hashed_state, data.trie_updates))) + Ok(Self::ready(data)) } } #[derive(Clone)] enum LazyTrieDataMode { Ready, - Deferred(Arc ComputedTrieData + Send + Sync>), + Deferred(Arc SortedTrieData + Send + Sync>), #[cfg(feature = "std")] Pending, } @@ -200,11 +190,14 @@ impl fmt::Debug for LazyTrieDataMode { } } -/// Producer consumed by a spawned task to compute sorted trie data for a [`LazyTrieData`] handle. +/// Producer that computes and publishes sorted trie data for a [`LazyTrieData`] handle. +/// +/// The caller is responsible for running [`Self::compute_and_publish`]. Dropping the producer +/// without publishing leaves pending accessors blocked indefinitely. #[must_use = "LazyTrieDataProducer must be consumed with compute_and_publish to wake trie data waiters"] pub struct LazyTrieDataProducer { /// Shared result initialized exactly once by this producer. - value: Arc>, + value: Arc>, /// Unsorted inputs consumed when the producer computes trie data. inputs: PendingInputs, } @@ -217,7 +210,7 @@ impl fmt::Debug for LazyTrieDataProducer { impl LazyTrieDataProducer { /// Computes sorted trie data, publishes it to waiters, and returns it to the task owner. - pub fn compute_and_publish(self) -> ComputedTrieData { + pub fn compute_and_publish(self) -> SortedTrieData { let Self { value, inputs } = self; let computed = Self::sort(inputs.hashed_state, inputs.trie_updates); let _ = value.set(computed.clone()); @@ -228,7 +221,7 @@ impl LazyTrieDataProducer { pub fn sort( hashed_state: Arc, trie_updates: Arc, - ) -> ComputedTrieData { + ) -> SortedTrieData { #[cfg(feature = "rayon")] let (sorted_hashed_state, sorted_trie_updates) = rayon::join( || match Arc::try_unwrap(hashed_state) { @@ -253,7 +246,7 @@ impl LazyTrieDataProducer { }, ); - ComputedTrieData::new(Arc::new(sorted_hashed_state), Arc::new(sorted_trie_updates)) + SortedTrieData::new(Arc::new(sorted_hashed_state), Arc::new(sorted_trie_updates)) } } @@ -271,12 +264,9 @@ mod tests { use crate::HashedStorage; use super::*; - use alloy_primitives::{map::B256Map, B256, U256}; + use alloy_primitives::{B256, U256}; use reth_primitives_traits::Account; - use std::{ - thread, - time::{Duration, Instant}, - }; + use std::{thread, time::Duration}; fn empty_pending() -> (LazyTrieData, LazyTrieDataProducer) { LazyTrieData::pending( @@ -285,16 +275,9 @@ mod tests { ) } - #[test] - fn test_lazy_ready_is_initialized() { - let lazy = LazyTrieData::ready(ComputedTrieData::default()); - let _ = lazy.hashed_state(); - let _ = lazy.trie_updates(); - } - #[test] fn test_lazy_clone_shares_state() { - let lazy1 = LazyTrieData::ready(ComputedTrieData::default()); + let lazy1 = LazyTrieData::ready(SortedTrieData::default()); let lazy2 = lazy1.clone(); // Both point to the same data @@ -304,36 +287,11 @@ mod tests { #[test] fn test_lazy_deferred() { - let lazy = LazyTrieData::deferred(ComputedTrieData::default); + let lazy = LazyTrieData::deferred(SortedTrieData::default); assert!(lazy.hashed_state().is_empty()); assert!(lazy.trie_updates().is_empty()); } - #[test] - fn ready_returns_immediately() { - let bundle = ComputedTrieData::default(); - let deferred = LazyTrieData::ready(bundle.clone()); - - let result = deferred.get(); - - assert_eq!(result.sorted.hashed_state.total_len(), bundle.sorted.hashed_state.total_len()); - assert_eq!(result.sorted.trie_updates.total_len(), bundle.sorted.trie_updates.total_len()); - } - - #[test] - fn pending_waits_for_task_and_caches_result() { - let (deferred, task) = empty_pending(); - - let published = task.compute_and_publish(); - let first = deferred.get(); - let second = deferred.get(); - - assert!(Arc::ptr_eq(&published.sorted.hashed_state, &first.sorted.hashed_state)); - assert!(Arc::ptr_eq(&published.sorted.trie_updates, &first.sorted.trie_updates)); - assert!(Arc::ptr_eq(&first.sorted.hashed_state, &second.sorted.hashed_state)); - assert!(Arc::ptr_eq(&first.sorted.trie_updates, &second.sorted.trie_updates)); - } - #[test] fn pending_wait_blocks_until_task_publishes() { let (deferred, task) = empty_pending(); @@ -345,8 +303,8 @@ mod tests { let published = task.compute_and_publish(); let result = handle.join().unwrap(); - assert!(Arc::ptr_eq(&published.sorted.hashed_state, &result.sorted.hashed_state)); - assert!(Arc::ptr_eq(&published.sorted.trie_updates, &result.sorted.trie_updates)); + assert!(Arc::ptr_eq(&published.hashed_state, &result.hashed_state)); + assert!(Arc::ptr_eq(&published.trie_updates, &result.trie_updates)); } #[test] @@ -359,10 +317,10 @@ mod tests { let result1 = deferred.get().clone(); let result2 = handle.join().unwrap(); - assert!(Arc::ptr_eq(&published.sorted.hashed_state, &result1.sorted.hashed_state)); - assert!(Arc::ptr_eq(&published.sorted.trie_updates, &result1.sorted.trie_updates)); - assert!(Arc::ptr_eq(&result1.sorted.hashed_state, &result2.sorted.hashed_state)); - assert!(Arc::ptr_eq(&result1.sorted.trie_updates, &result2.sorted.trie_updates)); + assert!(Arc::ptr_eq(&published.hashed_state, &result1.hashed_state)); + assert!(Arc::ptr_eq(&published.trie_updates, &result1.trie_updates)); + assert!(Arc::ptr_eq(&result1.hashed_state, &result2.hashed_state)); + assert!(Arc::ptr_eq(&result1.trie_updates, &result2.trie_updates)); } #[test] @@ -373,7 +331,7 @@ mod tests { .with_accounts([(hashed_address, Some(Account::default()))]) .with_storages([( hashed_address, - HashedStorage::from_iter([(hashed_slot, U256::from(1))]), + HashedStorage::from_iter([(hashed_slot, U256::ONE)]), )]); let (deferred, task) = @@ -381,26 +339,7 @@ mod tests { let _ = task.compute_and_publish(); let result = deferred.get().clone(); - assert_eq!(result.sorted.hashed_state.total_len(), 2); - assert_eq!(result.sorted.trie_updates.total_len(), 0); - } - - #[test] - fn wait_does_not_block_after_first_compute() { - let mut accounts = B256Map::default(); - for i in 0..100 { - accounts.insert(B256::with_last_byte(i), Some(Account::default())); - } - let (deferred, task) = LazyTrieData::pending( - Arc::new(HashedPostState { accounts, storages: Default::default() }), - Arc::new(TrieUpdates::default()), - ); - - let _ = task.compute_and_publish(); - let _ = deferred.get().clone(); - let start = Instant::now(); - let _ = deferred.get().clone(); - - assert!(start.elapsed() < Duration::from_millis(10)); + assert_eq!(result.hashed_state.total_len(), 2); + assert_eq!(result.trie_updates.total_len(), 0); } } diff --git a/crates/trie/common/src/updates.rs b/crates/trie/common/src/updates.rs index 6a905c6e1ee..ccc41af79de 100644 --- a/crates/trie/common/src/updates.rs +++ b/crates/trie/common/src/updates.rs @@ -206,19 +206,15 @@ impl TrieUpdates { TrieUpdatesSorted { account_nodes, storage_tries } } - /// Converts trie updates into [`TrieUpdatesSortedRef`]. - pub fn into_sorted_ref(&self) -> TrieUpdatesSortedRef<'_> { + /// Allocates sorted collections borrowing the trie updates as [`TrieUpdatesSortedRef`]. + pub fn to_sorted_ref(&self) -> TrieUpdatesSortedRef<'_> { let mut account_nodes = self.account_nodes.iter().collect::>(); account_nodes.sort_unstable_by(|a, b| a.0.cmp(b.0)); TrieUpdatesSortedRef { removed_nodes: self.removed_nodes.iter().collect::>(), account_nodes, - storage_tries: self - .storage_tries - .iter() - .map(|m| (*m.0, m.1.into_sorted_ref())) - .collect(), + storage_tries: self.storage_tries.iter().map(|m| (*m.0, m.1.to_sorted_ref())).collect(), } } @@ -368,8 +364,9 @@ impl StorageTrieUpdates { StorageTrieUpdatesSorted { storage_nodes } } - /// Convert storage trie updates into [`StorageTrieUpdatesSortedRef`]. - pub fn into_sorted_ref(&self) -> StorageTrieUpdatesSortedRef<'_> { + /// Allocates sorted collections borrowing the storage updates as + /// [`StorageTrieUpdatesSortedRef`]. + pub fn to_sorted_ref(&self) -> StorageTrieUpdatesSortedRef<'_> { StorageTrieUpdatesSortedRef { removed_nodes: self.removed_nodes.iter().collect::>(), storage_nodes: self.storage_nodes.iter().collect::>(), @@ -635,9 +632,16 @@ impl TrieUpdatesSorted { return acc; } - // Large k: k-way merge. + Self::merge_iter(items.iter().map(AsRef::as_ref)) + } + + /// Batch-merge sorted trie updates yielded **newest to oldest**, using a k-way merge. + /// + /// Traverses the iterator separately for account and storage nodes without collecting its + /// inputs. The iterator is cloned for the account-node traversal. + pub fn merge_iter<'a>(items: impl Iterator + Clone) -> Self { let account_nodes = - kway_merge_sorted(items.iter().map(|i| i.as_ref().account_nodes.as_slice())); + kway_merge_sorted(items.clone().map(|item| item.account_nodes.as_slice())); struct StorageAcc<'a> { slices: Vec<&'a [(Nibbles, Option)]>, @@ -646,7 +650,7 @@ impl TrieUpdatesSorted { let mut acc: B256Map> = B256Map::default(); for item in items { - for (addr, storage) in &item.as_ref().storage_tries { + for (addr, storage) in &item.storage_tries { let entry = acc.entry(*addr).or_insert_with(|| StorageAcc { slices: Vec::new() }); entry.slices.push(storage.storage_nodes.as_slice()); } @@ -923,8 +927,8 @@ mod tests { storage_nodes: vec![(Nibbles::from_nibbles_unchecked([0x0b]), None)], }; - let hashed_address1 = B256::from([1; 32]); - let hashed_address2 = B256::from([2; 32]); + let hashed_address1 = B256::repeat_byte(1); + let hashed_address2 = B256::repeat_byte(2); let mut updates1 = TrieUpdatesSorted { account_nodes: vec![], @@ -950,7 +954,7 @@ mod tests { fn test_trie_updates_sorted_disjointed_merge_batch() { let kept_node = Nibbles::from_nibbles_unchecked([0x01]); let removed_node = Nibbles::from_nibbles_unchecked([0x02]); - let kept_storage = B256::from([3; 32]); + let kept_storage = B256::repeat_byte(3); let slot1 = Nibbles::from_nibbles_unchecked([0x0a]); let slot2 = Nibbles::from_nibbles_unchecked([0x0b]); @@ -1058,8 +1062,8 @@ mod tests { let node_path = Nibbles::from_nibbles_unchecked([0x03]); let deleted_node_path = Nibbles::from_nibbles_unchecked([0x04]); - let storage = B256::from([5; 32]); - let deleted_storage = B256::from([6; 32]); + let storage = B256::repeat_byte(5); + let deleted_storage = B256::repeat_byte(6); let storage_node_path = Nibbles::from_nibbles_unchecked([0x0c]); let deleted_storage_node_path = Nibbles::from_nibbles_unchecked([0x0d]); let batch = TrieUpdatesSorted::new( @@ -1112,7 +1116,7 @@ mod tests { #[test] fn test_trie_updates_sorted_disjointed_merge_batch_uses_exact_key_masking() { - let hashed_address = B256::from([7; 32]); + let hashed_address = B256::repeat_byte(7); let grandparent = Nibbles::from_nibbles_unchecked([0x05]); let parent = Nibbles::from_nibbles_unchecked([0x05, 0x04]); let child = Nibbles::from_nibbles_unchecked([0x05, 0x04, 0x03]); @@ -1164,7 +1168,7 @@ mod tests { #[test] fn test_trie_updates_sorted_disjointed_merge_batch_ignores_empty_storage_mask() { - let storage = B256::from([6; 32]); + let storage = B256::repeat_byte(6); let slot = Nibbles::from_nibbles_unchecked([0x0d]); let batch = TrieUpdatesSorted::new( @@ -1194,7 +1198,7 @@ mod tests { /// Test extending with storage tries adds both nodes and removed nodes correctly #[test] fn test_trie_updates_extend_from_sorted_with_storage_tries() { - let hashed_address = B256::from([1; 32]); + let hashed_address = B256::repeat_byte(1); let mut updates = TrieUpdates::default(); @@ -1261,6 +1265,38 @@ mod tests { assert!(updates.account_nodes.contains_key(&Nibbles::from_nibbles_unchecked([0x01]))); assert!(!updates.account_nodes.contains_key(&Nibbles::default())); } + + #[test] + fn test_trie_updates_sorted_merge_iter() { + let path = Nibbles::from_nibbles_unchecked([1]); + let updates = (0..31) + .map(|index| { + let nodes = vec![(path, (index % 2 != 0).then(BranchNodeCompact::default))]; + TrieUpdatesSorted::new( + nodes.clone(), + B256Map::from_iter([ + (B256::ZERO, StorageTrieUpdatesSorted { storage_nodes: nodes.clone() }), + ( + B256::with_last_byte(index + 1), + StorageTrieUpdatesSorted { storage_nodes: nodes }, + ), + ]), + ) + }) + .collect::>(); + + for count in [0, 1, 2, 29, 30, 31] { + let mut expected = TrieUpdatesSorted::default(); + for update in updates[..count].iter().rev() { + expected.extend_ref_and_sort(update); + } + + // A forward-only iterator ensures merging does not require DoubleEndedIterator. + let iter = (0..).take(count).map(|index| &updates[index]); + assert_eq!(TrieUpdatesSorted::merge_iter(iter), expected); + assert_eq!(TrieUpdatesSorted::merge_slice(&updates[..count]), expected); + } + } } /// Bincode-compatible trie updates type serde implementations. diff --git a/crates/trie/db/Cargo.toml b/crates/trie/db/Cargo.toml index 0600e389bab..d6c335ac672 100644 --- a/crates/trie/db/Cargo.toml +++ b/crates/trie/db/Cargo.toml @@ -34,7 +34,7 @@ reth-primitives-traits = { workspace = true, features = ["test-utils", "arbitrar reth-db = { workspace = true, features = ["test-utils"] } reth-provider = { workspace = true, features = ["test-utils"] } reth-stages-types.workspace = true -reth-trie-common = { workspace = true, features = ["test-utils", "arbitrary"] } +reth-trie-common = { workspace = true, features = ["test-utils", "arbitrary", "eip1186"] } reth-trie = { workspace = true, features = ["test-utils"] } alloy-consensus.workspace = true @@ -73,3 +73,10 @@ test-utils = [ "reth-trie/test-utils", "reth-stages-types/test-utils", ] +account-ext = [ + "reth-trie/account-ext", + "reth-provider/account-ext", + "reth-primitives-traits/account-ext", + "reth-stages-types/account-ext", + "revm/account-ext", +] diff --git a/crates/trie/db/src/changesets.rs b/crates/trie/db/src/changesets.rs index 4ba454b1d8f..f38e63ef61a 100644 --- a/crates/trie/db/src/changesets.rs +++ b/crates/trie/db/src/changesets.rs @@ -10,10 +10,10 @@ use reth_storage_errors::provider::ProviderError; use reth_trie::{ hashed_cursor::{HashedCursorFactory, HashedPostStateCursorFactory}, trie_cursor::{InMemoryTrieCursorFactory, TrieCursorFactory}, - StateRoot, + HashedPostStateSorted, StateRoot, }; -use reth_trie_common::updates::TrieUpdatesSorted; -use std::ops::RangeInclusive; +use reth_trie_common::{updates::TrieUpdatesSorted, TrieInputSorted}; +use std::{iter::once, ops::RangeInclusive, sync::Arc}; use tracing::debug; /// Computes trie changesets for a block. @@ -38,6 +38,7 @@ where compute_range_trie_changesets( provider, state_trie_provider, + &[], block_number..=block_number, db_tip_block, ) @@ -45,8 +46,15 @@ where /// Computes aggregate trie changesets for an inclusive block range. /// -/// The returned changesets restore the trie from the state after `range.end()` to the state before -/// `range.start()`. `db_tip_block` must be the current database tip for `provider`. +/// Returns the value before `range.start()` of every trie node touched by a block in `range`, +/// including nodes created and removed within it. +/// +/// `state_trie_provider` must expose the complete trie and hashed state at `db_tip_block`. +/// +/// `forward_updates` contains original executed-block trie updates on that same chain, sorted by +/// strictly increasing block number. Consecutive available blocks are reverted together, preserving +/// forward paths omitted by the aggregate calculation. Missing blocks are reverted individually so +/// transient nodes are retained. /// /// # Errors /// @@ -55,6 +63,7 @@ where pub fn compute_range_trie_changesets( provider: &Provider, state_trie_provider: &StateTrieProvider, + forward_updates: &[(BlockNumber, Arc)], range: RangeInclusive, db_tip_block: BlockNumber, ) -> Result @@ -62,13 +71,18 @@ where Provider: ChangeSetReader + StorageChangeSetReader + BlockNumReader, StateTrieProvider: TrieCursorFactory + HashedCursorFactory, { - let start_block = *range.start(); - let end_block = *range.end(); + debug_assert!( + forward_updates.is_sorted_by(|(a, _), (b, _)| a < b), + "forward updates must have strictly increasing block numbers" + ); - if start_block > end_block { + if range.is_empty() { return Ok(TrieUpdatesSorted::default()) } + let start_block = *range.start(); + let end_block = *range.end(); + if end_block > db_tip_block { return Err(ProviderError::InsufficientChangesets { requested: end_block, @@ -84,67 +98,109 @@ where "Computing range trie changesets from database state" ); - // Collect the state revert for the requested range. - let range_state_revert = reth_trie::HashedPostStateSorted::from_reverts(provider, range)?; - let range_prefix_sets = range_state_revert.construct_prefix_sets(); - - let (range_nodes, range_state) = if end_block == db_tip_block { - debug!( - target: "trie::changesets", - start_block, - end_block, - db_tip_block, - "Skipping tail trie revert computation for tip-ended range" - ); - - (TrieUpdatesSorted::default(), range_state_revert) - } else { - // Collect the state revert from the database tip to just after the range. - let tail_state_revert = end_block - .checked_add(1) - .map(|next_block| { - reth_trie::HashedPostStateSorted::from_reverts(provider, next_block..) - }) - .transpose()? - .unwrap_or_default(); - - // Compute trie reverts from the database tip to just after the range. - let tail_prefix_sets = tail_state_revert.construct_prefix_sets().freeze(); - let tail_trie_revert = StateRoot::new( + let mut overlay = TrieInputSorted::default(); + // Rewind later blocks to reconstruct the trie at the end of the requested range. + if end_block < db_tip_block { + rewind_trie_range( + provider, state_trie_provider, - HashedPostStateCursorFactory::new(state_trie_provider, &tail_state_revert), - ) - .with_prefix_sets(tail_prefix_sets) - .root_with_updates() - .map_err(ProviderError::other)? - .1 - .into_sorted(); - - // Overlay the post-range trie and compute the trie revert to the pre-range state. - let mut pre_range_state_revert = tail_state_revert; - pre_range_state_revert.extend_ref_and_sort(&range_state_revert); - - (tail_trie_revert, pre_range_state_revert) - }; + forward_updates, + (end_block + 1)..=db_tip_block, + &mut overlay, + &mut None, + )?; + } - let range_trie_revert = StateRoot::new( - InMemoryTrieCursorFactory::new(state_trie_provider, &range_nodes), - HashedPostStateCursorFactory::new(state_trie_provider, &range_state), - ) - .with_prefix_sets(range_prefix_sets.freeze()) - .root_with_updates() - .map_err(ProviderError::other)? - .1 - .into_sorted(); + let mut reverts = Some(TrieUpdatesSorted::default()); + rewind_trie_range( + provider, + state_trie_provider, + forward_updates, + range, + &mut overlay, + &mut reverts, + )?; + let reverts = reverts.expect("revert collection is enabled"); debug!( target: "trie::changesets", start_block, end_block, - num_account_nodes = range_trie_revert.account_nodes_ref().len(), - num_storage_tries = range_trie_revert.storage_tries_ref().len(), + num_account_nodes = reverts.account_nodes_ref().len(), + num_storage_tries = reverts.storage_tries_ref().len(), "Computed range trie changesets successfully" ); - Ok(range_trie_revert) + Ok(reverts) +} + +/// Rewinds the current state and trie views, accumulating changesets when `reverts` is `Some`. +fn rewind_trie_range( + provider: &Provider, + state_trie_provider: &StateTrieProvider, + forward_updates: &[(BlockNumber, Arc)], + blocks: RangeInclusive, + overlay: &mut TrieInputSorted, + reverts: &mut Option, +) -> Result<(), ProviderError> +where + Provider: ChangeSetReader + StorageChangeSetReader + BlockNumReader, + StateTrieProvider: TrieCursorFactory + HashedCursorFactory, +{ + let mut end = *blocks.end(); + while end >= *blocks.start() { + let mut start = end; + let mut segment_range = 0..0; + + // Select updates of consecutive blocks iterating backwards from `end`. + // Without the original updates, reverting one block at a time preserves nodes that + // appear and disappear inside the range. An endpoint-only calculation would omit them. + if let Ok(index) = forward_updates.binary_search_by_key(&end, |(block, _)| *block) { + segment_range = index..index + 1; + while segment_range.start > 0 && start > *blocks.start() { + if forward_updates[segment_range.start - 1].0 != start - 1 { + break + } + segment_range.start -= 1; + start -= 1; + } + } + + // Collect reverts for the segment and use them to generate the trie reverts for the + // segment. + let segment_state_reverts = HashedPostStateSorted::from_reverts(provider, start..=end)?; + let prefixes = segment_state_reverts.construct_prefix_sets().freeze(); + Arc::make_mut(&mut overlay.state).extend_ref_and_sort(&segment_state_reverts); + + let segment_trie_reverts = StateRoot::new( + InMemoryTrieCursorFactory::new(state_trie_provider, overlay.nodes.as_ref()), + HashedPostStateCursorFactory::new(state_trie_provider, overlay.state.as_ref()), + ) + .with_prefix_sets(prefixes) + .root_with_updates() + .map_err(ProviderError::other)? + .1 + .into_sorted(); + + // A path missing from the calculated reverts has the same value at both segment ends, so + // its newest forward update is also its value before the segment. Merging the forward + // updates keeps nodes that appear and disappear inside the segment, e.g. a node created + // in block 6 and deleted in block 8 when reverting 6..=8. + let segment_trie_forward = + forward_updates[segment_range].iter().rev().map(|(_, updates)| updates.as_ref()); + let segment_trie_reverts = + TrieUpdatesSorted::merge_iter(once(&segment_trie_reverts).chain(segment_trie_forward)); + + Arc::make_mut(&mut overlay.nodes).extend_ref_and_sort(&segment_trie_reverts); + if let Some(reverts) = reverts { + reverts.extend_ref_and_sort(&segment_trie_reverts); + } + + if start == *blocks.start() { + break + } + end = start - 1; + } + + Ok(()) } diff --git a/crates/trie/db/src/prefix_set.rs b/crates/trie/db/src/prefix_set.rs index 4335e2bd3f1..9ab04343410 100644 --- a/crates/trie/db/src/prefix_set.rs +++ b/crates/trie/db/src/prefix_set.rs @@ -1,6 +1,6 @@ use alloy_primitives::{ keccak256, - map::{HashMap, HashSet}, + map::{B256Map, HashSet}, Address, BlockNumber, B256, }; use core::ops::RangeInclusive; @@ -30,7 +30,7 @@ where // Initialize prefix sets. let mut account_prefix_set = PrefixSetMut::default(); - let mut storage_prefix_sets = HashMap::::default(); + let mut storage_prefix_sets = B256Map::::default(); let mut destroyed_accounts = HashSet::default(); // Get account changesets using the provider (handles static files + database) diff --git a/crates/trie/db/src/proof.rs b/crates/trie/db/src/proof.rs index 934c3fecd4e..1b46624ad23 100644 --- a/crates/trie/db/src/proof.rs +++ b/crates/trie/db/src/proof.rs @@ -6,8 +6,9 @@ use reth_trie::{ hashed_cursor::HashedPostStateCursorFactory, proof::{Proof, StorageProof}, trie_cursor::InMemoryTrieCursorFactory, - AccountProof, DecodedMultiProofV2, HashedPostStateSorted, HashedStorage, MultiProof, - MultiProofTargets, MultiProofTargetsV2, StorageMultiProof, TrieInput, TrieInputSorted, + updates::TrieUpdatesSorted, + AccountProof, HashedPostStateSorted, HashedStorage, MultiProof, MultiProofTargets, + StorageMultiProof, TrieInput, TrieInputSorted, }; /// Extends [`Proof`] with operations specific for working with a database transaction. @@ -32,13 +33,6 @@ pub trait DatabaseProof<'a> { input: TrieInput, targets: MultiProofTargets, ) -> Result; - - /// Generates a V2 decoded state multiproof for target hashed accounts and storage keys. - fn overlay_multiproof_v2( - &self, - input: TrieInput, - targets: MultiProofTargetsV2, - ) -> Result; } impl<'a, TX: DbTx, A: TrieTableAdapter> DatabaseProof<'a> @@ -55,14 +49,7 @@ impl<'a, TX: DbTx, A: TrieTableAdapter> DatabaseProof<'a> address: Address, slots: &[B256], ) -> Result { - let nodes_sorted = input.nodes.into_sorted(); - let state_sorted = input.state.into_sorted(); - Proof::new( - InMemoryTrieCursorFactory::new(self.trie_cursor_factory().clone(), &nodes_sorted), - HashedPostStateCursorFactory::new(self.hashed_cursor_factory().clone(), &state_sorted), - ) - .with_prefix_sets_mut(input.prefix_sets) - .account_proof(address, slots) + with_overlay(self, input, |proof| proof.account_proof(address, slots)) } fn overlay_multiproof( @@ -70,29 +57,7 @@ impl<'a, TX: DbTx, A: TrieTableAdapter> DatabaseProof<'a> input: TrieInput, targets: MultiProofTargets, ) -> Result { - let nodes_sorted = input.nodes.into_sorted(); - let state_sorted = input.state.into_sorted(); - Proof::new( - InMemoryTrieCursorFactory::new(self.trie_cursor_factory().clone(), &nodes_sorted), - HashedPostStateCursorFactory::new(self.hashed_cursor_factory().clone(), &state_sorted), - ) - .with_prefix_sets_mut(input.prefix_sets) - .multiproof(targets) - } - - fn overlay_multiproof_v2( - &self, - input: TrieInput, - targets: MultiProofTargetsV2, - ) -> Result { - let nodes_sorted = input.nodes.into_sorted(); - let state_sorted = input.state.into_sorted(); - Proof::new( - InMemoryTrieCursorFactory::new(self.trie_cursor_factory().clone(), &nodes_sorted), - HashedPostStateCursorFactory::new(self.hashed_cursor_factory().clone(), &state_sorted), - ) - .with_prefix_sets_mut(input.prefix_sets) - .multiproof_v2(targets) + with_overlay(self, input, |proof| proof.multiproof(targets)) } } @@ -119,11 +84,7 @@ pub trait DatabaseStorageProof<'a, TX> { } impl<'a, TX: DbTx, A: TrieTableAdapter> DatabaseStorageProof<'a, TX> - for StorageProof< - 'static, - DatabaseTrieCursorFactory<&'a TX, A>, - DatabaseHashedCursorFactory<&'a TX>, - > + for StorageProof, DatabaseHashedCursorFactory<&'a TX>> { fn from_tx(tx: &'a TX, address: Address) -> Self { Self::new( @@ -176,3 +137,23 @@ impl<'a, TX: DbTx, A: TrieTableAdapter> DatabaseStorageProof<'a, TX> .storage_multiproof(targets) } } + +// Keeps sorting, cursor layering, and prefix invalidation identical across proof entrypoints. +fn with_overlay( + proof: &Proof, + input: TrieInput, + f: impl FnOnce( + Proof< + InMemoryTrieCursorFactory, + HashedPostStateCursorFactory, + >, + ) -> R, +) -> R { + let nodes = input.nodes.into_sorted(); + let state = input.state.into_sorted(); + f(Proof::new( + InMemoryTrieCursorFactory::new(proof.trie_cursor_factory().clone(), &nodes), + HashedPostStateCursorFactory::new(proof.hashed_cursor_factory().clone(), &state), + ) + .with_prefix_sets_mut(input.prefix_sets)) +} diff --git a/crates/trie/db/src/state.rs b/crates/trie/db/src/state.rs index 61ffb4848a6..b747b9b97da 100644 --- a/crates/trie/db/src/state.rs +++ b/crates/trie/db/src/state.rs @@ -383,13 +383,13 @@ mod tests { let mut hashed_state = HashedPostState::default(); hashed_state.accounts.insert( - B256::from(U256::from(1)), + B256::with_last_byte(1), Some(Account { nonce: 1, balance: U256::from(10), ..Default::default() }), ); - hashed_state.accounts.insert(B256::from(U256::from(2)), None); + hashed_state.accounts.insert(B256::with_last_byte(2), None); hashed_state.storages.insert( - B256::from(U256::from(1)), - HashedStorage::from_iter([(B256::from(U256::from(3)), U256::from(30))]), + B256::with_last_byte(1), + HashedStorage::from_iter([(B256::with_last_byte(3), U256::from(30))]), ); let sorted = hashed_state.into_sorted(); @@ -440,8 +440,8 @@ mod tests { let address1 = Address::with_last_byte(1); let address2 = Address::with_last_byte(2); - let slot1 = B256::from(U256::from(11)); - let slot2 = B256::from(U256::from(22)); + let slot1 = B256::with_last_byte(11); + let slot2 = B256::with_last_byte(22); // Account changesets: only first occurrence per address should be kept. provider @@ -547,8 +547,8 @@ mod tests { let address1 = Address::with_last_byte(1); let address2 = Address::with_last_byte(2); - let plain_slot1 = B256::from(U256::from(11)); - let plain_slot2 = B256::from(U256::from(22)); + let plain_slot1 = B256::with_last_byte(11); + let plain_slot2 = B256::with_last_byte(22); let hashed_slot1 = keccak256(plain_slot1); let hashed_slot2 = keccak256(plain_slot2); @@ -650,8 +650,8 @@ mod tests { let address1 = Address::with_last_byte(1); let address2 = Address::with_last_byte(2); - let plain_slot1 = B256::from(U256::from(11)); - let plain_slot2 = B256::from(U256::from(22)); + let plain_slot1 = B256::with_last_byte(11); + let plain_slot2 = B256::with_last_byte(22); provider .tx_ref() diff --git a/crates/trie/db/src/storage.rs b/crates/trie/db/src/storage.rs index e634723f1b2..e249bfb0fd6 100644 --- a/crates/trie/db/src/storage.rs +++ b/crates/trie/db/src/storage.rs @@ -159,8 +159,8 @@ mod tests { assert!(!provider.cached_storage_settings().use_hashed_state()); let address = Address::with_last_byte(42); - let slot1 = B256::from(U256::from(100)); - let slot2 = B256::from(U256::from(200)); + let slot1 = B256::with_last_byte(100); + let slot2 = B256::with_last_byte(200); append_headers_to_static_files(&factory, 5); @@ -209,8 +209,8 @@ mod tests { assert!(provider.cached_storage_settings().is_v2()); let address = Address::with_last_byte(42); - let plain_slot1 = B256::from(U256::from(100)); - let plain_slot2 = B256::from(U256::from(200)); + let plain_slot1 = B256::with_last_byte(100); + let plain_slot2 = B256::with_last_byte(200); let hashed_slot1 = keccak256(plain_slot1); let hashed_slot2 = keccak256(plain_slot2); diff --git a/crates/trie/db/src/trie_cursor.rs b/crates/trie/db/src/trie_cursor.rs index 250d049d874..cd9c8adc424 100644 --- a/crates/trie/db/src/trie_cursor.rs +++ b/crates/trie/db/src/trie_cursor.rs @@ -17,15 +17,16 @@ use std::marker::PhantomData; /// Trait abstracting nibble encoding for trie keys. /// /// Allows the same cursor implementation to work with both legacy (65-byte) and -/// packed (33-byte) nibble encodings. The underlying cursor types are monomorphized per -/// adapter, while [`DatabaseTrieCursorFactory`] selects the encoding at runtime. +/// packed (33-byte) nibble encodings. [`DatabaseTrieCursorFactory`] fixes the encoding through +/// its adapter type parameter. Callers must choose an adapter matching the database's storage +/// settings; [`crate::with_adapter!`] provides runtime dispatch between the adapter types. pub trait TrieKeyAdapter: Clone + Send + Sync + 'static { /// The key type for account trie lookups (e.g., `StoredNibbles` or `PackedStoredNibbles`). - type AccountKey: Key + From + Clone; + type AccountKey: Key + From; /// The subkey type for storage trie `DupSort` lookups /// (e.g., `StoredNibblesSubKey` or `PackedStoredNibblesSubKey`). - type StorageSubKey: Key + From + Clone + PartialEq; + type StorageSubKey: Key + From; /// The storage trie entry type that pairs a subkey with a `BranchNodeCompact`. type StorageValue: Value + StorageTrieEntryLike; @@ -39,9 +40,8 @@ pub trait TrieKeyAdapter: Clone + Send + Sync + 'static { /// Trait for storage trie entry types that carry a subkey and node. /// -/// Needed because [`StorageTrieEntry`] and [`PackedStorageTrieEntry`] are separate structs -/// with different field types, but `DatabaseStorageTrieCursor` must access `.nibbles()` and -/// `.node()` generically through `A::StorageValue`. +/// Provides access to the encoded subkey and branch node, as well as construction and +/// decomposition into owned parts, independently of the subkey encoding. pub trait StorageTrieEntryLike: Sized { /// The subkey type. type SubKey: Clone; @@ -135,11 +135,9 @@ impl TrieKeyAdapter for PackedKeyAdapter { } } -/// Helper trait to map a [`TrieKeyAdapter`] to the correct table types. +/// Maps a [`TrieKeyAdapter`] to account and storage trie tables using its key and value types. /// -/// This indirection is needed because the `tables!` macro generates non-generic -/// table types, so we use separate "view" types for packed encoding that share -/// the same MDBX table name. +/// The selected table types must match the database's stored nibble encoding. pub trait TrieTableAdapter: TrieKeyAdapter { /// The account trie table type. type AccountTrieTable: Table; diff --git a/crates/trie/db/tests/fuzz_in_memory_nodes.rs b/crates/trie/db/tests/fuzz_in_memory_nodes.rs index d4b2bcd20e6..36441693405 100644 --- a/crates/trie/db/tests/fuzz_in_memory_nodes.rs +++ b/crates/trie/db/tests/fuzz_in_memory_nodes.rs @@ -22,14 +22,15 @@ type DbStateRoot<'a, TX, A> = type DbStorageRoot<'a, TX, A> = StorageRoot, DatabaseHashedCursorFactory<&'a TX>>; -proptest! { - #![proptest_config(ProptestConfig { - cases: 128, ..ProptestConfig::default() - })] - - #[test] - fn fuzz_in_memory_account_nodes(mut init_state: BTreeMap, state_updates: [BTreeMap>; 10]) { - let factory = create_test_provider_factory(); +#[test] +fn fuzz_in_memory_account_nodes() { + // The cases share one factory: each drops its provider without committing, so the next case + // starts from an empty database. + let factory = create_test_provider_factory(); + proptest!(ProptestConfig::with_cases(128), |( + init_state in any::>(), + state_updates in any::<[BTreeMap>; 10]>(), + )| { let provider = factory.provider_rw().unwrap(); let mut hashed_account_cursor = provider.tx_ref().cursor_write::().unwrap(); @@ -78,12 +79,19 @@ proptest! { assert_eq!(expected_root, state_root); } }); - } + }); +} - #[test] - fn fuzz_in_memory_storage_nodes(mut init_storage: BTreeMap, storage_updates: [BTreeMap; 10]) { +#[test] +fn fuzz_in_memory_storage_nodes() { + // The cases share one factory: each drops its provider without committing, so the next case + // starts from an empty database. + let factory = create_test_provider_factory(); + proptest!(ProptestConfig::with_cases(128), |( + init_storage in any::>(), + storage_updates in any::<[BTreeMap; 10]>(), + )| { let hashed_address = B256::random(); - let factory = create_test_provider_factory(); let provider = factory.provider_rw().unwrap(); let mut hashed_storage_cursor = provider.tx_ref().cursor_write::().unwrap(); @@ -132,5 +140,5 @@ proptest! { assert_eq!(expected_root, storage_root); } }); - } + }); } diff --git a/crates/trie/db/tests/proof.rs b/crates/trie/db/tests/proof.rs index 0136a11a895..61cb95695b3 100644 --- a/crates/trie/db/tests/proof.rs +++ b/crates/trie/db/tests/proof.rs @@ -2,15 +2,12 @@ use alloy_consensus::EMPTY_ROOT_HASH; use alloy_primitives::{address, b256, keccak256, Address, Bytes, B256, U256}; -use alloy_rlp::{Decodable, EMPTY_STRING_CODE}; +use alloy_rlp::EMPTY_STRING_CODE; use reth_chainspec::{Chain, ChainSpec, HOLESKY, MAINNET}; use reth_primitives_traits::Account; use reth_provider::test_utils::{create_test_provider_factory, insert_genesis}; use reth_storage_api::StorageSettingsCache; -use reth_trie::{ - proof::Proof, AccountProof, MultiProofTargetsV2, Nibbles, ProofV2Target, StorageProof, - TrieInput, TrieNode, -}; +use reth_trie::{proof::Proof, AccountProof, Nibbles, StorageProof}; use reth_trie_db::{DatabaseHashedCursorFactory, DatabaseProof, DatabaseTrieCursorFactory}; use std::{ str::FromStr, @@ -137,47 +134,6 @@ fn testspec_empty_storage_proof() { }); } -#[test] -fn multiproof_v2_includes_branch_after_terminal_extension() { - let factory = create_test_provider_factory(); - let root = insert_genesis(&factory, TEST_SPEC.clone()).unwrap(); - let target = Address::ZERO; - let provider = factory.provider().unwrap(); - - reth_trie_db::with_adapter!(provider, |A| { - let proof = as DatabaseProof>::from_tx(provider.tx_ref()); - let legacy = proof.account_proof(target, &[]).unwrap(); - assert_eq!(legacy.proof.len(), 1); - assert!(matches!( - TrieNode::decode(&mut &legacy.proof[0][..]).unwrap(), - TrieNode::Extension(_) - )); - - let proof = as DatabaseProof>::from_tx(provider.tx_ref()); - let multiproof = proof - .overlay_multiproof_v2( - TrieInput::default(), - MultiProofTargetsV2 { - account_targets: vec![ProofV2Target::new(keccak256(target))], - ..Default::default() - }, - ) - .unwrap(); - let account_proof = multiproof.account_proof(target, &[]).unwrap(); - - assert_eq!(account_proof.proof.len(), 2); - assert!(matches!( - TrieNode::decode(&mut &account_proof.proof[0][..]).unwrap(), - TrieNode::Extension(_) - )); - assert!(matches!( - TrieNode::decode(&mut &account_proof.proof[1][..]).unwrap(), - TrieNode::Branch(_) - )); - assert_eq!(account_proof.verify(root), Ok(())); - }); -} - #[test] fn empty_state_trie_account_proof() { // Empty database => empty account (state) trie, root == EMPTY_ROOT_HASH. @@ -265,21 +221,13 @@ fn holesky_deposit_contract_proof() { let factory = create_test_provider_factory(); let root = insert_genesis(&factory, HOLESKY.clone()).unwrap(); - let target = address!("0x4242424242424242424242424242424242424242"); + let target = Address::repeat_byte(0x42); // existent - let slot_22 = - B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000022") - .unwrap(); - let slot_23 = - B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000023") - .unwrap(); - let slot_24 = - B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000024") - .unwrap(); + let slot_22 = B256::with_last_byte(0x22); + let slot_23 = B256::with_last_byte(0x23); + let slot_24 = B256::with_last_byte(0x24); // non-existent - let slot_100 = - B256::from_str("0x0000000000000000000000000000000000000000000000000000000000000100") - .unwrap(); + let slot_100 = b256!("0x0000000000000000000000000000000000000000000000000000000000000100"); let slots = Vec::from([slot_22, slot_23, slot_24, slot_100]); // `cast proof 0x4242424242424242424242424242424242424242 0x22 0x23 0x24 0x100 --block 0` @@ -355,3 +303,47 @@ fn holesky_deposit_contract_proof() { assert_eq!(account_proof.verify(root), Ok(())); }); } + +#[test] +#[cfg(feature = "account-ext")] +fn extension_only_genesis_account_proof() { + let target = Address::with_last_byte(1); + let extension = reth_primitives_traits::AccountExtension::copy_from_slice(&[0x82, 0xaa]); + let mut spec = ChainSpec::default(); + spec.genesis.alloc.entry(target).or_default().extension = extension.clone(); + spec.genesis.alloc.entry(Address::with_last_byte(3)).or_default().balance = U256::ONE; + let expected_root = + reth_chainspec::make_genesis_header(&spec.genesis, &spec.hardforks).state_root; + let factory = create_test_provider_factory(); + let root = insert_genesis(&factory, Arc::new(spec)).unwrap(); + assert_eq!(root, expected_root); + + let provider = factory.provider().unwrap(); + reth_trie_db::with_adapter!(provider, |A| { + let proof = as DatabaseProof>::from_tx(provider.tx_ref()) + .account_proof(target, &[]) + .unwrap(); + let account = proof.info.as_ref().unwrap(); + assert_eq!(account.extension, extension); + assert!(!account.is_empty()); + assert_eq!(proof.verify(root), Ok(())); + + let response = proof.clone().into_eip1186_response(Vec::new()); + assert_eq!(response.account_proof, proof.proof); + let restored = AccountProof::from_eip1186_proof(response); + assert_eq!(restored.info.as_ref().unwrap().extension, extension); + assert_eq!(restored.verify(root), Ok(())); + + let mut tampered = proof.into_eip1186_response(Vec::new()); + tampered.balance = U256::ONE; + assert!(AccountProof::from_eip1186_proof(tampered).verify(root).is_err()); + + let absent = Address::with_last_byte(2); + let proof = as DatabaseProof>::from_tx(provider.tx_ref()) + .account_proof(absent, &[]) + .unwrap(); + let restored = AccountProof::from_eip1186_proof(proof.into_eip1186_response(Vec::new())); + assert!(restored.info.is_none()); + assert_eq!(restored.verify(root), Ok(())); + }); +} diff --git a/crates/trie/db/tests/trie.rs b/crates/trie/db/tests/trie.rs index 126b46e00f6..e8d945b3aa8 100644 --- a/crates/trie/db/tests/trie.rs +++ b/crates/trie/db/tests/trie.rs @@ -9,7 +9,10 @@ use alloy_primitives::{ Address, B256, U256, }; use alloy_rlp::Encodable; -use proptest::{prelude::ProptestConfig, proptest}; +use proptest::{ + prelude::{any, ProptestConfig}, + proptest, +}; use proptest_arbitrary_interop::arb; use reth_db::{tables, test_utils::TempDatabase, DatabaseEnv}; use reth_db_api::{ @@ -69,7 +72,7 @@ fn incremental_vs_full_root(inputs: &[&str], modified: &str) { let mut hashed_storage_cursor = tx.tx_ref().cursor_dup_write::().unwrap(); let data = inputs.iter().map(|x| B256::from_str(x).unwrap()); - let value = U256::from(0); + let value = U256::ZERO; for key in data { hashed_storage_cursor.upsert(hashed_address, &StorageEntry { key, value }).unwrap(); } @@ -83,7 +86,7 @@ fn incremental_vs_full_root(inputs: &[&str], modified: &str) { // 1. Some state transition happens, update the hashed storage to the new value let modified_key = B256::from_str(modified).unwrap(); - let value = U256::from(1); + let value = U256::ONE; if hashed_storage_cursor.seek_by_key_subkey(hashed_address, modified_key).unwrap().is_some() { hashed_storage_cursor.delete_current().unwrap(); @@ -163,13 +166,13 @@ fn test_empty_account() { ( Address::random(), ( - Account { balance: U256::from(0), ..Default::default() }, + Account { balance: U256::ZERO, ..Default::default() }, BTreeMap::from([(B256::with_last_byte(0x4), U256::from(12))]), ), ), ( Address::random(), - (Account { balance: U256::from(0), ..Default::default() }, BTreeMap::default()), + (Account { balance: U256::ZERO, ..Default::default() }, BTreeMap::default()), ), ( Address::random(), @@ -180,10 +183,7 @@ fn test_empty_account() { bytecode_hash: Some(keccak256("test")), ..Default::default() }, - BTreeMap::from([ - (B256::ZERO, U256::from(3)), - (B256::with_last_byte(2), U256::from(1)), - ]), + BTreeMap::from([(B256::ZERO, U256::from(3)), (B256::with_last_byte(2), U256::ONE)]), ), ), ]); @@ -368,7 +368,7 @@ fn test_storage_root() { let address = Address::random(); let storage = - BTreeMap::from([(B256::ZERO, U256::from(3)), (B256::with_last_byte(2), U256::from(1))]); + BTreeMap::from([(B256::ZERO, U256::from(3)), (B256::with_last_byte(2), U256::ONE)]); let code = "el buen fla"; let account = Account { @@ -581,7 +581,7 @@ fn account_and_storage_trie() { hash_builder.add_leaf(Nibbles::unpack(key5), &encode_account(&account5, None)); let key6 = b256!("0xB340000000000000000000000000000000000000000000000000000000000000"); - let account6 = Account { balance: U256::from(1).mul(ether), ..Default::default() }; + let account6 = Account { balance: U256::ONE.mul(ether), ..Default::default() }; hashed_account_cursor.upsert(key6, &account6).unwrap(); hash_builder.add_leaf(Nibbles::unpack(key6), &encode_account(&account6, None)); @@ -831,14 +831,14 @@ fn account_trie_around_extension_node_with_dbtrie() { assert_trie_updates(&account_updates); } -proptest! { - #![proptest_config(ProptestConfig { - cases: 128, ..ProptestConfig::default() - })] - - #[test] - fn fuzz_state_root_incremental(account_changes: [BTreeMap; 5]) { - let factory = create_test_provider_factory(); +#[test] +fn fuzz_state_root_incremental() { + // The cases share one factory: each drops its provider without committing, so the next case + // starts from an empty database. + let factory = create_test_provider_factory(); + proptest!(ProptestConfig::with_cases(128), |( + account_changes in any::<[BTreeMap; 5]>(), + )| { let tx = factory.provider_rw().unwrap(); let mut hashed_account_cursor = tx.tx_ref().cursor_write::().unwrap(); @@ -867,7 +867,7 @@ proptest! { assert_eq!(expected_root, state_root); tx.write_trie_updates(trie_updates).unwrap(); } - } + }); } #[test] @@ -892,7 +892,7 @@ fn extension_node_storage_trie( tx: &DatabaseProviderRW>, N>, hashed_address: B256, ) -> (B256, StorageTrieUpdates) { - let value = U256::from(1); + let value = U256::ONE; let mut hashed_storage = tx.tx_ref().cursor_write::().unwrap(); @@ -921,11 +921,8 @@ fn extension_node_storage_trie( fn extension_node_trie( tx: &DatabaseProviderRW>, N>, ) -> B256 { - let a = Account { - balance: U256::from(1u64), - bytecode_hash: Some(B256::random()), - ..Default::default() - }; + let a = + Account { balance: U256::ONE, bytecode_hash: Some(B256::random()), ..Default::default() }; let val = encode_account(&a, None); let mut hashed_accounts = tx.tx_ref().cursor_write::().unwrap(); diff --git a/crates/trie/db/tests/walker.rs b/crates/trie/db/tests/walker.rs index 6ac85b8002f..2f52c4da2f6 100644 --- a/crates/trie/db/tests/walker.rs +++ b/crates/trie/db/tests/walker.rs @@ -69,7 +69,7 @@ fn test_cursor(mut trie: T, expected: &[Vec]) where T: TrieCursor, { - let mut walker = TrieWalker::<_>::state_trie(&mut trie, Default::default()); + let mut walker = TrieWalker::<_>::state_trie(&mut trie, Default::default()).unwrap(); assert!(walker.key().unwrap().is_empty()); // We're traversing the path in lexicographical order. @@ -125,7 +125,7 @@ fn cursor_rootnode_with_changesets() { let mut trie = trie_factory.storage_trie_cursor(hashed_address).unwrap(); // No changes - let mut cursor = TrieWalker::<_>::state_trie(&mut trie, Default::default()); + let mut cursor = TrieWalker::<_>::state_trie(&mut trie, Default::default()).unwrap(); assert_eq!(cursor.key().copied(), Some(Nibbles::new())); // root assert!(cursor.can_skip_current_node); // due to root_hash cursor.advance().unwrap(); // skips to the end of trie @@ -134,7 +134,7 @@ fn cursor_rootnode_with_changesets() { // We insert something that's not part of the existing trie/prefix. let mut changed = PrefixSetMut::default(); changed.insert(Nibbles::from_nibbles([0xF, 0x1])); - let mut cursor = TrieWalker::<_>::state_trie(&mut trie, changed.freeze()); + let mut cursor = TrieWalker::<_>::state_trie(&mut trie, changed.freeze()).unwrap(); // Root node assert_eq!(cursor.key().copied(), Some(Nibbles::new())); diff --git a/crates/trie/db/tests/witness.rs b/crates/trie/db/tests/witness.rs index b15520dc949..4967df96226 100644 --- a/crates/trie/db/tests/witness.rs +++ b/crates/trie/db/tests/witness.rs @@ -85,7 +85,7 @@ fn includes_empty_node_preimage() { accounts: HashMap::from_iter([(hashed_address, Some(Account::default()))]), storages: HashMap::from_iter([( hashed_address, - HashedStorage::from_iter([(hashed_slot, U256::from(1))]), + HashedStorage::from_iter([(hashed_slot, U256::ONE)]), )]), }) .unwrap(); @@ -104,7 +104,7 @@ fn includes_empty_node_preimage() { accounts: HashMap::from_iter([(hashed_address, Some(Account::default()))]), storages: HashMap::from_iter([( hashed_address, - HashedStorage::from_iter([(hashed_slot, U256::from(1))]), + HashedStorage::from_iter([(hashed_slot, U256::ONE)]), )]), }) .unwrap(); @@ -129,7 +129,7 @@ fn includes_nodes_for_destroyed_storage_nodes() { // Insert account and slot into database provider.insert_account_for_hashing([(address, Some(Account::default()))]).unwrap(); provider - .insert_storage_for_hashing([(address, [StorageEntry { key: slot, value: U256::from(1) }])]) + .insert_storage_for_hashing([(address, [StorageEntry { key: slot, value: U256::ONE }])]) .unwrap(); reth_trie_db::with_adapter!(provider, |A| { @@ -179,10 +179,10 @@ fn correctly_decodes_branch_node_values() { let mut hashed_storage_cursor = provider.tx_ref().cursor_dup_write::().unwrap(); hashed_storage_cursor - .upsert(hashed_address, &StorageEntry { key: hashed_slot1, value: U256::from(1) }) + .upsert(hashed_address, &StorageEntry { key: hashed_slot1, value: U256::ONE }) .unwrap(); hashed_storage_cursor - .upsert(hashed_address, &StorageEntry { key: hashed_slot2, value: U256::from(1) }) + .upsert(hashed_address, &StorageEntry { key: hashed_slot2, value: U256::ONE }) .unwrap(); reth_trie_db::with_adapter!(provider, |A| { @@ -231,7 +231,7 @@ fn skips_storage_root_node_for_account_only_changes_in_canonical_mode() { provider .insert_account_for_hashing([( address, - Some(Account { balance: U256::from(1), ..Default::default() }), + Some(Account { balance: U256::ONE, ..Default::default() }), )]) .unwrap(); provider @@ -319,7 +319,7 @@ fn canonical_mode_handles_mixed_storage_inserts_and_removals() { let mut hashed_storage_cursor = provider.tx_ref().cursor_dup_write::().unwrap(); hashed_storage_cursor - .upsert(hashed_address, &StorageEntry { key: removed_slot, value: U256::from(1) }) + .upsert(hashed_address, &StorageEntry { key: removed_slot, value: U256::ONE }) .unwrap(); hashed_storage_cursor .upsert(hashed_address, &StorageEntry { key: retained_slot, value: U256::from(2) }) diff --git a/crates/trie/parallel/Cargo.toml b/crates/trie/parallel/Cargo.toml index 806bcc6c75b..06672dce2b6 100644 --- a/crates/trie/parallel/Cargo.toml +++ b/crates/trie/parallel/Cargo.toml @@ -16,14 +16,12 @@ workspace = true reth-primitives-traits = { workspace = true, features = ["std"] } reth-execution-errors.workspace = true reth-provider.workspace = true -reth-storage-errors.workspace = true reth-tasks = { workspace = true, features = ["rayon"] } reth-trie.workspace = true # alloy alloy-evm.workspace = true alloy-primitives.workspace = true -alloy-rlp.workspace = true # tracing tracing.workspace = true @@ -45,6 +43,7 @@ rand = { workspace = true, optional = true } [dev-dependencies] # reth +reth-db-common.workspace = true reth-chainspec.workspace = true reth-ethereum-primitives.workspace = true reth-primitives-traits.workspace = true @@ -58,7 +57,7 @@ tokio = { workspace = true, features = ["rt", "rt-multi-thread", "macros"] } [features] default = ["metrics"] -metrics = ["reth-metrics", "dep:metrics", "reth-trie/metrics"] +metrics = ["reth-metrics", "dep:metrics"] trie-debug = [ "dep:rand", ] diff --git a/crates/trie/parallel/src/error.rs b/crates/trie/parallel/src/error.rs index 75ecaa590ee..6ac1ef8d335 100644 --- a/crates/trie/parallel/src/error.rs +++ b/crates/trie/parallel/src/error.rs @@ -8,12 +8,6 @@ pub enum StateRootTaskError { /// Provider error. #[error(transparent)] Provider(#[from] ProviderError), - /// Proof dispatch error. - #[error("proof dispatch failed: {_0}")] - ProofDispatch(ProviderError), - /// A proof worker failed before it could process queued work. - #[error("proof worker failed: {_0}")] - ProofWorker(String), /// Sparse trie error. #[error(transparent)] SparseTrie(#[from] SparseTrieError), @@ -25,17 +19,11 @@ pub enum StateRootTaskError { Canceled, /// Other unspecified error. #[error("{_0}")] - Other(String), + Other(#[source] Box), } impl From for StateRootTaskError { fn from(error: StateProofError) -> Self { - match error { - StateProofError::Database(err) => Self::Provider(ProviderError::Database(err)), - StateProofError::Rlp(err) => Self::Provider(ProviderError::Rlp(err)), - StateProofError::TrieInconsistency(msg) => { - Self::Provider(ProviderError::TrieWitnessError(msg)) - } - } + Self::Provider(error.into()) } } diff --git a/crates/trie/parallel/src/lib.rs b/crates/trie/parallel/src/lib.rs index e8da61d8d3c..2c1944e5426 100644 --- a/crates/trie/parallel/src/lib.rs +++ b/crates/trie/parallel/src/lib.rs @@ -16,10 +16,3 @@ pub mod proof_task; /// State root task interface types shared between the engine tree and the payload builder. pub mod state_root_task; - -/// Async value encoder for V2 proofs. -pub(crate) mod value_encoder; - -/// Proof task manager metrics. -#[cfg(feature = "metrics")] -pub mod proof_task_metrics; diff --git a/crates/trie/parallel/src/proof_task.rs b/crates/trie/parallel/src/proof_task.rs deleted file mode 100644 index 4851eb6264f..00000000000 --- a/crates/trie/parallel/src/proof_task.rs +++ /dev/null @@ -1,1192 +0,0 @@ -//! Parallel proof computation using worker pools with dedicated database transactions. -//! -//! -//! # Architecture -//! -//! - **Worker Pools**: Pre-spawned workers with dedicated database transactions -//! - Storage pool: Handles storage proofs -//! - Account pool: Handles account multiproofs -//! - **Direct Channel Access**: `ProofWorkerHandle` provides type-safe queue methods with direct -//! access to worker channels, eliminating routing overhead -//! - **Automatic Shutdown**: Workers terminate gracefully when all handles are dropped -//! -//! # Message Flow -//! -//! 1. The `SparseTrieCacheTask` prepares a storage or account job and hands it to -//! `ProofWorkerHandle`. The job carries a `ProofResultContext` so the worker knows how to send -//! the result back. -//! 2. A worker receives the job, runs the proof, and sends a `ProofResultMessage` through the -//! provided `ProofResultSender`. -//! 3. The `SparseTrieCacheTask` receives the message and proceeds with its state-root logic. -//! -//! Each job gets its own direct channel so results go straight back to the `SparseTrieCacheTask`. -//! That keeps ordering decisions in one place and lets workers run independently. -//! -//! ```text -//! SparseTrieCacheTask -> ProofWorkerHandle -> Storage/Account Worker -//! ^ | -//! | v -//! ProofResultMessage <-- ProofResultSender -//! ``` - -use crate::{ - error::StateRootTaskError, - value_encoder::{AsyncAccountValueEncoder, ValueEncoderStats}, -}; -use alloy_primitives::{ - map::{B256Map, B256Set}, - B256, U256, -}; -use crossbeam_channel::{unbounded, Receiver as CrossbeamReceiver, Sender as CrossbeamSender}; -use reth_execution_errors::StateProofError; -use reth_primitives_traits::FastInstant as Instant; -use reth_provider::{DatabaseProviderROFactory, ProviderError, ProviderResult}; -use reth_storage_errors::db::DatabaseError; -use reth_tasks::Runtime; -use reth_trie::{ - hashed_cursor::{HashedCursorFactory, HashedStorageCursor, InstrumentedHashedCursor}, - proof_v2, - trie_cursor::{InstrumentedTrieCursor, TrieCursorFactory, TrieStorageCursor}, - DecodedMultiProofV2, HashedPostState, MultiProofTargetsV2, ProofTrieNodeV2, ProofV2Target, -}; -use std::{ - cell::RefCell, - rc::Rc, - sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, - Arc, - }, - time::Duration, -}; -use tracing::{debug, debug_span, error, instrument, trace}; - -#[cfg(feature = "metrics")] -use crate::proof_task_metrics::{ - ProofTaskCursorMetrics, ProofTaskCursorMetricsCache, ProofTaskTrieMetrics, -}; - -/// Type alias for the V2 account proof calculator with instrumented cursors. -type V2AccountProofCalculator<'a, Provider> = proof_v2::ProofCalculator< - InstrumentedTrieCursor<'a, ::AccountTrieCursor<'a>>, - InstrumentedHashedCursor<'a, ::AccountCursor<'a>>, - AsyncAccountValueEncoder< - InstrumentedTrieCursor<'a, ::StorageTrieCursor<'a>>, - InstrumentedHashedCursor<'a, ::StorageCursor<'a>>, - >, ->; - -/// Type alias for the V2 storage proof calculator with instrumented cursors. -type V2StorageProofCalculator<'a, Provider> = proof_v2::StorageProofCalculator< - InstrumentedTrieCursor<'a, ::StorageTrieCursor<'a>>, - InstrumentedHashedCursor<'a, ::StorageCursor<'a>>, ->; - -/// Tracks worker availability counts. -/// -/// It uses cacheline-aligned flags to avoid core-to-core chatter. -#[derive(Debug)] -struct AvailabilitySheet { - /// One flag per worker, each on its own cacheline. Workers store `true` when idle, - /// `false` when busy. Only the owning worker writes; the dispatcher only reads. - flags: Vec>, -} - -impl AvailabilitySheet { - /// Creates a new sheet with `count` workers, all initially marked as busy. - fn new(count: usize) -> Self { - let flags = - (0..count).map(|_| crossbeam_utils::CachePadded::new(AtomicBool::new(false))).collect(); - Self { flags } - } - - /// Returns `true` if more than one worker is currently idle. - /// - /// Note, that this is somewhat racy since a flag that was just saying `idle` and we counted it - /// as such might turn into `busy` right away. - fn has_multiple_idle(&self) -> bool { - let mut idle = 0u32; - for flag in &self.flags { - if flag.load(Ordering::Relaxed) { - idle += 1; - if idle > 1 { - return true; - } - } - } - false - } - - /// Marks the given worker as idle. - fn mark_idle(&self, worker_id: usize) { - self.flags[worker_id].store(true, Ordering::Relaxed); - } - - /// Marks the given worker as busy. - fn mark_busy(&self, worker_id: usize) { - self.flags[worker_id].store(false, Ordering::Relaxed); - } -} - -/// A handle that provides type-safe access to proof worker pools. -/// -/// The handle stores direct senders to both storage and account worker pools, -/// eliminating the need for a routing thread. All handles share reference-counted -/// channels, and workers shut down gracefully when all handles are dropped. -#[derive(Debug, Clone)] -pub struct ProofWorkerHandle { - /// Direct sender to storage worker pool - storage_work_tx: CrossbeamSender, - /// Direct sender to account worker pool - account_work_tx: CrossbeamSender, - /// Per-worker availability flags for storage workers. Used to determine whether to chunk - /// multiproofs. - storage_availability: Arc, - /// Per-worker availability flags for account workers. Used to determine whether to chunk - /// multiproofs. - account_availability: Arc, - /// Total number of storage workers spawned - storage_worker_count: usize, - /// Total number of account workers spawned - account_worker_count: usize, -} - -impl ProofWorkerHandle { - /// Spawns storage and account worker pools with dedicated database transactions. - /// - /// Returns a handle for submitting proof tasks to the worker pools. - /// Workers run until the last handle is dropped. - /// - /// # Parameters - /// - `runtime`: The centralized runtime used to spawn blocking worker tasks - /// - `task_ctx`: Shared context with database view and prefix sets - /// - `halve_workers`: Whether to halve the worker pool size (for small blocks) - #[instrument( - name = "ProofWorkerHandle::new", - level = "debug", - target = "trie::proof_task", - skip_all - )] - pub fn new( - runtime: &Runtime, - task_ctx: ProofTaskCtx, - halve_workers: bool, - proof_result_tx: ProofResultSender, - ) -> Self - where - Factory: DatabaseProviderROFactory - + Clone - + Send - + Sync - + 'static, - { - let (storage_work_tx, storage_work_rx) = unbounded::(); - let (account_work_tx, account_work_rx) = unbounded::(); - - let divisor = if halve_workers { 2 } else { 1 }; - let storage_worker_count = - runtime.proof_storage_worker_pool().current_num_threads() / divisor; - let account_worker_count = - runtime.proof_account_worker_pool().current_num_threads() / divisor; - - let storage_availability = Arc::new(AvailabilitySheet::new(storage_worker_count)); - let account_availability = Arc::new(AvailabilitySheet::new(account_worker_count)); - - debug!( - target: "trie::proof_task", - storage_worker_count, - account_worker_count, - halve_workers, - "Spawning proof worker pools" - ); - - // broadcast blocks until all workers exit (channel close), so run on - // tokio's blocking pool. - let storage_rt = runtime.clone(); - let storage_task_ctx = task_ctx.clone(); - let storage_avail = storage_availability.clone(); - let storage_result_tx = proof_result_tx.clone(); - let storage_parent_span = tracing::Span::current(); - runtime.spawn_blocking_named("storage-workers", move || { - let worker_id = AtomicUsize::new(0); - storage_rt.proof_storage_worker_pool().broadcast(storage_worker_count, |_| { - let worker_id = worker_id.fetch_add(1, Ordering::Relaxed); - let span = debug_span!(target: "trie::proof_task", parent: storage_parent_span.clone(), "storage_worker", ?worker_id); - let _guard = span.enter(); - - #[cfg(feature = "metrics")] - let metrics = ProofTaskTrieMetrics::default(); - #[cfg(feature = "metrics")] - let cursor_metrics = ProofTaskCursorMetrics::new(); - - let worker = StorageProofWorker::new( - storage_task_ctx.clone(), - storage_work_rx.clone(), - worker_id, - storage_avail.clone(), - #[cfg(feature = "metrics")] - metrics, - #[cfg(feature = "metrics")] - cursor_metrics, - ); - if let Err(error) = worker.run() { - error!( - target: "trie::proof_task", - worker_id, - ?error, - "Storage worker failed" - ); - let _ = storage_result_tx.send(ProofResultMessage { - result: Err(StateRootTaskError::ProofWorker(format!( - "storage worker {worker_id}: {error}" - ))), - elapsed: Duration::ZERO, - state: Default::default(), - }); - } - }); - }); - - let account_rt = runtime.clone(); - let account_tx = storage_work_tx.clone(); - let account_avail = account_availability.clone(); - let account_result_tx = proof_result_tx; - let account_parent_span = tracing::Span::current(); - runtime.spawn_blocking_named("account-workers", move || { - let worker_id = AtomicUsize::new(0); - account_rt.proof_account_worker_pool().broadcast(account_worker_count, |_| { - let worker_id = worker_id.fetch_add(1, Ordering::Relaxed); - let span = debug_span!(target: "trie::proof_task", parent: account_parent_span.clone(), "account_worker", ?worker_id); - let _guard = span.enter(); - - #[cfg(feature = "metrics")] - let metrics = ProofTaskTrieMetrics::default(); - #[cfg(feature = "metrics")] - let cursor_metrics = ProofTaskCursorMetrics::new(); - - let worker = AccountProofWorker::new( - task_ctx.clone(), - account_work_rx.clone(), - worker_id, - account_tx.clone(), - account_avail.clone(), - #[cfg(feature = "metrics")] - metrics, - #[cfg(feature = "metrics")] - cursor_metrics, - ); - if let Err(error) = worker.run() { - error!( - target: "trie::proof_task", - worker_id, - ?error, - "Account worker failed" - ); - let _ = account_result_tx.send(ProofResultMessage { - result: Err(StateRootTaskError::ProofWorker(format!( - "account worker {worker_id}: {error}" - ))), - elapsed: Duration::ZERO, - state: Default::default(), - }); - } - }); - }); - - Self { - storage_work_tx, - account_work_tx, - storage_availability, - account_availability, - storage_worker_count, - account_worker_count, - } - } - - /// Returns `true` if more than one storage worker is currently idle. - pub fn has_multiple_idle_storage_workers(&self) -> bool { - self.storage_availability.has_multiple_idle() - } - - /// Returns `true` if more than one account worker is currently idle. - pub fn has_multiple_idle_account_workers(&self) -> bool { - self.account_availability.has_multiple_idle() - } - - /// Returns the number of pending storage tasks in the queue. - pub fn pending_storage_tasks(&self) -> usize { - self.storage_work_tx.len() - } - - /// Returns the number of pending account tasks in the queue. - pub fn pending_account_tasks(&self) -> usize { - self.account_work_tx.len() - } - - /// Returns the total number of storage workers in the pool. - pub const fn total_storage_workers(&self) -> usize { - self.storage_worker_count - } - - /// Returns the total number of account workers in the pool. - pub const fn total_account_workers(&self) -> usize { - self.account_worker_count - } - - /// Dispatch a storage proof computation to storage worker pool - /// - /// The result will be sent via the `proof_result_sender` channel. - pub fn dispatch_storage_proof( - &self, - input: StorageProofInput, - proof_result_sender: CrossbeamSender, - ) -> Result<(), ProviderError> { - let hashed_address = input.hashed_address; - self.storage_work_tx - .send(StorageWorkerJob::StorageProof { input, proof_result_sender }) - .map_err(|err| { - let StorageWorkerJob::StorageProof { proof_result_sender, .. } = err.0; - let _ = proof_result_sender.send(StorageProofResultMessage { - hashed_address, - result: Err( - DatabaseError::Other("storage workers unavailable".to_string()).into() - ), - }); - - ProviderError::other(std::io::Error::other("storage workers unavailable")) - }) - } - - /// Dispatch an account multiproof computation - /// - /// The result will be sent via the `result_sender` channel included in the input. - pub fn dispatch_account_multiproof( - &self, - input: AccountMultiproofInput, - ) -> Result<(), ProviderError> { - self.account_work_tx - .send(AccountWorkerJob::AccountMultiproof { input: Box::new(input) }) - .map_err(|err| { - let error = - ProviderError::other(std::io::Error::other("account workers unavailable")); - - let AccountWorkerJob::AccountMultiproof { input } = err.0; - let ProofResultContext { sender: result_tx, state, start_time: start } = - input.into_proof_result_sender(); - - let _ = result_tx.send(ProofResultMessage { - result: Err(StateRootTaskError::ProofDispatch(error.clone())), - elapsed: start.elapsed(), - state, - }); - - error - }) - } -} - -/// Data used for initializing cursor factories that is shared across all proof worker instances. -#[derive(Clone, Debug)] -pub struct ProofTaskCtx { - /// The factory for creating state providers. - factory: Factory, - /// Maximum random jitter to apply before each proof computation (trie-debug only). - #[cfg(feature = "trie-debug")] - proof_jitter: Option, -} - -impl ProofTaskCtx { - /// Creates a new [`ProofTaskCtx`] with the given factory. - pub const fn new(factory: Factory) -> Self { - Self { - factory, - #[cfg(feature = "trie-debug")] - proof_jitter: None, - } - } - - /// Sets the maximum proof jitter duration (trie-debug only). - #[cfg(feature = "trie-debug")] - pub const fn with_proof_jitter(mut self, jitter: Option) -> Self { - self.proof_jitter = jitter; - self - } -} - -/// This contains all information shared between account proof worker instances. -#[derive(Debug)] -pub struct ProofTaskTx { - /// The provider that implements `TrieCursorFactory` and `HashedCursorFactory`. - provider: Provider, - - /// Identifier for the worker within the worker pool, used only for tracing. - id: usize, -} - -impl ProofTaskTx { - /// Initializes a [`ProofTaskTx`] with the given provider and ID. - const fn new(provider: Provider, id: usize) -> Self { - Self { provider, id } - } -} - -impl ProofTaskTx -where - Provider: TrieCursorFactory + HashedCursorFactory, -{ - fn compute_v2_storage_proof( - &self, - input: StorageProofInput, - calculator: &mut proof_v2::StorageProofCalculator, - ) -> Result - where - TC: TrieStorageCursor, - HC: HashedStorageCursor, - { - let StorageProofInput { hashed_address, mut targets, needs_root } = input; - - let span = debug_span!( - target: "trie::proof_task", - "Storage proof calculation", - n = %targets.len(), - ); - let _span_guard = span.enter(); - - let proof_start = Instant::now(); - - // If targets is empty it means the caller only wants the root node. - let (proof, root) = if targets.is_empty() { - let root_node = calculator.storage_root_node(hashed_address)?; - let root = calculator.compute_root_hash(core::slice::from_ref(&root_node))?; - (vec![root_node], root) - } else { - // A partial proof cannot provide the storage root. Calculate it separately without - // changing the target's parent context, then reset the storage cursors by starting the - // targeted proof. - let root = if needs_root && targets.iter().all(|target| target.parent.is_known()) { - let root_node = calculator.storage_root_node(hashed_address)?; - calculator.compute_root_hash(core::slice::from_ref(&root_node))? - } else { - None - }; - - let proof = calculator.storage_proof(hashed_address, &mut targets)?; - let root = if root.is_some() { root } else { calculator.compute_root_hash(&proof)? }; - (proof, root) - }; - - trace!( - target: "trie::proof_task", - hashed_address = ?hashed_address, - proof_time_us = proof_start.elapsed().as_micros(), - ?root, - worker_id = self.id, - "Completed V2 storage proof calculation" - ); - - Ok(StorageProofResult { proof, root }) - } -} - -/// Channel used by worker threads to deliver proof results back to -/// `SparseTrieCacheTask`. -/// -/// Workers use this sender to deliver proof results or terminal initialization errors directly to -/// `SparseTrieCacheTask`. -pub type ProofResultSender = CrossbeamSender; - -/// Message containing a completed proof result with metadata for direct delivery to -/// `SparseTrieCacheTask`. -/// -/// This type enables workers to send proof results directly to the `SparseTrieCacheTask` event -/// loop. -#[derive(Debug)] -pub struct ProofResultMessage { - /// The proof calculation result - pub result: Result, - /// Time taken for the entire proof calculation (from dispatch to completion) - pub elapsed: Duration, - /// Original state update that triggered this proof - pub state: HashedPostState, -} - -/// Context for sending proof calculation results back to `SparseTrieCacheTask`. -/// -/// This struct contains all context needed to send and track proof calculation results. -/// Workers use this to deliver completed proofs back to the main event loop. -#[derive(Debug, Clone)] -pub struct ProofResultContext { - /// Channel sender for result delivery - pub sender: ProofResultSender, - /// Original state update that triggered this proof - pub state: HashedPostState, - /// Calculation start time for measuring elapsed duration - pub start_time: Instant, -} - -impl ProofResultContext { - /// Creates a new proof result context. - pub const fn new( - sender: ProofResultSender, - state: HashedPostState, - start_time: Instant, - ) -> Self { - Self { sender, state, start_time } - } -} - -/// The results of a storage proof calculation. -#[derive(Debug)] -pub(crate) struct StorageProofResult { - /// The calculated V2 proof nodes - pub proof: Vec, - /// The storage root calculated by the V2 proof - pub root: Option, -} - -impl StorageProofResult { - /// Returns the calculated root of the trie, if one can be calculated from the proof. - const fn root(&self) -> Option { - self.root - } -} - -/// Message containing a completed storage proof result with metadata. -#[derive(Debug)] -pub struct StorageProofResultMessage { - /// The hashed address this storage proof belongs to - #[allow(dead_code)] - pub(crate) hashed_address: B256, - /// The storage proof calculation result - pub(crate) result: Result, -} - -/// Internal message for storage workers. -#[derive(Debug)] -pub(crate) enum StorageWorkerJob { - /// Storage proof computation request - StorageProof { - /// Storage proof input parameters - input: StorageProofInput, - /// Context for sending the proof result. - proof_result_sender: CrossbeamSender, - }, -} - -/// Worker for storage trie operations. -/// -/// Each worker maintains a dedicated database transaction and processes -/// storage proof requests. -struct StorageProofWorker { - /// Shared task context with database factory and prefix sets - task_ctx: ProofTaskCtx, - /// Channel for receiving work - work_rx: CrossbeamReceiver, - /// Unique identifier for this worker (used for tracing) - worker_id: usize, - /// Per-worker availability flags - availability: Arc, - /// Metrics collector for this worker - #[cfg(feature = "metrics")] - metrics: ProofTaskTrieMetrics, - /// Cursor metrics for this worker - #[cfg(feature = "metrics")] - cursor_metrics: ProofTaskCursorMetrics, -} - -impl StorageProofWorker -where - Factory: DatabaseProviderROFactory, -{ - /// Creates a new storage proof worker. - const fn new( - task_ctx: ProofTaskCtx, - work_rx: CrossbeamReceiver, - worker_id: usize, - availability: Arc, - #[cfg(feature = "metrics")] metrics: ProofTaskTrieMetrics, - #[cfg(feature = "metrics")] cursor_metrics: ProofTaskCursorMetrics, - ) -> Self { - Self { - task_ctx, - work_rx, - worker_id, - availability, - #[cfg(feature = "metrics")] - metrics, - #[cfg(feature = "metrics")] - cursor_metrics, - } - } - - /// Runs the worker loop, processing jobs until the channel closes. - /// - /// # Lifecycle - /// - /// 1. Initializes database provider and transaction - /// 2. Advertises availability - /// 3. Processes jobs in a loop: - /// - Receives job from channel - /// - Marks worker as busy - /// - Processes the job - /// - Marks worker as available - /// 4. Shuts down when channel closes - /// - /// # Panic Safety - /// - /// If this function panics, the worker thread terminates but other workers - /// continue operating and the system degrades gracefully. - fn run(mut self) -> ProviderResult<()> { - // Create provider from factory - let provider = self.task_ctx.factory.database_provider_ro()?; - let proof_tx = ProofTaskTx::new(provider, self.worker_id); - - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - "Storage worker started" - ); - - let mut storage_proofs_processed = 0u64; - let mut cursor_metrics_cache = ProofTaskCursorMetricsCache::default(); - let trie_cursor = proof_tx.provider.storage_trie_cursor(B256::ZERO)?; - let hashed_cursor = proof_tx.provider.hashed_storage_cursor(B256::ZERO)?; - let instrumented_trie_cursor = - InstrumentedTrieCursor::new(trie_cursor, &mut cursor_metrics_cache.storage_trie_cursor); - let instrumented_hashed_cursor = InstrumentedHashedCursor::new( - hashed_cursor, - &mut cursor_metrics_cache.storage_hashed_cursor, - ); - let mut v2_calculator = proof_v2::StorageProofCalculator::new_storage( - instrumented_trie_cursor, - instrumented_hashed_cursor, - ); - - // Initially mark this worker as available. - self.availability.mark_idle(self.worker_id); - - let mut total_idle_time = Duration::ZERO; - let mut idle_start = Instant::now(); - - while let Ok(job) = self.work_rx.recv() { - total_idle_time += idle_start.elapsed(); - - // Mark worker as busy. - self.availability.mark_busy(self.worker_id); - - #[cfg(feature = "trie-debug")] - if let Some(max_jitter) = self.task_ctx.proof_jitter { - let jitter = - Duration::from_nanos(rand::random_range(0..=max_jitter.as_nanos() as u64)); - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - jitter_us = jitter.as_micros(), - "Storage worker applying proof jitter" - ); - std::thread::sleep(jitter); - } - - match job { - StorageWorkerJob::StorageProof { input, proof_result_sender } => { - self.process_storage_proof( - &proof_tx, - &mut v2_calculator, - input, - proof_result_sender, - &mut storage_proofs_processed, - ); - } - } - - // Mark worker as available again. - self.availability.mark_idle(self.worker_id); - - idle_start = Instant::now(); - } - - // Drop calculator to release mutable borrows on cursor_metrics_cache. - drop(v2_calculator); - - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - storage_proofs_processed, - total_idle_time_us = total_idle_time.as_micros(), - "Storage worker shutting down" - ); - - #[cfg(feature = "metrics")] - { - self.metrics.record_storage_worker_idle_time(total_idle_time); - self.cursor_metrics.record(&mut cursor_metrics_cache); - } - - Ok(()) - } - - /// Processes a storage proof request. - fn process_storage_proof( - &self, - proof_tx: &ProofTaskTx, - v2_calculator: &mut proof_v2::StorageProofCalculator, - input: StorageProofInput, - proof_result_sender: CrossbeamSender, - storage_proofs_processed: &mut u64, - ) where - Provider: TrieCursorFactory + HashedCursorFactory, - TC: TrieStorageCursor, - HC: HashedStorageCursor, - { - let hashed_address = input.hashed_address; - let proof_start = Instant::now(); - - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - hashed_address = ?hashed_address, - targets_len = input.targets.len(), - "Processing V2 storage proof" - ); - - let result = proof_tx.compute_v2_storage_proof(input, v2_calculator); - - let proof_elapsed = proof_start.elapsed(); - *storage_proofs_processed += 1; - - let root = result.as_ref().ok().and_then(|result| result.root()); - - if proof_result_sender.send(StorageProofResultMessage { hashed_address, result }).is_err() { - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - hashed_address = ?hashed_address, - storage_proofs_processed, - "Proof result receiver dropped, discarding result" - ); - } - - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - hashed_address = ?hashed_address, - proof_time_us = proof_elapsed.as_micros(), - total_processed = storage_proofs_processed, - ?root, - "Storage proof completed" - ); - } -} - -/// Worker for account trie operations. -/// -/// Each worker maintains a dedicated database transaction and processes -/// account multiproof requests. -struct AccountProofWorker { - /// Shared task context with database factory and prefix sets - task_ctx: ProofTaskCtx, - /// Channel for receiving work - work_rx: CrossbeamReceiver, - /// Unique identifier for this worker (used for tracing) - worker_id: usize, - /// Channel for dispatching storage proof work (for pre-dispatched target proofs) - storage_work_tx: CrossbeamSender, - /// Per-worker availability flags - availability: Arc, - /// Metrics collector for this worker - #[cfg(feature = "metrics")] - metrics: ProofTaskTrieMetrics, - /// Cursor metrics for this worker - #[cfg(feature = "metrics")] - cursor_metrics: ProofTaskCursorMetrics, -} - -impl AccountProofWorker -where - Factory: DatabaseProviderROFactory, -{ - /// Creates a new account proof worker. - const fn new( - task_ctx: ProofTaskCtx, - work_rx: CrossbeamReceiver, - worker_id: usize, - storage_work_tx: CrossbeamSender, - availability: Arc, - #[cfg(feature = "metrics")] metrics: ProofTaskTrieMetrics, - #[cfg(feature = "metrics")] cursor_metrics: ProofTaskCursorMetrics, - ) -> Self { - Self { - task_ctx, - work_rx, - worker_id, - storage_work_tx, - availability, - #[cfg(feature = "metrics")] - metrics, - #[cfg(feature = "metrics")] - cursor_metrics, - } - } - - /// Runs the worker loop, processing jobs until the channel closes. - /// - /// # Lifecycle - /// - /// 1. Initializes database provider and transaction - /// 2. Advertises availability - /// 3. Processes jobs in a loop: - /// - Receives job from channel - /// - Marks worker as busy - /// - Processes the job - /// - Marks worker as available - /// 4. Shuts down when channel closes - /// - /// # Panic Safety - /// - /// If this function panics, the worker thread terminates but other workers - /// continue operating and the system degrades gracefully. - fn run(mut self) -> ProviderResult<()> { - let provider = self.task_ctx.factory.database_provider_ro()?; - - trace!( - target: "trie::proof_task", - worker_id=self.worker_id, - "Account worker started" - ); - - let mut account_proofs_processed = 0u64; - let mut cursor_metrics_cache = ProofTaskCursorMetricsCache::default(); - - // Create both account and storage calculators for V2 proofs. - // The storage calculator is wrapped in Rc> for sharing with value encoders. - let account_trie_cursor = provider.account_trie_cursor()?; - let account_hashed_cursor = provider.hashed_account_cursor()?; - - let storage_trie_cursor = provider.storage_trie_cursor(B256::ZERO)?; - let storage_hashed_cursor = provider.hashed_storage_cursor(B256::ZERO)?; - - let instrumented_account_trie_cursor = InstrumentedTrieCursor::new( - account_trie_cursor, - &mut cursor_metrics_cache.account_trie_cursor, - ); - let instrumented_account_hashed_cursor = InstrumentedHashedCursor::new( - account_hashed_cursor, - &mut cursor_metrics_cache.account_hashed_cursor, - ); - let instrumented_storage_trie_cursor = InstrumentedTrieCursor::new( - storage_trie_cursor, - &mut cursor_metrics_cache.storage_trie_cursor, - ); - let instrumented_storage_hashed_cursor = InstrumentedHashedCursor::new( - storage_hashed_cursor, - &mut cursor_metrics_cache.storage_hashed_cursor, - ); - - let mut v2_account_calculator = - proof_v2::ProofCalculator::< - _, - _, - AsyncAccountValueEncoder< - InstrumentedTrieCursor< - '_, - ::StorageTrieCursor<'_>, - >, - InstrumentedHashedCursor< - '_, - ::StorageCursor<'_>, - >, - >, - >::new(instrumented_account_trie_cursor, instrumented_account_hashed_cursor); - let v2_storage_calculator = - Rc::new(RefCell::new(proof_v2::StorageProofCalculator::new_storage( - instrumented_storage_trie_cursor, - instrumented_storage_hashed_cursor, - ))); - - // Count this worker as available only after successful initialization. - self.availability.mark_idle(self.worker_id); - - let mut total_idle_time = Duration::ZERO; - let mut idle_start = Instant::now(); - let mut value_encoder_stats_cache = ValueEncoderStats::default(); - - while let Ok(job) = self.work_rx.recv() { - total_idle_time += idle_start.elapsed(); - - // Mark worker as busy. - self.availability.mark_busy(self.worker_id); - - #[cfg(feature = "trie-debug")] - if let Some(max_jitter) = self.task_ctx.proof_jitter { - let jitter = - Duration::from_nanos(rand::random_range(0..=max_jitter.as_nanos() as u64)); - trace!( - target: "trie::proof_task", - worker_id = self.worker_id, - jitter_us = jitter.as_micros(), - "Account worker applying proof jitter" - ); - std::thread::sleep(jitter); - } - - match job { - AccountWorkerJob::AccountMultiproof { input } => { - let value_encoder_stats = self.process_account_multiproof::( - &mut v2_account_calculator, - v2_storage_calculator.clone(), - *input, - &mut account_proofs_processed, - ); - total_idle_time += value_encoder_stats.storage_wait_time; - value_encoder_stats_cache.extend(&value_encoder_stats); - } - } - - // Mark worker as available again. - self.availability.mark_idle(self.worker_id); - - idle_start = Instant::now(); - } - - // Drop calculators to release mutable borrows on cursor_metrics_cache. - drop(v2_account_calculator); - drop(v2_storage_calculator); - - trace!( - target: "trie::proof_task", - worker_id=self.worker_id, - account_proofs_processed, - total_idle_time_us = total_idle_time.as_micros(), - "Account worker shutting down" - ); - - #[cfg(feature = "metrics")] - { - self.metrics.record_account_worker_idle_time(total_idle_time); - self.cursor_metrics.record(&mut cursor_metrics_cache); - self.metrics.record_value_encoder_stats(&value_encoder_stats_cache); - } - - Ok(()) - } - - fn compute_v2_account_multiproof<'a, Provider>( - &self, - v2_account_calculator: &mut V2AccountProofCalculator<'a, Provider>, - v2_storage_calculator: Rc>>, - targets: MultiProofTargetsV2, - ) -> Result<(DecodedMultiProofV2, ValueEncoderStats), StateRootTaskError> - where - Provider: TrieCursorFactory + HashedCursorFactory + 'a, - { - let MultiProofTargetsV2 { mut account_targets, storage_targets } = targets; - - let span = debug_span!( - target: "trie::proof_task", - "Account multiproof calculation", - account_targets = account_targets.len(), - storage_targets = storage_targets.values().map(|t| t.len()).sum::(), - ); - let _span_guard = span.enter(); - - trace!(target: "trie::proof_task", "Processing V2 account multiproof"); - - let storage_proof_receivers = - dispatch_v2_storage_proofs(&self.storage_work_tx, &account_targets, storage_targets)?; - - let mut value_encoder = - AsyncAccountValueEncoder::new(storage_proof_receivers, v2_storage_calculator); - - let account_proofs = - v2_account_calculator.proof(&mut value_encoder, &mut account_targets)?; - - let (storage_proofs, value_encoder_stats) = value_encoder.finalize()?; - - let proof = DecodedMultiProofV2 { account_proofs, storage_proofs }; - - Ok((proof, value_encoder_stats)) - } - - /// Processes an account multiproof request. - /// - /// Returns stats from the value encoder used during proof computation. - fn process_account_multiproof<'a, Provider>( - &self, - v2_account_calculator: &mut V2AccountProofCalculator<'a, Provider>, - v2_storage_calculator: Rc>>, - input: AccountMultiproofInput, - account_proofs_processed: &mut u64, - ) -> ValueEncoderStats - where - Provider: TrieCursorFactory + HashedCursorFactory + 'a, - { - let proof_start = Instant::now(); - - let AccountMultiproofInput { targets, proof_result_sender } = input; - let (result, value_encoder_stats) = match self.compute_v2_account_multiproof::( - v2_account_calculator, - v2_storage_calculator, - targets, - ) { - Ok((proof, stats)) => (Ok(proof), stats), - Err(e) => (Err(e), ValueEncoderStats::default()), - }; - - let ProofResultContext { sender: result_tx, state, start_time: start } = - proof_result_sender; - - let proof_elapsed = proof_start.elapsed(); - let total_elapsed = start.elapsed(); - *account_proofs_processed += 1; - - // Send result to SparseTrieCacheTask - if result_tx.send(ProofResultMessage { result, elapsed: total_elapsed, state }).is_err() { - trace!( - target: "trie::proof_task", - worker_id=self.worker_id, - account_proofs_processed, - "Account multiproof receiver dropped, discarding result" - ); - } - - trace!( - target: "trie::proof_task", - proof_time_us = proof_elapsed.as_micros(), - total_elapsed_us = total_elapsed.as_micros(), - total_processed = account_proofs_processed, - "Account multiproof completed" - ); - - value_encoder_stats - } -} - -/// Queues V2 storage proofs for all accounts in the targets and returns receivers. -/// -/// This function queues all storage proof tasks to the worker pool but returns immediately -/// with receivers, allowing the account trie walk to proceed in parallel with storage proof -/// computation. This enables interleaved parallelism for better performance. -/// -/// Propagates errors up if queuing fails. Receivers must be consumed by the caller. -fn dispatch_v2_storage_proofs( - storage_work_tx: &CrossbeamSender, - account_targets: &[ProofV2Target], - storage_targets: B256Map>, -) -> Result>, StateRootTaskError> { - if storage_targets.is_empty() { - return Ok(B256Map::default()) - } - - let mut storage_proof_receivers = - B256Map::with_capacity_and_hasher(storage_targets.len(), Default::default()); - - // Collect hashed addresses from account targets that need their storage roots computed. - let account_target_addresses: B256Set = account_targets.iter().map(|t| t.key()).collect(); - - // Sort storage targets by address for optimal dispatch order. - // Since trie walk processes accounts in lexicographical order, dispatching in the same order - // reduces head-of-line blocking when consuming results. - let mut sorted_storage_targets: Vec<_> = storage_targets.into_iter().collect(); - sorted_storage_targets.sort_unstable_by_key(|(addr, _)| *addr); - - // Dispatch all proofs for targeted storage slots - for (hashed_address, targets) in sorted_storage_targets { - // Create channel for receiving StorageProofResultMessage - let (result_tx, result_rx) = crossbeam_channel::unbounded(); - let needs_root = account_target_addresses.contains(&hashed_address); - let input = StorageProofInput::new(hashed_address, targets, needs_root); - - storage_work_tx - .send(StorageWorkerJob::StorageProof { input, proof_result_sender: result_tx }) - .map_err(|_| { - StateRootTaskError::Other(format!( - "Failed to queue storage proof for {hashed_address:?}: storage worker pool unavailable", - )) - })?; - - storage_proof_receivers.insert(hashed_address, result_rx); - } - - Ok(storage_proof_receivers) -} - -/// Input parameters for storage proof computation. -#[derive(Debug)] -pub struct StorageProofInput { - /// The hashed address for which the proof is calculated. - pub hashed_address: B256, - /// The set of proof targets - pub targets: Vec, - /// Whether the account proof needs the storage root for leaf encoding. - pub needs_root: bool, -} - -impl StorageProofInput { - /// Creates a new [`StorageProofInput`] with the given hashed address and target slots. - pub const fn new(hashed_address: B256, targets: Vec, needs_root: bool) -> Self { - Self { hashed_address, targets, needs_root } - } -} - -/// Input parameters for account multiproof computation. -#[derive(Debug)] -pub struct AccountMultiproofInput { - /// The targets for which to compute the multiproof. - pub targets: MultiProofTargetsV2, - /// Context for sending the proof result. - pub proof_result_sender: ProofResultContext, -} - -impl AccountMultiproofInput { - /// Returns the [`ProofResultContext`] for this input, consuming the input. - fn into_proof_result_sender(self) -> ProofResultContext { - self.proof_result_sender - } -} - -/// Internal message for account workers. -#[derive(Debug)] -enum AccountWorkerJob { - /// Account multiproof computation request - AccountMultiproof { - /// Account multiproof input parameters - input: Box, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use reth_chainspec::ChainSpec; - use reth_provider::test_utils::create_test_provider_factory_with_chain_spec; - use std::sync::Arc; - - fn test_ctx(factory: Factory) -> ProofTaskCtx { - ProofTaskCtx::new(factory) - } - - /// Ensures `ProofWorkerHandle::new` spawns workers correctly. - #[test] - fn spawn_proof_workers_creates_handle() { - let chain_spec = Arc::new(ChainSpec::default()); - let anchor_hash = chain_spec.genesis_hash(); - let provider_factory = create_test_provider_factory_with_chain_spec(chain_spec); - let factory = reth_storage_overlay::OverlayStateProviderFactory::new( - provider_factory, - reth_storage_overlay::OverlayManager::< - reth_ethereum_primitives::EthPrimitives, - >::default() - .overlay_builder(anchor_hash), - ); - let ctx = test_ctx(factory); - - let runtime = reth_tasks::Runtime::test(); - let (proof_result_tx, _) = unbounded(); - let proof_handle = ProofWorkerHandle::new(&runtime, ctx, false, proof_result_tx); - - // Verify handle can be cloned - let _cloned_handle = proof_handle.clone(); - - // Workers shut down automatically when handle is dropped - drop(proof_handle); - } -} diff --git a/crates/trie/parallel/src/proof_task/metrics.rs b/crates/trie/parallel/src/proof_task/metrics.rs new file mode 100644 index 00000000000..4efe8345563 --- /dev/null +++ b/crates/trie/parallel/src/proof_task/metrics.rs @@ -0,0 +1,26 @@ +//! Proof worker metrics. + +use reth_metrics::{metrics::Histogram, Metrics}; +use std::time::Duration; + +/// Idle time metrics of the proof workers. +#[derive(Clone, Metrics)] +#[metrics(scope = "trie.proof_task")] +pub struct ProofWorkerIdleMetrics { + /// Histogram for storage worker idle time in seconds (waiting for proof jobs). + storage_worker_idle_time_seconds: Histogram, + /// Histogram for account worker idle time in seconds (waiting for proof jobs). + account_worker_idle_time_seconds: Histogram, +} + +impl ProofWorkerIdleMetrics { + /// Record storage worker idle time. + pub fn record_storage_worker_idle_time(&self, duration: Duration) { + self.storage_worker_idle_time_seconds.record(duration.as_secs_f64()); + } + + /// Record account worker idle time. + pub fn record_account_worker_idle_time(&self, duration: Duration) { + self.account_worker_idle_time_seconds.record(duration.as_secs_f64()); + } +} diff --git a/crates/trie/parallel/src/proof_task/mod.rs b/crates/trie/parallel/src/proof_task/mod.rs new file mode 100644 index 00000000000..e86a25d5a3a --- /dev/null +++ b/crates/trie/parallel/src/proof_task/mod.rs @@ -0,0 +1,784 @@ +//! Parallel proof computation using worker pools with dedicated database transactions. +//! +//! +//! # Architecture +//! +//! - **Worker Pools**: Pre-spawned workers with dedicated database transactions +//! - Storage pool: Handles storage proofs, one account's storage trie per job +//! - Account pool: Handles account proofs, calculating storage roots inline +//! - **Automatic Shutdown**: Workers terminate gracefully when the handle is dropped +//! +//! # Message Flow +//! +//! 1. [`ProofWorkerHandle::dispatch`] splits proof targets into storage and account jobs and queues +//! each job for the matching worker pool. +//! 2. A worker receives a job, runs the proof, and sends one [`ProofWorkerResult`] to the handle's +//! result channel. +//! 3. The owner of the handle receives the results through [`ProofWorkerHandle::results`]. +//! +//! ```text +//! ProofWorkerHandle::dispatch -> Storage/Account Worker +//! | +//! v +//! ProofWorkerHandle::results <-- ProofWorkerResult +//! ``` + +#[cfg(feature = "metrics")] +pub mod metrics; + +use crate::error::StateRootTaskError; +use alloy_primitives::B256; +use crossbeam_channel::{unbounded, Receiver as CrossbeamReceiver, Sender as CrossbeamSender}; +use reth_primitives_traits::FastInstant as Instant; +use reth_provider::{DatabaseProviderROFactory, ProviderError, ProviderResult, StateProofProvider}; +use reth_tasks::{Runtime, WorkerPool}; +use reth_trie::{DecodedMultiProofV2, MultiProofTargetsV2, ProofV2Target}; +use std::{ + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tracing::{debug, debug_span, error, instrument, trace}; + +#[cfg(feature = "metrics")] +use self::metrics::ProofWorkerIdleMetrics; + +/// Tracks worker availability counts. +/// +/// It uses cacheline-aligned flags to avoid core-to-core chatter. +#[derive(Debug)] +struct AvailabilitySheet { + /// One flag per worker, each on its own cacheline. Workers store `true` when idle, + /// `false` when busy. Only the owning worker writes; the dispatcher only reads. + flags: Vec>, +} + +impl AvailabilitySheet { + /// Creates a new sheet with `count` workers, all initially marked as busy. + fn new(count: usize) -> Self { + let flags = + (0..count).map(|_| crossbeam_utils::CachePadded::new(AtomicBool::new(false))).collect(); + Self { flags } + } + + /// Returns `true` if more than one worker is currently idle. + /// + /// Note, that this is somewhat racy since a flag that was just saying `idle` and we counted it + /// as such might turn into `busy` right away. + fn has_multiple_idle(&self) -> bool { + let mut idle = 0u32; + for flag in &self.flags { + if flag.load(Ordering::Relaxed) { + idle += 1; + if idle > 1 { + return true; + } + } + } + false + } + + /// Marks the given worker as idle. + fn mark_idle(&self, worker_id: usize) { + self.flags[worker_id].store(true, Ordering::Relaxed); + } + + /// Marks the given worker as busy. + fn mark_busy(&self, worker_id: usize) { + self.flags[worker_id].store(false, Ordering::Relaxed); + } +} + +/// Owns the storage and account proof worker pools and the channel their results arrive on. +/// +/// Every job queued by [`Self::dispatch`] sends exactly one [`ProofWorkerResult`] to +/// [`Self::results`]. A worker that fails to start sends an error that belongs to no job instead, +/// and jobs left queued when every worker of a pool has failed get no result. Workers shut down +/// once the handle is dropped. +#[derive(Debug)] +pub struct ProofWorkerHandle { + /// Sender shared with the workers, kept so that [`Self::results`] never disconnects while the + /// handle is alive. + _result_tx: CrossbeamSender, + /// Results sent by both worker pools. + result_rx: CrossbeamReceiver, + /// Direct sender to storage worker pool + storage_work_tx: CrossbeamSender, + /// Direct sender to account worker pool + account_work_tx: CrossbeamSender, + /// Per-worker availability flags for storage workers. Used to determine whether to chunk + /// multiproofs. + storage_availability: Arc, + /// Per-worker availability flags for account workers. Used to determine whether to chunk + /// multiproofs. + account_availability: Arc, + /// Total number of storage workers spawned + storage_worker_count: usize, + /// Total number of account workers spawned + account_worker_count: usize, +} + +impl ProofWorkerHandle { + /// Spawns storage and account worker pools with dedicated database transactions. + /// + /// Returns a handle for submitting proof tasks to the worker pools. + /// Workers run until the handle is dropped. + /// + /// # Parameters + /// - `runtime`: The centralized runtime used to spawn blocking worker tasks + /// - `task_ctx`: Shared context with the factory of providers that serve the proofs. Workers + /// request proofs with exact masks, as needed to produce trie updates from them. + /// - `worker_counts`: How many workers of each pool to spawn for this block, with a minimum of + /// one worker of each kind + /// - `metrics`: Idle-time metric handles shared across blocks (with the metrics feature). + #[instrument( + name = "ProofWorkerHandle::new", + level = "debug", + target = "trie::proof_task", + skip_all + )] + pub fn new( + runtime: &Runtime, + task_ctx: ProofTaskCtx, + worker_counts: ProofWorkerCounts, + #[cfg(feature = "metrics")] metrics: ProofWorkerIdleMetrics, + ) -> Self + where + Factory: + DatabaseProviderROFactory + Clone + Send + Sync + 'static, + { + let (result_tx, result_rx) = unbounded(); + let (storage_work_tx, storage_work_rx) = unbounded(); + let (account_work_tx, account_work_rx) = unbounded(); + + let storage_worker_count = + worker_counts.storage.max(1).min(runtime.proof_storage_worker_pool().num_threads()); + let account_worker_count = + worker_counts.account.max(1).min(runtime.proof_account_worker_pool().num_threads()); + + let storage_availability = Arc::new(AvailabilitySheet::new(storage_worker_count)); + let account_availability = Arc::new(AvailabilitySheet::new(account_worker_count)); + + debug!( + target: "trie::proof_task", + storage_worker_count, + account_worker_count, + "Spawning proof worker pools" + ); + + spawn_workers( + runtime, + ProofWorkerKind::Storage, + storage_worker_count, + ProofWorker { + task_ctx: task_ctx.clone(), + work_rx: storage_work_rx, + result_tx: result_tx.clone(), + availability: storage_availability.clone(), + #[cfg(feature = "metrics")] + metrics: metrics.clone(), + }, + ProofWorker::<_, StorageProofJob>::run, + ); + + spawn_workers( + runtime, + ProofWorkerKind::Account, + account_worker_count, + ProofWorker { + task_ctx, + work_rx: account_work_rx, + result_tx: result_tx.clone(), + availability: account_availability.clone(), + #[cfg(feature = "metrics")] + metrics, + }, + ProofWorker::<_, AccountProofJob>::run, + ); + + Self { + _result_tx: result_tx, + result_rx, + storage_work_tx, + account_work_tx, + storage_availability, + account_availability, + storage_worker_count, + account_worker_count, + } + } + + /// Returns `true` if more than one storage worker is currently idle. + pub fn has_multiple_idle_storage_workers(&self) -> bool { + self.storage_availability.has_multiple_idle() + } + + /// Returns `true` if more than one account worker is currently idle. + pub fn has_multiple_idle_account_workers(&self) -> bool { + self.account_availability.has_multiple_idle() + } + + /// Returns the total number of storage workers in the pool. + pub const fn total_storage_workers(&self) -> usize { + self.storage_worker_count + } + + /// Returns the total number of account workers in the pool. + pub const fn total_account_workers(&self) -> usize { + self.account_worker_count + } + + /// Returns the receiver of job results. + /// + /// Results arrive in completion order. The receiver never disconnects while the handle is + /// alive. + pub const fn results(&self) -> &CrossbeamReceiver { + &self.result_rx + } + + /// Queues the proofs for `targets` and returns the number of jobs queued. + /// + /// The storage targets of each account become one storage job, and all account targets + /// together become one account job. Each job sends exactly one [`ProofWorkerResult`] to + /// [`Self::results`]. A storage job without slot targets proves only the account's storage + /// root node. + /// + /// Fails once a worker pool has shut down, which only happens after its workers reported start + /// failures on [`Self::results`]. + pub fn dispatch(&self, targets: MultiProofTargetsV2) -> Result { + let MultiProofTargetsV2 { account_targets, storage_targets } = targets; + let dispatched_at = Instant::now(); + let mut jobs = 0; + + for (hashed_address, targets) in storage_targets { + let job = StorageProofJob { hashed_address, targets, dispatched_at }; + self.storage_work_tx.send(job).map_err(|_| ProofWorkersUnavailable)?; + jobs += 1; + } + + if !account_targets.is_empty() { + let job = AccountProofJob { targets: account_targets, dispatched_at }; + self.account_work_tx.send(job).map_err(|_| ProofWorkersUnavailable)?; + jobs += 1; + } + + Ok(jobs) + } +} + +/// A message received from [`ProofWorkerHandle::results`]: the outcome of one queued job, or a +/// worker's failure to start. +/// +/// A storage job yields the storage proof of a single account and no account proofs. An account +/// job yields account proofs and no storage proofs. A failed job does not stop its worker. +pub type ProofWorkerResult = Result; + +/// Error returned by [`ProofWorkerHandle::dispatch`] when a worker pool has shut down. +#[derive(Debug, Clone, Copy, thiserror::Error)] +#[error("proof workers unavailable")] +pub struct ProofWorkersUnavailable; + +/// How many workers [`ProofWorkerHandle::new`] spawns for a block. +/// +/// Worker instances are recreated for each block on persistent thread pools. Each worker opens a +/// database transaction and trie cursors, so the caller sizes the counts per block. Counts are +/// clamped to at least one worker of each kind and capped by that kind's pool capacity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProofWorkerCounts { + /// Number of storage proof workers to spawn. + pub storage: usize, + /// Number of account proof workers to spawn. + pub account: usize, +} + +impl ProofWorkerCounts { + /// Creates counts for the given number of storage and account workers. + pub const fn new(storage: usize, account: usize) -> Self { + Self { storage, account } + } + + /// Creates counts that use every thread of both pools. + pub fn full(runtime: &Runtime) -> Self { + Self::new( + runtime.proof_storage_worker_pool().num_threads(), + runtime.proof_account_worker_pool().num_threads(), + ) + } +} + +/// Data used for opening proof providers that is shared across all proof worker instances. +#[derive(Clone, Debug)] +pub struct ProofTaskCtx { + /// The factory for creating state providers. + factory: Factory, + /// Maximum random jitter to apply before each proof computation (trie-debug only). + #[cfg(feature = "trie-debug")] + proof_jitter: Option, +} + +impl ProofTaskCtx { + /// Creates a new [`ProofTaskCtx`] with the given factory. + pub const fn new(factory: Factory) -> Self { + Self { + factory, + #[cfg(feature = "trie-debug")] + proof_jitter: None, + } + } + + /// Sets the maximum proof jitter duration (trie-debug only). + #[cfg(feature = "trie-debug")] + pub const fn with_proof_jitter(mut self, jitter: Option) -> Self { + self.proof_jitter = jitter; + self + } +} + +/// The worker pool a proof worker belongs to. +#[derive(Clone, Copy, Debug)] +enum ProofWorkerKind { + Storage, + Account, +} + +impl ProofWorkerKind { + /// Returns the runtime pool the workers of this kind run on. + fn pool(self, runtime: &Runtime) -> &WorkerPool { + match self { + Self::Storage => runtime.proof_storage_worker_pool(), + Self::Account => runtime.proof_account_worker_pool(), + } + } + + /// Returns the name of the pool used in diagnostics. + const fn name(self) -> &'static str { + match self { + Self::Storage => "storage", + Self::Account => "account", + } + } + + /// Returns the name of the blocking task that hosts the pool's workers. + const fn task_name(self) -> &'static str { + match self { + Self::Storage => "storage-workers", + Self::Account => "account-workers", + } + } + + /// Returns the span for the worker with the given ID. + fn span(self, parent: &tracing::Span, worker_id: usize) -> tracing::Span { + match self { + Self::Storage => { + debug_span!(target: "trie::proof_task", parent: parent, "storage_worker", ?worker_id) + } + Self::Account => { + debug_span!(target: "trie::proof_task", parent: parent, "account_worker", ?worker_id) + } + } + } +} + +/// Runs `run` as `worker_count` workers of the given kind on a blocking task. +/// +/// A worker that fails to start sends a [`ProofWorkerError`] to the result channel. +fn spawn_workers( + runtime: &Runtime, + kind: ProofWorkerKind, + worker_count: usize, + worker: ProofWorker, + run: fn(&ProofWorker, usize) -> ProviderResult<()>, +) where + Factory: Send + Sync + 'static, + J: Send + 'static, +{ + let rt = runtime.clone(); + let parent_span = tracing::Span::current(); + runtime.spawn_blocking_named(kind.task_name(), move || { + let next_worker_id = AtomicUsize::new(0); + kind.pool(&rt).broadcast(worker_count, |_| { + let worker_id = next_worker_id.fetch_add(1, Ordering::Relaxed); + let _guard = kind.span(&parent_span, worker_id).entered(); + + if let Err(error) = run(&worker, worker_id) { + error!( + target: "trie::proof_task", + kind = kind.name(), + worker_id, + ?error, + "Proof worker failed" + ); + let error = StateRootTaskError::Other(Box::new(ProofWorkerError { + kind, + worker_id, + source: error, + })); + // The send fails only once the handle is dropped, when nobody waits for results. + let _ = worker.result_tx.send(Err(error)); + } + }); + }); +} + +/// State shared by the workers of one pool, each holding its own database transaction. +struct ProofWorker { + /// Shared task context with database factory and prefix sets + task_ctx: ProofTaskCtx, + /// Channel for receiving jobs + work_rx: CrossbeamReceiver, + /// Sender for job results and worker failures + result_tx: CrossbeamSender, + /// Per-worker availability flags + availability: Arc, + /// Metrics shared by all workers of the handle. + #[cfg(feature = "metrics")] + metrics: ProofWorkerIdleMetrics, +} + +impl ProofWorker { + /// Runs `process` for every job until the handle is dropped, advertising the worker as busy + /// while a job runs. Returns the total time spent waiting for jobs. + fn process_jobs(&self, worker_id: usize, mut process: impl FnMut(J)) -> Duration { + // Count this worker as available only after successful initialization. + self.availability.mark_idle(worker_id); + + let mut total_idle_time = Duration::ZERO; + let mut idle_start = Instant::now(); + + while let Ok(job) = self.work_rx.recv() { + total_idle_time += idle_start.elapsed(); + self.availability.mark_busy(worker_id); + + #[cfg(feature = "trie-debug")] + if let Some(max_jitter) = self.task_ctx.proof_jitter { + let jitter = + Duration::from_nanos(rand::random_range(0..=max_jitter.as_nanos() as u64)); + trace!( + target: "trie::proof_task", + worker_id, + jitter_us = jitter.as_micros(), + "Proof worker applying proof jitter" + ); + std::thread::sleep(jitter); + } + + process(job); + + self.availability.mark_idle(worker_id); + idle_start = Instant::now(); + } + + total_idle_time + } + + /// Sends a job result. The send fails only once the handle is dropped, when the result is no + /// longer needed. + fn send_result(&self, worker_id: usize, result: ProofWorkerResult) { + if self.result_tx.send(result).is_err() { + trace!( + target: "trie::proof_task", + worker_id, + "Proof result receiver dropped, discarding result" + ); + } + } +} + +impl ProofWorker +where + Factory: DatabaseProviderROFactory, +{ + /// Opens a database transaction and serves storage proof jobs until the handle is dropped. + fn run(&self, worker_id: usize) -> ProviderResult<()> { + let provider = self.task_ctx.factory.database_provider_ro()?; + + trace!(target: "trie::proof_task", worker_id, "Storage worker started"); + + let mut storage_proofs_processed = 0u64; + let total_idle_time = self.process_jobs(worker_id, |job| { + let StorageProofJob { hashed_address, targets, dispatched_at } = job; + let _span = debug_span!( + target: "trie::proof_task", + "Storage proof calculation", + ?hashed_address, + targets = targets.len(), + ) + .entered(); + let proof_start = Instant::now(); + + let proof = provider.multiproof_v2( + MultiProofTargetsV2 { + account_targets: Vec::new(), + storage_targets: std::iter::once((hashed_address, targets)).collect(), + }, + true, + ); + + trace!( + target: "trie::proof_task", + worker_id, + ?hashed_address, + proof_time_us = proof_start.elapsed().as_micros(), + dispatch_to_completion_us = dispatched_at.elapsed().as_micros(), + "Storage proof completed" + ); + + let result = + proof.map_err(|source| ProofJobError::Storage { hashed_address, source }.into()); + self.send_result(worker_id, result); + storage_proofs_processed += 1; + }); + + trace!( + target: "trie::proof_task", + worker_id, + storage_proofs_processed, + total_idle_time_us = total_idle_time.as_micros(), + "Storage worker shutting down" + ); + + #[cfg(feature = "metrics")] + self.metrics.record_storage_worker_idle_time(total_idle_time); + + Ok(()) + } +} + +impl ProofWorker +where + Factory: DatabaseProviderROFactory, +{ + /// Opens a database transaction and serves account proof jobs until the handle is dropped. + fn run(&self, worker_id: usize) -> ProviderResult<()> { + let provider = self.task_ctx.factory.database_provider_ro()?; + + trace!(target: "trie::proof_task", worker_id, "Account worker started"); + + let mut account_proofs_processed = 0u64; + let total_idle_time = self.process_jobs(worker_id, |job| { + let AccountProofJob { targets, dispatched_at } = job; + let _span = debug_span!( + target: "trie::proof_task", + "Account proof calculation", + targets = targets.len(), + ) + .entered(); + let proof_start = Instant::now(); + + let proof = provider.multiproof_v2( + MultiProofTargetsV2 { + account_targets: targets, + storage_targets: Default::default(), + }, + true, + ); + + trace!( + target: "trie::proof_task", + worker_id, + proof_time_us = proof_start.elapsed().as_micros(), + dispatch_to_completion_us = dispatched_at.elapsed().as_micros(), + "Account proof completed" + ); + + let result = proof.map_err(|source| ProofJobError::Account { source }.into()); + self.send_result(worker_id, result); + account_proofs_processed += 1; + }); + + trace!( + target: "trie::proof_task", + worker_id, + account_proofs_processed, + total_idle_time_us = total_idle_time.as_micros(), + "Account worker shutting down" + ); + + #[cfg(feature = "metrics")] + self.metrics.record_account_worker_idle_time(total_idle_time); + + Ok(()) + } +} + +/// A queued storage proof of one account. +#[derive(Debug)] +struct StorageProofJob { + hashed_address: B256, + targets: Vec, + dispatched_at: Instant, +} + +/// A queued account proof. +#[derive(Debug)] +struct AccountProofJob { + targets: Vec, + dispatched_at: Instant, +} + +/// Context for a provider failure that prevents a proof worker from starting. +#[derive(Debug, thiserror::Error)] +#[error("{} worker {worker_id}: {source}", kind.name())] +struct ProofWorkerError { + kind: ProofWorkerKind, + worker_id: usize, + #[source] + source: ProviderError, +} + +/// A failed proof job, identified by the proof it calculated. +#[derive(Debug, thiserror::Error)] +enum ProofJobError { + #[error("storage proof of account {hashed_address}: {source}")] + Storage { + hashed_address: B256, + #[source] + source: ProviderError, + }, + #[error("account proof: {source}")] + Account { + #[source] + source: ProviderError, + }, +} + +impl From for StateRootTaskError { + fn from(error: ProofJobError) -> Self { + Self::Other(Box::new(error)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossbeam_channel::RecvTimeoutError; + use reth_chainspec::ChainSpec; + use reth_provider::test_utils::create_test_provider_factory_with_chain_spec; + use std::sync::Arc; + + fn test_ctx(factory: Factory) -> ProofTaskCtx { + ProofTaskCtx::new(factory) + } + + /// Ensures `ProofWorkerHandle::new` spawns workers correctly. + #[test] + fn spawn_proof_workers_creates_handle() { + let chain_spec = Arc::new(ChainSpec::default()); + let provider_factory = create_test_provider_factory_with_chain_spec(chain_spec); + let anchor_hash = reth_db_common::init::init_genesis(&provider_factory).unwrap(); + let factory = reth_storage_overlay::OverlayStateProviderFactory::new( + provider_factory, + reth_storage_overlay::OverlayManager::< + reth_ethereum_primitives::EthPrimitives, + >::default() + .overlay_builder(anchor_hash), + ); + let ctx = test_ctx(factory.clone()); + + let runtime = reth_tasks::Runtime::test(); + let proof_worker_handle = ProofWorkerHandle::new( + &runtime, + ctx, + ProofWorkerCounts::full(&runtime), + #[cfg(feature = "metrics")] + Default::default(), + ); + let proof_result_rx = proof_worker_handle.results().clone(); + + // Workers shut down automatically when the handle is dropped. + drop(proof_worker_handle); + + // Keep the database alive until both pools release their providers. + assert_eq!( + proof_result_rx.recv_timeout(Duration::from_secs(30)).unwrap_err(), + RecvTimeoutError::Disconnected + ); + drop(factory); + } + + /// Ensures a single pool serves quarter, half, and full-capacity worker counts. + #[test] + fn single_pool_serves_fractional_worker_counts() { + let provider_factory = + create_test_provider_factory_with_chain_spec(Arc::new(ChainSpec::default())); + let anchor_hash = reth_db_common::init::init_genesis(&provider_factory).unwrap(); + let factory = reth_storage_overlay::OverlayStateProviderFactory::new( + provider_factory, + reth_storage_overlay::OverlayManager::::default() + .overlay_builder(anchor_hash), + ); + let ctx = test_ctx(factory); + + let runtime = reth_tasks::RuntimeBuilder::new( + reth_tasks::RuntimeConfig::default() + .with_rayon(reth_tasks::RayonConfig { cpu_threads: Some(2), ..Default::default() }), + ) + .build() + .unwrap(); + let pool_threads = runtime.proof_storage_worker_pool().num_threads(); + assert_eq!(pool_threads, 8); + assert_eq!(runtime.proof_account_worker_pool().num_threads(), pool_threads); + + for counts in [ + ProofWorkerCounts::new(0, 0), + ProofWorkerCounts::new(pool_threads / 4, pool_threads / 4), + ProofWorkerCounts::new(pool_threads / 2, pool_threads / 2), + ProofWorkerCounts::full(&runtime), + ProofWorkerCounts::new(pool_threads + 1, pool_threads + 2), + ] { + let proof_worker_handle = ProofWorkerHandle::new( + &runtime, + ctx.clone(), + counts, + #[cfg(feature = "metrics")] + Default::default(), + ); + assert_eq!( + proof_worker_handle.total_storage_workers(), + counts.storage.max(1).min(pool_threads) + ); + assert_eq!( + proof_worker_handle.total_account_workers(), + counts.account.max(1).min(pool_threads) + ); + + let address = B256::ZERO; + let storage_targets = MultiProofTargetsV2 { + storage_targets: std::iter::once((address, vec![ProofV2Target::new(B256::ZERO)])) + .collect(), + ..Default::default() + }; + assert_eq!(proof_worker_handle.dispatch(storage_targets).unwrap(), 1); + let storage_proof = proof_worker_handle + .results() + .recv_timeout(Duration::from_secs(30)) + .unwrap() + .unwrap(); + assert!(storage_proof.account_proofs.is_empty()); + assert!(storage_proof.storage_proofs.contains_key(&address)); + + let account_targets = MultiProofTargetsV2 { + account_targets: vec![ProofV2Target::new(address)], + ..Default::default() + }; + assert_eq!(proof_worker_handle.dispatch(account_targets).unwrap(), 1); + let account_proof = proof_worker_handle + .results() + .recv_timeout(Duration::from_secs(30)) + .unwrap() + .unwrap(); + assert!(account_proof.storage_proofs.is_empty()); + + let results = proof_worker_handle.results().clone(); + drop(proof_worker_handle); + // Every broadcast holds a result sender until all its workers have exited. An + // initialization error is a message here, rather than a successful shutdown. + assert!(matches!( + results.recv_timeout(Duration::from_secs(30)), + Err(crossbeam_channel::RecvTimeoutError::Disconnected) + )); + } + } +} diff --git a/crates/trie/parallel/src/proof_task_metrics.rs b/crates/trie/parallel/src/proof_task_metrics.rs deleted file mode 100644 index 92e35c7938a..00000000000 --- a/crates/trie/parallel/src/proof_task_metrics.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::value_encoder::ValueEncoderStats; -use reth_metrics::{metrics::Histogram, Metrics}; -use reth_trie::{ - hashed_cursor::{HashedCursorMetrics, HashedCursorMetricsCache}, - trie_cursor::{TrieCursorMetrics, TrieCursorMetricsCache}, - TrieType, -}; -use std::time::Duration; - -/// Metrics for the proof task. -#[derive(Clone, Metrics)] -#[metrics(scope = "trie.proof_task")] -pub struct ProofTaskTrieMetrics { - /// Histogram for storage worker idle time in seconds (waiting for proof jobs). - storage_worker_idle_time_seconds: Histogram, - /// Histogram for account worker idle time in seconds (waiting for proof jobs + storage - /// results). - account_worker_idle_time_seconds: Histogram, - /// Histogram for `Dispatched` deferred encoder variant count. - deferred_encoder_dispatched: Histogram, - /// Histogram for `Sync` deferred encoder variant count. - deferred_encoder_sync: Histogram, - /// Histogram for dispatched storage proofs that fell back to sync due to missing root. - deferred_encoder_dispatched_missing_root: Histogram, - /// Histogram for time account workers spent blocked waiting for storage proof results - /// (seconds). This is the portion of account worker idle time attributable to storage - /// worker latency rather than queue wait. - account_worker_storage_wait_seconds: Histogram, -} - -impl ProofTaskTrieMetrics { - /// Record storage worker idle time. - pub fn record_storage_worker_idle_time(&self, duration: Duration) { - self.storage_worker_idle_time_seconds.record(duration.as_secs_f64()); - } - - /// Record account worker idle time. - pub fn record_account_worker_idle_time(&self, duration: Duration) { - self.account_worker_idle_time_seconds.record(duration.as_secs_f64()); - } - - /// Record value encoder stats (deferred encoder variant counts and storage wait time). - pub(crate) fn record_value_encoder_stats(&self, stats: &ValueEncoderStats) { - self.deferred_encoder_dispatched.record(stats.dispatched_count as f64); - self.deferred_encoder_sync.record(stats.sync_count as f64); - self.deferred_encoder_dispatched_missing_root - .record(stats.dispatched_missing_root_count as f64); - self.account_worker_storage_wait_seconds.record(stats.storage_wait_time.as_secs_f64()); - } -} - -/// Cursor metrics for proof task operations. -#[derive(Clone, Debug)] -pub struct ProofTaskCursorMetrics { - /// Metrics for account trie cursor operations. - pub account_trie_cursor: TrieCursorMetrics, - /// Metrics for account hashed cursor operations. - pub account_hashed_cursor: HashedCursorMetrics, - /// Metrics for storage trie cursor operations. - pub storage_trie_cursor: TrieCursorMetrics, - /// Metrics for storage hashed cursor operations. - pub storage_hashed_cursor: HashedCursorMetrics, -} - -impl ProofTaskCursorMetrics { - /// Create a new instance with properly initialized cursor metrics. - pub fn new() -> Self { - Self { - account_trie_cursor: TrieCursorMetrics::new(TrieType::State), - account_hashed_cursor: HashedCursorMetrics::new(TrieType::State), - storage_trie_cursor: TrieCursorMetrics::new(TrieType::Storage), - storage_hashed_cursor: HashedCursorMetrics::new(TrieType::Storage), - } - } - - /// Record the cached metrics from the provided cache and reset the cache counters. - /// - /// This method adds the current counter values from the cache to the Prometheus metrics - /// and then resets all cache counters to zero. - pub fn record(&mut self, cache: &mut ProofTaskCursorMetricsCache) { - self.account_trie_cursor.record(&mut cache.account_trie_cursor); - self.account_hashed_cursor.record(&mut cache.account_hashed_cursor); - self.storage_trie_cursor.record(&mut cache.storage_trie_cursor); - self.storage_hashed_cursor.record(&mut cache.storage_hashed_cursor); - cache.reset(); - } -} - -impl Default for ProofTaskCursorMetrics { - fn default() -> Self { - Self::new() - } -} - -/// Cached cursor metrics for proof task operations. -#[derive(Clone, Debug, Default, Copy)] -pub struct ProofTaskCursorMetricsCache { - /// Cached metrics for account trie cursor operations. - pub account_trie_cursor: TrieCursorMetricsCache, - /// Cached metrics for account hashed cursor operations. - pub account_hashed_cursor: HashedCursorMetricsCache, - /// Cached metrics for storage trie cursor operations. - pub storage_trie_cursor: TrieCursorMetricsCache, - /// Cached metrics for storage hashed cursor operations. - pub storage_hashed_cursor: HashedCursorMetricsCache, -} - -impl ProofTaskCursorMetricsCache { - /// Extend this cache by adding the counts from another cache. - /// - /// This accumulates the counter values from `other` into this cache. - pub fn extend(&mut self, other: &Self) { - self.account_trie_cursor.extend(&other.account_trie_cursor); - self.account_hashed_cursor.extend(&other.account_hashed_cursor); - self.storage_trie_cursor.extend(&other.storage_trie_cursor); - self.storage_hashed_cursor.extend(&other.storage_hashed_cursor); - } - - /// Reset all counters to zero. - pub const fn reset(&mut self) { - self.account_trie_cursor.reset(); - self.account_hashed_cursor.reset(); - self.storage_trie_cursor.reset(); - self.storage_hashed_cursor.reset(); - } - - /// Record the spans for metrics. - pub fn record_spans(&self) { - self.account_trie_cursor.record_span("account_trie_cursor"); - self.account_hashed_cursor.record_span("account_hashed_cursor"); - self.storage_trie_cursor.record_span("storage_trie_cursor"); - self.storage_hashed_cursor.record_span("storage_hashed_cursor"); - } -} diff --git a/crates/trie/parallel/src/state_root_task.rs b/crates/trie/parallel/src/state_root_task.rs index 937130c6dc5..64e84d026f4 100644 --- a/crates/trie/parallel/src/state_root_task.rs +++ b/crates/trie/parallel/src/state_root_task.rs @@ -2,7 +2,7 @@ //! //! The "state-root task" is the background multiproof and sparse-trie pipeline that computes //! state roots incrementally while a block executes. This module holds its boundary types: -//! the input messages, the [`StateRootSink`](crate::state_root_task::StateRootSink) and +//! the input messages and //! stream views that feed it, and the handles //! that await its result. The per-block strategy abstraction that decides whether and how the //! task runs lives in `reth-engine-tree` under `tree::state_root_strategy`. @@ -47,11 +47,8 @@ pub struct StateRootComputeOutcome { /// Handle to a background sparse trie state root computation. /// -/// Used by both the engine (during `newPayload`) and the payload builder (during `FCU`-triggered -/// block building). Provides channels for streaming state updates into the pipeline and receiving -/// the final computed state root. -/// -/// Created by the engine's state-root strategy. +/// Provides best-effort access hints, one authoritative update stream, and the final computation +/// result. Dropping the handle cancels the task if it is still running. #[derive(Debug)] pub struct StateRootHandle { /// The state root that the cached sparse trie is anchored at (parent block's state root). @@ -145,7 +142,7 @@ impl StateRootHandle { .take() .expect("state_root already taken") .recv() - .map_err(|_| StateRootTaskError::Other("sparse trie task dropped".to_string()))? + .map_err(|_| StateRootTaskError::Other("sparse trie task dropped".into()))? } /// Takes the state root receiver for use with custom waiting logic (e.g., timeouts). @@ -182,6 +179,7 @@ impl StateRootHandle { cancel_guard: Some(self.cancel_guard), state_root_rx: self.state_root_rx.take(), hashed_state_rx: self.hashed_state_rx.take(), + on_payload_built: None, } } } @@ -212,6 +210,8 @@ pub struct PayloadStateRootHandle { state_root_rx: Option>>, hashed_state_rx: Option>>, + /// Returns retained state to the engine once the completed block's identity is known. + on_payload_built: Option>, } impl fmt::Debug for PayloadStateRootHandle { @@ -239,7 +239,31 @@ impl PayloadStateRootHandle { >, hashed_state_rx: Option>>, ) -> Self { - Self { name, hook, cancel_guard: None, state_root_rx: Some(state_root_rx), hashed_state_rx } + Self { + name, + hook, + cancel_guard: None, + state_root_rx: Some(state_root_rx), + hashed_state_rx, + on_payload_built: None, + } + } + + /// Attaches a callback receiving the completed payload's block hash and state root. + /// + /// The callback must verify that any retained state matches the returned payload. + pub fn with_on_payload_built( + mut self, + callback: impl FnOnce(B256, B256) + Send + 'static, + ) -> Self { + self.on_payload_built = Some(Box::new(callback)); + self + } + + /// Takes the callback to invoke after a successful build, before returning its payload. + /// Aborted, cancelled, or failed builds must drop the callback without invoking it. + pub fn take_on_payload_built(&mut self) -> Option> { + self.on_payload_built.take() } /// Returns the task name used in logs. @@ -266,7 +290,7 @@ impl PayloadStateRootHandle { .take() .expect("state_root already taken") .recv() - .map_err(|_| StateRootTaskError::Other("state root task dropped".to_string()))? + .map_err(|_| StateRootTaskError::Other("state root task dropped".into()))? } /// Takes the state root receiver for use with custom waiting logic (e.g., timeouts). @@ -294,8 +318,7 @@ impl PayloadStateRootHandle { /// Hashed account and storage keys that a state-root task may want to prefetch. /// /// Hints are not authoritative. They may be missing, duplicated, stale, or ignored by a task. -/// The conversions from and to proof-target types allocate; that cost is accepted because -/// hints are produced on prewarm workers, off the block-execution thread. +/// Conversions to and from proof-target types allocate new collections. #[derive(Debug, Clone, Default)] pub struct StateAccessHint { /// Hashed account keys that may be touched later in the block. @@ -335,7 +358,10 @@ impl From for MultiProofTargetsV2 { } /// Semantic update stream consumed by state-root tasks. -pub trait StateRootSink: Send + Sync + 'static { +/// +/// These callbacks submit messages without reporting task completion or failure. Consumers must +/// obtain the computation result separately, for example through [`StateRootHandle::state_root`]. +trait StateRootSink: Send + Sync + 'static { /// Best-effort access hint from transaction prewarming. fn on_access_hint(&self, _hint: StateAccessHint) {} @@ -363,7 +389,7 @@ impl fmt::Debug for StateRootHintStream { impl StateRootHintStream { /// Creates a new hint stream view. - pub fn new(inner: Arc) -> Self { + fn new(inner: Arc) -> Self { Self { inner } } @@ -375,9 +401,9 @@ impl StateRootHintStream { /// Authoritative update capability of a state-root stream. /// -/// Exactly one of these exists per state-root task, so exactly one producer can end the -/// update stream: either the EVM state hook made with [`Self::into_state_hook`] (finishes on -/// drop) or a pre-hashed update producer such as BAL streaming (calls [`Self::finish`]). The +/// A [`StateRootHandle`] provides this capability exactly once, so its producer can end the +/// update stream either through the EVM state hook made with [`Self::into_state_hook`] (finishes +/// on drop) or through a pre-hashed update stream (calls [`Self::finish`]). The /// type is deliberately not `Clone` and finishing consumes it, so a second end-of-stream /// signal cannot be produced. /// @@ -396,7 +422,7 @@ impl fmt::Debug for StateRootUpdateStream { impl StateRootUpdateStream { /// Creates a new authoritative update stream backed by the given sink. - pub fn new(inner: Arc) -> Self { + fn new(inner: Arc) -> Self { Self { inner } } @@ -419,7 +445,7 @@ impl StateRootUpdateStream { } } -/// EVM hook that forwards state updates into a [`StateRootSink`]. +/// EVM hook that forwards state updates to the state-root task. /// /// Dropping the hook signals the end of the update stream, so the hook is deliberately not /// `Clone`: a second copy would fire a spurious end-of-stream signal. @@ -469,6 +495,10 @@ impl SparseTrieStateRootSink { } } +// Send errors mean the receiving pipeline has stopped, so further updates and the finish +// signal cannot be processed. Task errors are delivered through the separate result channel; +// if the task drops that channel without a result, StateRootHandle::state_root reports an error. +// Cancellation abandons the result. Ignoring send errors here does not report a successful root. impl StateRootSink for SparseTrieStateRootSink { fn on_access_hint(&self, hint: StateAccessHint) { let _ = self.sender.send(StateRootMessage::PrefetchProofs(hint.into())); @@ -549,7 +579,7 @@ mod tests { assert!(account.mark_selfdestructed_locally()); account.info.nonce = 1; account.storage.insert( - U256::from(1), + U256::ONE, EvmStorageSlot::new_changed(U256::ZERO, U256::from(2), TransactionId::ZERO), ); @@ -571,7 +601,7 @@ mod tests { assert!(account.mark_selfdestructed_locally()); account.selfdestruct(); account.storage.insert( - U256::from(1), + U256::ONE, EvmStorageSlot::new_changed(U256::ZERO, U256::from(2), TransactionId::ZERO), ); @@ -592,7 +622,7 @@ mod tests { let address = Address::repeat_byte(0x05); let mut account = Account::default(); // Pre-state: the account exists and holds a balance. - account.info.balance = U256::from(1); + account.info.balance = U256::ONE; account.set_current_info_as_original(); // This block drains it. Not selfdestructed: an ordinary value transfer out. account.mark_touch(); @@ -643,7 +673,7 @@ mod tests { }; let address = Address::repeat_byte(0x07); - let pre = AccountInfo { balance: U256::from(1), ..Default::default() }; + let pre = AccountInfo { balance: U256::ONE, ..Default::default() }; // The EvmState the state hook observes: a funded account drained to empty. let mut account = Account::from(pre.clone()); @@ -767,32 +797,17 @@ mod tests { #[derive(Default)] struct CountingSink { - access_hints: AtomicUsize, state_updates: AtomicUsize, - hashed_state_updates: AtomicUsize, finished_updates: AtomicUsize, } impl StateRootSink for CountingSink { - fn on_access_hint(&self, hint: StateAccessHint) { - assert_eq!(hint.accounts, vec![B256::repeat_byte(0x01)]); - assert_eq!( - hint.storages.get(&B256::repeat_byte(0x02)), - Some(&vec![B256::repeat_byte(0x03)]) - ); - self.access_hints.fetch_add(1, Ordering::Relaxed); - } - fn on_state_update(&self, state: EvmState) { assert!(state.is_empty()); self.state_updates.fetch_add(1, Ordering::Relaxed); } - fn on_hashed_state_update(&self, state: HashedPostState) { - assert!(state.accounts.is_empty()); - assert!(state.storages.is_empty()); - self.hashed_state_updates.fetch_add(1, Ordering::Relaxed); - } + fn on_hashed_state_update(&self, _state: HashedPostState) {} fn on_updates_finished(&self) { self.finished_updates.fetch_add(1, Ordering::Relaxed); @@ -822,31 +837,6 @@ mod tests { assert_eq!(hint.storages[&storage_account], vec![storage_slot]); } - #[test] - fn state_root_capabilities_forward_to_sink() { - let sink = Arc::new(CountingSink::default()); - - let hint_stream = StateRootHintStream::new(sink.clone()); - let mut storages = B256Map::default(); - storages.insert(B256::repeat_byte(0x02), vec![B256::repeat_byte(0x03)]); - hint_stream - .on_access_hint(StateAccessHint { accounts: vec![B256::repeat_byte(0x01)], storages }); - - let updates = StateRootUpdateStream::new(sink.clone()); - updates.on_hashed_state_update(HashedPostState::default()); - updates.finish(); - - { - let mut hook = StateRootUpdateStream::new(sink.clone()).into_state_hook(); - hook.on_state(EvmState::default()); - } - - assert_eq!(sink.access_hints.load(Ordering::Relaxed), 1); - assert_eq!(sink.state_updates.load(Ordering::Relaxed), 1); - assert_eq!(sink.hashed_state_updates.load(Ordering::Relaxed), 1); - assert_eq!(sink.finished_updates.load(Ordering::Relaxed), 2); - } - /// A hook dropped by a panic unwind must not finish the stream: the updates are /// incomplete, and a finish marker would make the task compute a root from them. #[test] @@ -924,16 +914,6 @@ mod tests { assert_eq!(outcome.state_root, B256::repeat_byte(0x42)); } - #[test] - #[should_panic(expected = "state_root already taken")] - fn payload_state_root_receiver_can_only_be_taken_once() { - let (_state_root_tx, state_root_rx) = std::sync::mpsc::channel(); - let mut handle = PayloadStateRootHandle::new("test", None, state_root_rx, None); - - let _state_root_rx = handle.take_state_root_rx(); - let _ = handle.take_state_root_rx(); - } - #[test] fn payload_state_root_receiver_retains_cancellation() { let (updates_tx, _updates_rx) = crossbeam_channel::unbounded(); diff --git a/crates/trie/parallel/src/value_encoder.rs b/crates/trie/parallel/src/value_encoder.rs deleted file mode 100644 index 6f2b4745ca0..00000000000 --- a/crates/trie/parallel/src/value_encoder.rs +++ /dev/null @@ -1,309 +0,0 @@ -use crate::proof_task::StorageProofResultMessage; -use alloy_primitives::{map::B256Map, B256}; -use alloy_rlp::Encodable; -use core::cell::RefCell; -use crossbeam_channel::Receiver as CrossbeamReceiver; -use reth_execution_errors::trie::StateProofError; -use reth_primitives_traits::Account; -use reth_storage_errors::db::DatabaseError; -use reth_trie::{ - hashed_cursor::HashedStorageCursor, - proof_v2::{DeferredValueEncoder, LeafValueEncoder, StorageProofCalculator}, - trie_cursor::TrieStorageCursor, - ProofTrieNodeV2, -}; -use std::{ - rc::Rc, - time::{Duration, Instant}, -}; - -/// Stats collected by [`AsyncAccountValueEncoder`] during proof computation. -/// -/// Tracks time spent waiting for storage proofs and counts of each deferred encoder variant used. -#[derive(Debug, Default, Clone, Copy)] -pub(crate) struct ValueEncoderStats { - /// Accumulated time spent waiting for storage proof results from dispatched workers. - pub(crate) storage_wait_time: Duration, - /// Number of times the `Dispatched` variant was used (proof pre-dispatched to workers). - pub(crate) dispatched_count: u64, - /// Number of times the `Sync` variant was used (synchronous computation). - pub(crate) sync_count: u64, - /// Number of times a dispatched storage proof had no root node and fell back to sync - /// computation. - pub(crate) dispatched_missing_root_count: u64, -} - -impl ValueEncoderStats { - /// Extends this metrics by adding the values from another. - pub(crate) fn extend(&mut self, other: &Self) { - self.storage_wait_time += other.storage_wait_time; - self.dispatched_count += other.dispatched_count; - self.sync_count += other.sync_count; - self.dispatched_missing_root_count += other.dispatched_missing_root_count; - } -} - -/// Returned from [`AsyncAccountValueEncoder`], used to track an async storage root calculation. -pub(crate) enum AsyncAccountDeferredValueEncoder { - /// A storage proof job was dispatched to the worker pool. - Dispatched { - hashed_address: B256, - account: Account, - /// The receiver for the storage proof result. This is an `Option` so that `encode` can - /// take ownership of the receiver, preventing the `Drop` impl from trying to receive on - /// it again. - proof_result_rx: - Option, DatabaseError>>, - /// Shared storage proof results. - storage_proof_results: Rc>>>, - /// Shared stats for tracking wait time and counts. - stats: Rc>, - /// Shared storage proof calculator for synchronous fallback when dispatched proof has no - /// root. - storage_calculator: Rc>>, - }, - /// Synchronous storage root computation. - Sync { - /// Shared storage proof calculator for computing storage roots. - storage_calculator: Rc>>, - hashed_address: B256, - account: Account, - }, -} - -impl Drop for AsyncAccountDeferredValueEncoder { - fn drop(&mut self) { - // If this is a Dispatched encoder that was never consumed via encode(), we need to - // receive the storage proof result to avoid losing it. - let res = if let Self::Dispatched { - hashed_address, - proof_result_rx, - storage_proof_results, - stats, - .. - } = self - { - // Take the receiver out - if it's None (already consumed by encode), nothing to do - let Some(proof_result_rx) = proof_result_rx.take() else { return }; - - (|| -> Result<(), StateProofError> { - let rx = proof_result_rx?; - - let wait_start = Instant::now(); - let msg = rx.recv().map_err(|_| { - StateProofError::Database(DatabaseError::Other(format!( - "Storage proof channel closed for {hashed_address:?}", - ))) - })?; - let result = msg.result?; - - stats.borrow_mut().storage_wait_time += wait_start.elapsed(); - - storage_proof_results.borrow_mut().insert(*hashed_address, result.proof); - Ok(()) - })() - } else { - return; - }; - - if let Err(err) = res { - tracing::error!(target: "trie::parallel", %err, "Failed to collect storage proof in deferred encoder drop"); - } - } -} - -impl DeferredValueEncoder for AsyncAccountDeferredValueEncoder -where - TC: TrieStorageCursor, - HC: HashedStorageCursor, -{ - #[allow(clippy::clone_on_copy)] - fn encode(mut self, buf: &mut Vec) -> Result<(), StateProofError> { - let (account, root) = match &mut self { - Self::Dispatched { - hashed_address, - account, - proof_result_rx, - storage_proof_results, - stats, - storage_calculator, - } => { - let hashed_address = *hashed_address; - let account = account.clone(); - // Take the receiver so Drop won't try to receive on it again - let proof_result_rx = proof_result_rx - .take() - .expect("encode called on already-consumed Dispatched encoder"); - let wait_start = Instant::now(); - let result = proof_result_rx? - .recv() - .map_err(|_| { - StateProofError::Database(DatabaseError::Other(format!( - "Storage proof channel closed for {hashed_address:?}", - ))) - })? - .result?; - stats.borrow_mut().storage_wait_time += wait_start.elapsed(); - - storage_proof_results.borrow_mut().insert(hashed_address, result.proof); - - let root = match result.root { - Some(root) => root, - None => { - // In `compute_v2_account_multiproof` we ensure that all dispatched storage - // proofs computations for which there is also an account proof will return - // a root node, but it could happen randomly that an account which is not in - // the account proof targets, but _is_ in storage proof targets, will need - // to be encoded as part of general trie traversal, so we need to handle - // that case here. - stats.borrow_mut().dispatched_missing_root_count += 1; - - let mut calculator = storage_calculator.borrow_mut(); - let root_node = calculator.storage_root_node(hashed_address)?; - calculator - .compute_root_hash(&[root_node])? - .expect("storage_root_node returns a node at empty path") - } - }; - - (account, root) - } - Self::Sync { storage_calculator, hashed_address, account } => { - let hashed_address = *hashed_address; - let account = account.clone(); - let mut calculator = storage_calculator.borrow_mut(); - let root_node = calculator.storage_root_node(hashed_address)?; - let storage_root = calculator - .compute_root_hash(&[root_node])? - .expect("storage_root_node returns a node at empty path"); - - (account, storage_root) - } - }; - - let account = account.into_trie_account(root); - account.encode(buf); - Ok(()) - } -} - -/// Implements the [`LeafValueEncoder`] trait for accounts. -/// -/// Accepts a set of pre-dispatched storage proof receivers for accounts whose storage roots are -/// being computed asynchronously by worker threads. -/// -/// For accounts without pre-dispatched proofs, uses a shared -/// [`StorageProofCalculator`] to compute storage roots synchronously, reusing cursors across -/// multiple accounts. -pub(crate) struct AsyncAccountValueEncoder { - /// Storage proof jobs which were dispatched ahead of time. - dispatched: B256Map>, - /// Tracks storage proof results received from the storage workers. [`Rc`] + [`RefCell`] is - /// required because [`DeferredValueEncoder`] cannot have a lifetime. - storage_proof_results: Rc>>>, - /// Shared storage proof calculator for synchronous computation. Reuses cursors and internal - /// buffers across multiple storage root calculations. - storage_calculator: Rc>>, - /// Shared stats for tracking wait time and variant counts. - stats: Rc>, -} - -impl AsyncAccountValueEncoder { - /// Initializes a [`Self`] using a storage proof calculator which will be reused to calculate - /// storage roots synchronously. - /// - /// # Parameters - /// - `dispatched`: Pre-dispatched storage proof receivers for target accounts - /// - `storage_calculator`: Shared storage proof calculator for synchronous computation - pub(crate) fn new( - dispatched: B256Map>, - storage_calculator: Rc>>, - ) -> Self { - Self { - dispatched, - storage_proof_results: Default::default(), - storage_calculator, - stats: Default::default(), - } - } - - /// Consume [`Self`] and return all collected storage proofs along with accumulated stats. - /// - /// This method collects any remaining dispatched proofs that weren't consumed during proof - /// calculation and includes their wait time in the returned stats. - /// - /// # Panics - /// - /// This method panics if any deferred encoders produced by [`Self::deferred_encoder`] have not - /// been dropped. - pub(crate) fn finalize( - self, - ) -> Result<(B256Map>, ValueEncoderStats), StateProofError> { - let mut storage_proof_results = Rc::into_inner(self.storage_proof_results) - .expect("no deferred encoders are still allocated") - .into_inner(); - - let mut stats = Rc::into_inner(self.stats) - .expect("no deferred encoders are still allocated") - .into_inner(); - - // Any remaining dispatched proofs need to have their results collected. - // These are proofs that were pre-dispatched but not consumed during proof calculation. - for (hashed_address, rx) in &self.dispatched { - let wait_start = Instant::now(); - let result = rx - .recv() - .map_err(|_| { - StateProofError::Database(DatabaseError::Other(format!( - "Storage proof channel closed for {hashed_address:?}", - ))) - })? - .result?; - stats.storage_wait_time += wait_start.elapsed(); - - storage_proof_results.insert(*hashed_address, result.proof); - } - - Ok((storage_proof_results, stats)) - } -} - -impl LeafValueEncoder for AsyncAccountValueEncoder -where - TC: TrieStorageCursor, - HC: HashedStorageCursor, -{ - type Value = Account; - type DeferredEncoder = AsyncAccountDeferredValueEncoder; - - fn deferred_encoder( - &mut self, - hashed_address: B256, - account: Self::Value, - ) -> Self::DeferredEncoder { - // If the proof job has already been dispatched for this account then it's not necessary to - // dispatch another. - if let Some(rx) = self.dispatched.remove(&hashed_address) { - self.stats.borrow_mut().dispatched_count += 1; - return AsyncAccountDeferredValueEncoder::Dispatched { - hashed_address, - account, - proof_result_rx: Some(Ok(rx)), - storage_proof_results: self.storage_proof_results.clone(), - stats: self.stats.clone(), - storage_calculator: self.storage_calculator.clone(), - } - } - - // If the address didn't have a job dispatched for it then we can assume it has no targets, - // and we only need its root. - - // Compute storage root synchronously using the shared calculator - self.stats.borrow_mut().sync_count += 1; - AsyncAccountDeferredValueEncoder::Sync { - storage_calculator: self.storage_calculator.clone(), - hashed_address, - account, - } - } -} diff --git a/crates/trie/sparse/Cargo.toml b/crates/trie/sparse/Cargo.toml index 0888117dd74..84a3ab6ce81 100644 --- a/crates/trie/sparse/Cargo.toml +++ b/crates/trie/sparse/Cargo.toml @@ -26,7 +26,6 @@ alloy-primitives.workspace = true either = { workspace = true, optional = true } rayon = { workspace = true, optional = true } smallvec = { workspace = true, optional = true } -slotmap = { workspace = true, optional = true } strum = { workspace = true, features = ["derive"] } # metrics @@ -49,13 +48,13 @@ rand.workspace = true rand_08.workspace = true [features] +dst = [] default = ["std", "metrics"] std = [ "dep:either", "dep:rayon", "dep:reth-primitives-traits", "dep:smallvec", - "dep:slotmap", "dep:tracing", "alloy-primitives/std", "alloy-rlp/std", diff --git a/crates/trie/sparse/src/arena/branch_child_idx.rs b/crates/trie/sparse/src/arena/branch_child_idx.rs index 825d42c9e29..15e41a35cdf 100644 --- a/crates/trie/sparse/src/arena/branch_child_idx.rs +++ b/crates/trie/sparse/src/arena/branch_child_idx.rs @@ -3,7 +3,7 @@ use core::{ iter::Enumerate, ops::{Index, IndexMut}, }; -use smallvec::SmallVec; +use smallvec::{Array, SmallVec}; /// A dense index into a branch node's children array. /// @@ -49,15 +49,15 @@ impl BranchChildIdx { } } -impl Index for SmallVec<[T; 4]> { - type Output = T; +impl Index for SmallVec { + type Output = A::Item; fn index(&self, idx: BranchChildIdx) -> &Self::Output { &self.as_slice()[idx.get()] } } -impl IndexMut for SmallVec<[T; 4]> { +impl IndexMut for SmallVec { fn index_mut(&mut self, idx: BranchChildIdx) -> &mut Self::Output { &mut self.as_mut_slice()[idx.get()] } diff --git a/crates/trie/sparse/src/arena/cursor.rs b/crates/trie/sparse/src/arena/cursor.rs index d328e10bc1a..090a84935f5 100644 --- a/crates/trie/sparse/src/arena/cursor.rs +++ b/crates/trie/sparse/src/arena/cursor.rs @@ -1,6 +1,6 @@ use super::{ branch_child_idx::{BranchChildIdx, BranchChildIter}, - ArenaSparseNode, ArenaSparseNodeBranchChild, ArenaSparseNodeState, Index, NodeArena, + ArenaSparseNode, ArenaSparseNodeState, BranchChild, Index, NodeArena, }; use alloc::vec::Vec; use reth_trie_common::Nibbles; @@ -9,15 +9,20 @@ use tracing::{instrument, trace}; const TRACE_TARGET: &str = "trie::arena::cursor"; /// An entry on the cursor's traversal stack, tracking an ancestor node during trie walks. +/// +/// The node's absolute path is not stored: it is the first `path_len` nibbles of the cursor's +/// [`ArenaCursor::path`], which every entry on the stack is a prefix of. #[derive(Debug, Clone)] pub(super) struct ArenaCursorStackEntry { /// The arena index of this node. pub(super) index: Index, - /// The absolute path of this node in the trie (not including its `short_key`). - pub(super) path: Nibbles, + /// The nibble length of the absolute path of this node (not including its `short_key`). + /// A [`Nibbles`] holds at most 64 nibbles, so this always fits in a `u8`. + path_len: u8, /// The dense index at which to resume child iteration in [`ArenaCursor::next`]. - /// Only meaningful when this entry's node is a branch. - pub(super) next_dense_idx: usize, + /// Only meaningful when this entry's node is a branch. Ranges over `0..=16`, where 16 means + /// the last child of a full branch has been visited. + next_dense_idx: u8, } /// Result of [`ArenaCursor::seek`] describing the state at the deepest ancestor node. @@ -61,6 +66,14 @@ pub(super) enum NextResult { #[derive(Debug, Default, Clone)] pub(super) struct ArenaCursor { stack: Vec, + /// A path that every stack entry's path is a prefix of: the entry with `path_len` nibbles + /// has the absolute path `path[..path_len]`. + /// + /// It holds the last seeked target, or the path of the deepest node a [`Self::next`] walk + /// descended to, and may therefore be longer than the head's own path. Popping never + /// shortens it, which is what keeps the ancestor test in [`Self::seek`] to a single + /// [`Nibbles::common_prefix_length`]. + path: Nibbles, /// Whether the head entry should be popped at the start of the next [`Self::next`] call. /// Set when `next` returns [`NextResult::NonBranch`] or [`NextResult::Branch`]. needs_pop: bool, @@ -87,6 +100,23 @@ impl ArenaCursor { self.stack.len() - 1 } + /// Returns the absolute path of the node at the top of the stack. + pub(super) fn head_path(&self) -> Nibbles { + self.entry_path(self.head_entry()) + } + + /// Returns the nibble length of the absolute path of the node at the top of the stack. + /// Equivalent to `head_path().len()` but avoids constructing the path. + pub(super) fn head_path_len(&self) -> usize { + self.head_entry().path_len as usize + } + + /// Returns the last nibble of the head node's absolute path, or `None` if that path is + /// empty. + pub(super) fn head_last_nibble(&self) -> Option { + self.head_path_len().checked_sub(1).map(|i| self.path.get_unchecked(i)) + } + /// Replaces the root entry on the stack with a new one. /// /// The stack must contain exactly the root (depth 0) or be empty (freshly constructed). @@ -100,37 +130,61 @@ impl ArenaCursor { ); self.stack.clear(); self.needs_pop = false; - self.push(arena, idx, path); + self.path = path; + self.push(arena, idx, path.len()); } - /// Pushes an entry onto the stack for the node at the given index and path. - fn push(&mut self, arena: &NodeArena, idx: Index, path: Nibbles) { + /// Pushes an entry onto the stack for the node at the given index, whose absolute path is + /// the first `path_len` nibbles of [`Self::path`]. + fn push(&mut self, arena: &NodeArena, idx: Index, path_len: usize) { debug_assert!(arena.contains_key(idx), "push called with invalid arena index"); - self.stack.push(ArenaCursorStackEntry { index: idx, path, next_dense_idx: 0 }); - trace!(target: TRACE_TARGET, entry = ?self.stack.last().expect("just pushed"), "Pushed stack entry"); + debug_assert!( + path_len <= self.path.len(), + "pushed path length {path_len} exceeds cursor path {:?}", + self.path, + ); + self.stack.push(ArenaCursorStackEntry { + index: idx, + path_len: path_len as u8, + next_dense_idx: 0, + }); + trace!( + target: TRACE_TARGET, + ?idx, + path = ?self.path.slice_unchecked(0, path_len), + "Pushed stack entry", + ); } /// Pops the top entry from the stack and propagates dirty state to the parent. /// Returns the popped entry. /// - /// Uses `arena.get()` for the popped node because callers (e.g. pruning) may remove - /// the node from the arena between the time it was pushed and the time it is popped. + /// The node must still be in the arena. Pop its entry before removing it, since a new + /// insertion can immediately reuse its index. #[instrument(level = "trace", target = TRACE_TARGET, skip(self, arena))] pub(super) fn pop(&mut self, arena: &mut NodeArena) -> ArenaCursorStackEntry { let entry = self.stack.pop().expect("pop can't be called on empty stack"); - trace!(target: TRACE_TARGET, entry = ?entry, "Popped stack entry"); + self.needs_pop = false; + trace!( + target: TRACE_TARGET, + idx = ?entry.index, + path = ?self.entry_path(&entry), + "Popped stack entry", + ); + let node = &arena[entry.index]; #[cfg(debug_assertions)] - if let Some(ArenaSparseNode::Subtrie(s)) = arena.get(entry.index) { + if let ArenaSparseNode::Subtrie(s) = node { + let entry_path = self.path.slice_unchecked(0, entry.path_len as usize); debug_assert_eq!( - s.path, entry.path, + s.path, entry_path, "subtrie cached path {:?} does not match stack entry path {:?}", - s.path, entry.path, + s.path, entry_path, ); } if let Some(parent) = self.stack.last() { - let child_is_dirty = arena.get(entry.index).is_some_and(|node| match node { + let child_is_dirty = match node { ArenaSparseNode::Branch(b) => matches!(b.state, ArenaSparseNodeState::Dirty), ArenaSparseNode::Leaf { state, .. } => matches!(state, ArenaSparseNodeState::Dirty), ArenaSparseNode::Subtrie(s) => { @@ -138,7 +192,7 @@ impl ArenaCursor { matches!(root.state_ref(), Some(ArenaSparseNodeState::Dirty)) } _ => false, - }); + }; if child_is_dirty { *arena[parent.index].state_mut() = ArenaSparseNodeState::Dirty; } @@ -159,29 +213,22 @@ impl ArenaCursor { } /// Returns the logical path of the branch at the top of the stack. - /// The logical path is `entry.path + branch.short_key`. + /// The logical path is `head_path() + branch.short_key`. pub(super) fn head_logical_branch_path(&self, arena: &NodeArena) -> Nibbles { - logical_branch_path(arena, self.stack.last().expect("cursor is non-empty")) + self.logical_branch_path(arena, self.head_entry()) } /// Returns the length of the logical path of the branch at the top of the stack. /// Equivalent to `head_logical_branch_path(arena).len()` but avoids constructing the path. pub(super) fn head_logical_branch_path_len(&self, arena: &NodeArena) -> usize { - logical_branch_path_len(arena, self.stack.last().expect("cursor is non-empty")) - } - - /// Returns the absolute path of a child at `child_nibble` under the branch at the top of - /// the stack. The result is `stack_head.path + branch.short_key + child_nibble`. - pub(super) fn child_path(&self, arena: &NodeArena, child_nibble: u8) -> Nibbles { - let mut path = logical_branch_path(arena, self.stack.last().expect("cursor is non-empty")); - path.push_unchecked(child_nibble); - path + let head = self.head_entry(); + head.path_len as usize + arena[head.index].branch_ref().short_key.len() } /// Returns the logical path of the parent branch entry (second from top of the stack). /// Panics if the stack has fewer than 2 entries. pub(super) fn parent_logical_branch_path(&self, arena: &NodeArena) -> Nibbles { - logical_branch_path(arena, self.parent().expect("cursor must have a parent")) + self.logical_branch_path(arena, self.parent().expect("cursor must have a parent")) } /// Replaces the arena index stored in the head entry with `new_idx`, and updates the @@ -193,9 +240,9 @@ impl ArenaCursor { root: &mut Index, new_idx: Index, ) { + let child_nibble = self.head_last_nibble(); let head = self.stack.last_mut().expect("cursor must have head"); let old_idx = head.index; - let child_nibble = head.path.last(); head.index = new_idx; let Some(parent) = self.parent() else { @@ -210,24 +257,21 @@ impl ArenaCursor { let child_idx = BranchChildIdx::new(parent_branch.state_mask, child_nibble) .expect("child nibble not found in parent state_mask"); - debug_assert!( - matches!( - parent_branch.children[child_idx], - ArenaSparseNodeBranchChild::Revealed(idx) - if idx == old_idx - ), + debug_assert_eq!( + parent_branch.children[child_idx].revealed_index(), + Some(old_idx), "parent child at nibble {child_nibble} does not match old_idx", ); - parent_branch.children[child_idx] = ArenaSparseNodeBranchChild::Revealed(new_idx); + parent_branch.children[child_idx] = BranchChild::revealed(new_idx); } /// Advances the DFS traversal to the next actionable node. /// /// If a previous call returned [`NextResult::NonBranch`] or [`NextResult::Branch`], /// the head entry is automatically popped (with dirty-state propagation) before - /// descending further. This means callers never need to call [`Self::pop`] after - /// `next` — it is handled internally on the subsequent call. + /// descending further, unless the caller already popped it. Callers removing the head + /// node must call [`Self::pop`] before the removal. /// /// Returns [`NextResult::NonBranch`] when the head is a non-branch node the caller /// should process, or [`NextResult::Branch`] when a branch has exhausted its @@ -243,7 +287,6 @@ impl ArenaCursor { ) -> NextResult { if self.needs_pop { self.pop(arena); - self.needs_pop = false; } loop { @@ -251,6 +294,7 @@ impl ArenaCursor { return NextResult::Done; }; let head_idx = head.index; + let head_path_len = head.path_len as usize; let ArenaSparseNode::Branch(branch) = &arena[head_idx] else { self.needs_pop = true; @@ -258,7 +302,8 @@ impl ArenaCursor { }; let state_mask = branch.state_mask; - let start = head.next_dense_idx; + let short_key = branch.short_key; + let start = head.next_dense_idx as usize; let child_depth = self.stack.len(); let mut descended = false; @@ -267,17 +312,20 @@ impl ArenaCursor { continue; } - let child_idx = match &arena[head_idx].branch_ref().children[branch_child_idx] { - ArenaSparseNodeBranchChild::Revealed(child_idx) => *child_idx, - ArenaSparseNodeBranchChild::Blinded(_) => continue, + let Some(child_idx) = + arena[head_idx].branch_ref().children[branch_child_idx].revealed_index() + else { + continue; }; if should_descend(child_depth, &arena[child_idx]) { // Record where to resume iteration when we return to this entry. self.stack.last_mut().expect("head exists").next_dense_idx = - branch_child_idx.get() + 1; - let path = self.child_path(arena, nibble); - self.push(arena, child_idx, path); + branch_child_idx.get() as u8 + 1; + self.path.truncate(head_path_len); + self.path.extend(&short_key); + self.path.push_unchecked(nibble); + self.push(arena, child_idx, self.path.len()); descended = true; break; } @@ -297,25 +345,38 @@ impl ArenaCursor { /// Returns a [`SeekResult`] describing the state at the stack head. #[instrument(level = "trace", target = TRACE_TARGET, skip(self, arena), ret)] pub(super) fn seek(&mut self, arena: &mut NodeArena, full_path: &Nibbles) -> SeekResult { - // Pop stack until head is ancestor of full_path. - while self.stack.len() > 1 && - !full_path.starts_with(&self.stack.last().expect("cursor has root").path) - { + // Every entry's path is a prefix of `self.path`, so an entry is an ancestor of + // `full_path` exactly when its length fits within the common prefix of the previous + // target and this one. + let common = self.path.common_prefix_length(full_path); + while self.stack.len() > 1 && self.head_entry().path_len as usize > common { self.pop(arena); } + if self.head_entry().path_len as usize > common { + // The target is not below the walk's root. Callers only seek within the root's + // prefix, so this cannot happen for a revealed root; leaving `self.path` alone + // keeps the entry paths derivable. + debug_assert!( + false, + "seek target {full_path:?} is not below the walk's root {:?}", + self.head_path(), + ); + return SeekResult::Diverged; + } + self.path = *full_path; + loop { - let head = self.stack.last().expect("cursor has root"); + let head = self.head_entry(); let head_idx = head.index; + let head_path_len = head.path_len as usize; let head_branch = match &arena[head_idx] { ArenaSparseNode::EmptyRoot { .. } => { return SeekResult::EmptyRoot; } ArenaSparseNode::Leaf { key, .. } => { - let mut leaf_full_path = head.path; - leaf_full_path.extend(key); - return if &leaf_full_path == full_path { + return if full_path.slice_unchecked(head_path_len, full_path.len()) == *key { SeekResult::RevealedLeaf } else { SeekResult::Diverged @@ -328,46 +389,46 @@ impl ArenaCursor { _ => unreachable!("unexpected node type on stack: {:?}", arena[head_idx]), }; - let head_branch_logical_path = logical_branch_path(arena, head); + let short_key = &head_branch.short_key; + let logical_len = head_path_len + short_key.len(); // If full_path doesn't extend past the branch's logical path, the target is at or - // within the branch's short_key — treat as diverged. - if full_path.len() <= head_branch_logical_path.len() || - !full_path.starts_with(&head_branch_logical_path) + // within the branch's short_key — treat as diverged. Most branches carry no + // short_key, in which case there is nothing left to compare. + if full_path.len() <= logical_len || + (!short_key.is_empty() && + full_path.slice_unchecked(head_path_len, logical_len) != *short_key) { return SeekResult::Diverged; } - let child_nibble = full_path.get_unchecked(head_branch_logical_path.len()); + let child_nibble = full_path.get_unchecked(logical_len); let Some(branch_child_idx) = BranchChildIdx::new(head_branch.state_mask, child_nibble) else { return SeekResult::NoChild { child_nibble }; }; - match &head_branch.children[branch_child_idx] { - ArenaSparseNodeBranchChild::Blinded(_) => { - return SeekResult::Blinded; - } - ArenaSparseNodeBranchChild::Revealed(child_idx) => { - let child_idx = *child_idx; - let path = self.child_path(arena, child_nibble); - self.push(arena, child_idx, path); - } - } + let Some(child_idx) = head_branch.children[branch_child_idx].revealed_index() else { + return SeekResult::Blinded; + }; + self.push(arena, child_idx, logical_len + 1); } } -} -/// Returns the logical path of a branch stack entry. The logical path is -/// `entry.path + branch.short_key`. -fn logical_branch_path(arena: &NodeArena, entry: &ArenaCursorStackEntry) -> Nibbles { - let mut path = entry.path; - path.extend(&arena[entry.index].branch_ref().short_key); - path -} + /// Returns the entry at the top of the stack, which must be non-empty. + fn head_entry(&self) -> &ArenaCursorStackEntry { + self.stack.last().expect("cursor is non-empty") + } -/// Returns the length of the logical path of a branch stack entry. -/// Equivalent to `logical_branch_path(arena, entry).len()` but avoids constructing the path. -fn logical_branch_path_len(arena: &NodeArena, entry: &ArenaCursorStackEntry) -> usize { - entry.path.len() + arena[entry.index].branch_ref().short_key.len() + /// Returns the absolute path of a stack entry. + fn entry_path(&self, entry: &ArenaCursorStackEntry) -> Nibbles { + self.path.slice_unchecked(0, entry.path_len as usize) + } + + /// Returns the logical path of a branch stack entry: `entry path + branch.short_key`. + fn logical_branch_path(&self, arena: &NodeArena, entry: &ArenaCursorStackEntry) -> Nibbles { + let mut path = self.entry_path(entry); + path.extend(&arena[entry.index].branch_ref().short_key); + path + } } diff --git a/crates/trie/sparse/src/arena/mod.rs b/crates/trie/sparse/src/arena/mod.rs index c0c4091a591..3f336693ee8 100644 --- a/crates/trie/sparse/src/arena/mod.rs +++ b/crates/trie/sparse/src/arena/mod.rs @@ -1,84 +1,86 @@ mod branch_child_idx; mod cursor; +mod node_arena; mod nodes; use branch_child_idx::{BranchChildIdx, BranchChildIter}; use cursor::{ArenaCursor, NextResult, SeekResult}; -use nodes::{ - ArenaSparseNode, ArenaSparseNodeBranch, ArenaSparseNodeBranchChild, ArenaSparseNodeState, -}; +use node_arena::{BranchChild, Index, NodeArena}; +use nodes::{ArenaSparseNode, ArenaSparseNodeBranch, ArenaSparseNodeState}; use crate::{ LeafLookup, LeafLookupError, LeafUpdate, SparseTrie, SparseTrieUpdates, TrieNodeEpoch, }; -use alloc::{borrow::Cow, boxed::Box, collections::VecDeque, vec::Vec}; +use alloc::{borrow::Cow, boxed::Box, vec::Vec}; use alloy_primitives::{keccak256, map::B256Map, B256}; use alloy_trie::TrieMask; use core::{cmp::Reverse, mem}; -use reth_execution_errors::SparseTrieResult; +use reth_execution_errors::{SparseTrieErrorKind, SparseTrieResult}; use reth_trie_common::{ BranchNodeMasks, BranchNodeRef, ExtensionNodeRef, LeafNodeRef, Nibbles, ProofTrieNodeV2, ProofV2TargetParent, RlpNode, TrieNodeV2, EMPTY_ROOT_HASH, }; -use slotmap::{DefaultKey, SlotMap}; use smallvec::SmallVec; use tracing::{instrument, trace}; -/// Alias for the slotmap key type used as node references throughout the arena trie. -type Index = DefaultKey; -/// Alias for the slotmap used as the node arena throughout the arena trie. -type NodeArena = SlotMap; - const TRACE_TARGET: &str = "trie::arena"; /// The maximum path length (in nibbles) for nodes that live in the upper trie. Nodes at this /// depth or deeper belong to lower subtries. const UPPER_TRIE_MAX_DEPTH: usize = 2; -/// Compacts an arena by BFS-copying all reachable nodes into a fresh `SlotMap`, dropping +/// Compacts an arena by BFS-copying all reachable nodes into a fresh arena, dropping /// unreachable (pruned) slots. Parents are stored before children for cache-friendly top-down /// traversal. fn compact_arena(arena: &mut NodeArena, root: &mut Index) { - let mut new_arena = SlotMap::with_capacity(arena.len()); - let mut queue = VecDeque::new(); - - let root_node = arena.remove(*root).expect("root exists"); + let mut new_arena = NodeArena::with_capacity(arena.len()); + let mut marks = new_arena.adopt_blinded(arena); + let root_node = arena.drain_node(*root); let new_root = new_arena.insert(root_node); - queue.push_back(new_root); - while let Some(new_idx) = queue.pop_front() { - // Invariant: any node popped from `queue` has been moved into `new_arena` but - // its Branch.children have not been rewritten yet — every Revealed(idx) here is - // still an old-arena index, and the child is still present in `arena` because - // only this parent's iteration can remove it (each child has exactly one parent). + // Nodes append consecutively without removals, so the destination vector is also the BFS + // queue. Each visited node's children still reference the source arena until rewritten. + let mut next = 0; + while next < new_arena.len() { + let new_idx = Index::new(next); + next += 1; let old_children: SmallVec<[(usize, Index); 16]> = match &new_arena[new_idx] { ArenaSparseNode::Branch(b) => b .children .iter() .enumerate() - .filter_map(|(i, c)| match c { - ArenaSparseNodeBranchChild::Revealed(old_idx) => Some((i, *old_idx)), - _ => None, + .filter_map(|(pos, child)| match child.revealed_index() { + Some(old_child_idx) => Some((pos, old_child_idx)), + // A blinded child needs no rewrite, its slot survived the adoption. + None => { + marks.mark(*child); + None + } }) .collect(), _ => continue, }; for (child_pos, old_child_idx) in old_children { - let child_node = arena.remove(old_child_idx).expect("child exists"); + let child_node = arena.drain_node(old_child_idx); let new_child_idx = new_arena.insert(child_node); + debug_assert_eq!( + new_child_idx.get() + 1, + new_arena.len(), + "compaction must append, the destination doubles as the BFS queue", + ); let ArenaSparseNode::Branch(b) = &mut new_arena[new_idx] else { unreachable!() }; - b.children[child_pos] = ArenaSparseNodeBranchChild::Revealed(new_child_idx); - queue.push_back(new_child_idx); + b.children[child_pos] = BranchChild::revealed(new_child_idx); } } debug_assert!( - arena.is_empty(), + arena.iter().next().is_none(), "compact_arena: {} orphaned nodes remaining after BFS drain", - arena.len(), + arena.iter().count(), ); + new_arena.sweep_blinded(marks); *arena = new_arena; *root = new_root; } @@ -136,7 +138,7 @@ impl ArenaSparseSubtrie { /// [`ArenaSparseNode::EmptyRoot`]. The caller must overwrite `subtrie.arena[subtrie.root]` /// before use. fn new(record_updates: bool) -> Box { - let mut arena = SlotMap::new(); + let mut arena = NodeArena::new(); let root = arena.insert(ArenaSparseNode::EmptyRoot { state: ArenaSparseNodeState::Revealed }); let buffers = ArenaTrieBuffers { @@ -155,7 +157,7 @@ impl ArenaSparseSubtrie { } /// Asserts that `num_leaves` and `num_dirty_leaves` match the actual counts in the arena. - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] fn debug_assert_counters(&self) { let (actual_leaves, actual_dirty) = ArenaParallelSparseTrie::count_leaves_and_dirty(&self.arena, self.root); @@ -188,12 +190,14 @@ impl ArenaSparseSubtrie { } let old_count = self.arena.len(); - // Do not reserve the old arena's size: discarded nodes should release their capacity. - let mut new_arena = SlotMap::new(); + // Reserve an upper bound on the retained nodes and hand the excess back after the copy, + // so that copying never reallocates but discarded nodes still release their capacity. + let mut new_arena = NodeArena::with_capacity(old_count); + let mut marks = new_arena.adopt_blinded(&mut self.arena); let mut new_num_leaves = 0u64; // The subtrie root is retained by the owning upper trie. - let root_node = self.arena.remove(self.root).expect("root exists"); + let root_node = self.arena.drain_node(self.root); let new_root = new_arena.insert(root_node); let mut stack = Vec::new(); if let Some(frame) = @@ -203,12 +207,17 @@ impl ArenaSparseSubtrie { } while let Some(frame) = stack.last_mut() { - let Some((child_pos, nibble, old_child_idx)) = frame.next_revealed_child(&new_arena) - else { + let Some((child_pos, nibble, child)) = frame.next_child(&new_arena) else { stack.pop(); continue; }; + let Some(old_child_idx) = child.revealed_index() else { + // A blinded child needs no rewrite, its slot survived the adoption. + marks.mark(child); + continue; + }; + let parent_new_idx = frame.new_idx; let mut child_path = frame.branch_logical_path; child_path.push(nibble); @@ -232,12 +241,14 @@ impl ArenaSparseSubtrie { cached_rlp_node = ?rlp_node, "pruning node", ); + let new_child = new_arena.insert_blinded(rlp_node); + marks.mark(new_child); let ArenaSparseNode::Branch(b) = &mut new_arena[parent_new_idx] else { unreachable!() }; - b.children[child_pos] = ArenaSparseNodeBranchChild::Blinded(rlp_node); + b.children[child_pos] = new_child; } else { - let child_node = self.arena.remove(old_child_idx).expect("child exists"); + let child_node = self.arena.drain_node(old_child_idx); let new_child_idx = new_arena.insert(child_node); if let Some(frame) = prepare_retained_node( &new_arena, @@ -250,17 +261,19 @@ impl ArenaSparseSubtrie { let ArenaSparseNode::Branch(b) = &mut new_arena[parent_new_idx] else { unreachable!() }; - b.children[child_pos] = ArenaSparseNodeBranchChild::Revealed(new_child_idx); + b.children[child_pos] = BranchChild::revealed(new_child_idx); } } + new_arena.sweep_blinded(marks); + new_arena.shrink_nodes_to_fit(); let pruned = old_count - new_arena.len(); self.num_leaves = new_num_leaves; self.num_dirty_leaves = 0; self.arena = new_arena; self.root = new_root; - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_counters(); return pruned; @@ -272,19 +285,15 @@ impl ArenaSparseSubtrie { } impl CopyFrame { - fn next_revealed_child(&mut self, new_arena: &NodeArena) -> Option<(usize, u8, Index)> { + fn next_child(&mut self, new_arena: &NodeArena) -> Option<(usize, u8, BranchChild)> { let ArenaSparseNode::Branch(b) = &new_arena[self.new_idx] else { unreachable!() }; - loop { - let nibble = self.remaining_child_mask.first_set_bit_index()?; - self.remaining_child_mask.unset_bit(nibble); - let child_idx = BranchChildIdx::new(self.state_mask, nibble) - .expect("remaining_child_mask must be a subset of state_mask"); + let nibble = self.remaining_child_mask.first_set_bit_index()?; + self.remaining_child_mask.unset_bit(nibble); + let child_idx = BranchChildIdx::new(self.state_mask, nibble) + .expect("remaining_child_mask must be a subset of state_mask"); - if let ArenaSparseNodeBranchChild::Revealed(old_idx) = b.children[child_idx] { - return Some((child_idx.get(), nibble, old_idx)) - } - } + Some((child_idx.get(), nibble, b.children[child_idx])) } } @@ -399,7 +408,7 @@ impl ArenaSparseSubtrie { // Drain remaining cursor entries, propagating dirty state. self.buffers.cursor.drain(&mut self.arena); - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_counters(); } @@ -435,7 +444,7 @@ impl ArenaSparseSubtrie { // Drain remaining cursor entries, propagating dirty state. self.buffers.cursor.drain(&mut self.arena); - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_counters(); Ok(()) @@ -454,7 +463,7 @@ impl ArenaSparseSubtrie { new_epoch, ); self.num_dirty_leaves = 0; - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_counters(); } } @@ -542,7 +551,7 @@ impl Default for ArenaParallelismThresholds { /// /// ## Structure /// -/// Uses arena allocation ([`slotmap::SlotMap`]) for node storage with direct index-based child +/// Uses arena allocation (`NodeArena`) for node storage with direct index-based child /// pointers, avoiding the per-node hashing overhead of a `HashMap`-based trie. The trie is split /// into two tiers: /// @@ -561,7 +570,7 @@ impl Default for ArenaParallelismThresholds { /// /// Nodes are lazily revealed from proof data via [`SparseTrie::reveal_nodes`]. Each node is /// placed into the upper arena or delegated to its subtrie based on path depth. Unrevealed -/// children are stored as `ArenaSparseNodeBranchChild::Blinded` with their RLP encoding. +/// children are stored as blinded `BranchChild`s referencing their RLP encoding. /// When multiple subtries have pending reveals, they are processed in parallel using rayon /// (controlled by [`ArenaParallelismThresholds::min_revealed_nodes`]). /// @@ -591,7 +600,7 @@ impl Default for ArenaParallelismThresholds { /// ## Pruning /// /// [`SparseTrie::prune`] replaces nodes older than its epoch cutoff with -/// `ArenaSparseNodeBranchChild::Blinded` entries using their cached RLP, then compacts the +/// blinded `BranchChild` entries using their cached RLP, then compacts the /// arenas. Subtries are pruned in parallel when their leaf count exceeds /// [`ArenaParallelismThresholds::min_leaves_for_prune`]. #[derive(Debug, Clone)] @@ -645,21 +654,14 @@ impl ArenaParallelSparseTrie { let mut root_node = mem::replace(&mut self.upper_arena[child_idx], ArenaSparseNode::TakenSubtrie); - // Migrate any revealed children from the upper arena into the subtrie arena. - if let ArenaSparseNode::Branch(b) = &mut root_node { - for child in &mut b.children { - if let ArenaSparseNodeBranchChild::Revealed(idx) = child { - *idx = - Self::migrate_nodes(&mut subtrie.arena, &mut self.upper_arena, *idx, None); - } - } - } + // Migrate any children from the upper arena into the subtrie arena. + Self::migrate_children(&mut subtrie.arena, &mut self.upper_arena, &mut root_node); subtrie.arena[subtrie.root] = root_node; let (leaves, dirty) = Self::count_leaves_and_dirty(&subtrie.arena, subtrie.root); subtrie.num_leaves = leaves; subtrie.num_dirty_leaves = dirty; - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] subtrie.debug_assert_counters(); self.upper_arena[child_idx] = ArenaSparseNode::Subtrie(subtrie); } @@ -669,18 +671,14 @@ impl ArenaParallelSparseTrie { /// structural changes like root-level splits or subtrie unwraps that can place /// non-subtrie nodes at the boundary depth. fn maybe_wrap_branch_children(&mut self, cursor: &ArenaCursor) { - let head = cursor.head().expect("cursor is non-empty"); - let head_idx = head.index; - let head_path = head.path; + let head_idx = cursor.head().expect("cursor is non-empty").index; + let head_path = cursor.head_path(); let ArenaSparseNode::Branch(b) = &self.upper_arena[head_idx] else { return }; let short_key = b.short_key; let children: SmallVec<[_; 4]> = b .child_iter() - .filter_map(|(nibble, child)| match child { - ArenaSparseNodeBranchChild::Revealed(idx) => Some((nibble, *idx)), - ArenaSparseNodeBranchChild::Blinded(_) => None, - }) + .filter_map(|(nibble, child)| Some((nibble, child.revealed_index()?))) .collect(); for (nibble, child_idx) in children { @@ -703,7 +701,7 @@ impl ArenaParallelSparseTrie { level = "trace", target = TRACE_TARGET, skip_all, - fields(subtrie_path = ?cursor.head().expect("cursor is non-empty").path), + fields(subtrie_path = ?cursor.head_path()), )] fn maybe_unwrap_subtrie(&mut self, cursor: &mut ArenaCursor) { let subtrie_idx = cursor.head().expect("cursor is non-empty").index; @@ -716,12 +714,8 @@ impl ArenaParallelSparseTrie { return; } - let child_nibble = cursor - .head() - .expect("cursor is non-empty") - .path - .last() - .expect("subtrie path must have at least one nibble"); + let child_nibble = + cursor.head_last_nibble().expect("subtrie path must have at least one nibble"); let parent_idx = cursor.parent().expect("cursor has parent").index; // Pop the subtrie entry before mutating, so collapse_branch sees the parent as @@ -761,6 +755,19 @@ impl ArenaParallelSparseTrie { self.recycle_subtrie(node); } + /// Puts a subtrie that was taken for parallel processing back into its upper arena slot. + /// + /// The slot must still hold the [`ArenaSparseNode::TakenSubtrie`] placeholder: indices carry + /// no generation, so a slot that was removed and handed out again would silently be + /// overwritten. + fn restore_taken_subtrie(&mut self, idx: Index, subtrie: Box) { + debug_assert!( + matches!(self.upper_arena[idx], ArenaSparseNode::TakenSubtrie), + "taken subtrie slot {idx:?} was removed or reused before restoration", + ); + self.upper_arena[idx] = ArenaSparseNode::Subtrie(subtrie); + } + /// Handles cascading structural changes on the branch at the cursor head after a child /// has been removed. /// @@ -773,9 +780,7 @@ impl ArenaParallelSparseTrie { /// - **2+ children**: nothing to do. fn maybe_collapse_or_remove_branch(&mut self, cursor: &mut ArenaCursor) { loop { - let branch_entry = cursor.head().expect("cursor is non-empty"); - let branch_idx = branch_entry.index; - let branch_path = branch_entry.path; + let branch_idx = cursor.head().expect("cursor is non-empty").index; // Read-only phase: extract the count and remaining-child info we need before // mutating. All values here are Copy so the borrow is released. @@ -797,7 +802,7 @@ impl ArenaParallelSparseTrie { return; } // Remove the empty branch from its parent. - let branch_nibble = branch_path.last().expect("non-root branch"); + let branch_nibble = cursor.head_last_nibble().expect("non-root branch"); cursor.pop(&mut self.upper_arena); self.upper_arena.remove(branch_idx); let parent_idx = cursor.head().expect("cursor is non-empty").index; @@ -814,11 +819,7 @@ impl ArenaParallelSparseTrie { let (remaining_nibble, remaining_child_idx) = { let b = self.upper_arena[branch_idx].branch_ref(); let nibble = b.state_mask.iter().next().expect("branch has at least one child"); - let child_idx = match &b.children[0] { - ArenaSparseNodeBranchChild::Revealed(idx) => Some(*idx), - ArenaSparseNodeBranchChild::Blinded(_) => None, - }; - (nibble, child_idx) + (nibble, b.children[0].revealed_index()) }; let Some(child_idx) = remaining_child_idx else { @@ -925,15 +926,15 @@ impl ArenaParallelSparseTrie { let mut masks = BranchNodeMasks::default(); for (nibble, child) in branch.child_iter() { - let (hash_bit, tree_bit) = match child { - ArenaSparseNodeBranchChild::Blinded(_) => ( + let (hash_bit, tree_bit) = match child.revealed_index() { + Some(child_idx) => { + let child = &arena[child_idx]; + (child.hash_mask_bit(), child.tree_mask_bit()) + } + None => ( branch.branch_masks.hash_mask.is_bit_set(nibble), branch.branch_masks.tree_mask.is_bit_set(nibble), ), - ArenaSparseNodeBranchChild::Revealed(child_idx) => { - let child = &arena[*child_idx]; - (child.hash_mask_bit(), child.tree_mask_bit()) - } }; masks.set_child_bits(nibble, hash_bit, tree_bit); @@ -994,8 +995,8 @@ impl ArenaParallelSparseTrie { return rlp_node; } } - ArenaSparseNode::Subtrie(_) | ArenaSparseNode::TakenSubtrie => { - unreachable!("Subtrie/TakenSubtrie should not appear inside a subtrie's own arena"); + ArenaSparseNode::Subtrie(_) | ArenaSparseNode::TakenSubtrie | ArenaSparseNode::Free => { + unreachable!("Subtrie/TakenSubtrie/Free cannot be a subtrie's root"); } } @@ -1020,9 +1021,8 @@ impl ArenaParallelSparseTrie { NextResult::Branch => {} }; - let head = cursor.head().expect("cursor is non-empty"); - let head_idx = head.index; - let head_path = head.path; + let head_idx = cursor.head().expect("cursor is non-empty").index; + let head_path = cursor.head_path(); // The branch at `head_idx` is exhausted. All its dirty child branches // have already been encoded and cached. Collect all children's RLP nodes @@ -1038,13 +1038,13 @@ impl ArenaParallelSparseTrie { rlp_node_buf.clear(); let mut node_epoch = TrieNodeEpoch::UNMODIFIED; let state_mask = arena[head_idx].branch_ref().state_mask; - for (child_idx, _nibble) in BranchChildIter::new(state_mask) { - match &arena[head_idx].branch_ref().children[child_idx] { - ArenaSparseNodeBranchChild::Blinded(rlp_node) => { - rlp_node_buf.push(rlp_node.clone()); + for (dense_idx, _nibble) in BranchChildIter::new(state_mask) { + let child = arena[head_idx].branch_ref().children[dense_idx]; + match child.revealed_index() { + None => { + rlp_node_buf.push(arena.blinded(child).clone()); } - ArenaSparseNodeBranchChild::Revealed(child_idx) => { - let child_idx = *child_idx; + Some(child_idx) => { match &arena[child_idx] { ArenaSparseNode::Leaf { .. } => { Self::encode_leaf( @@ -1079,7 +1079,9 @@ impl ArenaParallelSparseTrie { _ => panic!("subtrie root must be a cached Branch or Leaf"), } } - ArenaSparseNode::TakenSubtrie | ArenaSparseNode::EmptyRoot { .. } => { + ArenaSparseNode::TakenSubtrie | + ArenaSparseNode::EmptyRoot { .. } | + ArenaSparseNode::Free => { unreachable!("Unexpected child {:?}", arena[child_idx]); } } @@ -1162,29 +1164,27 @@ impl ArenaParallelSparseTrie { ) -> Option<&'a Vec> { loop { match &arena[current] { - ArenaSparseNode::EmptyRoot { .. } | ArenaSparseNode::TakenSubtrie => return None, + ArenaSparseNode::EmptyRoot { .. } | + ArenaSparseNode::TakenSubtrie | + ArenaSparseNode::Free => return None, ArenaSparseNode::Leaf { key, value, .. } => { - let remaining = full_path.slice(path_offset..); + let remaining = full_path.slice_unchecked(path_offset, full_path.len()); return (remaining == *key).then_some(value); } ArenaSparseNode::Branch(b) => { let short_key = &b.short_key; let logical_end = path_offset + short_key.len(); if full_path.len() <= logical_end || - full_path.slice(path_offset..logical_end) != *short_key + (!short_key.is_empty() && + full_path.slice_unchecked(path_offset, logical_end) != *short_key) { return None; } let child_nibble = full_path.get_unchecked(logical_end); let child_idx = BranchChildIdx::new(b.state_mask, child_nibble)?; - match &b.children[child_idx] { - ArenaSparseNodeBranchChild::Blinded(_) => return None, - ArenaSparseNodeBranchChild::Revealed(child_idx) => { - current = *child_idx; - path_offset = logical_end + 1; - } - } + current = b.children[child_idx].revealed_index()?; + path_offset = logical_end + 1; } ArenaSparseNode::Subtrie(subtrie) => { return Self::get_leaf_value_in_arena( @@ -1210,7 +1210,9 @@ impl ArenaParallelSparseTrie { ) -> Result { loop { match &arena[current] { - ArenaSparseNode::EmptyRoot { .. } | ArenaSparseNode::TakenSubtrie => { + ArenaSparseNode::EmptyRoot { .. } | + ArenaSparseNode::TakenSubtrie | + ArenaSparseNode::Free => { return Ok(LeafLookup::NonExistent); } ArenaSparseNode::Leaf { key, value, .. } => { @@ -1246,8 +1248,10 @@ impl ArenaParallelSparseTrie { return Ok(LeafLookup::NonExistent); }; - match &b.children[child_idx] { - ArenaSparseNodeBranchChild::Blinded(rlp_node) => { + let child = b.children[child_idx]; + match child.revealed_index() { + None => { + let rlp_node = arena.blinded(child); let hash = rlp_node .as_hash() .unwrap_or_else(|| keccak256(rlp_node.as_slice())); @@ -1255,8 +1259,8 @@ impl ArenaParallelSparseTrie { blinded_path.push_unchecked(child_nibble); return Err(LeafLookupError::BlindedNode { path: blinded_path, hash }); } - ArenaSparseNodeBranchChild::Revealed(child_idx) => { - current = *child_idx; + Some(child_idx) => { + current = child_idx; path_offset = logical_end + 1; } } @@ -1326,14 +1330,13 @@ impl ArenaParallelSparseTrie { new_leaf_path: Nibbles, value: &[u8], ) -> bool { - let old_child_entry = cursor.head().expect("cursor must have head"); - let old_child_idx = old_child_entry.index; + let old_child_idx = cursor.head().expect("cursor must have head").index; let old_child_short_key = arena[old_child_idx].short_key().expect("top of stack is a leaf"); let diverge_len = new_leaf_path.common_prefix_length(old_child_short_key); trace!( target: TRACE_TARGET, - path = ?old_child_entry.path, + path = ?cursor.head_path(), ?new_leaf_path, ?old_child_short_key, diverge_len, @@ -1378,10 +1381,10 @@ impl ArenaParallelSparseTrie { (new_leaf_nibble, new_leaf_idx, old_child_nibble, old_child_idx) }; - let state_mask = TrieMask::from(1u16 << first_nibble | 1u16 << second_nibble); - let mut children = SmallVec::with_capacity(2); - children.push(ArenaSparseNodeBranchChild::Revealed(first_child)); - children.push(ArenaSparseNodeBranchChild::Revealed(second_child)); + let state_mask = TrieMask::from_nibble(first_nibble) | TrieMask::from_nibble(second_nibble); + let mut children = SmallVec::new(); + children.push(BranchChild::revealed(first_child)); + children.push(BranchChild::revealed(second_child)); let new_branch_idx = arena.insert(ArenaSparseNode::Branch(ArenaSparseNodeBranch { state: ArenaSparseNodeState::Dirty, @@ -1427,10 +1430,9 @@ impl ArenaParallelSparseTrie { } SeekResult::EmptyRoot => { let head_idx = head.index; - let head_path = head.path; arena[head_idx] = ArenaSparseNode::Leaf { state: ArenaSparseNodeState::Dirty, - key: full_path.slice(head_path.len()..), + key: full_path.slice(cursor.head_path_len()..), value: value.to_vec(), }; ( @@ -1460,8 +1462,7 @@ impl ArenaParallelSparseTrie { ) } SeekResult::Diverged => { - let head_path = head.path; - let full_path_from_head = full_path.slice(head_path.len()..); + let full_path_from_head = full_path.slice(cursor.head_path_len()..); let split_dirtied_existing = Self::split_and_insert_leaf(arena, cursor, root, full_path_from_head, value); @@ -1482,8 +1483,7 @@ impl ArenaParallelSparseTrie { SeekResult::NoChild { child_nibble } => { let head_idx = head.index; - let head_branch_logical_path = cursor.head_logical_branch_path(arena); - let leaf_key = full_path.slice(head_branch_logical_path.len() + 1..); + let leaf_key = full_path.slice(cursor.head_logical_branch_path_len(arena) + 1..); let new_leaf = arena.insert(ArenaSparseNode::Leaf { state: ArenaSparseNodeState::Dirty, key: leaf_key, @@ -1491,7 +1491,7 @@ impl ArenaParallelSparseTrie { }); let branch = arena[head_idx].branch_mut(); - branch.set_child(child_nibble, ArenaSparseNodeBranchChild::Revealed(new_leaf)); + branch.set_child(child_nibble, BranchChild::revealed(new_leaf)); // Re-seek to position the cursor on the newly inserted leaf. cursor.seek(arena, full_path); @@ -1540,9 +1540,8 @@ impl ArenaParallelSparseTrie { } SeekResult::RevealedLeaf => { // RevealedLeaf guarantees the leaf's full path matches the target exactly. - let head = cursor.head().expect("cursor is non-empty"); - let head_idx = head.index; - let head_path = head.path; + let head_idx = cursor.head().expect("cursor is non-empty").index; + let head_path = cursor.head_path(); trace!( target: TRACE_TARGET, @@ -1659,9 +1658,7 @@ impl ArenaParallelSparseTrie { // would fail the `num_removals != num_changed` check, skip the proof // request for the blinded sibling, and later panic in // `maybe_collapse_or_remove_branch` when the subtrie empties inline. - let num_changed = - subtrie_updates.iter().filter(|(_, _, u)| matches!(u, LeafUpdate::Changed(_))).count() - as u64; + let num_changed = subtrie_updates.iter().filter(|(_, _, u)| u.is_changed()).count() as u64; if num_removals == 0 || num_removals != num_changed { return None; @@ -1678,7 +1675,7 @@ impl ArenaParallelSparseTrie { } let child_nibble = - subtrie_entry.path.last().expect("subtrie path must have at least one nibble"); + cursor.head_last_nibble().expect("subtrie path must have at least one nibble"); let parent_entry = cursor.parent()?; let parent_branch = arena[parent_entry.index].branch_ref(); @@ -1722,8 +1719,8 @@ impl ArenaParallelSparseTrie { root: &mut Index, updates: &mut Option, ) -> bool { - let branch_entry = cursor.head().expect("cursor is non-empty"); - let branch_idx = branch_entry.index; + let branch_idx = cursor.head().expect("cursor is non-empty").index; + let branch_path = cursor.head_path(); let branch = arena[branch_idx].branch_ref(); let remaining_nibble = branch.state_mask.iter().next().expect("branch has at least one child"); @@ -1741,7 +1738,7 @@ impl ArenaParallelSparseTrie { trace!( target: TRACE_TARGET, - path = ?branch_entry.path, + path = ?branch_path, short_key = ?branch_short_key, branch_masks = ?branch.branch_masks, ?remaining_nibble, @@ -1764,9 +1761,7 @@ impl ArenaParallelSparseTrie { let mut prefix = branch_short_key; prefix.push_unchecked(remaining_nibble); - let ArenaSparseNodeBranchChild::Revealed(child_idx) = branch.children[0] else { - unreachable!() - }; + let child_idx = branch.children[0].revealed_index().expect("remaining child is revealed"); // Prepend the prefix to the child's key/short_key and mark dirty. // Track whether a leaf was newly dirtied by this collapse. @@ -1787,7 +1782,7 @@ impl ArenaParallelSparseTrie { false } ArenaSparseNode::Subtrie(subtrie) => { - subtrie.path = branch_entry.path; + subtrie.path = branch_path; match &mut subtrie.arena[subtrie.root] { ArenaSparseNode::Branch(b) => { let mut new_short_key = prefix; @@ -1833,8 +1828,8 @@ impl ArenaParallelSparseTrie { let mut leaves = 0u64; let mut dirty = 0u64; for c in &b.children { - if let ArenaSparseNodeBranchChild::Revealed(child_idx) = c { - let (l, d) = Self::count_leaves_and_dirty(arena, *child_idx); + if let Some(child_idx) = c.revealed_index() { + let (l, d) = Self::count_leaves_and_dirty(arena, child_idx); leaves += l; dirty += d; } @@ -1851,7 +1846,7 @@ impl ArenaParallelSparseTrie { /// /// Uses the cursor to DFS the upper arena, checking each visited node's path length. #[instrument(level = "trace", target = TRACE_TARGET, skip_all)] - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] fn debug_assert_subtrie_structure(&mut self) { let mut cursor = mem::take(&mut self.buffers.cursor); cursor.reset(&self.upper_arena, self.root, Nibbles::default()); @@ -1861,9 +1856,8 @@ impl ArenaParallelSparseTrie { match result { NextResult::Done => break, NextResult::NonBranch | NextResult::Branch => { - let head = cursor.head().expect("cursor is non-empty"); - let path_len = head.path.len(); - let node = &self.upper_arena[head.index]; + let path_len = cursor.head_path_len(); + let node = &self.upper_arena[cursor.head().expect("cursor is non-empty").index]; if Self::should_be_subtrie(path_len) { debug_assert!( @@ -1902,13 +1896,7 @@ impl ArenaParallelSparseTrie { let mut node = src.remove(src_idx).expect("node exists in source arena"); // Recursively migrate children first so their new indices are known. - if let ArenaSparseNode::Branch(b) = &mut node { - for child in &mut b.children { - if let ArenaSparseNodeBranchChild::Revealed(child_idx) = child { - *child_idx = Self::migrate_nodes(dst, src, *child_idx, None); - } - } - } + Self::migrate_children(dst, src, &mut node); if let Some(slot) = dst_slot { dst[slot] = node; @@ -1918,16 +1906,28 @@ impl ArenaParallelSparseTrie { } } + /// Moves a branch's children from `src` to `dst`: revealed children are migrated recursively + /// with [`Self::migrate_nodes`], blinded children have their RLP moved into `dst`'s side + /// table. `node` must already be detached from `src`; non-branch nodes are left untouched. + fn migrate_children(dst: &mut NodeArena, src: &mut NodeArena, node: &mut ArenaSparseNode) { + let ArenaSparseNode::Branch(b) = node else { return }; + for child in &mut b.children { + *child = match child.revealed_index() { + Some(child_idx) => { + BranchChild::revealed(Self::migrate_nodes(dst, src, child_idx, None)) + } + None => dst.insert_blinded(src.take_blinded(*child)), + }; + } + } + /// Removes a pruned node from the arena and blinds the parent's child slot with the node's /// cached RLP. - fn remove_pruned_node( - arena: &mut NodeArena, - cursor: &ArenaCursor, - idx: Index, - nibble: Option, - ) -> ArenaSparseNode { - let path = cursor.head().expect("cursor is non-empty").path; - let node = arena.remove(idx).expect("node must exist to be pruned"); + fn remove_pruned_node(arena: &mut NodeArena, cursor: &mut ArenaCursor) -> ArenaSparseNode { + // Entry paths are derived from the cursor's path, so read the head path before popping. + let path = cursor.head_path(); + let entry = cursor.pop(arena); + let node = arena.remove(entry.index).expect("node must exist to be pruned"); let rlp_node = node .state_ref() .and_then(ArenaSparseNodeState::cached_rlp_node) @@ -1941,12 +1941,13 @@ impl ArenaParallelSparseTrie { "pruning node", ); - let parent_idx = cursor.parent().expect("pruned child has parent").index; - let child_nibble = nibble.expect("non-root child"); + let parent_idx = cursor.head().expect("pruned child has parent").index; + let child_nibble = path.last().expect("non-root child"); + let blinded = arena.insert_blinded(rlp_node); let parent_branch = arena[parent_idx].branch_mut(); let child_idx = BranchChildIdx::new(parent_branch.state_mask, child_nibble) .expect("child nibble not found in parent state_mask"); - parent_branch.children[child_idx] = ArenaSparseNodeBranchChild::Blinded(rlp_node); + parent_branch.children[child_idx] = blinded; node } @@ -1989,10 +1990,11 @@ impl ArenaParallelSparseTrie { let dense_child_idx = BranchChildIdx::new(head_branch.state_mask, child_nibble) .expect("Blinded result but child nibble not in state_mask"); - let cached_rlp = match &head_branch.children[dense_child_idx] { - ArenaSparseNodeBranchChild::Blinded(rlp) => rlp.clone(), - ArenaSparseNodeBranchChild::Revealed(_) => return None, - }; + let child = head_branch.children[dense_child_idx]; + if !child.is_blinded() { + return None; + } + let cached_rlp = arena.blinded(child).clone(); trace!( target: TRACE_TARGET, @@ -2002,20 +2004,20 @@ impl ArenaParallelSparseTrie { ); let proof_node = mem::replace(node, ProofTrieNodeV2::empty()); - let mut arena_node = ArenaSparseNode::from_proof_node(proof_node); + let mut arena_node = ArenaSparseNode::from_proof_node(arena, proof_node); let state = arena_node.state_mut(); *state = ArenaSparseNodeState::Cached { rlp_node: cached_rlp, epoch: TrieNodeEpoch::UNMODIFIED }; let child_idx = arena.insert(arena_node); - arena[head_idx].branch_mut().children[dense_child_idx] = - ArenaSparseNodeBranchChild::Revealed(child_idx); + arena[head_idx].branch_mut().children[dense_child_idx] = BranchChild::revealed(child_idx); + arena.take_blinded(child); Some(child_idx) } - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] fn collect_reachable_nodes( arena: &NodeArena, idx: Index, @@ -2026,14 +2028,14 @@ impl ArenaParallelSparseTrie { } if let ArenaSparseNode::Branch(b) = &arena[idx] { for child in &b.children { - if let ArenaSparseNodeBranchChild::Revealed(child_idx) = child { - Self::collect_reachable_nodes(arena, *child_idx, reachable); + if let Some(child_idx) = child.revealed_index() { + Self::collect_reachable_nodes(arena, child_idx, reachable); } } } } - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] fn assert_no_orphaned_nodes(arena: &NodeArena, root: Index, label: &str) { let mut reachable = alloy_primitives::map::HashSet::default(); Self::collect_reachable_nodes(arena, root, &mut reachable); @@ -2048,12 +2050,12 @@ impl ArenaParallelSparseTrie { } } -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, not(feature = "dst")))] impl Drop for ArenaParallelSparseTrie { fn drop(&mut self) { Self::assert_no_orphaned_nodes(&self.upper_arena, self.root, "upper arena"); - for (_, node) in &self.upper_arena { + for (_, node) in self.upper_arena.iter() { if let Some(subtrie) = node.as_subtrie() { Self::assert_no_orphaned_nodes( &subtrie.arena, @@ -2067,7 +2069,7 @@ impl Drop for ArenaParallelSparseTrie { impl Default for ArenaParallelSparseTrie { fn default() -> Self { - let mut upper_arena = SlotMap::new(); + let mut upper_arena = NodeArena::new(); let root = upper_arena .insert(ArenaSparseNode::EmptyRoot { state: ArenaSparseNodeState::Revealed }); Self { @@ -2107,8 +2109,6 @@ impl SparseTrie for ArenaParallelSparseTrie { "set_root called on a trie that already has revealed nodes" ); - self.set_updates(retain_updates); - match root { TrieNodeV2::EmptyRoot => { trace!(target: TRACE_TARGET, "Setting empty root"); @@ -2127,8 +2127,8 @@ impl SparseTrie for ArenaParallelSparseTrie { trace!(target: TRACE_TARGET, state_mask = ?branch.state_mask, num_children = branch.state_mask.count_bits(), "Setting branch root"); let mut children = SmallVec::with_capacity(branch.state_mask.count_bits() as usize); for (stack_ptr, _nibble) in branch.state_mask.iter().enumerate() { - children - .push(ArenaSparseNodeBranchChild::Blinded(branch.stack[stack_ptr].clone())); + let child = self.upper_arena.insert_blinded(branch.stack[stack_ptr].clone()); + children.push(child); } self.upper_arena[self.root] = ArenaSparseNode::Branch(ArenaSparseNodeBranch { @@ -2139,11 +2139,16 @@ impl SparseTrie for ArenaParallelSparseTrie { branch_masks: masks.unwrap_or_default(), }); } - TrieNodeV2::Extension(_) => { - panic!("set_root does not support Extension nodes; extensions are represented as branches with a short_key") + TrieNodeV2::Extension(node) => { + return Err(SparseTrieErrorKind::Reveal { + path: Nibbles::new(), + node: Box::new(node), + } + .into()); } } + self.set_updates(retain_updates); Ok(()) } @@ -2207,9 +2212,8 @@ impl SparseTrie for ArenaParallelSparseTrie { } } SeekResult::RevealedSubtrie => { - let subtrie_entry = cursor.head().expect("cursor is non-empty"); - let child_idx = subtrie_entry.index; - let prefix = subtrie_entry.path; + let child_idx = cursor.head().expect("cursor is non-empty").index; + let prefix = cursor.head_path(); let subtrie_start = node_idx; while node_idx < nodes.len() && nodes[node_idx].path.starts_with(&prefix) { @@ -2277,7 +2281,7 @@ impl SparseTrie for ArenaParallelSparseTrie { if let Some(err) = results.into_iter().find(|r| r.is_err()) { // Restore before returning so we don't leave TakenSubtrie holes. for (idx, subtrie, _) in taken { - self.upper_arena[idx] = ArenaSparseNode::Subtrie(subtrie); + self.restore_taken_subtrie(idx, subtrie); } return err; } @@ -2285,10 +2289,10 @@ impl SparseTrie for ArenaParallelSparseTrie { // Restore taken subtries into the upper arena. for (idx, subtrie, _) in taken { - self.upper_arena[idx] = ArenaSparseNode::Subtrie(subtrie); + self.restore_taken_subtrie(idx, subtrie); } - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_subtrie_structure(); Ok(()) @@ -2333,7 +2337,7 @@ impl SparseTrie for ArenaParallelSparseTrie { // Count total dirty leaves across all subtries to make one global parallelism decision. let mut total_dirty_leaves: u64 = 0; let mut taken: Vec<(Index, Box)> = Vec::new(); - for (idx, node) in &mut self.upper_arena { + for (idx, node) in self.upper_arena.iter_mut() { let ArenaSparseNode::Subtrie(s) = node else { continue }; if s.num_dirty_leaves == 0 { continue; @@ -2402,7 +2406,7 @@ impl SparseTrie for ArenaParallelSparseTrie { let (_, subtrie) = taken.pop().expect("taken subtries must not be exhausted"); debug_assert_eq!( subtrie.path, - self.buffers.cursor.head().expect("cursor is non-empty").path, + self.buffers.cursor.head_path(), "taken subtrie path mismatch", ); self.upper_arena[head_idx] = ArenaSparseNode::Subtrie(subtrie); @@ -2446,7 +2450,7 @@ impl SparseTrie for ArenaParallelSparseTrie { #[instrument(level = "trace", target = TRACE_TARGET, skip_all)] fn clear(&mut self) { - self.upper_arena = SlotMap::new(); + self.upper_arena = NodeArena::new(); self.root = self .upper_arena .insert(ArenaSparseNode::EmptyRoot { state: ArenaSparseNodeState::Revealed }); @@ -2491,9 +2495,7 @@ impl SparseTrie for ArenaParallelSparseTrie { break } - let head = cursor.head().expect("cursor is non-empty"); - let head_idx = head.index; - let head_path = head.path; + let head_idx = cursor.head().expect("cursor is non-empty").index; match &self.upper_arena[head_idx] { ArenaSparseNode::Branch(_) | ArenaSparseNode::Leaf { .. } => { @@ -2510,12 +2512,7 @@ impl SparseTrie for ArenaParallelSparseTrie { continue; } - Self::remove_pruned_node( - &mut self.upper_arena, - &cursor, - head_idx, - head_path.last(), - ); + Self::remove_pruned_node(&mut self.upper_arena, &mut cursor); pruned += 1; } ArenaSparseNode::Subtrie(_) => { @@ -2524,12 +2521,7 @@ impl SparseTrie for ArenaParallelSparseTrie { .and_then(ArenaSparseNodeState::cached_epoch) .expect("prune must run after hashing"); if root_epoch.should_prune(prune_before) { - let removed = Self::remove_pruned_node( - &mut self.upper_arena, - &cursor, - head_idx, - head_path.last(), - ); + let removed = Self::remove_pruned_node(&mut self.upper_arena, &mut cursor); let ArenaSparseNode::Subtrie(s) = &removed else { unreachable!() }; pruned += s.arena.len(); self.recycle_subtrie(removed); @@ -2588,7 +2580,7 @@ impl SparseTrie for ArenaParallelSparseTrie { // Restore taken subtries into the upper arena. for (child_idx, subtrie) in taken { - self.upper_arena[child_idx] = ArenaSparseNode::Subtrie(subtrie); + self.restore_taken_subtrie(child_idx, subtrie); } } @@ -2645,9 +2637,8 @@ impl SparseTrie for ArenaParallelSparseTrie { } // Subtrie — forward all consecutive updates under this subtrie's prefix. SeekResult::RevealedSubtrie => { - let subtrie_entry = cursor.head().expect("cursor is non-empty"); - let child_idx = subtrie_entry.index; - let subtrie_root_path = subtrie_entry.path; + let child_idx = cursor.head().expect("cursor is non-empty").index; + let subtrie_root_path = cursor.head_path(); let subtrie_start = update_idx; while update_idx < sorted.len() && @@ -2685,7 +2676,7 @@ impl SparseTrie for ArenaParallelSparseTrie { // Filter out Touched, as they don't affect the structure of the trie. So an // update set with 2 removals and one Touched could still result in an empty // sub trie. - .filter(|(_, _, u)| matches!(u, LeafUpdate::Changed(_))) + .filter(|(_, _, u)| u.is_changed()) .all(|(_, _, u)| matches!(u, LeafUpdate::Changed(v) if v.is_empty())); let subtrie_num_leaves = match &self.upper_arena[child_idx] { ArenaSparseNode::Subtrie(s) => s.num_leaves, @@ -2745,11 +2736,12 @@ impl SparseTrie for ArenaParallelSparseTrie { ); match result { UpsertLeafResult::NewChild => { - let head = cursor.head().expect("cursor is non-empty"); - if Self::should_be_subtrie(head.path.len()) { + if Self::should_be_subtrie(cursor.head_path_len()) { // The new child itself sits at the subtrie // boundary — wrap it directly. - self.maybe_wrap_in_subtrie(head.index, &head.path); + let head_idx = + cursor.head().expect("cursor is non-empty").index; + self.maybe_wrap_in_subtrie(head_idx, &cursor.head_path()); } else { // The new child is above the boundary (e.g. a // split at depth 1 creates children at depth 2). @@ -2792,9 +2784,11 @@ impl SparseTrie for ArenaParallelSparseTrie { // 3. A non-Subtrie node at UPPER_TRIE_MAX_DEPTH that needs // wrapping. self.maybe_collapse_or_remove_branch(&mut cursor); - let head = - cursor.head().expect("cursor always has root after collapse"); - self.maybe_wrap_in_subtrie(head.index, &head.path); + let head_idx = cursor + .head() + .expect("cursor always has root after collapse") + .index; + self.maybe_wrap_in_subtrie(head_idx, &cursor.head_path()); } RemoveLeafResult::NotFound => {} } @@ -2811,7 +2805,7 @@ impl SparseTrie for ArenaParallelSparseTrie { self.buffers.cursor = cursor; if taken.is_empty() { - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_subtrie_structure(); return Ok(()); @@ -2843,7 +2837,7 @@ impl SparseTrie for ArenaParallelSparseTrie { } // Restore the subtrie into the upper arena. - self.upper_arena[child_idx] = ArenaSparseNode::Subtrie(subtrie); + self.restore_taken_subtrie(child_idx, subtrie); } // Navigate to each taken subtrie via seek to propagate dirty state @@ -2888,7 +2882,7 @@ impl SparseTrie for ArenaParallelSparseTrie { self.buffers.cursor = cursor; } - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] self.debug_assert_subtrie_structure(); Ok(()) @@ -2897,7 +2891,7 @@ impl SparseTrie for ArenaParallelSparseTrie { #[cfg(test)] mod tests { - use super::TRACE_TARGET; + use super::*; use crate::{ ArenaParallelSparseTrie, ArenaParallelismThresholds, LeafUpdate, SparseTrie, TrieNodeEpoch, }; @@ -2912,6 +2906,70 @@ mod tests { TrieNodeEpoch::new(value) } + #[test] + fn unsupported_root_preserves_update_retention_and_allows_valid_root() { + let mut trie = ArenaParallelSparseTrie::default(); + let root = TrieNodeV2::Extension(reth_trie_common::ExtensionNode::new( + Nibbles::from_nibbles([1]), + RlpNode::word_rlp(&B256::ZERO), + )); + let error = trie.set_root(root, None, true).unwrap_err(); + assert!( + matches!(error.kind(), SparseTrieErrorKind::Reveal { path, .. } if path.is_empty()) + ); + assert!(trie.buffers.updates.is_none()); + + trie.set_root(TrieNodeV2::EmptyRoot, None, true).unwrap(); + assert_eq!(trie.root(epoch(1)), EMPTY_ROOT_HASH); + assert!(trie.buffers.updates.is_some()); + } + + #[test] + fn pruning_pops_cursor_before_reusing_node_slot() { + use super::{ + ArenaCursor, ArenaSparseNode, ArenaSparseNodeBranch, ArenaSparseNodeState, BranchChild, + NextResult, NodeArena, + }; + use reth_trie_common::RlpNode; + + let mut arena = NodeArena::new(); + let rlp = RlpNode::word_rlp(&B256::repeat_byte(1)); + let state = ArenaSparseNodeState::Cached { rlp_node: rlp.clone(), epoch: epoch(0) }; + let leaf = + ArenaSparseNode::Leaf { state: state.clone(), key: Default::default(), value: vec![1] }; + let first = arena.insert(leaf.clone()); + let second = arena.insert(leaf); + let root = arena.insert(ArenaSparseNode::Branch(ArenaSparseNodeBranch { + state: state.clone(), + children: [BranchChild::revealed(first), BranchChild::revealed(second)] + .into_iter() + .collect(), + state_mask: alloy_trie::TrieMask::new(3), + short_key: Default::default(), + branch_masks: Default::default(), + })); + let mut cursor = ArenaCursor::default(); + cursor.reset(&arena, root, Default::default()); + assert!(matches!(cursor.next(&mut arena, |_, _| true), NextResult::NonBranch)); + assert_eq!(cursor.head().unwrap().index, first); + + ArenaParallelSparseTrie::remove_pruned_node(&mut arena, &mut cursor); + assert_eq!(cursor.head().unwrap().index, root); + let reused = arena.insert(ArenaSparseNode::Leaf { + state: ArenaSparseNodeState::Dirty, + key: Default::default(), + value: vec![2], + }); + assert_eq!(reused, first); + + assert!(matches!(cursor.next(&mut arena, |_, _| true), NextResult::NonBranch)); + assert_eq!(cursor.head().unwrap().index, second); + assert!(matches!(cursor.next(&mut arena, |_, _| true), NextResult::Branch)); + assert_eq!(arena[root].state_ref(), Some(&state)); + assert_eq!(arena.blinded(arena[root].branch_ref().children[0]), &rlp); + assert!(matches!(cursor.next(&mut arena, |_, _| true), NextResult::Done)); + } + /// Test harness for proptest-based arena sparse trie testing. /// /// Wraps [`TrieTestHarness`] and adds `ArenaParallelSparseTrie`-specific helpers for @@ -2962,7 +3020,7 @@ mod tests { let mut leaf_updates: B256Map = changes .iter() .map(|(&slot, &value)| { - let rlp_value = if value == U256::ZERO { + let rlp_value = if value.is_zero() { Vec::new() } else { alloy_rlp::encode_fixed_size(&value).to_vec() @@ -3076,7 +3134,7 @@ mod tests { // Filter out zero-valued entries from the initial dataset (zeros mean "absent"). let initial: BTreeMap = initial.into_iter() - .filter(|(_, v)| *v != U256::ZERO) + .filter(|(_, v)| !v.is_zero()) .collect(); let mut rng = rand::rngs::StdRng::seed_from_u64(shuffle_seed); diff --git a/crates/trie/sparse/src/arena/node_arena.rs b/crates/trie/sparse/src/arena/node_arena.rs new file mode 100644 index 00000000000..278ab3eddaa --- /dev/null +++ b/crates/trie/sparse/src/arena/node_arena.rs @@ -0,0 +1,623 @@ +use super::ArenaSparseNode; +use alloc::vec::Vec; +use core::{ + fmt, + ops::{Index as IndexOps, IndexMut}, +}; +use reth_trie_common::RlpNode; + +/// Bits per word of a [`BlindedMarks`] bitmap. +const WORD_BITS: usize = u64::BITS as usize; + +/// Arena of trie nodes addressed by an [`Index`]. +/// +/// Nodes live in a flat vector; [`NodeArena::remove`] leaves an [`ArenaSparseNode::Free`] tombstone +/// behind and pushes the slot onto a LIFO free list. Unlike a slotmap there are no generation +/// counters, so an index handed out again refers to the new occupant: an index must not be reused +/// across a removal unless the caller knows nothing was inserted in between. +#[derive(Debug, Clone, Default)] +pub(super) struct NodeArena { + nodes: Vec, + free: Vec, + blinded: Vec, + blinded_free: Vec, +} + +impl NodeArena { + /// Creates an empty arena. + pub(super) const fn new() -> Self { + Self { nodes: Vec::new(), free: Vec::new(), blinded: Vec::new(), blinded_free: Vec::new() } + } + + /// Creates an empty arena with room for `capacity` nodes. + pub(super) fn with_capacity(capacity: usize) -> Self { + Self { nodes: Vec::with_capacity(capacity), ..Self::new() } + } + + /// Inserts a node, reusing a free slot when one is available, and returns its index. + pub(super) fn insert(&mut self, node: ArenaSparseNode) -> Index { + if let Some(idx) = self.free.pop() { + self.nodes[idx.get()] = node; + return idx; + } + let idx = Index::new(self.nodes.len()); + self.nodes.push(node); + idx + } + + /// Removes the node at `idx` without recycling its slot. + /// + /// For an arena that is being emptied into a fresh one — pruning and compaction both do that + /// and then drop the source — maintaining the free list is wasted work. [`Self::len`] is no + /// longer accurate afterwards. + /// + /// # Panics + /// + /// Panics if the slot is out of bounds or free. + pub(super) fn drain_node(&mut self, idx: Index) -> ArenaSparseNode { + let node = core::mem::replace(&mut self.nodes[idx.get()], ArenaSparseNode::Free); + assert!(!matches!(node, ArenaSparseNode::Free), "drained a free arena slot"); + node + } + + /// Removes the node at `idx`, returning it if the slot was occupied. + pub(super) fn remove(&mut self, idx: Index) -> Option { + let slot = self.nodes.get_mut(idx.get())?; + if matches!(slot, ArenaSparseNode::Free) { + return None; + } + let node = core::mem::replace(slot, ArenaSparseNode::Free); + self.free.push(idx); + Some(node) + } + + /// Returns the node at `idx`, or `None` if the slot is out of bounds or free. + pub(super) fn get(&self, idx: Index) -> Option<&ArenaSparseNode> { + match self.nodes.get(idx.get()) { + Some(ArenaSparseNode::Free) | None => None, + Some(node) => Some(node), + } + } + + /// Returns `true` if `idx` refers to an occupied slot, or `false` if it is out of bounds or + /// free. + pub(super) fn contains_key(&self, idx: Index) -> bool { + self.get(idx).is_some() + } + + /// Returns the number of occupied slots. + pub(super) const fn len(&self) -> usize { + self.nodes.len() - self.free.len() + } + + /// Iterates over the occupied slots. + pub(super) fn iter(&self) -> impl Iterator + '_ { + self.nodes + .iter() + .enumerate() + .filter(|(_, node)| !matches!(node, ArenaSparseNode::Free)) + .map(|(idx, node)| (Index(idx as u32), node)) + } + + /// Iterates mutably over the occupied slots. + pub(super) fn iter_mut(&mut self) -> impl Iterator + '_ { + self.nodes + .iter_mut() + .enumerate() + .filter(|(_, node)| !matches!(node, ArenaSparseNode::Free)) + .map(|(idx, node)| (Index(idx as u32), node)) + } + + /// Stores the RLP of an unrevealed child and returns a [`BranchChild`] referencing it. + pub(super) fn insert_blinded(&mut self, rlp: RlpNode) -> BranchChild { + let slot = if let Some(slot) = self.blinded_free.pop() { + self.blinded[slot as usize] = rlp; + slot + } else { + let slot = self.blinded.len(); + assert!(slot < BranchChild::BLINDED as usize, "blinded slot overflows the tag bit"); + self.blinded.push(rlp); + slot as u32 + }; + BranchChild::blinded(slot) + } + + /// Returns the RLP of a blinded child. + /// + /// # Panics + /// + /// Panics if `child` is revealed. + pub(super) fn blinded(&self, child: BranchChild) -> &RlpNode { + &self.blinded[child.blinded_slot().expect("child is revealed") as usize] + } + + /// Takes the RLP of a blinded child, releasing its slot. + /// + /// # Panics + /// + /// Panics if `child` is revealed. + pub(super) fn take_blinded(&mut self, child: BranchChild) -> RlpNode { + let slot = child.blinded_slot().expect("child is revealed"); + self.blinded_free.push(slot); + core::mem::take(&mut self.blinded[slot as usize]) + } + + /// Releases the node vector's unused capacity. + pub(super) fn shrink_nodes_to_fit(&mut self) { + self.nodes.shrink_to_fit(); + } + + /// Takes over `src`'s side table of blinded children, so that blinded [`BranchChild`]s copied + /// out of `src` stay valid without their RLP being moved one slot at a time. + /// + /// The copy must record every child it carries over in the returned marks and finish with + /// [`Self::sweep_blinded`], which reclaims unreferenced slots and may rewrite the blinded + /// children stored in this arena. Blinded handles must not be retained across the sweep. + pub(super) fn adopt_blinded(&mut self, src: &mut Self) -> BlindedMarks { + debug_assert!(self.blinded.is_empty(), "adopting into a non-empty side table"); + self.blinded = core::mem::take(&mut src.blinded); + self.blinded_free = core::mem::take(&mut src.blinded_free); + BlindedMarks { words: alloc::vec![0; self.blinded.len().div_ceil(WORD_BITS)] } + } + + /// Reclaims unmarked slots in the side table adopted by [`Self::adopt_blinded`]. + /// Sparse tables are compacted and their branch child references rewritten, so a live tail + /// slot cannot retain an arbitrarily large table. Other tables keep their slot indices. + pub(super) fn sweep_blinded(&mut self, marks: BlindedMarks) { + let words = marks.words; + let end = words + .iter() + .rposition(|word| *word != 0) + .map_or(0, |idx| idx * WORD_BITS + WORD_BITS - words[idx].leading_zeros() as usize); + debug_assert!(end <= self.blinded.len(), "marked slot outside the side table"); + self.blinded.truncate(end); + self.blinded_free.clear(); + + let words = &words[..end.div_ceil(WORD_BITS)]; + let live_count = words.iter().map(|word| word.count_ones() as usize).sum::(); + if end > 0 && live_count <= end / 4 { + // A slot's new index is the number of marked slots before it. Store one prefix + // count per bitmap word instead of allocating a mapping for every old slot. + let mut offsets = Vec::with_capacity(words.len()); + let mut next = 0; + for (idx, &word) in words.iter().enumerate() { + offsets.push(next as u32); + let mut marked = word; + while marked != 0 { + let slot = idx * WORD_BITS + marked.trailing_zeros() as usize; + self.blinded.swap(next, slot); + next += 1; + marked &= marked - 1; + } + } + self.blinded.truncate(next); + for (_, node) in self.iter_mut() { + if let ArenaSparseNode::Branch(branch) = node { + for child in &mut branch.children { + if let Some(slot) = child.blinded_slot() { + let word = slot as usize / WORD_BITS; + let bit = slot as usize % WORD_BITS; + debug_assert_ne!(words[word] & (1 << bit), 0, "unmarked child"); + let preceding = words[word] & ((1 << bit) - 1); + *child = BranchChild::blinded(offsets[word] + preceding.count_ones()); + } + } + } + } + shrink_excess_capacity(&mut self.blinded); + shrink_excess_capacity(&mut self.blinded_free); + return; + } + + // Push high to low so that `insert_blinded` hands the lowest slot out first and the tail + // of the table keeps draining. + let word_count = end.div_ceil(WORD_BITS); + for (idx, word) in words[..word_count].iter().enumerate().rev() { + // The last word covers only the slots below `end`. + let covered = if idx + 1 == word_count && end % WORD_BITS != 0 { + (1 << (end % WORD_BITS)) - 1 + } else { + u64::MAX + }; + let mut unmarked = !word & covered; + while unmarked != 0 { + let bit = u64::BITS - 1 - unmarked.leading_zeros(); + unmarked ^= 1 << bit; + self.blinded_free.push(idx as u32 * u64::BITS + bit); + } + } + + shrink_excess_capacity(&mut self.blinded); + shrink_excess_capacity(&mut self.blinded_free); + } +} + +impl IndexOps for NodeArena { + type Output = ArenaSparseNode; + + #[inline] + fn index(&self, idx: Index) -> &Self::Output { + let node = &self.nodes[idx.get()]; + assert!(!matches!(node, ArenaSparseNode::Free), "indexed a free arena slot"); + node + } +} + +impl IndexMut for NodeArena { + #[inline] + fn index_mut(&mut self, idx: Index) -> &mut Self::Output { + let node = &mut self.nodes[idx.get()]; + assert!(!matches!(node, ArenaSparseNode::Free), "indexed a free arena slot"); + node + } +} + +/// A reference to a node inside a [`NodeArena`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub(super) struct Index(u32); + +impl Index { + /// Creates an index that fits below the blinded-child tag bit. + /// + /// # Panics + /// + /// Panics if `index` is at least 2^31. + pub(super) const fn new(index: usize) -> Self { + assert!(index < BranchChild::BLINDED as usize, "arena index overflows the blinded tag bit"); + Self(index as u32) + } + + /// Returns the index as a `usize`, suitable for indexing into the arena's backing vector. + pub(super) const fn get(self) -> usize { + self.0 as usize + } +} + +/// The slots of a side table of blinded children that a copy into a fresh arena carried over. +/// +/// See [`NodeArena::adopt_blinded`]. +#[must_use = "adopted blinded children must be marked and swept"] +pub(super) struct BlindedMarks { + words: Vec, +} + +impl BlindedMarks { + /// Records that `child`'s slot is still referenced. + /// + /// # Panics + /// + /// Panics if `child` is revealed. + pub(super) fn mark(&mut self, child: BranchChild) { + let slot = child.blinded_slot().expect("child is revealed") as usize; + let idx = slot / WORD_BITS; + if idx >= self.words.len() { + self.words.resize(idx + 1, 0); + } + self.words[idx] |= 1 << (slot % WORD_BITS); + } +} + +/// A reference from a branch node to one of its children. +/// +/// Packs the two cases into a single `u32`: a revealed child holds an [`Index`] into the arena's +/// nodes, a blinded child a slot in the arena's side table of unrevealed RLP. The top bit +/// discriminates, so both fit in four bytes and a branch's children stay dense and small. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) struct BranchChild(u32); + +impl BranchChild { + /// Tag bit marking a child as blinded. + const BLINDED: u32 = 1 << 31; + + /// Returns a child referencing the revealed node at `idx`. + /// + /// # Panics + /// + /// Panics if `idx` is at least 2^31. + pub(super) const fn revealed(idx: Index) -> Self { + assert!(idx.0 & Self::BLINDED == 0, "arena index overflows the blinded tag bit"); + Self(idx.0) + } + + /// Returns a child referencing the blinded RLP at `slot`. + /// + /// # Panics + /// + /// Panics if `slot` is at least 2^31. + const fn blinded(slot: u32) -> Self { + assert!(slot & Self::BLINDED == 0, "blinded slot overflows the tag bit"); + Self(slot | Self::BLINDED) + } + + /// Returns `true` if this child reference is blinded (not yet revealed in the arena). + pub(super) const fn is_blinded(self) -> bool { + self.0 & Self::BLINDED != 0 + } + + /// Returns the arena index of a revealed child, or `None` if it is blinded. + pub(super) const fn revealed_index(self) -> Option { + if self.is_blinded() { + None + } else { + Some(Index(self.0)) + } + } + + /// Returns the side-table slot of a blinded child, or `None` if it is revealed. + const fn blinded_slot(self) -> Option { + if self.is_blinded() { + Some(self.0 & !Self::BLINDED) + } else { + None + } + } +} + +impl fmt::Debug for BranchChild { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.blinded_slot() { + Some(slot) => write!(f, "Blinded({slot})"), + None => write!(f, "Revealed({})", self.0), + } + } +} + +/// Reclaims capacity after a large contraction while leaving room for subsequent growth. +fn shrink_excess_capacity(vec: &mut Vec) { + // Shrink at quarter-full to avoid reallocating on small prunes. Empty vectors release all + // capacity; non-empty vectors retain room for twice their current length. + if vec.len() <= vec.capacity() / 4 { + vec.shrink_to(vec.len() * 2); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::arena::{ArenaSparseNodeBranch, ArenaSparseNodeState}; + use alloy_primitives::B256; + + #[test] + fn removed_slots_are_recycled() { + let mut arena = NodeArena::new(); + let first = arena.insert(ArenaSparseNode::TakenSubtrie); + let second = arena.insert(ArenaSparseNode::TakenSubtrie); + assert_eq!(arena.len(), 2); + + assert!(arena.remove(first).is_some()); + assert!(arena.get(first).is_none()); + assert!(!arena.contains_key(first)); + assert!(arena.remove(first).is_none()); + assert_eq!(arena.len(), 1); + + let third = arena.insert(ArenaSparseNode::TakenSubtrie); + assert_eq!(third, first, "the freed slot is handed out again"); + assert_ne!(third, second); + assert_eq!(arena.len(), 2); + assert_eq!(arena.iter().count(), 2); + } + + #[test] + fn contains_key_excludes_free_slots() { + let mut arena = NodeArena::new(); + let removed = arena.insert(ArenaSparseNode::TakenSubtrie); + let drained = arena.insert(ArenaSparseNode::TakenSubtrie); + assert!(arena.contains_key(removed)); + assert!(arena.contains_key(drained)); + assert!(!arena.contains_key(Index::new(2))); + + arena.remove(removed); + arena.drain_node(drained); + assert!(!arena.contains_key(removed)); + assert!(!arena.contains_key(drained)); + } + + #[test] + #[should_panic(expected = "indexed a free arena slot")] + fn indexing_a_free_slot_panics() { + let mut arena = NodeArena::new(); + let idx = arena.insert(ArenaSparseNode::TakenSubtrie); + arena.remove(idx); + let _ = &arena[idx]; + } + + #[test] + #[should_panic(expected = "indexed a free arena slot")] + fn mutably_indexing_a_free_slot_panics() { + let mut arena = NodeArena::new(); + let idx = arena.insert(ArenaSparseNode::TakenSubtrie); + arena.remove(idx); + arena[idx] = ArenaSparseNode::TakenSubtrie; + } + + #[test] + #[should_panic(expected = "drained a free arena slot")] + fn draining_a_free_slot_panics() { + let mut arena = NodeArena::new(); + let idx = arena.insert(ArenaSparseNode::TakenSubtrie); + arena.drain_node(idx); + arena.drain_node(idx); + } + + #[test] + fn branch_child_preserves_largest_index() { + let index = Index::new((1 << 31) - 1); + assert_eq!(BranchChild::revealed(index).revealed_index(), Some(index)); + assert_eq!(BranchChild::blinded(index.0).blinded_slot(), Some(index.0)); + } + + #[test] + #[should_panic(expected = "arena index overflows the blinded tag bit")] + fn arena_index_rejects_blinded_tag_bit() { + Index::new(1 << 31); + } + + #[test] + fn blinded_slots_are_recycled() { + let mut arena = NodeArena::new(); + let rlp = RlpNode::word_rlp(&B256::repeat_byte(1)); + let child = arena.insert_blinded(rlp.clone()); + assert!(child.is_blinded()); + assert_eq!(child.revealed_index(), None); + assert_eq!(arena.blinded(child), &rlp); + + assert_eq!(arena.take_blinded(child), rlp); + let other = RlpNode::word_rlp(&B256::repeat_byte(2)); + assert_eq!(arena.insert_blinded(other.clone()), child, "the freed slot is reused"); + assert_eq!(arena.blinded(child), &other); + } + + #[test] + fn sweeping_an_adopted_table_keeps_marked_slots() { + let mut src = NodeArena::new(); + // More than one word of the mark bitmap. + let children: Vec<_> = (0..200u8) + .map(|byte| src.insert_blinded(RlpNode::word_rlp(&B256::repeat_byte(byte)))) + .collect(); + + let mut dst = NodeArena::new(); + let mut marks = dst.adopt_blinded(&mut src); + for child in children.iter().step_by(3) { + marks.mark(*child); + } + dst.sweep_blinded(marks); + + let last_marked = (children.len() - 1) / 3 * 3; + assert_eq!(dst.blinded.len(), last_marked + 1, "the unmarked tail is dropped"); + + // Every unmarked slot below the tail is handed out again, lowest first. + let expected: Vec = (0..=last_marked as u32).filter(|slot| slot % 3 != 0).collect(); + let handed_out: Vec = expected + .iter() + .map(|_| dst.insert_blinded(RlpNode::default()).blinded_slot().unwrap()) + .collect(); + assert_eq!(handed_out, expected); + + for (byte, child) in (0..200u8).step_by(3).zip(children.iter().step_by(3)) { + assert_eq!(dst.blinded(*child), &RlpNode::word_rlp(&B256::repeat_byte(byte))); + } + } + + #[test] + fn sweeping_releases_excess_side_table_capacity() { + let mut src = NodeArena::new(); + let rlp = RlpNode::word_rlp(&B256::repeat_byte(1)); + let children: Vec<_> = (0..4096).map(|_| src.insert_blinded(rlp.clone())).collect(); + for child in &children[2048..] { + src.take_blinded(*child); + } + + let mut dst = NodeArena::new(); + let mut marks = dst.adopt_blinded(&mut src); + for child in children[..64].iter().step_by(2) { + marks.mark(*child); + } + dst.sweep_blinded(marks); + + assert!(dst.blinded.capacity() <= 128, "release the discarded RLP capacity"); + assert!(dst.blinded_free.capacity() <= 64, "release the discarded free-list capacity"); + for child in children[..64].iter().step_by(2) { + assert_eq!(dst.blinded(*child), &rlp); + } + for slot in (1..63).step_by(2) { + assert_eq!(dst.insert_blinded(rlp.clone()).blinded_slot(), Some(slot)); + } + + let mut empty = NodeArena::new(); + let marks = empty.adopt_blinded(&mut dst); + empty.sweep_blinded(marks); + assert_eq!(empty.blinded.capacity(), 0); + assert_eq!(empty.blinded_free.capacity(), 0); + } + + #[test] + fn sweeping_keeps_capacity_for_small_contractions() { + let mut src = NodeArena::new(); + let children: Vec<_> = (0..128).map(|_| src.insert_blinded(RlpNode::default())).collect(); + let capacity = src.blinded.capacity(); + let mut dst = NodeArena::new(); + let mut marks = dst.adopt_blinded(&mut src); + for child in &children[..96] { + marks.mark(*child); + } + dst.sweep_blinded(marks); + assert_eq!(dst.blinded.capacity(), capacity); + } + + #[test] + fn sweeping_compacts_a_live_replacement_at_the_tail() { + let mut src = NodeArena::new(); + for _ in 0..65_536 { + src.insert_blinded(RlpNode::default()); + } + let mut dst = NodeArena::new(); + let mut marks = dst.adopt_blinded(&mut src); + let rlp = RlpNode::word_rlp(&B256::repeat_byte(1)); + let replacement = dst.insert_blinded(rlp.clone()); + assert_eq!(replacement.blinded_slot(), Some(65_536)); + let root = dst.insert(branch([replacement])); + marks.mark(replacement); + dst.sweep_blinded(marks); + + let child = dst[root].branch_ref().children[0]; + assert_eq!(dst.blinded(child), &rlp); + assert_eq!(child.blinded_slot(), Some(0)); + assert_eq!(dst.blinded.len(), 1); + assert!(dst.blinded.capacity() <= 2); + assert_eq!(dst.blinded_free.capacity(), 0); + + // Subsequent sweeps visit only the compacted table, not the old high-water mark. + let mut next = NodeArena::new(); + let mut marks = next.adopt_blinded(&mut dst); + assert_eq!(marks.words.len(), 1); + marks.mark(child); + next.insert(dst.drain_node(root)); + next.sweep_blinded(marks); + assert_eq!(next.blinded(child), &rlp); + assert_eq!(next.blinded.len(), 1); + } + + #[test] + fn sweeping_rewrites_sparse_slots_across_bitmap_words() { + let mut src = NodeArena::new(); + let children: Vec<_> = (0..200u8) + .map(|byte| src.insert_blinded(RlpNode::word_rlp(&B256::repeat_byte(byte)))) + .collect(); + let mut dst = NodeArena::new(); + let mut marks = dst.adopt_blinded(&mut src); + let kept = [199, 64, 0, 130, 63]; + for slot in kept { + marks.mark(children[slot]); + } + let leaf = dst.insert(ArenaSparseNode::TakenSubtrie); + let root = dst.insert(branch( + kept.map(|slot| children[slot]).into_iter().chain([BranchChild::revealed(leaf)]), + )); + dst.sweep_blinded(marks); + + assert_eq!(dst.blinded.len(), kept.len()); + assert!(dst.blinded.capacity() <= kept.len() * 2); + assert_eq!(dst.blinded_free.capacity(), 0); + let branch = dst[root].branch_ref(); + for (pos, slot) in kept.into_iter().enumerate() { + assert_eq!( + dst.blinded(branch.children[pos]), + &RlpNode::word_rlp(&B256::repeat_byte(slot as u8)) + ); + } + assert_eq!(branch.children[kept.len()].revealed_index(), Some(leaf)); + assert!(dst.contains_key(leaf)); + assert_eq!(dst.insert_blinded(RlpNode::default()).blinded_slot(), Some(5)); + } + + fn branch(children: impl IntoIterator) -> ArenaSparseNode { + let children: smallvec::SmallVec<_> = children.into_iter().collect(); + ArenaSparseNode::Branch(ArenaSparseNodeBranch { + state_mask: alloy_trie::TrieMask::new((1 << children.len()) - 1), + children, + state: ArenaSparseNodeState::Revealed, + short_key: Default::default(), + branch_masks: Default::default(), + }) + } +} diff --git a/crates/trie/sparse/src/arena/nodes.rs b/crates/trie/sparse/src/arena/nodes.rs index df226831057..00eaba72eb3 100644 --- a/crates/trie/sparse/src/arena/nodes.rs +++ b/crates/trie/sparse/src/arena/nodes.rs @@ -1,6 +1,6 @@ use super::{ branch_child_idx::{BranchChildIdx, BranchChildIter}, - ArenaSparseSubtrie, Index, NodeArena, + ArenaSparseSubtrie, BranchChild, NodeArena, }; use alloc::{boxed::Box, vec::Vec}; use alloy_primitives::{keccak256, B256}; @@ -50,30 +50,15 @@ impl ArenaSparseNodeState { } } -/// Represents a reference from a branch node to one of its children. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum ArenaSparseNodeBranchChild { - /// The child node has been revealed and is present in the arena. - Revealed(Index), - /// The child node has not been revealed; only its RLP-encoded node is known. - Blinded(RlpNode), -} - -impl ArenaSparseNodeBranchChild { - /// Returns `true` if this child reference is blinded (not yet revealed in the arena). - pub(super) const fn is_blinded(&self) -> bool { - matches!(self, Self::Blinded(_)) - } -} - /// The branch-specific data stored in an [`ArenaSparseNode::Branch`]. #[derive(Debug, Clone)] pub(super) struct ArenaSparseNodeBranch { /// Cached or dirty state of this node. pub(super) state: ArenaSparseNodeState, /// Revealed or blinded children, packed densely. The `state_mask` tracks which - /// nibble positions have entries in this `SmallVec`. - pub(super) children: SmallVec<[ArenaSparseNodeBranchChild; 4]>, + /// nibble positions have entries in this `SmallVec`, which is sized to hold the maximum + /// of 16 children inline and therefore never spills. + pub(super) children: SmallVec<[BranchChild; 16]>, /// Bitmask indicating which of the 16 child slots are occupied (have an entry /// in `children`). pub(super) state_mask: TrieMask, @@ -92,7 +77,7 @@ impl ArenaSparseNodeBranch { /// Inserts a child at `nibble`, updating the state mask, children array, and marking the /// branch as dirty. - pub(super) fn set_child(&mut self, nibble: u8, child: ArenaSparseNodeBranchChild) { + pub(super) fn set_child(&mut self, nibble: u8, child: BranchChild) { let insert_pos = BranchChildIdx::insertion_point(self.state_mask, nibble); self.state_mask.set_bit(nibble); self.children.insert(insert_pos.get(), child); @@ -113,12 +98,12 @@ impl ArenaSparseNodeBranch { self.state = ArenaSparseNodeState::Dirty; } - /// Returns a reference to the sibling child in a branch with exactly 2 children. + /// Returns the sibling child in a branch with exactly 2 children. /// /// # Panics /// /// Panics (debug) if the branch does not have exactly 2 children, or if `nibble` is not set. - pub(super) fn sibling_child(&self, nibble: u8) -> &ArenaSparseNodeBranchChild { + pub(super) fn sibling_child(&self, nibble: u8) -> BranchChild { debug_assert_eq!( self.state_mask.count_bits(), 2, @@ -127,14 +112,12 @@ impl ArenaSparseNodeBranch { let child_idx = BranchChildIdx::new(self.state_mask, nibble).expect("nibble not found in state_mask"); // With exactly 2 children the dense array has indices 0 and 1. - &self.children[1 - child_idx.get()] + self.children[1 - child_idx.get()] } - /// Iterates over `(nibble, &ArenaSparseNodeBranchChild)` pairs in nibble order. - pub(super) fn child_iter( - &self, - ) -> impl Iterator + '_ { - BranchChildIter::new(self.state_mask).map(|(idx, nibble)| (nibble, &self.children[idx])) + /// Iterates over `(nibble, child)` pairs in nibble order. + pub(super) fn child_iter(&self) -> impl Iterator + '_ { + BranchChildIter::new(self.state_mask).map(|(idx, nibble)| (nibble, self.children[idx])) } /// Returns a [`BranchNodeCompact`] from this branch's masks and children hashes. @@ -142,13 +125,9 @@ impl ArenaSparseNodeBranch { let mut hashes = Vec::with_capacity(self.branch_masks.hash_mask.count_bits() as usize); for (nibble, child) in self.child_iter() { if self.branch_masks.hash_mask.is_bit_set(nibble) { - let hash = match child { - ArenaSparseNodeBranchChild::Blinded(rlp_node) => { - rlp_node.as_hash().expect("blinded child must be a hash") - } - ArenaSparseNodeBranchChild::Revealed(child_idx) => { - arena[*child_idx].cached_hash() - } + let hash = match child.revealed_index() { + Some(child_idx) => arena[child_idx].cached_hash(), + None => arena.blinded(child).as_hash().expect("blinded child must be a hash"), }; hashes.push(hash); } @@ -186,6 +165,10 @@ pub(super) enum ArenaSparseNode { Subtrie(Box), /// Placeholder for a subtrie that has been temporarily taken for parallel operations. TakenSubtrie, + /// Tombstone for a vacated slot. [`NodeArena::remove`](super::NodeArena::remove) also puts + /// the slot on the arena's free list, [`NodeArena::drain_node`](super::NodeArena::drain_node) + /// does not. + Free, } impl ArenaSparseNode { @@ -252,7 +235,7 @@ impl ArenaSparseNode { } /// Returns a reference to the subtrie if this is a `Subtrie` node, or `None`. - #[cfg(debug_assertions)] + #[cfg(all(debug_assertions, not(feature = "dst")))] pub(super) const fn as_subtrie(&self) -> Option<&ArenaSparseSubtrie> { match self { Self::Subtrie(s) => Some(s), @@ -306,13 +289,14 @@ impl ArenaSparseNode { } impl ArenaSparseNode { - /// Converts a [`ProofTrieNodeV2`] into an [`ArenaSparseNode`]. + /// Converts a [`ProofTrieNodeV2`] into an [`ArenaSparseNode`], storing the RLP of a branch's + /// unrevealed children in `arena`'s blinded side table. /// /// # Panics /// /// Panics if the node is an `Extension`, which should have been merged into a branch /// by [`TrieNodeV2`]. - pub(super) fn from_proof_node(proof_node: ProofTrieNodeV2) -> Self { + pub(super) fn from_proof_node(arena: &mut NodeArena, proof_node: ProofTrieNodeV2) -> Self { let ProofTrieNodeV2 { node, masks, .. } = proof_node; match node { TrieNodeV2::EmptyRoot => Self::EmptyRoot { state: ArenaSparseNodeState::Revealed }, @@ -324,7 +308,7 @@ impl ArenaSparseNode { TrieNodeV2::Branch(branch) => { let children = branch.stack[..branch.state_mask.count_bits() as usize] .iter() - .map(|rlp| ArenaSparseNodeBranchChild::Blinded(rlp.clone())) + .map(|rlp| arena.insert_blinded(rlp.clone())) .collect(); Self::Branch(ArenaSparseNodeBranch { state: ArenaSparseNodeState::Revealed, diff --git a/crates/trie/sparse/src/state.rs b/crates/trie/sparse/src/state.rs index a43139571b4..769d3300d6d 100644 --- a/crates/trie/sparse/src/state.rs +++ b/crates/trie/sparse/src/state.rs @@ -369,6 +369,17 @@ where self.storage.tries.get_mut(account).and_then(|trie| trie.root(new_epoch)) } + /// Returns the roots of all revealed storage tries, keyed by hashed account address. + /// + /// Dirty roots are computed at `new_epoch`; blind storage tries are omitted. + pub fn storage_roots(&mut self, new_epoch: TrieNodeEpoch) -> B256Map { + self.storage + .tries + .iter_mut() + .filter_map(|(address, trie)| trie.root(new_epoch).map(|root| (*address, root))) + .collect() + } + /// Returns mutable reference to the revealed account sparse trie. fn revealed_trie_mut(&mut self) -> SparseStateTrieResult<&mut A> { self.state.as_revealed_mut().ok_or_else(|| SparseTrieErrorKind::Blind.into()) diff --git a/crates/trie/sparse/src/traits.rs b/crates/trie/sparse/src/traits.rs index 2f8ffc08b25..cae190fb363 100644 --- a/crates/trie/sparse/src/traits.rs +++ b/crates/trie/sparse/src/traits.rs @@ -78,7 +78,9 @@ pub trait SparseTrie: Sized + Debug + Send + Sync { /// /// # Returns /// - /// `Ok(())` if successful, or an error if revealing fails. + /// `Ok(())` if successful, or an error if revealing fails. Implementations may reject + /// unsupported root variants. The arena implementation requires extensions to be merged + /// into branch nodes and returns an error for standalone [`TrieNodeV2::Extension`] roots. /// /// # Panics /// @@ -103,9 +105,9 @@ pub trait SparseTrie: Sized + Debug + Send + Sync { /// Reveals one or more trie nodes if they have not been revealed before. /// - /// This function decodes trie nodes and inserts them into the trie structure. It handles - /// different node types (leaf, extension, branch) by appropriately adding them to the trie and - /// recursively revealing their children. + /// Attaches supplied nodes where their paths identify blinded nodes in the current trie. + /// Nodes already revealed, paths diverging from the trie, and paths naming absent children + /// may be skipped. /// /// # Arguments /// @@ -114,7 +116,8 @@ pub trait SparseTrie: Sized + Debug + Send + Sync { /// /// # Returns /// - /// `Ok(())` if successful, or an error if any of the nodes was not revealed. + /// `Ok(())` if processing succeeds, including when nodes are skipped, or an error if + /// revelation fails. Success does not guarantee that every supplied node was incorporated. /// /// # Note /// diff --git a/crates/trie/sparse/src/trie.rs b/crates/trie/sparse/src/trie.rs index e4a3e429c56..d1bd197ca75 100644 --- a/crates/trie/sparse/src/trie.rs +++ b/crates/trie/sparse/src/trie.rs @@ -2,12 +2,10 @@ use crate::{ ArenaParallelSparseTrie, LeafUpdate, SparseTrie as SparseTrieTrait, SparseTrieUpdates, TrieNodeEpoch, }; -use alloc::{borrow::Cow, boxed::Box}; +use alloc::boxed::Box; use alloy_primitives::{map::B256Map, B256}; use reth_execution_errors::{SparseTrieErrorKind, SparseTrieResult}; -use reth_trie_common::{ - BranchNodeMasks, Nibbles, ProofTrieNodeV2, ProofV2TargetParent, RlpNode, TrieMask, TrieNodeV2, -}; +use reth_trie_common::{BranchNodeMasks, ProofTrieNodeV2, ProofV2TargetParent, TrieNodeV2}; /// A sparse trie that is either in a "blind" state (no nodes are revealed, root node hash is /// unknown) or in a "revealed" state (root node has been revealed and the trie can be updated). @@ -244,221 +242,6 @@ impl RevealableSparseTrie { } } -/// Enum representing sparse trie node type. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SparseNodeType { - /// Empty trie node. - Empty, - /// A placeholder that stores only the hash for a node that has not been fully revealed. - Hash, - /// Sparse leaf node. - Leaf, - /// Sparse extension node. - Extension { - /// A flag indicating whether the extension node should be stored in the database. - store_in_db_trie: Option, - }, - /// Sparse branch node. - Branch { - /// A flag indicating whether the branch node should be stored in the database. - store_in_db_trie: Option, - }, -} - -impl SparseNodeType { - /// Returns true if the node is a hash node. - pub const fn is_hash(&self) -> bool { - matches!(self, Self::Hash) - } - - /// Returns true if the node is a branch node. - pub const fn is_branch(&self) -> bool { - matches!(self, Self::Branch { .. }) - } - - /// Returns true if the node should be stored in the database. - pub const fn store_in_db_trie(&self) -> Option { - match *self { - Self::Extension { store_in_db_trie } | Self::Branch { store_in_db_trie } => { - store_in_db_trie - } - _ => None, - } - } -} - -/// Enum representing trie nodes in sparse trie. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SparseNode { - /// Empty trie node. - Empty, - /// Sparse leaf node with remaining key suffix. - Leaf { - /// Remaining key suffix for the leaf node. - key: Nibbles, - /// Tracker for the node's state, e.g. cached `RlpNode` tracking. - state: SparseNodeState, - }, - /// Sparse extension node with key. - Extension { - /// The key slice stored by this extension node. - key: Nibbles, - /// Tracker for the node's state, e.g. cached `RlpNode` tracking. - state: SparseNodeState, - }, - /// Sparse branch node with state mask. - Branch { - /// The bitmask representing children present in the branch node. - state_mask: TrieMask, - /// Tracker for the node's state, e.g. cached `RlpNode` tracking. - state: SparseNodeState, - /// The mask of the children that are blinded. - blinded_mask: TrieMask, - /// The hashes of the children that are blinded. - blinded_hashes: Box<[B256; 16]>, - }, -} - -impl SparseNode { - /// Create new [`SparseNode::Branch`] from state mask and blinded nodes. - #[cfg(test)] - pub fn new_branch(state_mask: TrieMask, blinded_children: &[(u8, B256)]) -> Self { - let mut blinded_mask = TrieMask::default(); - let mut blinded_hashes = Box::new([B256::ZERO; 16]); - - for (nibble, hash) in blinded_children { - blinded_mask.set_bit(*nibble); - blinded_hashes[*nibble as usize] = *hash; - } - Self::Branch { state_mask, state: SparseNodeState::Dirty, blinded_mask, blinded_hashes } - } - - /// Create new [`SparseNode::Branch`] with two bits set. - pub fn new_split_branch(bit_a: u8, bit_b: u8) -> Self { - let state_mask = TrieMask::new( - // set bits for both children - (1u16 << bit_a) | (1u16 << bit_b), - ); - Self::Branch { - state_mask, - state: SparseNodeState::Dirty, - blinded_mask: TrieMask::default(), - blinded_hashes: Box::new([B256::ZERO; 16]), - } - } - - /// Create new [`SparseNode::Extension`] from the key slice. - pub const fn new_ext(key: Nibbles) -> Self { - Self::Extension { key, state: SparseNodeState::Dirty } - } - - /// Create new [`SparseNode::Leaf`] from leaf key and value. - pub const fn new_leaf(key: Nibbles) -> Self { - Self::Leaf { key, state: SparseNodeState::Dirty } - } - - /// Returns the cached [`RlpNode`] of the node, if it's available. - pub fn cached_rlp_node(&self) -> Option> { - match &self { - Self::Empty => None, - Self::Leaf { state, .. } | - Self::Extension { state, .. } | - Self::Branch { state, .. } => state.cached_rlp_node().map(Cow::Borrowed), - } - } - - /// Returns the cached hash of the node, if it's available. - pub fn cached_hash(&self) -> Option { - match &self { - Self::Empty => None, - Self::Leaf { state, .. } | - Self::Extension { state, .. } | - Self::Branch { state, .. } => state.cached_hash(), - } - } - - /// Sets the hash of the node for testing purposes. - /// - /// For [`SparseNode::Empty`] nodes, this method panics. - #[cfg(any(test, feature = "test-utils"))] - pub fn set_state(&mut self, new_state: SparseNodeState) { - match self { - Self::Empty => { - panic!("Cannot set hash for Empty or Hash nodes") - } - Self::Leaf { state, .. } | - Self::Extension { state, .. } | - Self::Branch { state, .. } => { - *state = new_state; - } - } - } - - /// Sets the state of the node and returns a new node with the same state. - #[cfg(any(test, feature = "test-utils"))] - pub fn with_state(mut self, state: SparseNodeState) -> Self { - self.set_state(state); - self - } -} - -/// Tracks the current state of a node in the trie, specifically regarding whether it's been updated -/// or not. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SparseNodeState { - /// The node has been updated and its new `RlpNode` has not yet been calculated. - /// - /// If a node is dirty and has children (branches or extensions) then at least once child must - /// also be dirty. - Dirty, - /// The node has a cached `RlpNode`, either from being revealed or computed after an update. - Cached { - /// The RLP node which is used to represent this node in its parent. Usually this is the - /// RLP encoding of the node's hash, except for when the node RLP encodes to <32 - /// bytes. - rlp_node: RlpNode, - /// Flag indicating if this node is cached in the database. - /// - /// NOTE for extension nodes this actually indicates the node's child branch is in the - /// database, not the extension itself. - store_in_db_trie: Option, - }, -} - -impl SparseNodeState { - /// Returns the cached [`RlpNode`] of the node, if it's available. - pub const fn cached_rlp_node(&self) -> Option<&RlpNode> { - match self { - Self::Cached { rlp_node, .. } => Some(rlp_node), - Self::Dirty => None, - } - } - - /// Returns the cached hash of the node, if it's available. - pub fn cached_hash(&self) -> Option { - self.cached_rlp_node().and_then(|n| n.as_hash()) - } - - /// Returns whether or not this node is stored in the db, or None if it's not known. - pub const fn store_in_db_trie(&self) -> Option { - match self { - Self::Cached { store_in_db_trie, .. } => *store_in_db_trie, - Self::Dirty => None, - } - } -} - -/// RLP node stack item. -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct RlpNodeStackItem { - /// Path to the node. - pub path: Nibbles, - /// RLP node. - pub rlp_node: RlpNode, - /// Type of the node. - pub node_type: SparseNodeType, -} - impl SparseTrieUpdates { /// Clears the updates, but keeps the backing data structures allocated. pub fn clear(&mut self) { diff --git a/crates/trie/sparse/tests/suite/find_leaf.rs b/crates/trie/sparse/tests/suite/find_leaf.rs index e78ae18d734..b3fd218b713 100644 --- a/crates/trie/sparse/tests/suite/find_leaf.rs +++ b/crates/trie/sparse/tests/suite/find_leaf.rs @@ -6,7 +6,7 @@ pub(super) fn test_find_leaf_exists(new_trie: fn() -> T) { let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -35,7 +35,7 @@ pub(super) fn test_find_leaf_nonexistent(new_trie: fn() -> T) { let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -99,7 +99,7 @@ pub(super) fn test_find_leaf_value_mismatch(new_trie: fn() -> T) let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -131,7 +131,7 @@ pub(super) fn test_find_leaf_nonexistent_branch_divergence(new_tr key2.0[1] = 0x56; let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -162,7 +162,7 @@ pub(super) fn test_find_leaf_nonexistent_extension_divergence(new key1.0[1] = 0x34; key1.0[2] = 0x56; - let base_storage: BTreeMap = once((key1, U256::from(1))).collect(); + let base_storage: BTreeMap = once((key1, U256::ONE)).collect(); let harness = SuiteTestHarness::new(base_storage); let trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -191,7 +191,7 @@ pub(super) fn test_find_leaf_nonexistent_leaf_divergence(new_trie existing_key.0[0] = 0x12; existing_key.0[1] = 0x34; - let base_storage: BTreeMap = once((existing_key, U256::from(1))).collect(); + let base_storage: BTreeMap = once((existing_key, U256::ONE)).collect(); let harness = SuiteTestHarness::new(base_storage); let trie: T = harness.init_trie_fully_revealed(false, new_trie); diff --git a/crates/trie/sparse/tests/suite/get_leaf_value.rs b/crates/trie/sparse/tests/suite/get_leaf_value.rs index a9873e72e30..095b6c33c70 100644 --- a/crates/trie/sparse/tests/suite/get_leaf_value.rs +++ b/crates/trie/sparse/tests/suite/get_leaf_value.rs @@ -8,7 +8,7 @@ pub(super) fn test_get_leaf_value_after_update(new_trie: fn() -> let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -48,7 +48,7 @@ pub(super) fn test_get_leaf_value_after_removal(new_trie: fn() -> let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); diff --git a/crates/trie/sparse/tests/suite/lifecycle.rs b/crates/trie/sparse/tests/suite/lifecycle.rs index 1cd8988952e..270f5744bcc 100644 --- a/crates/trie/sparse/tests/suite/lifecycle.rs +++ b/crates/trie/sparse/tests/suite/lifecycle.rs @@ -315,7 +315,7 @@ pub(super) fn test_touched_prewarm_then_changed_update(new_trie: let key5 = B256::with_last_byte(0x50); let base_storage: BTreeMap = [ - (key1, U256::from(1)), + (key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3)), (key4, U256::from(4)), @@ -497,7 +497,7 @@ pub(super) fn test_find_leaf_before_update_to_check_existence(new let nonexistent_key = B256::with_last_byte(0x40); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let mut harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -619,4 +619,73 @@ pub(super) fn test_prune_then_reuse_for_next_block(new_trie: fn() harness.original_root(), "cold path root should match reference (K1 and K5 updated)" ); + + // --- Block 3: prune a trie that already holds blinded children, then re-reveal one --- + let _ = trie.take_updates(); + trie.prune(epoch(2)); + + let mut changeset3: BTreeMap = BTreeMap::new(); + changeset3.insert(keys[7], U256::from(777)); + let mut leaf_updates3 = SuiteTestHarness::leaf_updates(&changeset3); + harness.reveal_and_update(&mut trie, &mut leaf_updates3); + let root3 = trie.root(epoch(3)); + + harness.apply_changeset(changeset3); + assert_eq!(root3, harness.original_root(), "block 3 root should match reference"); +} + +/// Many blocks of reveal, update, root and prune over subtries that are several levels deep. +/// +/// Keys share four first-byte prefixes, two per first nibble so that the nodes below them are +/// subtrie roots rather than children of an extension. Each subtrie holds hundreds of leaves and +/// keeps blinded children at every level, so consecutive prunes drop, carry over and create +/// blinded children in the same subtrie while the updates in between re-reveal them. +pub(super) fn test_repeated_prune_and_reveal_with_deep_subtries( + new_trie: fn() -> T, +) { + const INITIAL_KEYS: u64 = 1024; + const PREFIXES: [u8; 4] = [0x1a, 0x1c, 0xc4, 0xc7]; + + let key = |i: u64| { + let mut hashed = keccak256(i.to_be_bytes()); + hashed.0[0] = PREFIXES[(i % 4) as usize]; + hashed + }; + + let storage: BTreeMap = + (0..INITIAL_KEYS).map(|i| (key(i), U256::from(i + 1))).collect(); + let mut harness = SuiteTestHarness::new(storage); + let mut trie: T = harness.init_trie_with_targets(&[], true, new_trie); + + let mut next_new_key = INITIAL_KEYS; + for block in 1..=24u64 { + // Busy blocks keep the previous block's nodes as well. Every fourth block touches one + // key per subtrie and keeps only its own nodes, which discards most of what the busy + // blocks revealed. + let quiet = block % 4 == 0; + let (touched, inserted, retained_blocks) = if quiet { (4, 0, 1) } else { (64, 4, 2) }; + + let mut changeset = BTreeMap::new(); + // Modify and delete existing keys; some of them were already deleted by earlier blocks. + for j in 0..touched { + let value = + if (block + j) % 5 == 0 { U256::ZERO } else { U256::from(block * 1000 + j) }; + changeset.insert(key((block * 37 + j * 53) % INITIAL_KEYS), value); + } + for _ in 0..inserted { + changeset.insert(key(next_new_key), U256::from(next_new_key)); + next_new_key += 1; + } + + let mut leaf_updates = SuiteTestHarness::leaf_updates(&changeset); + harness.reveal_and_update(&mut trie, &mut leaf_updates); + let root = trie.root(epoch(block)); + + harness.apply_changeset(changeset); + assert_eq!(root, harness.original_root(), "block {block} root should match reference"); + + let _ = trie.take_updates(); + trie.prune(epoch(block + 1 - retained_blocks)); + assert_eq!(trie.root(epoch(block)), root, "block {block} root should survive the prune"); + } } diff --git a/crates/trie/sparse/tests/suite/main.rs b/crates/trie/sparse/tests/suite/main.rs index 7fdab8685ab..2852a1ba44c 100644 --- a/crates/trie/sparse/tests/suite/main.rs +++ b/crates/trie/sparse/tests/suite/main.rs @@ -17,7 +17,7 @@ //! - [`find_leaf`]: Tests for `find_leaf` //! - [`lifecycle`]: Integration tests exercising multiple methods together -use alloy_primitives::{map::B256Map, B256, U256}; +use alloy_primitives::{keccak256, map::B256Map, B256, U256}; use alloy_rlp::{encode_fixed_size, Decodable}; use alloy_trie::EMPTY_ROOT_HASH; use reth_trie::test_utils::TrieTestHarness; @@ -78,11 +78,8 @@ impl SuiteTestHarness { changes .iter() .map(|(&slot, &value)| { - let rlp_value = if value == U256::ZERO { - Vec::new() - } else { - encode_fixed_size(&value).to_vec() - }; + let rlp_value = + if value.is_zero() { Vec::new() } else { encode_fixed_size(&value).to_vec() }; (slot, LeafUpdate::Changed(rlp_value)) }) .collect() @@ -312,4 +309,5 @@ sparse_trie_tests! { test_get_leaf_value_for_storage_root_lookup, test_find_leaf_before_update_to_check_existence, test_prune_then_reuse_for_next_block, + test_repeated_prune_and_reveal_with_deep_subtries, } diff --git a/crates/trie/sparse/tests/suite/prune.rs b/crates/trie/sparse/tests/suite/prune.rs index 624b3b41cd2..8ee43284d4c 100644 --- a/crates/trie/sparse/tests/suite/prune.rs +++ b/crates/trie/sparse/tests/suite/prune.rs @@ -13,7 +13,7 @@ pub(super) fn test_prune_retains_recent_leaves(new_trie: fn() -> key_e.0[0] = 0x50; let storage: BTreeMap = BTreeMap::from([ - (key_a, U256::from(1)), + (key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3)), (key_d, U256::from(4)), @@ -63,7 +63,7 @@ pub(super) fn test_prune_retains_structurally_modified_branch(new let old_keys = [key(0x00, 0x00), key(0x00, 0x10), key(0x00, 0x20)]; let sibling = key(0x01, 0x00); let mut storage = BTreeMap::from([ - (old_keys[0], U256::from(1)), + (old_keys[0], U256::ONE), (old_keys[1], U256::from(2)), (old_keys[2], U256::from(3)), (sibling, U256::from(4)), @@ -232,7 +232,7 @@ pub(super) fn test_prune_mixed_embedded_and_hashed_nodes(new_trie for i in 4..8u8 { let mut key = B256::ZERO; key.0[0] = i; - storage.insert(key, U256::from(1)); + storage.insert(key, U256::ONE); } let mut trie = (new_trie)(); @@ -291,8 +291,7 @@ pub(super) fn test_prune_then_update_no_panic(new_trie: fn() -> T /// leaf or empty root), `prune` should immediately return 0 without walking. pub(super) fn test_prune_only_descends_into_branch_root(new_trie: fn() -> T) { // Single-leaf trie: root is a leaf node, not a branch. - let storage: BTreeMap = - BTreeMap::from([(B256::with_last_byte(0x10), U256::from(1))]); + let storage: BTreeMap = BTreeMap::from([(B256::with_last_byte(0x10), U256::ONE)]); let harness = SuiteTestHarness::new(storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -327,7 +326,7 @@ pub(super) fn test_prune_handles_small_subtrie_root_nodes(new_tri // Small subtrie: single small leaf let mut small_key = B256::ZERO; small_key.0[0] = 0x20; - storage.insert(small_key, U256::from(1)); + storage.insert(small_key, U256::ONE); let harness = SuiteTestHarness::new(storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); diff --git a/crates/trie/sparse/tests/suite/reveal_nodes.rs b/crates/trie/sparse/tests/suite/reveal_nodes.rs index 8da0e3c9fdf..dbceec3c954 100644 --- a/crates/trie/sparse/tests/suite/reveal_nodes.rs +++ b/crates/trie/sparse/tests/suite/reveal_nodes.rs @@ -13,7 +13,7 @@ pub(super) fn test_reveal_nodes_empty_slice(new_trie: fn() -> T) let mut key_b = B256::ZERO; key_b.0[0] = 0x20; let storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2))]); let harness = SuiteTestHarness::new(storage); let root_node = harness.root_node(); @@ -41,7 +41,7 @@ pub(super) fn test_reveal_nodes_single_leaf(new_trie: fn() -> T) let mut key_c = B256::ZERO; key_c.0[0] = 0x30; let storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(storage); @@ -63,7 +63,7 @@ pub(super) fn test_reveal_nodes_idempotent(new_trie: fn() -> T) { let mut key_c = B256::ZERO; key_c.0[0] = 0x30; let storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(storage); @@ -248,7 +248,7 @@ pub(super) fn test_reveal_insert_reveal_preserves_branch_state(ne let key_c = B256::with_last_byte(0x02); let original_storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(original_storage); @@ -269,7 +269,7 @@ pub(super) fn test_reveal_insert_reveal_preserves_branch_state(ne // Root must match a reference trie with all 3 keys. let root = trie.root(epoch(0)); let expected_storage = - BTreeMap::from([(key_a, U256::from(1)), (key_b, insert_value), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, insert_value), (key_c, U256::from(3))]); let expected_harness = SuiteTestHarness::new(expected_storage); assert_eq!( root, @@ -303,7 +303,7 @@ pub(super) fn test_remove_then_reveal_does_not_overwrite_collapsed_node = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(original_storage); @@ -353,7 +353,7 @@ pub(super) fn test_insert_then_reveal_does_not_overwrite_branch( }; let original_storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2))]); let harness = SuiteTestHarness::new(original_storage); @@ -379,7 +379,7 @@ pub(super) fn test_insert_then_reveal_does_not_overwrite_branch( // Root must match a reference trie with all 3 keys. let root = trie.root(epoch(0)); let expected_storage = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, insert_value)]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, insert_value)]); let expected_harness = SuiteTestHarness::new(expected_storage); assert_eq!( root, diff --git a/crates/trie/sparse/tests/suite/root.rs b/crates/trie/sparse/tests/suite/root.rs index 45f0c89abd3..7bcc2cb7fd1 100644 --- a/crates/trie/sparse/tests/suite/root.rs +++ b/crates/trie/sparse/tests/suite/root.rs @@ -18,7 +18,7 @@ pub(super) fn test_root_cached_returns_without_recomputation(new_ let mut key_c = B256::ZERO; key_c.0[0] = 0x30; let storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(storage); let mut trie: T = harness.init_trie_fully_revealed(true, new_trie); @@ -44,7 +44,7 @@ pub(super) fn test_root_after_single_leaf_update(new_trie: fn() - let mut key_c = B256::ZERO; key_c.0[0] = 0x30; let storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -62,7 +62,7 @@ pub(super) fn test_root_after_single_leaf_update(new_trie: fn() - // Build a reference trie with the updated value and verify. let expected_storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(999)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(999)), (key_c, U256::from(3))]); let expected_harness = SuiteTestHarness::new(expected_storage); assert_eq!( new_root, @@ -89,7 +89,7 @@ pub(super) fn test_root_deterministic_across_update_orders(new_tr k5.0[0] = 0x50; let pairs = [ - (k1, U256::from(1)), + (k1, U256::ONE), (k2, U256::from(2)), (k3, U256::from(3)), (k4, U256::from(4)), @@ -135,7 +135,7 @@ pub(super) fn test_root_deterministic_across_update_orders(new_tr pub(super) fn test_root_handles_small_root_node_without_hash(new_trie: fn() -> T) { // A single small leaf produces a root node whose RLP is < 32 bytes. let key = B256::with_last_byte(1); - let value = U256::from(1); + let value = U256::ONE; let storage: BTreeMap = BTreeMap::from([(key, value)]); let harness = SuiteTestHarness::new(storage); diff --git a/crates/trie/sparse/tests/suite/set_root.rs b/crates/trie/sparse/tests/suite/set_root.rs index 3d0d57fd54b..7af439bb64c 100644 --- a/crates/trie/sparse/tests/suite/set_root.rs +++ b/crates/trie/sparse/tests/suite/set_root.rs @@ -111,7 +111,7 @@ pub(super) fn test_set_root_does_not_retain_updates_when_not_requested = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(storage); // retain_updates = false diff --git a/crates/trie/sparse/tests/suite/take_updates.rs b/crates/trie/sparse/tests/suite/take_updates.rs index 581d7097b5e..b67205d28a5 100644 --- a/crates/trie/sparse/tests/suite/take_updates.rs +++ b/crates/trie/sparse/tests/suite/take_updates.rs @@ -8,7 +8,7 @@ pub(super) fn test_take_updates_returns_empty_when_not_tracking( let mut key_b = B256::ZERO; key_b.0[0] = 0x20; let storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2))]); let harness = SuiteTestHarness::new(storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -206,7 +206,7 @@ pub(super) fn test_take_updates_contains_updated_and_removed_nodes( new_trie: fn() -> T, ) { - let val = U256::from(1u64); + let val = U256::ONE; let mut key_existing = B256::ZERO; key_existing.0[0] = 0xAA; @@ -287,7 +287,7 @@ pub(super) fn test_take_updates_no_duplicate_updated_and_removed_nodes = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(storage); let mut trie: T = harness.init_trie_fully_revealed(true, new_trie); diff --git a/crates/trie/sparse/tests/suite/update_leaves.rs b/crates/trie/sparse/tests/suite/update_leaves.rs index fe88d81dec3..d4272b7d8c4 100644 --- a/crates/trie/sparse/tests/suite/update_leaves.rs +++ b/crates/trie/sparse/tests/suite/update_leaves.rs @@ -11,7 +11,7 @@ pub(super) fn test_update_leaves_insert_new_leaf(new_trie: fn() - let new_key = B256::with_last_byte(0x40); let base_storage: BTreeMap = - BTreeMap::from([(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))]); + BTreeMap::from([(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))]); let harness = SuiteTestHarness::new(base_storage); @@ -31,7 +31,7 @@ pub(super) fn test_update_leaves_insert_new_leaf(new_trie: fn() - // Compute expected root with all 4 leaves. let expected_storage = BTreeMap::from([ - (key1, U256::from(1)), + (key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3)), (new_key, new_value), @@ -54,7 +54,7 @@ pub(super) fn test_update_leaves_modify_existing_leaf(new_trie: f let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - BTreeMap::from([(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))]); + BTreeMap::from([(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))]); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(true, new_trie); @@ -70,7 +70,7 @@ pub(super) fn test_update_leaves_modify_existing_leaf(new_trie: f // Compute expected root with the modified value. let expected_storage = - BTreeMap::from([(key1, U256::from(1)), (key2, new_value), (key3, U256::from(3))]); + BTreeMap::from([(key1, U256::ONE), (key2, new_value), (key3, U256::from(3))]); let expected_harness = SuiteTestHarness::new(expected_storage); assert_eq!( root, @@ -85,7 +85,7 @@ pub(super) fn test_update_leaves_modify_existing_leaf(new_trie: f /// hash matching a reference trie with that single leaf. pub(super) fn test_insert_single_leaf_into_empty_trie(new_trie: fn() -> T) { let key = B256::with_last_byte(42); - let value = U256::from(1); + let value = U256::ONE; let mut trie = (new_trie)(); let mut leaf_updates = SuiteTestHarness::leaf_updates(&BTreeMap::from([(key, value)])); @@ -157,7 +157,7 @@ pub(super) fn test_update_all_leaves_with_new_values(new_trie: fn .map(|b| if b % 2 == 0 { B256::repeat_byte(b) } else { B256::with_last_byte(b) }) .collect(); - let old_storage: BTreeMap = keys.iter().map(|&k| (k, U256::from(1))).collect(); + let old_storage: BTreeMap = keys.iter().map(|&k| (k, U256::ONE)).collect(); let new_storage: BTreeMap = keys.iter().map(|&k| (k, U256::from(999))).collect(); @@ -201,7 +201,7 @@ pub(super) fn test_two_leaves_at_adjacent_keys_root_correctness( key_50.0[0] = 0x50; let mut key_51 = B256::ZERO; key_51.0[0] = 0x51; - let value = U256::from(1); + let value = U256::ONE; let mut trie = (new_trie)(); trie.set_updates(true); @@ -242,7 +242,7 @@ pub(super) fn test_update_leaves_remove_leaf(new_trie: fn() -> T) let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - BTreeMap::from([(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))]); + BTreeMap::from([(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))]); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(true, new_trie); @@ -256,7 +256,7 @@ pub(super) fn test_update_leaves_remove_leaf(new_trie: fn() -> T) let root = trie.root(epoch(0)); // Expected: reference trie with only key1 and key3. - let expected_storage = BTreeMap::from([(key1, U256::from(1)), (key3, U256::from(3))]); + let expected_storage = BTreeMap::from([(key1, U256::ONE), (key3, U256::from(3))]); let expected_harness = SuiteTestHarness::new(expected_storage); assert_eq!( root, @@ -287,7 +287,7 @@ pub(super) fn test_remove_leaf_branch_collapses_to_extension(new_ key_537.0[1] = 0x70; let base_storage: BTreeMap = BTreeMap::from([ - (key_50231, U256::from(1)), + (key_50231, U256::ONE), (key_50233, U256::from(2)), (key_537, U256::from(3)), ]); @@ -302,7 +302,7 @@ pub(super) fn test_remove_leaf_branch_collapses_to_extension(new_ let root = trie.root(epoch(0)); // Expected: reference trie with only the two remaining leaves. - let expected_storage = BTreeMap::from([(key_50231, U256::from(1)), (key_50233, U256::from(2))]); + let expected_storage = BTreeMap::from([(key_50231, U256::ONE), (key_50233, U256::from(2))]); let expected_harness = SuiteTestHarness::new(expected_storage); assert_eq!( root, @@ -360,7 +360,7 @@ pub(super) fn test_remove_leaf_branch_collapses_to_leaf(new_trie: /// `EMPTY_ROOT_HASH`. pub(super) fn test_remove_last_leaf_produces_empty_root(new_trie: fn() -> T) { let key = B256::with_last_byte(0x12); - let base_storage: BTreeMap = BTreeMap::from([(key, U256::from(1))]); + let base_storage: BTreeMap = BTreeMap::from([(key, U256::ONE)]); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -396,7 +396,7 @@ pub(super) fn test_insert_then_remove_sequence(new_trie: fn() -> let k5 = key_from_nibbles(&[0x5, 0x3, 0x3, 0x0, 0x2]); let k6 = key_from_nibbles(&[0x5, 0x3, 0x3, 0x2, 0x0]); - let val = U256::from(1); + let val = U256::ONE; let all_keys = [k1, k2, k3, k4, k5, k6]; // Insert all 6 leaves into empty trie. @@ -443,7 +443,7 @@ pub(super) fn test_remove_nonexistent_leaf_preserves_hashes(new_t let key_c = B256::with_last_byte(0x30); let base_storage: BTreeMap = - BTreeMap::from([(key_a, U256::from(1)), (key_b, U256::from(2)), (key_c, U256::from(3))]); + BTreeMap::from([(key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3))]); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -597,7 +597,7 @@ pub(super) fn test_remove_leaf_blinded_sibling_requires_reveal(ne key.0[0] = 0x10; key }; - base_storage.insert(revealed_key, U256::from(1)); + base_storage.insert(revealed_key, U256::ONE); // 16 keys under first nibble 0x2 (slots 0x20..0x2F) — enough to produce a hash node. for i in 0u8..16 { @@ -659,7 +659,7 @@ pub(super) fn test_update_leaves_removal_branch_collapse_blinded_sibling( key.0[0] = 0x11; key }; - base_storage.insert(subtrie_key_a, U256::from(1)); + base_storage.insert(subtrie_key_a, U256::ONE); base_storage.insert(subtrie_key_b, U256::from(2)); // 16 keys under first nibble 0x2 — enough to produce a hash node. @@ -822,7 +822,7 @@ pub(super) fn test_update_leaves_touched_fully_revealed(new_trie: let key3 = B256::with_last_byte(0x30); let base_storage: BTreeMap = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -938,7 +938,7 @@ pub(super) fn test_update_leaves_touched_nonexistent_in_populated_trie = - [(key1, U256::from(1)), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); + [(key1, U256::ONE), (key2, U256::from(2)), (key3, U256::from(3))].into_iter().collect(); let harness = SuiteTestHarness::new(base_storage); let mut trie: T = harness.init_trie_fully_revealed(false, new_trie); @@ -1128,7 +1128,7 @@ pub(super) fn test_orphaned_value_update_falls_through_to_full_insertion = [ - (key_a, U256::from(1)), + (key_a, U256::ONE), (key_b, U256::from(2)), (key_c, U256::from(3)), (key_d, U256::from(4)), @@ -1347,7 +1347,7 @@ pub(super) fn test_branch_collapse_multi_empty_subtries_blinded_remaining = - BTreeMap::from([(key_d7, U256::from(1)), (key_d8, U256::from(2)), (key_dd, U256::from(3))]); + BTreeMap::from([(key_d7, U256::ONE), (key_d8, U256::from(2)), (key_dd, U256::from(3))]); let harness = SuiteTestHarness::new(base_storage); @@ -1425,7 +1425,7 @@ pub(super) fn test_subtrie_collapse_touched_with_blinded_sibling( key_cd1[0] = 0xCD; key_cd1[31] = 0x01; - let value = U256::from(1u64); + let value = U256::ONE; let base_storage: BTreeMap = [(key_ab1, value), (key_ab2, value), (key_ac1, value), (key_cd1, value)] @@ -1489,7 +1489,7 @@ pub(super) fn test_subtrie_emptied_by_deletes_with_touched(new_tr key_cd1[0] = 0xCD; key_cd1[31] = 0x01; - let value = U256::from(1u64); + let value = U256::ONE; let base_storage: BTreeMap = [(key_ab1, value), (key_ab2, value), (key_ac1, value), (key_cd1, value)] diff --git a/crates/trie/sparse/tests/suite/wipe_clear.rs b/crates/trie/sparse/tests/suite/wipe_clear.rs index ab4677a1cfd..435cdd1a6da 100644 --- a/crates/trie/sparse/tests/suite/wipe_clear.rs +++ b/crates/trie/sparse/tests/suite/wipe_clear.rs @@ -7,7 +7,7 @@ pub(super) fn test_clear_resets_trie_but_preserves_update_tracking T, ) { let storage: BTreeMap = BTreeMap::from([ - (B256::with_last_byte(0x10), U256::from(1)), + (B256::with_last_byte(0x10), U256::ONE), (B256::with_last_byte(0x20), U256::from(2)), (B256::with_last_byte(0x30), U256::from(3)), ]); @@ -38,7 +38,7 @@ pub(super) fn test_clear_resets_trie_but_preserves_update_tracking(new_trie: fn() -> T) { // Phase 1: build a trie with 5 leaves and compute root. let storage_1: BTreeMap = BTreeMap::from([ - (B256::with_last_byte(0x10), U256::from(1)), + (B256::with_last_byte(0x10), U256::ONE), (B256::with_last_byte(0x20), U256::from(2)), (B256::with_last_byte(0x30), U256::from(3)), (B256::with_last_byte(0x40), U256::from(4)), diff --git a/crates/trie/trie/Cargo.toml b/crates/trie/trie/Cargo.toml index 9b698d9a95c..4d6010927a2 100644 --- a/crates/trie/trie/Cargo.toml +++ b/crates/trie/trie/Cargo.toml @@ -36,6 +36,7 @@ tracing = { workspace = true, features = ["attributes"] } # misc auto_impl.workspace = true itertools.workspace = true +parking_lot.workspace = true # `metrics` feature reth-metrics = { workspace = true, optional = true } @@ -43,7 +44,6 @@ metrics = { workspace = true, optional = true } # `test-utils` feature triehash = { workspace = true, optional = true } -parking_lot = { workspace = true, optional = true } [dev-dependencies] # reth @@ -57,13 +57,13 @@ triehash.workspace = true # misc assert_matches.workspace = true criterion.workspace = true -parking_lot.workspace = true pretty_assertions.workspace = true proptest-arbitrary-interop.workspace = true proptest.workspace = true rand.workspace = true [features] +dst = ["reth-trie-sparse/dst"] metrics = ["reth-metrics", "dep:metrics"] serde = [ "alloy-primitives/serde", @@ -72,11 +72,6 @@ serde = [ "alloy-eips/serde", "reth-trie-common/serde", "reth-primitives-traits/serde", - "alloy-primitives/serde", - "alloy-consensus/serde", - "alloy-trie/serde", - "alloy-eips/serde", - "reth-trie-common/serde", "reth-stages-types/serde", "parking_lot/serde", "rand/serde", @@ -84,7 +79,6 @@ serde = [ ] test-utils = [ "triehash", - "parking_lot", "reth-primitives-traits/test-utils", "reth-trie-common/test-utils", "reth-trie-sparse/test-utils", @@ -95,6 +89,12 @@ serde-bincode-compat = [ "alloy-eips/serde-bincode-compat", "reth-trie-common/serde-bincode-compat", ] +account-ext = [ + "reth-stages-types/account-ext", + "alloy-trie/account-ext", + "reth-primitives-traits/account-ext", + "revm/account-ext", +] [[bench]] name = "proof_v2" diff --git a/crates/trie/trie/benches/proof_v2.rs b/crates/trie/trie/benches/proof_v2.rs index f007e19a604..243c5659ae5 100644 --- a/crates/trie/trie/benches/proof_v2.rs +++ b/crates/trie/trie/benches/proof_v2.rs @@ -27,7 +27,7 @@ fn generate_test_data( let mut runner = TestRunner::deterministic(); // Use a fixed hashed address for the storage trie - let hashed_address = B256::from([0x42; 32]); + let hashed_address = B256::repeat_byte(0x42); // Generate random storage slots (key -> value) let storage_strategy = diff --git a/crates/trie/trie/src/changesets.rs b/crates/trie/trie/src/changesets.rs index 30942e5eb61..c13786f082d 100644 --- a/crates/trie/trie/src/changesets.rs +++ b/crates/trie/trie/src/changesets.rs @@ -201,7 +201,7 @@ mod tests { #[test] fn test_storage_changesets() { - let hashed_address = B256::from([1u8; 32]); + let hashed_address = B256::repeat_byte(1u8); // Create some initial storage trie state let path1 = Nibbles::from_nibbles([0x1, 0x2]); diff --git a/crates/trie/trie/src/hashed_cursor/metrics.rs b/crates/trie/trie/src/hashed_cursor/metrics.rs index 8cca1a1c1be..d88931ab039 100644 --- a/crates/trie/trie/src/hashed_cursor/metrics.rs +++ b/crates/trie/trie/src/hashed_cursor/metrics.rs @@ -1,23 +1,18 @@ -use super::{HashedCursor, HashedStorageCursor}; +use super::{HashedCursor, HashedCursorFactory, HashedStorageCursor}; +use crate::TrieType; use alloy_primitives::B256; +use reth_metrics::metrics::{self, Histogram}; use reth_primitives_traits::FastInstant as Instant; use reth_storage_errors::db::DatabaseError; use std::time::Duration; -use tracing::trace_span; - -#[cfg(feature = "metrics")] -use crate::TrieType; -#[cfg(feature = "metrics")] -use reth_metrics::metrics::{self, Histogram}; /// Prometheus metrics for hashed cursor operations. /// /// Tracks the number of cursor operations for monitoring and performance analysis. -#[cfg(feature = "metrics")] #[derive(Clone, Debug)] pub struct HashedCursorMetrics { - /// Histogram tracking overall time spent in database operations - overall_duration: Histogram, + /// Histogram tracking overall time spent in database operations, in seconds. + overall_duration_seconds: Histogram, /// Histogram for `next()` operations next_histogram: Histogram, /// Histogram for `seek()` operations @@ -26,15 +21,14 @@ pub struct HashedCursorMetrics { is_storage_empty_histogram: Histogram, } -#[cfg(feature = "metrics")] impl HashedCursorMetrics { /// Create a new metrics instance with the specified trie type label. pub fn new(trie_type: TrieType) -> Self { let trie_type_str = trie_type.as_str(); Self { - overall_duration: metrics::histogram!( - "trie.hashed_cursor.overall_duration", + overall_duration_seconds: metrics::histogram!( + "trie.hashed_cursor.overall_duration_seconds", "type" => trie_type_str ), next_histogram: metrics::histogram!( @@ -55,35 +49,11 @@ impl HashedCursorMetrics { } } - /// Record the cached metrics from the provided cache and reset the cache counters. - /// - /// This method adds the current counter values from the cache to the Prometheus metrics - /// and then resets all cache counters to zero. - pub fn record(&mut self, cache: &mut HashedCursorMetricsCache) { - self.next_histogram.record(cache.next_count as f64); - self.seek_histogram.record(cache.seek_count as f64); - self.is_storage_empty_histogram.record(cache.is_storage_empty_count as f64); - self.overall_duration.record(cache.total_duration.as_secs_f64()); - cache.reset(); - } -} - -/// Cached metrics counters for hashed cursor operations. -#[derive(Debug, Copy, Clone)] -pub struct HashedCursorMetricsCache { - /// Counter for `next()` calls - pub next_count: usize, - /// Counter for `seek()` calls - pub seek_count: usize, - /// Counter for `is_storage_empty()` calls (if applicable) - pub is_storage_empty_count: usize, - /// Total duration spent in database operations - pub total_duration: Duration, -} - -impl Default for HashedCursorMetricsCache { - fn default() -> Self { - Self { + /// Wraps a cursor with owned counters that are recorded when the cursor is dropped. + pub fn instrument(&self, cursor: C) -> InstrumentedHashedCursor { + InstrumentedHashedCursor { + cursor, + metrics: self.clone(), next_count: 0, seek_count: 0, is_storage_empty_count: 0, @@ -92,61 +62,30 @@ impl Default for HashedCursorMetricsCache { } } -impl HashedCursorMetricsCache { - /// Reset all counters to zero. - pub const fn reset(&mut self) { - self.next_count = 0; - self.seek_count = 0; - self.is_storage_empty_count = 0; - self.total_duration = Duration::ZERO; - } - - /// Extend this cache by adding the counts from another cache. - /// - /// This accumulates the counter values from `other` into this cache. - pub fn extend(&mut self, other: &Self) { - self.next_count += other.next_count; - self.seek_count += other.seek_count; - self.is_storage_empty_count += other.is_storage_empty_count; - self.total_duration += other.total_duration; - } - - /// Record the span for metrics. - pub fn record_span(&self, name: &'static str) { - let _span = trace_span!( - target: "trie::trie_cursor", - "Hashed cursor metrics", - name, - next_count = self.next_count, - seek_count = self.seek_count, - is_storage_empty_count = self.is_storage_empty_count, - total_duration = self.total_duration.as_secs_f64(), - ) - .entered(); - } -} - /// A wrapper around a [`HashedCursor`] that tracks metrics for cursor operations. /// +/// Counters are recorded into the supplied metric handles when the cursor is dropped. +/// /// This implementation counts the number of times each cursor operation is called: /// - `next()` - Move to the next entry /// - `seek()` - Seek to a key or the next greater key #[derive(Debug)] -pub struct InstrumentedHashedCursor<'metrics, C> { - /// The underlying cursor being wrapped +pub struct InstrumentedHashedCursor { + /// The underlying cursor being wrapped. cursor: C, - /// Cached metrics counters - metrics: &'metrics mut HashedCursorMetricsCache, + /// Metric handles used to record counters when the cursor is dropped. + metrics: HashedCursorMetrics, + /// Counter for `next()` calls. + next_count: usize, + /// Counter for `seek()` calls. + seek_count: usize, + /// Counter for `is_storage_empty` calls. + is_storage_empty_count: usize, + /// Total duration spent in cursor operations. + total_duration: Duration, } -impl<'metrics, C> InstrumentedHashedCursor<'metrics, C> { - /// Create a new metrics cursor wrapping the given cursor. - pub const fn new(cursor: C, metrics: &'metrics mut HashedCursorMetricsCache) -> Self { - Self { cursor, metrics } - } -} - -impl<'metrics, C> HashedCursor for InstrumentedHashedCursor<'metrics, C> +impl HashedCursor for InstrumentedHashedCursor where C: HashedCursor, { @@ -154,17 +93,17 @@ where fn seek(&mut self, key: B256) -> Result, DatabaseError> { let start = Instant::now(); - self.metrics.seek_count += 1; + self.seek_count += 1; let result = self.cursor.seek(key); - self.metrics.total_duration += start.elapsed(); + self.total_duration += start.elapsed(); result } fn next(&mut self) -> Result, DatabaseError> { let start = Instant::now(); - self.metrics.next_count += 1; + self.next_count += 1; let result = self.cursor.next(); - self.metrics.total_duration += start.elapsed(); + self.total_duration += start.elapsed(); result } @@ -173,15 +112,15 @@ where } } -impl<'metrics, C> HashedStorageCursor for InstrumentedHashedCursor<'metrics, C> +impl HashedStorageCursor for InstrumentedHashedCursor where C: HashedStorageCursor, { fn is_storage_empty(&mut self) -> Result { let start = Instant::now(); - self.metrics.is_storage_empty_count += 1; + self.is_storage_empty_count += 1; let result = self.cursor.is_storage_empty(); - self.metrics.total_duration += start.elapsed(); + self.total_duration += start.elapsed(); result } @@ -189,3 +128,56 @@ where self.cursor.set_hashed_address(hashed_address) } } + +impl Drop for InstrumentedHashedCursor { + fn drop(&mut self) { + self.metrics.next_histogram.record(self.next_count as f64); + self.metrics.seek_histogram.record(self.seek_count as f64); + self.metrics.is_storage_empty_histogram.record(self.is_storage_empty_count as f64); + self.metrics.overall_duration_seconds.record(self.total_duration.as_secs_f64()); + } +} + +/// Instruments every account and storage cursor opened by the wrapped factory. +/// +/// Counters are owned by each cursor and recorded into the supplied handles on drop. Wrap the +/// outermost overlay factory to include both overlay and database operations in these counters. +#[derive(Clone, Debug)] +pub struct InstrumentedHashedCursorFactory { + factory: F, + account_metrics: HashedCursorMetrics, + storage_metrics: HashedCursorMetrics, +} + +impl InstrumentedHashedCursorFactory { + /// Wraps a factory with account and storage metric handles shared by its cursors. + pub const fn new( + factory: F, + account_metrics: HashedCursorMetrics, + storage_metrics: HashedCursorMetrics, + ) -> Self { + Self { factory, account_metrics, storage_metrics } + } +} + +impl HashedCursorFactory for InstrumentedHashedCursorFactory { + type AccountCursor<'a> + = InstrumentedHashedCursor> + where + Self: 'a; + type StorageCursor<'a> + = InstrumentedHashedCursor> + where + Self: 'a; + + fn hashed_account_cursor(&self) -> Result, DatabaseError> { + Ok(self.account_metrics.instrument(self.factory.hashed_account_cursor()?)) + } + + fn hashed_storage_cursor( + &self, + hashed_address: B256, + ) -> Result, DatabaseError> { + Ok(self.storage_metrics.instrument(self.factory.hashed_storage_cursor(hashed_address)?)) + } +} diff --git a/crates/trie/trie/src/hashed_cursor/mock.rs b/crates/trie/trie/src/hashed_cursor/mock.rs index 15edd97adee..3fef3ce904b 100644 --- a/crates/trie/trie/src/hashed_cursor/mock.rs +++ b/crates/trie/trie/src/hashed_cursor/mock.rs @@ -56,7 +56,7 @@ impl MockHashedCursorFactory { let storage_map: BTreeMap = hashed_storage .storage .into_iter() - .filter_map(|(slot, value)| (value != U256::ZERO).then_some((slot, value))) + .filter_map(|(slot, value)| (!value.is_zero()).then_some((slot, value))) .collect(); (addr, storage_map) }) @@ -198,7 +198,7 @@ impl HashedCursor for MockHashedCursor { #[instrument(skip(self), ret(level = "trace"))] fn seek(&mut self, key: B256) -> Result, DatabaseError> { // Find the first key that is greater than or equal to the given key. - let entry = self.values().iter().find_map(|(k, v)| (k >= &key).then(|| (*k, v.clone()))); + let entry = self.values().range(key..).next().map(|(k, v)| (*k, v.clone())); if let Some((key, _)) = &entry { self.current_key = Some(*key); } @@ -211,13 +211,17 @@ impl HashedCursor for MockHashedCursor { #[instrument(skip(self), ret(level = "trace"))] fn next(&mut self) -> Result, DatabaseError> { - let mut iter = self.values().iter(); + let mut iter = match self.current_key { + Some(current) => self.values().range(current..), + None => self.values().range::(..), + }; // Jump to the first key that has a prefix of the current key if it's set, or to the first // key otherwise. - iter.find(|(k, _)| { - self.current_key.as_ref().is_none_or(|current| k.starts_with(current.as_slice())) - }) - .expect("current key should exist in values"); + iter.next() + .filter(|(k, _)| { + self.current_key.as_ref().is_none_or(|current| k.starts_with(current.as_slice())) + }) + .expect("current key should exist in values"); // Get the next key-value pair. let entry = iter.next().map(|(k, v)| (*k, v.clone())); if let Some((key, _)) = &entry { diff --git a/crates/trie/trie/src/hashed_cursor/mod.rs b/crates/trie/trie/src/hashed_cursor/mod.rs index ea526cc769c..4b698d7ad75 100644 --- a/crates/trie/trie/src/hashed_cursor/mod.rs +++ b/crates/trie/trie/src/hashed_cursor/mod.rs @@ -16,10 +16,14 @@ pub mod noop; pub mod mock; /// Metrics tracking hashed cursor implementations. +#[cfg(feature = "metrics")] pub mod metrics; #[cfg(feature = "metrics")] -pub use metrics::HashedCursorMetrics; -pub use metrics::{HashedCursorMetricsCache, InstrumentedHashedCursor}; +pub use metrics::{HashedCursorMetrics, InstrumentedHashedCursor, InstrumentedHashedCursorFactory}; + +/// Uses the underlying factory directly when cursor metrics are disabled. +#[cfg(not(feature = "metrics"))] +pub type InstrumentedHashedCursorFactory = F; /// The factory trait for creating cursors over the hashed state. #[auto_impl::auto_impl(&)] @@ -67,14 +71,16 @@ pub trait HashedCursor { /// The cursor for iterating over hashed storage entries. #[auto_impl::auto_impl(&mut)] pub trait HashedStorageCursor: HashedCursor { - /// Returns `true` if there are no entries for a given key. + /// Returns `true` if the current hashed address has no storage entries. + /// + /// May be called immediately after [`Self::set_hashed_address`], without first seeking. + /// This may change the cursor position; seek before iterating entries afterward. fn is_storage_empty(&mut self) -> Result; /// Set the hashed address for the storage cursor. /// - /// # Important - /// - /// After calling this method, the subsequent operation MUST be a [`HashedCursor::seek`] call. + /// Call [`HashedCursor::seek`] before iterating entries with [`HashedCursor::next`]. + /// [`Self::is_storage_empty`] may be called before seeking. fn set_hashed_address(&mut self, hashed_address: B256); } diff --git a/crates/trie/trie/src/hashed_cursor/post_state.rs b/crates/trie/trie/src/hashed_cursor/post_state.rs index ee460545e53..642ea845046 100644 --- a/crates/trie/trie/src/hashed_cursor/post_state.rs +++ b/crates/trie/trie/src/hashed_cursor/post_state.rs @@ -299,6 +299,10 @@ where /// The returned account key is memoized and the cursor remains positioned at that key until /// [`HashedCursor::seek`] or [`HashedCursor::next`] are called. fn seek(&mut self, key: B256) -> Result, DatabaseError> { + // Forward scans can reuse cursor positions; backward seeks and exhausted cursors cannot. + if self.last_key.is_none_or(|last| key < last) { + self.reset(); + } let post_state_entry = self.post_state_cursor.seek(&key).cloned().map(|(k, v)| (k, v.into_option())); @@ -457,7 +461,7 @@ mod tests { #[test] fn test_seek_overlay_exact_hit_repositions_stale_db_on_next() { - let db_nodes = vec![(key(0x01), U256::from(1)), (key(0x03), U256::from(3))]; + let db_nodes = vec![(key(0x01), U256::ONE), (key(0x03), U256::from(3))]; let post_state_nodes = vec![(key(0x02), U256::from(2))]; let db_nodes_map: BTreeMap = db_nodes.into_iter().collect(); @@ -469,7 +473,7 @@ mod tests { let mut cursor = HashedPostStateCursor::new_storage(mock_cursor, &post_state, B256::ZERO); let result = cursor.seek(key(0x01)).unwrap(); - assert_eq!(result, Some((key(0x01), U256::from(1)))); + assert_eq!(result, Some((key(0x01), U256::ONE))); assert_eq!(visited_keys.lock().len(), 1); let result = cursor.seek(key(0x02)).unwrap(); diff --git a/crates/trie/trie/src/lib.rs b/crates/trie/trie/src/lib.rs index 90c54fbce28..ee58d94a087 100644 --- a/crates/trie/trie/src/lib.rs +++ b/crates/trie/trie/src/lib.rs @@ -4,7 +4,6 @@ //! //! ## Feature Flags //! -//! - `rayon`: uses rayon for parallel [`HashedPostState`] creation. //! - `test-utils`: Export utilities for testing #![doc( diff --git a/crates/trie/trie/src/node_iter.rs b/crates/trie/trie/src/node_iter.rs index c47917adca1..cc5c91f7674 100644 --- a/crates/trie/trie/src/node_iter.rs +++ b/crates/trie/trie/src/node_iter.rs @@ -322,6 +322,7 @@ mod tests { use alloy_primitives::{ b256, map::{B256Map, HashMap}, + B256, }; use alloy_trie::{ BranchNodeCompact, HashBuilder, Nibbles, TrieAccount, TrieMask, EMPTY_ROOT_HASH, @@ -343,7 +344,8 @@ mod tests { let mut prefix_set = PrefixSetMut::default(); prefix_set.extend_keys(state.clone().into_iter().map(|(nibbles, _)| nibbles)); - let walker = TrieWalker::<_>::state_trie(NoopAccountTrieCursor, prefix_set.freeze()); + let walker = + TrieWalker::<_>::state_trie(NoopAccountTrieCursor, prefix_set.freeze()).unwrap(); let hashed_post_state = HashedPostState::default() .with_accounts(state.into_iter().map(|(nibbles, account)| { @@ -402,8 +404,8 @@ mod tests { // │ │ └── 1 -> Leaf (`account_4`) // │ └── 1 -> Leaf (`account_5`, Key = 0x0) - let account_1 = b256!("0x0000000000000000000000000000000000000000000000000000000000000000"); - let account_2 = b256!("0x0000000000000000000000000000000000000000000000000000000000000010"); + let account_1 = B256::ZERO; + let account_2 = B256::with_last_byte(0x10); let account_3 = b256!("0x0000000000000000000000000000000000000000000000000000000000000100"); let account_4 = b256!("0x0000000000000000000000000000000000000000000000000000000000000101"); let account_5 = b256!("0x0000000000000000000000000000000000000000000000000000000000000110"); @@ -474,7 +476,8 @@ mod tests { let walker = TrieWalker::<_>::state_trie( trie_cursor_factory.account_trie_cursor().unwrap(), prefix_set, - ); + ) + .unwrap(); let hashed_cursor_factory = MockHashedCursorFactory::new( BTreeMap::from([ diff --git a/crates/trie/trie/src/progress.rs b/crates/trie/trie/src/progress.rs index 1eab18318f2..f7e721ea069 100644 --- a/crates/trie/trie/src/progress.rs +++ b/crates/trie/trie/src/progress.rs @@ -47,6 +47,7 @@ impl From for IntermediateStateRootState { last_hashed_key: value.last_account_key, }, storage_root_state: value.storage_root_checkpoint.map(|checkpoint| { + let account = checkpoint.account(); IntermediateStorageRootState { state: IntermediateRootState { hash_builder: HashBuilder::from(checkpoint.state), @@ -57,11 +58,7 @@ impl From for IntermediateStateRootState { .collect(), last_hashed_key: checkpoint.last_storage_key, }, - account: Account { - nonce: checkpoint.account_nonce, - balance: checkpoint.account_balance, - bytecode_hash: Some(checkpoint.account_bytecode_hash), - }, + account, } }), } diff --git a/crates/trie/trie/src/proof/mod.rs b/crates/trie/trie/src/proof/mod.rs index 0c787362f43..59bc61c73fd 100644 --- a/crates/trie/trie/src/proof/mod.rs +++ b/crates/trie/trie/src/proof/mod.rs @@ -1,12 +1,8 @@ use crate::{ - hashed_cursor::{ - HashedCursorFactory, HashedCursorMetricsCache, HashedStorageCursor, - InstrumentedHashedCursor, - }, + hashed_cursor::{HashedCursorFactory, HashedStorageCursor}, node_iter::{TrieElement, TrieNodeIter}, prefix_set::{PrefixSetMut, TriePrefixSetsMut}, - proof_v2::{self, SyncAccountValueEncoder}, - trie_cursor::{InstrumentedTrieCursor, TrieCursorFactory, TrieCursorMetricsCache}, + trie_cursor::TrieCursorFactory, walker::TrieWalker, HashBuilder, Nibbles, TRIE_ACCOUNT_RLP_MAX_SIZE, }; @@ -19,8 +15,8 @@ use alloy_rlp::{BufMut, Encodable}; use alloy_trie::proof::AddedRemovedKeys; use reth_execution_errors::trie::StateProofError; use reth_trie_common::{ - proof::ProofRetainer, AccountProof, BranchNodeMasks, BranchNodeMasksMap, DecodedMultiProofV2, - MultiProof, MultiProofTargets, MultiProofTargetsV2, StorageMultiProof, + proof::ProofRetainer, AccountProof, BranchNodeMasks, BranchNodeMasksMap, MultiProof, + MultiProofTargets, StorageMultiProof, }; /// A struct for generating merkle proofs. @@ -56,28 +52,6 @@ impl Proof { } impl Proof { - /// Set the trie cursor factory. - pub fn with_trie_cursor_factory(self, trie_cursor_factory: TF) -> Proof { - Proof { - trie_cursor_factory, - hashed_cursor_factory: self.hashed_cursor_factory, - prefix_sets: self.prefix_sets, - collect_branch_node_masks: self.collect_branch_node_masks, - added_removed_keys: self.added_removed_keys, - } - } - - /// Set the hashed cursor factory. - pub fn with_hashed_cursor_factory(self, hashed_cursor_factory: HF) -> Proof { - Proof { - trie_cursor_factory: self.trie_cursor_factory, - hashed_cursor_factory, - prefix_sets: self.prefix_sets, - collect_branch_node_masks: self.collect_branch_node_masks, - added_removed_keys: self.added_removed_keys, - } - } - /// Set the prefix sets. They have to be mutable in order to allow extension with proof target. pub fn with_prefix_sets_mut(mut self, prefix_sets: TriePrefixSetsMut) -> Self { self.prefix_sets = prefix_sets; @@ -136,59 +110,6 @@ where .account_proof(address, slots)?) } - /// Generate a state multiproof using the V2 proof calculator. - /// - /// This method uses `ProofCalculator` with `SyncAccountValueEncoder` for account proofs - /// and `StorageProofCalculator` for storage proofs. - pub fn multiproof_v2( - self, - targets: MultiProofTargetsV2, - ) -> Result { - let MultiProofTargetsV2 { mut account_targets, storage_targets } = targets; - - let storage_prefix_sets: B256Map<_> = self - .prefix_sets - .storage_prefix_sets - .into_iter() - .map(|(addr, ps)| (addr, ps.freeze())) - .collect(); - - // Compute account proofs using the V2 proof calculator with sync account encoding. - let account_trie_cursor = self.trie_cursor_factory.account_trie_cursor()?; - let hashed_account_cursor = self.hashed_cursor_factory.hashed_account_cursor()?; - let mut account_value_encoder = SyncAccountValueEncoder::new( - self.trie_cursor_factory.clone(), - self.hashed_cursor_factory.clone(), - ) - .with_storage_prefix_sets(storage_prefix_sets.clone()); - let mut account_calculator = - proof_v2::ProofCalculator::new(account_trie_cursor, hashed_account_cursor) - .with_prefix_set(self.prefix_sets.account_prefix_set.freeze()); - let account_proofs = - account_calculator.proof(&mut account_value_encoder, &mut account_targets)?; - - // Compute storage proofs for each targeted account. - let mut storage_proofs = - B256Map::with_capacity_and_hasher(storage_targets.len(), Default::default()); - for (hashed_address, mut targets) in storage_targets { - let storage_trie_cursor = - self.trie_cursor_factory.storage_trie_cursor(hashed_address)?; - let hashed_storage_cursor = - self.hashed_cursor_factory.hashed_storage_cursor(hashed_address)?; - let mut storage_calculator = proof_v2::StorageProofCalculator::new_storage( - storage_trie_cursor, - hashed_storage_cursor, - ); - if let Some(prefix_set) = storage_prefix_sets.get(&hashed_address) { - storage_calculator = storage_calculator.with_prefix_set(prefix_set.clone()); - } - let proofs = storage_calculator.storage_proof(hashed_address, &mut targets)?; - storage_proofs.insert(hashed_address, proofs); - } - - Ok(DecodedMultiProofV2 { account_proofs, storage_proofs }) - } - /// Generate a state multiproof according to specified targets. pub fn multiproof( mut self, @@ -201,7 +122,7 @@ where let mut prefix_set = self.prefix_sets.account_prefix_set.clone(); prefix_set.extend_keys(targets.keys().map(Nibbles::unpack)); let walker = - TrieWalker::<_, AddedRemovedKeys>::state_trie(trie_cursor, prefix_set.freeze()) + TrieWalker::<_, AddedRemovedKeys>::state_trie(trie_cursor, prefix_set.freeze())? .with_added_removed_keys(self.added_removed_keys.as_ref()); // Create a hash builder to rebuild the root node since it is not available in the database. @@ -276,7 +197,7 @@ where /// Generates storage merkle proofs. #[derive(Debug)] -pub struct StorageProof<'a, T, H, K = AddedRemovedKeys> { +pub struct StorageProof { /// The factory for traversing trie nodes. trie_cursor_factory: T, /// The factory for hashed cursors. @@ -289,13 +210,9 @@ pub struct StorageProof<'a, T, H, K = AddedRemovedKeys> { collect_branch_node_masks: bool, /// Provided by the user to give the necessary context to retain extra proofs. added_removed_keys: Option, - /// Optional reference to accumulate trie cursor metrics. - trie_cursor_metrics: Option<&'a mut TrieCursorMetricsCache>, - /// Optional reference to accumulate hashed cursor metrics. - hashed_cursor_metrics: Option<&'a mut HashedCursorMetricsCache>, } -impl StorageProof<'static, T, H> { +impl StorageProof { /// Create a new [`StorageProof`] instance. pub fn new(t: T, h: H, address: Address) -> Self { Self::new_hashed(t, h, keccak256(address)) @@ -310,47 +227,11 @@ impl StorageProof<'static, T, H> { prefix_set: PrefixSetMut::default(), collect_branch_node_masks: false, added_removed_keys: None, - trie_cursor_metrics: None, - hashed_cursor_metrics: None, } } } -impl<'a, T, H, K> StorageProof<'a, T, H, K> { - /// Set the trie cursor factory. - pub fn with_trie_cursor_factory( - self, - trie_cursor_factory: TF, - ) -> StorageProof<'a, TF, H, K> { - StorageProof { - trie_cursor_factory, - hashed_cursor_factory: self.hashed_cursor_factory, - hashed_address: self.hashed_address, - prefix_set: self.prefix_set, - collect_branch_node_masks: self.collect_branch_node_masks, - added_removed_keys: self.added_removed_keys, - trie_cursor_metrics: self.trie_cursor_metrics, - hashed_cursor_metrics: self.hashed_cursor_metrics, - } - } - - /// Set the hashed cursor factory. - pub fn with_hashed_cursor_factory( - self, - hashed_cursor_factory: HF, - ) -> StorageProof<'a, T, HF, K> { - StorageProof { - trie_cursor_factory: self.trie_cursor_factory, - hashed_cursor_factory, - hashed_address: self.hashed_address, - prefix_set: self.prefix_set, - collect_branch_node_masks: self.collect_branch_node_masks, - added_removed_keys: self.added_removed_keys, - trie_cursor_metrics: self.trie_cursor_metrics, - hashed_cursor_metrics: self.hashed_cursor_metrics, - } - } - +impl StorageProof { /// Set the changed prefixes. pub fn with_prefix_set_mut(mut self, prefix_set: PrefixSetMut) -> Self { self.prefix_set = prefix_set; @@ -363,24 +244,6 @@ impl<'a, T, H, K> StorageProof<'a, T, H, K> { self } - /// Set the trie cursor metrics cache to accumulate metrics into. - pub const fn with_trie_cursor_metrics( - mut self, - metrics: &'a mut TrieCursorMetricsCache, - ) -> Self { - self.trie_cursor_metrics = Some(metrics); - self - } - - /// Set the hashed cursor metrics cache to accumulate metrics into. - pub const fn with_hashed_cursor_metrics( - mut self, - metrics: &'a mut HashedCursorMetricsCache, - ) -> Self { - self.hashed_cursor_metrics = Some(metrics); - self - } - /// Configures the retainer to retain proofs for certain nodes which would otherwise fall /// outside the target set, when those nodes might be required to calculate the state root when /// keys have been added or removed to the trie. @@ -389,7 +252,7 @@ impl<'a, T, H, K> StorageProof<'a, T, H, K> { pub fn with_added_removed_keys( self, added_removed_keys: Option, - ) -> StorageProof<'a, T, H, K2> { + ) -> StorageProof { StorageProof { trie_cursor_factory: self.trie_cursor_factory, hashed_cursor_factory: self.hashed_cursor_factory, @@ -397,13 +260,11 @@ impl<'a, T, H, K> StorageProof<'a, T, H, K> { prefix_set: self.prefix_set, collect_branch_node_masks: self.collect_branch_node_masks, added_removed_keys, - trie_cursor_metrics: self.trie_cursor_metrics, - hashed_cursor_metrics: self.hashed_cursor_metrics, } } } -impl<'a, T, H, K> StorageProof<'a, T, H, K> +impl StorageProof where T: TrieCursorFactory, H: HashedCursorFactory, @@ -423,34 +284,21 @@ where self, targets: B256Set, ) -> Result { - let mut discard_hashed_cursor_metrics = HashedCursorMetricsCache::default(); - let hashed_cursor_metrics = - self.hashed_cursor_metrics.unwrap_or(&mut discard_hashed_cursor_metrics); - - let hashed_storage_cursor = - self.hashed_cursor_factory.hashed_storage_cursor(self.hashed_address)?; - let mut hashed_storage_cursor = - InstrumentedHashedCursor::new(hashed_storage_cursor, hashed_cursor_metrics); + self.hashed_cursor_factory.hashed_storage_cursor(self.hashed_address)?; // short circuit on empty storage if hashed_storage_cursor.is_storage_empty()? { return Ok(StorageMultiProof::empty()) } - let mut discard_trie_cursor_metrics = TrieCursorMetricsCache::default(); - let trie_cursor_metrics = - self.trie_cursor_metrics.unwrap_or(&mut discard_trie_cursor_metrics); - let target_nibbles = targets.into_iter().map(Nibbles::unpack).collect::>(); let mut prefix_set = self.prefix_set; prefix_set.extend_keys(target_nibbles.iter().copied()); let trie_cursor = self.trie_cursor_factory.storage_trie_cursor(self.hashed_address)?; - let trie_cursor = InstrumentedTrieCursor::new(trie_cursor, trie_cursor_metrics); - - let walker = TrieWalker::<_>::storage_trie(trie_cursor, prefix_set.freeze()) + let walker = TrieWalker::<_>::storage_trie(trie_cursor, prefix_set.freeze())? .with_added_removed_keys(self.added_removed_keys.as_ref()); let retainer = ProofRetainer::from_iter(target_nibbles) diff --git a/crates/trie/trie/src/proof_v2/calculator.rs b/crates/trie/trie/src/proof_v2/calculator.rs new file mode 100644 index 00000000000..eb448567bd2 --- /dev/null +++ b/crates/trie/trie/src/proof_v2/calculator.rs @@ -0,0 +1,3505 @@ +//! Proof calculation version 2 using leaf data and cached branch hashes. +//! +//! This module provides a rewritten proof calculator that: +//! - Combines hashed account or storage leaves with cached branch hashes from a trie cursor. +//! - Recalculates subtries containing proof targets or changed keys instead of using cached hashes. +//! - Returns proof nodes in depth-first post-order (children before parents). +//! - Automatically resets after each calculation +//! - Re-uses cursors across calculations +//! - Supports generic value types with lazy evaluation + +use super::{iter_sub_trie_targets, node::*, value::*, SubTrieTargets, TRACE_TARGET}; +use crate::{ + hashed_cursor::{HashedCursor, HashedStorageCursor}, + trie_cursor::{depth_first, TrieCursor, TrieStorageCursor}, +}; +use alloy_primitives::{keccak256, B256, U256}; +use alloy_rlp::Encodable; +use alloy_trie::{BranchNodeCompact, TrieMask}; +use reth_execution_errors::trie::StateProofError; +use reth_trie_common::{ + prefix_set::PrefixSet, BranchNodeMasks, BranchNodeRef, BranchNodeV2, Nibbles, ProofTrieNodeV2, + ProofV2Target, RlpNode, TrieNodeV2, +}; +use std::{cmp::Ordering, sync::Arc}; +use tracing::{error, instrument, trace}; + +/// Number of bytes to pre-allocate for [`ProofCalculator`]'s `rlp_encode_buf` field. +const RLP_ENCODE_BUF_SIZE: usize = 1024; + +/// A proof calculator that generates Merkle proofs from leaf data and cached branch hashes. +/// +/// The hashed cursor supplies leaves, while the trie cursor supplies cached branch hashes to skip +/// subtries. Subtries containing proof targets or keys in the configured prefix set are +/// recalculated. +/// +/// The calculator: +/// - Accepts proof targets in any order and sorts them in place by parent context, then key. +/// - Returns proof nodes in depth-first post-order (children before parents). +/// - Automatically resets after each calculation +/// - Re-uses cursors from one calculation to the next +#[derive(Debug)] +pub struct ProofCalculator { + /// Trie cursor for traversing stored branch nodes. + trie_cursor: TC, + /// Hashed cursor for iterating over leaf data. + hashed_cursor: HC, + /// Branches which are currently in the process of being constructed, each being a child of + /// the previous one. + branch_stack: Vec, + /// The path of the last branch in `branch_stack`. + branch_path: Nibbles, + /// Children of branches in the `branch_stack`. + /// + /// Each branch in `branch_stack` tracks which children are in this stack using its + /// `state_mask`; the number of children the branch has in this stack is equal to the number of + /// bits set in its `state_mask`. + /// + /// The children for the bottom branch in `branch_stack` are found at the bottom of this stack, + /// and so on. When a branch is removed from `branch_stack` its children are removed from this + /// one, and the branch is pushed onto this stack in their place (see [`Self::pop_branch`]. + /// + /// Given that keys are consumed in lexicographical order, only the last child on the stack + /// can still require structural changes (e.g. splitting its short key when inserting a new + /// branch). Once a child is finalized, [`Self::commit_last_child`] converts it to a + /// [`ProofTrieBranchChild::RlpNode`] if retained for the proof. Unretained children can remain + /// unencoded anywhere on the stack until [`Self::pop_branch`], giving deferred encoders more + /// time for async work. + child_stack: Vec>, + /// Cached branch data pulled from the `trie_cursor`. The calculator will use the cached + /// [`BranchNodeCompact::hashes`] to skip over the calculation of sub-tries in the overall + /// trie. The cached hashes cannot be used for any paths which are prefixes of a proof target. + cached_branch_stack: Vec<(Nibbles, BranchNodeCompact)>, + /// The proofs which will be returned from the calculation. This gets taken at the end of every + /// proof call. + retained_proofs: Vec, + /// Free-list of re-usable buffers of [`RlpNode`]s, used for encoding branch nodes to RLP. + /// + /// We are generally able to re-use these buffers across different branch nodes for the + /// duration of a proof calculation, but occasionally we will lose one when a branch + /// node is returned as a `ProofTrieNode`. + rlp_nodes_bufs: Vec>, + /// Re-usable byte buffer, used for RLP encoding. + rlp_encode_buf: Vec, + /// Prefix set for tracking changed keys. + prefix_set: PrefixSet, +} + +impl ProofCalculator { + /// Create a new [`ProofCalculator`] instance for calculating account proofs. + pub fn new(trie_cursor: TC, hashed_cursor: HC) -> Self { + Self { + trie_cursor, + hashed_cursor, + branch_stack: Vec::<_>::with_capacity(64), + branch_path: Nibbles::new(), + child_stack: Vec::<_>::with_capacity(64), + cached_branch_stack: Vec::<_>::with_capacity(64), + retained_proofs: Vec::<_>::with_capacity(32), + rlp_nodes_bufs: Vec::<_>::with_capacity(8), + rlp_encode_buf: Vec::<_>::with_capacity(RLP_ENCODE_BUF_SIZE), + prefix_set: PrefixSet::default(), + } + } + + /// Sets the prefix set and returns `self`. + /// + /// When given, all cached hashes matching the [`PrefixSet`] will be invalidated and their + /// subtries recalculated. + pub fn with_prefix_set(mut self, prefix_set: PrefixSet) -> Self { + self.prefix_set = prefix_set; + self + } + + /// Replaces the changed-key prefixes applied to subsequent calculations. + pub(super) fn set_prefix_set(&mut self, prefix_set: PrefixSet) { + self.prefix_set = prefix_set; + } +} + +impl ProofCalculator +where + TC: TrieCursor, + HC: HashedCursor, + VE: LeafValueEncoder, +{ + /// Takes a re-usable `RlpNode` buffer from the internal free-list, or allocates a new one if + /// the free-list is empty. + /// + /// The returned Vec will have a length of zero. + fn take_rlp_nodes_buf(&mut self) -> Vec { + self.rlp_nodes_bufs + .pop() + .map(|mut buf| { + buf.clear(); + buf + }) + .unwrap_or_else(|| Vec::with_capacity(16)) + } + + // Returns zero if `branch_stack` is empty, one otherwise. + // + // This is used when working with the `ext_len` field of [`ProofTrieBranch`]. The `ext_len` is + // calculated by taking the difference of the current `branch_path` and the new branch's path; + // if the new branch has a parent branch (ie `branch_stack` is not empty) then 1 is subtracted + // from the `ext_len` to account for the child's nibble on the parent. + #[inline] + const fn maybe_parent_nibble(&self) -> usize { + !self.branch_stack.is_empty() as usize + } + + /// Returns true if the proof of a node at the given path should be retained. A node is retained + /// if its path is a prefix of any target. + /// + /// This may move the `targets` iterator forward if the given path comes after the current + /// target. + /// + /// This method takes advantage of the [`std::slice::Iter`] component of [`TargetsCursor`] to + /// check the minimum number of targets. In general it looks at a current target and the next + /// target simultaneously, forming an end-exclusive range. + /// + /// ```text + /// * Given targets: [ 0x012, 0x045, 0x678 ] + /// * targets.current() returns: + /// - (0x012, Some(0x045)): covers (0x012..0x045) + /// - (0x045, Some(0x678)): covers (0x045..0x678) + /// - (0x678, None): covers (0x678..) + /// ``` + /// + /// As long as the path which is passed in lies within that range we can continue to use the + /// current target. Once the path goes beyond that range (ie path >= next target) then we can be + /// sure that no further paths will be in the range, and we can iterate forward. + /// + /// ```text + /// * Given: + /// - path: 0x04 + /// - targets.current() returns (0x012, Some(0x045)) + /// + /// * 0x04 comes _after_ 0x045 in depth-first order, so (0x012..0x045) does not contain 0x04. + /// + /// * targets.next() is called. + /// + /// * targets.current() now returns (0x045, Some(0x678)). This does contain 0x04. + /// + /// * 0x04 is a prefix of 0x045, and so is retained. + /// ``` + #[instrument( + target = TRACE_TARGET, + level = "trace", + skip_all, + fields(?path, ?check_parent_path), + ret, + )] + fn should_retain<'a>( + &self, + targets: &mut Option>, + path: &Nibbles, + check_parent_path: bool, + ) -> bool { + // If no targets are given then we never retain anything + let Some(targets) = targets.as_mut() else { return false }; + + let (mut lower, mut upper) = targets.current(); + + loop { + // If the node in question is a prefix of the target then we do not iterate targets + // further. + // + // Even if the node is a prefix of the target's key, a target with a parent path only + // retains nodes strictly below that already-revealed parent. + // + // _However_ even if the node doesn't match one target due to its parent path, it may + // match other targets whose keys match this node. So we search forwards and backwards + // for all targets which might match this node. + // + // For example, given a branch 0xabc, with children at 0, 1, and 2, and targets: + // - key: 0xabc0, parent path length: 1 + // - key: 0xabc1, parent path length: 0 + // - key: 0xabc2, parent path length: 3 <-- current + // - key: 0xabc3, parent path length: 2 + // + // When the branch node at 0xabc is visited it will be after the targets has iterated + // forward to 0xabc2 (because all children will have been visited already). At this + // point the target for 0xabc2 will not match the branch due to its prefix, but any of + // the other targets would, so we need to check those as well. + if lower.key_nibbles.starts_with(path) { + let is_below_parent = |target: &ProofV2Target| { + target.parent.path_len().is_none_or(|len| path.len() > len) + }; + return !check_parent_path || + (is_below_parent(lower) || + targets + .skip_iter() + .take_while(|target| target.key_nibbles.starts_with(path)) + .any(is_below_parent) || + targets + .rev_iter() + .take_while(|target| target.key_nibbles.starts_with(path)) + .any(is_below_parent)) + } + + // If the path isn't in the current range then iterate forward until it is (or until + // there is no upper bound, indicating unbounded). + if upper + .is_some_and(|upper| depth_first::cmp(path, &upper.key_nibbles) != Ordering::Less) + { + (lower, upper) = targets.next(); + trace!(target: TRACE_TARGET, target = ?lower, "upper target <= path, next target"); + } else { + return false + } + } + } + + /// Takes a child which has been removed from the `child_stack` and converts it to an + /// [`RlpNode`], retaining it as a proof node. + /// + /// Calling this method indicates that the child will not undergo any further modifications. + /// + /// The caller has already determined via [`Self::should_retain`] that this child is retained. + /// That decision must not be re-evaluated here, because `should_retain` advances the `targets` + /// cursor. Children which are not retained are converted later, in [`Self::pop_branch`]. + fn commit_child( + &mut self, + child_path: Nibbles, + child: ProofTrieBranchChild, + ) -> Result { + // An already encoded child only needs an extension if it has been rebased upward. + if matches!(&child, ProofTrieBranchChild::RlpNode { .. }) { + self.rlp_encode_buf.clear(); + return child.into_rlp(&mut self.rlp_encode_buf).map(|(node, _)| node) + } + + trace!(target: TRACE_TARGET, ?child_path, "Retaining child"); + + // Convert to `ProofTrieNodeV2`, which will be what is retained. + // + // If this node is a branch then its `rlp_nodes_buf` will be taken and not returned to the + // `rlp_nodes_bufs` free-list. + self.rlp_encode_buf.clear(); + let proof_node = child.into_proof_trie_node(child_path, &mut self.rlp_encode_buf)?; + + // Use the `ProofTrieNodeV2` to encode the `RlpNode`, and then push it onto retained nodes + // before returning. + self.rlp_encode_buf.clear(); + proof_node.node.encode(&mut self.rlp_encode_buf); + + self.retained_proofs.push(proof_node); + Ok(RlpNode::from_rlp(&self.rlp_encode_buf)) + } + + /// Returns the path of the child of the currently under-construction branch at the given + /// nibble. + #[inline] + fn child_path_at(&self, nibble: u8) -> Nibbles { + let mut child_path = self.branch_path; + debug_assert!(child_path.len() < 64); + child_path.push_unchecked(nibble); + child_path + } + + /// Returns index of the highest nibble which is set in the mask. + /// + /// # Panics + /// + /// Will panic in debug mode if the mask is empty. + #[inline] + fn highest_set_nibble(mask: TrieMask) -> u8 { + debug_assert!(!mask.is_empty()); + (u16::BITS - mask.leading_zeros() - 1) as u8 + } + + /// Returns the path of the child on top of the `child_stack`, or the root path if the stack is + /// empty. Returns None if the current branch has not yet pushed a child (empty `state_mask`). + fn last_child_path(&self) -> Option { + // If there is no branch under construction then the top child must be the root child. + let Some(branch) = self.branch_stack.last() else { + return Some(Nibbles::new()); + }; + + (!branch.state_mask.is_empty()) + .then(|| self.child_path_at(Self::highest_set_nibble(branch.state_mask))) + } + + /// If the last child of `child_stack` is retained for the proof, calls [`Self::commit_child`] + /// to replace it with a [`ProofTrieBranchChild::RlpNode`]. Otherwise, leaves it unencoded until + /// [`Self::pop_branch`]. + /// + /// If `child_stack` is empty then this is a no-op. + /// + /// NOTE that this method call relies on the `state_mask` of the top branch of the + /// `branch_stack` to determine the last child's path. When committing the last child prior to + /// pushing a new child, it's important to set the new child's `state_mask` bit _after_ the call + /// to this method. + #[instrument( + target = TRACE_TARGET, + level = "trace", + skip_all, + fields(child_path = ?self.last_child_path()), + )] + fn commit_last_child<'a>( + &mut self, + targets: &mut Option>, + ) -> Result<(), StateProofError> { + if matches!(self.child_stack.last(), Some(ProofTrieBranchChild::RlpNode { .. })) { + trace!(target: TRACE_TARGET, "Last child already committed, leaving stack unchanged"); + return Ok(()) + } + + let Some(child_path) = self.last_child_path() else { return Ok(()) }; + let child = + self.child_stack.pop().expect("child_stack can't be empty if there's a child path"); + + // Only commit immediately if retained for the proof. Otherwise, defer conversion + // to pop_branch() to give DeferredEncoder time for async work. + if self.should_retain(targets, &child_path, true) { + let (hash_mask_bit, tree_mask_bit) = child.mask_bits(); + let child_rlp_node = self.commit_child(child_path, child)?; + trace!(target: TRACE_TARGET, ?child_rlp_node, "Pushing committed child RlpNode onto stack"); + self.child_stack.push(ProofTrieBranchChild::RlpNode { + node: child_rlp_node, + short_key: Nibbles::new(), + hash_mask_bit, + tree_mask_bit, + }); + } else { + trace!(target: TRACE_TARGET, "Pushing uncommitted child onto stack"); + self.child_stack.push(child); + } + + Ok(()) + } + + /// Adds a child at its full path, possibly collapsing an existing branch and/or creating a new + /// one depending on the path. + fn push_child<'a>( + &mut self, + targets: &mut Option>, + mut child: ProofTrieBranchChild, + ) -> Result<(), StateProofError> { + let path = *child.short_key(); + + loop { + trace!( + target: TRACE_TARGET, + ?path, + branch_stack_len = ?self.branch_stack.len(), + branch_path = ?self.branch_path, + child_stack_len = ?self.child_stack.len(), + "push_child: loop", + ); + + // Get the `state_mask` of the branch currently being built. If there are no branches + // on the stack then the trie is either empty or contains only a single child. + let (nibble, short_key) = match self.branch_stack.last().map(|branch| branch.state_mask) + { + None if self.child_stack.is_empty() => { + // The first child is the root and already has the correct short key. + self.child_stack.push(child); + return Ok(()) + } + None => { + // Split the existing root child from the new child by their common prefix. + debug_assert_eq!(self.child_stack.len(), 1); + debug_assert!(!self + .child_stack + .last() + .expect("already checked for emptiness") + .short_key() + .is_empty()); + self.push_new_branch(path) + } + Some(state_mask) => { + // Find the number of nibbles shared by the current branch and the new child. + let common_prefix_len = self.branch_path.common_prefix_length(&path); + + // A branch which is not a prefix of the child cannot receive any more + // children, so finish it and try again with its parent. + if common_prefix_len < self.branch_path.len() { + self.pop_branch(targets)?; + continue + } + + // If this nibble is already occupied, split the existing child from the new + // child. Otherwise the new child can be inserted directly into this branch. + let nibble = path.get_unchecked(common_prefix_len); + if state_mask.is_bit_set(nibble) { + self.push_new_branch(path) + } else { + (nibble, trim_nibbles_prefix(&path, common_prefix_len + 1)) + } + } + }; + + // Store the child key relative to its new parent branch. + child.trim_short_key_prefix(path.len() - short_key.len()); + + // The preceding child is structurally final. Commit it if retained for the proof; + // otherwise its encoding can wait until pop_branch. + self.commit_last_child(targets)?; + + let branch = self.branch_stack.last_mut().expect("branch_stack cannot be empty"); + debug_assert!(!branch.state_mask.is_bit_set(nibble)); + + // Set the new child's bit after commit_last_child, which uses the mask to find the + // preceding child's path. + branch.state_mask.set_bit(nibble); + + // The parent now contains this child at `nibble`. + self.child_stack.push(child); + return Ok(()) + } + } + + /// Pushes a new branch onto the `branch_stack` based on the path and short key of the last + /// child on the `child_stack` and the path of the next child which will be pushed on to the + /// stack after this call. + /// + /// Returns the nibble of the branch's `state_mask` which should be set for the new child, and + /// the short key that child should use. + fn push_new_branch(&mut self, new_child_path: Nibbles) -> (u8, Nibbles) { + // Get the full path to the root of the existing child. The trie root is used when there is + // no parent branch. + let first_child_path = self + .last_child_path() + .expect("push_new_branch requires the current branch to have a child"); + + // Put both children in the same coordinate system by trimming the existing child's root + // path from the new child's full path. + let new_child_short_key = trim_nibbles_prefix(&new_child_path, first_child_path.len()); + let first_child_short_key = *self + .child_stack + .last() + .expect("push_new_branch can't be called with empty child_stack") + .short_key(); + debug_assert!(!first_child_short_key.is_empty()); + + // Their shared prefix becomes the new branch's extension. The first differing nibble from + // each key becomes that child's position in the branch. + let common_prefix_len = first_child_short_key.common_prefix_length(&new_child_short_key); + let first_child_nibble = first_child_short_key.get_unchecked(common_prefix_len); + let new_child_nibble = new_child_short_key.get_unchecked(common_prefix_len); + + // Remove the extension and branch nibble from the new child's remaining short key. + let new_child_short_key = trim_nibbles_prefix(&new_child_short_key, common_prefix_len + 1); + + // The new branch starts after the existing child's parent path and their shared extension. + let branch_path_len = first_child_path.len() + common_prefix_len; + self.branch_path = new_child_path.slice_unchecked(0, branch_path_len); + + // Rebase the existing child beneath the new branch by removing the extension and its + // branch nibble from the front of its short key. + let first_child = self + .child_stack + .last_mut() + .expect("push_new_branch can't be called with empty child_stack"); + first_child.trim_short_key_prefix(common_prefix_len + 1); + + // Push the new branch onto the `branch_stack`. We do not yet set the `state_mask` bit of + // the new child; whatever actually pushes the child onto the `child_stack` is expected to + // do that. + self.branch_stack.push(ProofTrieBranch { + ext_len: common_prefix_len as u8, + state_mask: TrieMask::from_nibble(first_child_nibble), + }); + + trace!( + target: TRACE_TARGET, + ?new_child_path, + ext_len = ?common_prefix_len, + ?first_child_nibble, + branch_path = ?self.branch_path, + "Pushed new branch", + ); + + (new_child_nibble, new_child_short_key) + } + + /// Pops the top branch off of the `branch_stack`, hashes its children on the `child_stack`, and + /// replaces those children on the `child_stack`. The `branch_path` field will be updated + /// accordingly. + /// + /// # Panics + /// + /// This method panics if `branch_stack` is empty. + #[instrument(target = TRACE_TARGET, level = "trace", skip_all)] + fn pop_branch<'a>( + &mut self, + targets: &mut Option>, + ) -> Result<(), StateProofError> { + trace!( + target: TRACE_TARGET, + branch = ?self.branch_stack.last(), + branch_path = ?self.branch_path, + child_stack_len = ?self.child_stack.len(), + "called", + ); + + // Retain the final child if needed before draining the branch's children. Unretained + // children are encoded below. + self.commit_last_child(targets)?; + + let mut rlp_nodes_buf = self.take_rlp_nodes_buf(); + let mut masks = BranchNodeMasks::default(); + let branch = self.branch_stack.pop().expect("branch_stack cannot be empty"); + + // Take the branch's children off the stack, using the state mask to determine how many + // there are. + let num_children = branch.state_mask.count_ones() as usize; + debug_assert!( + self.child_stack.len() >= num_children, + "Stack is missing necessary children ({num_children:?})" + ); + debug_assert!( + num_children >= 2, + "A branch must have at least two children, got {num_children}" + ); + + // Collect children into RlpNode Vec. Children are in lexicographic order. + rlp_nodes_buf.reserve(num_children); + for (nibble, child) in branch + .state_mask + .iter() + .zip(self.child_stack.drain(self.child_stack.len() - num_children..)) + { + let (hash_mask_bit, tree_mask_bit) = child.mask_bits(); + masks.set_child_bits(nibble, hash_mask_bit, tree_mask_bit); + + self.rlp_encode_buf.clear(); + let (child_rlp_node, freed_buf) = child.into_rlp(&mut self.rlp_encode_buf)?; + if let Some(buf) = freed_buf { + self.rlp_nodes_bufs.push(buf); + } + rlp_nodes_buf.push(child_rlp_node); + } + + debug_assert_eq!( + rlp_nodes_buf.len(), + num_children, + "children length must match number of bits set in state_mask" + ); + + // Calculate the short key of the parent extension (if the branch has a parent extension). + // It's important to calculate this short key prior to modifying the `branch_path`. + let short_key = trim_nibbles_prefix( + &self.branch_path, + self.branch_path.len() - branch.ext_len as usize, + ); + + // Compute hash for the branch node if it has a parent extension. + let rlp_node = if short_key.is_empty() { + None + } else { + self.rlp_encode_buf.clear(); + BranchNodeRef::new(&rlp_nodes_buf, branch.state_mask).encode(&mut self.rlp_encode_buf); + Some(RlpNode::from_rlp(&self.rlp_encode_buf)) + }; + + // Update the branch_path. If this branch is the only branch then only its extension needs + // to be trimmed, otherwise we also need to remove its nibble from its parent. + let new_path_len = + self.branch_path.len() - branch.ext_len as usize - self.maybe_parent_nibble(); + + // Wrap the `BranchNodeV2` so it can be pushed onto the child stack. + let branch_as_child = ProofTrieBranchChild::Branch { + node: BranchNodeV2::new(short_key, rlp_nodes_buf, branch.state_mask, rlp_node), + masks: (!masks.is_empty()).then_some(masks), + }; + + debug_assert!(self.branch_path.len() >= new_path_len); + self.branch_path = self.branch_path.slice_unchecked(0, new_path_len); + + self.child_stack.push(branch_as_child); + + Ok(()) + } + + /// Given the lower and upper bounds (exclusive) of a range of keys, iterates over the + /// `hashed_cursor` and calculates all trie nodes possible based on those keys. If the upper + /// bound is None then it is considered unbounded. + /// + /// It is expected that this method is "driven" by `next_uncached_key_range`, which decides + /// which ranges of keys need to be calculated based on what cached trie data is available. + #[instrument( + target = TRACE_TARGET, + level = "trace", + skip_all, + fields(?lower_bound, ?upper_bound), + )] + fn calculate_key_range<'a>( + &mut self, + value_encoder: &mut VE, + targets: &mut Option>, + hashed_cursor_state: &mut HashedCursorState, + lower_bound: Nibbles, + upper_bound: Option, + ) -> Result<(), StateProofError> { + // A helper closure for mapping entries returned from the `hashed_cursor`, converting the + // key to Nibbles and immediately creating the DeferredValueEncoder so that encoding of the + // leaf value can begin ASAP. + let mut map_hashed_cursor_entry = |(key_b256, val): (B256, _)| { + debug_assert_eq!(key_b256.len(), 32); + let key = Nibbles::unpack_array(key_b256.as_ref()); + let val = value_encoder.deferred_encoder(key_b256, val); + (key, val) + }; + + // If the cursor hasn't been used, or the last iterated key is prior to this range's key + // range, then seek forward to at least the first key. + if hashed_cursor_state.needs_seek_to(&lower_bound) { + trace!( + target: TRACE_TARGET, + current=?hashed_cursor_state.path(), + "Seeking hashed cursor to meet lower bound", + ); + + let lower_key = B256::right_padding_from(&lower_bound.pack()); + *hashed_cursor_state = HashedCursorState::seeked( + lower_bound, + self.hashed_cursor.seek(lower_key)?.map(&mut map_hashed_cursor_entry), + ); + } + + // Loop over all keys in the range, pushing each leaf onto the stack. + while hashed_cursor_state + .path() + .is_some_and(|key| upper_bound.is_none_or(|upper_bound| key < &upper_bound)) + { + let (key, val) = hashed_cursor_state.take(); + self.push_child(targets, ProofTrieBranchChild::Leaf { short_key: key, value: val })?; + *hashed_cursor_state = HashedCursorState::seeked( + key, + self.hashed_cursor.next()?.map(&mut map_hashed_cursor_entry), + ); + } + + trace!(target: TRACE_TARGET, "No further keys within range"); + Ok(()) + } + + /// Takes a cached branch from the trie cursor and invalidates its hashes if it may collapse. + fn take_cached_branch( + &mut self, + trie_cursor_state: &mut TrieCursorState, + ) -> (Nibbles, BranchNodeCompact) { + let (cached_path, mut cached_branch) = trie_cursor_state.take(); + + if self.prefix_set.contains(&cached_path) { + let mut unchanged_children = 0; + let mut child_path = cached_path; + for nibble in cached_branch.state_mask.iter() { + child_path.truncate(cached_path.len()); + child_path.push_unchecked(nibble); + if !self.prefix_set.contains(&child_path) { + unchanged_children += 1; + if unchanged_children > 1 { + break + } + } + } + + // If every dirty child is deleted, a lone unchanged child must be revealed so the + // branch can collapse into it. Keep the masks as structural hints, but prevent any of + // this branch's cached hashes from hiding that child. + if unchanged_children <= 1 { + Arc::make_mut(&mut cached_branch.hashes).fill(B256::ZERO); + trace!( + target: TRACE_TARGET, + ?cached_path, + ?unchanged_children, + "Invalidated cached hashes because branch may collapse", + ); + } + } + + (cached_path, cached_branch) + } + + /// Attempts to pop off the top branch of the `cached_branch_stack`, returning + /// [`PopCachedBranchOutcome::Popped`] on success. Returns other variants to indicate that the + /// stack is empty and what to do about it. + /// + /// This method only returns [`PopCachedBranchOutcome::CalculateLeaves`] if there is a cached + /// branch on top of the stack. + #[inline] + fn try_pop_cached_branch( + &mut self, + trie_cursor_state: &mut TrieCursorState, + traversal_upper_bound: Option<&Nibbles>, + uncalculated_lower_bound: &Option, + ) -> Result { + // If the `uncalculated_lower_bound` is None it indicates that there can be no more + // leaf data, so similarly there can be no more cached branch data. + let Some(uncalculated_lower_bound) = uncalculated_lower_bound else { + return Ok(PopCachedBranchOutcome::Exhausted) + }; + + // Use the branch on top of the stack unless a previously calculated range has already + // covered its entire subtrie. + while let Some(cached) = self.cached_branch_stack.pop() { + if cached + .0 + .next_without_prefix() + .is_some_and(|upper_bound| upper_bound <= *uncalculated_lower_bound) + { + trace!(target: TRACE_TARGET, cached_path=?cached.0, ?uncalculated_lower_bound, "Skipping covered cached branch"); + continue + } + return Ok(PopCachedBranchOutcome::Popped(cached)); + } + + // There is no cached branch on the stack. It's possible that another one exists + // farther on in the trie, but we perform some checks first to prevent unnecessary + // attempts to find it. + + // If [`TrieCursorState::path`] returns None it means that the cursor has been + // exhausted, so there can be no more cached data. + let Some(mut trie_cursor_path) = trie_cursor_state.path() else { + return Ok(PopCachedBranchOutcome::Exhausted) + }; + + // If the trie cursor is seeked to a branch whose leaves have already been processed + // then we can't use it, instead we seek forward and try again. + if trie_cursor_path < uncalculated_lower_bound { + *trie_cursor_state = TrieCursorState::seeked( + *uncalculated_lower_bound, + self.trie_cursor.seek(*uncalculated_lower_bound)?, + ); + + // Having just seeked forward we need to check if the cursor is now exhausted, + // extracting the new path at the same time. + if let Some(new_trie_cursor_path) = trie_cursor_state.path() { + trie_cursor_path = new_trie_cursor_path + } else { + return Ok(PopCachedBranchOutcome::Exhausted) + }; + } + + // If the trie cursor has reached the end of the traversal range then we consider cached + // data to be exhausted. The cursor itself remains positioned for reuse by a later range. + if traversal_upper_bound.is_some_and(|upper_bound| trie_cursor_path >= upper_bound) { + return Ok(PopCachedBranchOutcome::Exhausted) + } + + // At this point we can be sure that the cursor is in an `Available` state. We know for + // sure it's not `Exhausted` because of the calls to `path` above, and we know it's not + // `Taken` because we push all taken branches onto the `cached_branch_stack`, and the + // stack is empty. + // + // We will use this `Available` cached branch as our next branch. + let cached = self.take_cached_branch(trie_cursor_state); + trace!(target: TRACE_TARGET, cached=?cached, "Pushed next trie node onto cached_branch_stack"); + + // If the calculated range is not caught up to the next cached branch it means there + // are portions of the trie prior to that branch which may need to be calculated; + // return the uncalculated range up to that branch to make that happen. + // + // If the next cached branch's path is all zeros then we can skip this catch-up step, + // because there cannot be any keys prior to that range. + let cached_path = &cached.0; + if uncalculated_lower_bound < cached_path && !cached_path.is_zeroes() { + let range = (*uncalculated_lower_bound, Some(*cached_path)); + trace!(target: TRACE_TARGET, ?range, "Returning key range to calculate in order to catch up to cached branch"); + + // Push the cached branch onto the stack so it's available once the leaf range is done + // being calculated. + self.cached_branch_stack.push(cached); + + return Ok(PopCachedBranchOutcome::CalculateLeaves(range)); + } + + Ok(PopCachedBranchOutcome::Popped(cached)) + } + + /// Pop any under-construction branches that are now complete. Assumes that all trie data prior + /// to `next_path`, if any, has been computed. Any branches which were under-construction + /// previously, and which do not share a prefix with `next_path`, can be assumed to be + /// completed; they will not have any further keys added to them. + /// + /// Returns a range to calculate if a branch still has dirty keys to process, or popping it + /// exposes dirty keys which could split its extension. A missing lower bound disables these + /// checks when the caller has already scheduled the remaining range. + fn commit_branches<'a>( + &mut self, + targets: &mut Option>, + next_path: &Nibbles, + uncalculated_lower_bound: Option<&Nibbles>, + ) -> Result)>, StateProofError> { + let dirty_range = |prefix_set: &mut PrefixSet, upper_bound: Option| { + let uncalculated_lower_bound = uncalculated_lower_bound?; + + if upper_bound.as_ref().is_some_and(|upper| uncalculated_lower_bound >= upper) { + return None + } + + match upper_bound { + Some(upper_bound) => prefix_set + .contains_range(uncalculated_lower_bound..&upper_bound) + .then_some((*uncalculated_lower_bound, Some(upper_bound))), + None => prefix_set + .contains_from(uncalculated_lower_bound) + .then_some((*uncalculated_lower_bound, None)), + } + }; + + let mut popped_child_path_upper = None; + while !next_path.starts_with(&self.branch_path) { + // If the lower bound is still within this branch, process any remaining dirty keys + // before popping it so they can be added directly to the branch. + if uncalculated_lower_bound.is_some_and(|lower| lower.starts_with(&self.branch_path)) && + let Some(range) = + dirty_range(&mut self.prefix_set, self.branch_path.next_without_prefix()) + { + return Ok(Some(range)) + } + + let branch = self.branch_stack.last().expect("branch_stack cannot be empty"); + // Once popped, this branch becomes a child at this path. Its upper bound therefore + // covers any keys which could split the branch's extension on the right. + popped_child_path_upper = Some( + self.branch_path + .slice_unchecked(0, self.branch_path.len() - branch.ext_len as usize) + .next_without_prefix(), + ); + + self.pop_branch(targets)?; + } + + // Bound only this post-pop scan at the cached path so traversal can reuse its descendants. + // The pre-pop check must finish dirty keys inside the branch before it is encoded. + // + // An empty branch_stack is skipped because a popped local root does not need this check: + // any gap before `next_path` was already returned by `try_pop_cached_branch`, and forward + // traversal will split its extension and process later dirty keys as needed. + if !self.branch_stack.is_empty() && + let Some(upper_bound) = popped_child_path_upper && + let Some(range) = dirty_range( + &mut self.prefix_set, + Some(upper_bound.unwrap_or(*next_path).min(*next_path)), + ) + { + return Ok(Some(range)) + } + + Ok(None) + } + + // Returns the next child nibble on the current branch to process, or None if there are no + // further nibbles to process. + fn next_uncached_child_nibble( + prefix_set: &mut PrefixSet, + branch_path: &Nibbles, + uncalculated_lower_bound_ref: &Nibbles, + cached_state_mask: TrieMask, + ) -> Option { + let mut next_child_nibbles = cached_state_mask; + + // Also include child nibbles indicated by the prefix set. The prefix set can + // indicate children that need recalculation from leaves (e.g. new keys inserted + // under this branch). + if prefix_set.contains(branch_path) { + let branch_path_len = branch_path.len(); + let mut child_path = *branch_path; + for nibble in 0u8..16 { + child_path.truncate(branch_path_len); + child_path.push_unchecked(nibble); + if prefix_set.contains(&child_path) { + next_child_nibbles.set_bit(nibble); + } + } + } + + let _orig_next_child_nibbles = next_child_nibbles; + + // Mask out any child nibbles whose ranges have already been fully processed. + // This can happen when `calculate_key_range` finds no keys for a child's range, + // leaving the child's bit unset in `state_mask`. Without this, re-entering this + // function would select the same child again. + if uncalculated_lower_bound_ref.starts_with(branch_path) && + uncalculated_lower_bound_ref.len() > branch_path.len() + { + let lower_nibble = uncalculated_lower_bound_ref.get_unchecked(branch_path.len()); + // Clear all nibbles strictly below `lower_nibble`. If the lower bound is within the + // current child, the remainder of that child may still need to be processed. + let already_processed_mask = TrieMask::new((1u16 << lower_nibble) - 1); + next_child_nibbles &= !already_processed_mask; + trace!( + target: TRACE_TARGET, + ?branch_path, + ?_orig_next_child_nibbles, + ?already_processed_mask, + ?next_child_nibbles, + "Unset already processed key nibbles from next_child_nibbles", + ); + } else if !uncalculated_lower_bound_ref.starts_with(branch_path) && + uncalculated_lower_bound_ref > branch_path + { + // The lower bound has moved entirely past this branch (e.g. branch is 0x6 but + // lower is 0x7). All remaining children have been processed. + next_child_nibbles = TrieMask::default(); + trace!( + target: TRACE_TARGET, + ?branch_path, + ?_orig_next_child_nibbles, + ?next_child_nibbles, + "Unset all nibbles from next_child_nibbles due to branch_path being outside this subtrie", + ); + } + + next_child_nibbles.first_set_bit_index() + } + + /// Accepts the current state of both hashed and trie cursors, and determines the next range of + /// hashed keys which need to be processed using [`Self::calculate_key_range`]. + /// + /// This method will use cached branch node data from the trie cursor to skip over all possible + /// ranges of keys, to reduce computation as much as possible. + /// + /// # Returns + /// + /// - `None`: No more data to process, finish computation + /// + /// - `Some(lower, None)`: Indicates to process all keys starting at `lower`, with no upper + /// bound. This method won't be called again after this. + /// + /// - `Some(lower, Some(upper))`: Indicates to process all keys starting at `lower`, up to but + /// excluding `upper`, and then call this method once done. + /// + /// Once returned the `branch_stack` will be in the correct state to start calculating leaves + /// for the given range, if any. + #[instrument(target = TRACE_TARGET, level = "trace", skip_all)] + fn next_uncached_key_range<'a>( + &mut self, + targets: &mut Option>, + trie_cursor_state: &mut TrieCursorState, + traversal_upper_bound: Option<&Nibbles>, + mut uncalculated_lower_bound: Option, + ) -> Result)>, StateProofError> { + loop { + if let (Some(lower_bound), Some(upper_bound)) = + (uncalculated_lower_bound.as_ref(), traversal_upper_bound) && + lower_bound >= upper_bound + { + return Ok(None) + } + + // Pop the currently cached branch node. + // + // NOTE we pop off the `cached_branch_stack` because cloning the `BranchNodeCompact` + // means cloning an Arc, which incurs synchronization overhead. We have to be sure to + // push the cached branch back onto the stack once done. + let (cached_path, cached_branch) = match self.try_pop_cached_branch( + trie_cursor_state, + traversal_upper_bound, + &uncalculated_lower_bound, + )? { + PopCachedBranchOutcome::Popped(cached) => cached, + PopCachedBranchOutcome::Exhausted => { + // If cached branches are exhausted it's possible that there is still an + // unbounded range of leaves to be processed. `uncalculated_lower_bound` is + // used to return that range. + trace!(target: TRACE_TARGET, ?uncalculated_lower_bound, "Exhausted cached trie nodes"); + if let Some(lower) = uncalculated_lower_bound { + self.commit_branches(targets, &lower, None)?; + return Ok(Some((lower, traversal_upper_bound.copied()))); + } + return Ok(None) + } + PopCachedBranchOutcome::CalculateLeaves(range) => { + self.commit_branches(targets, &range.0, None)?; + return Ok(Some(range)); + } + }; + + let uncalculated_lower_bound_ref = uncalculated_lower_bound + .as_ref() + .expect("try_pop_cached_branch would return Exhausted if this were None"); + + trace!( + target: TRACE_TARGET, + branch_path = ?self.branch_path, + branch_state_mask = ?self.branch_stack.last().map(|b| b.state_mask), + ?cached_path, + cached_branch_state_mask = ?cached_branch.state_mask, + cached_branch_hash_mask = ?cached_branch.hash_mask, + "loop", + ); + + if let Some(range) = + self.commit_branches(targets, &cached_path, Some(uncalculated_lower_bound_ref))? + { + self.cached_branch_stack.push((cached_path, cached_branch)); + return Ok(Some(range)) + } + + // Since we've popped all constructed branches which don't contain `cached_path`, the + // remaining branch path must be its prefix. + debug_assert!( + self.branch_path.len() < cached_path.len() || self.branch_path == cached_path, + "branch_path {:?} is different-or-longer-than cached_path {cached_path:?}", + self.branch_path + ); + + // Dirty keys before this cached path may split the extension leading to it. Process + // them before using the cached branch as a hint. + if uncalculated_lower_bound_ref < &cached_path && + self.prefix_set.contains_range(uncalculated_lower_bound_ref..&cached_path) + { + self.cached_branch_stack.push((cached_path, cached_branch)); + return Ok(Some((*uncalculated_lower_bound_ref, Some(cached_path)))) + } + + let child_nibble = Self::next_uncached_child_nibble( + &mut self.prefix_set, + &cached_path, + uncalculated_lower_bound_ref, + cached_branch.state_mask, + ); + + let Some(child_nibble) = child_nibble else { + trace!( + target: TRACE_TARGET, + path=?cached_path, + ?cached_branch, + "No further cached children", + ); + + // no need to pop from `cached_branch_stack`, the current cached branch is already + // popped (see note at the top of the loop). + + // The completed branch has no more keys with its prefix. Set the lower bound which + // can be returned from this method to be the next possible prefix, if any. + uncalculated_lower_bound = cached_path.next_without_prefix(); + + continue + }; + + let mut child_path = cached_path; + child_path.push_unchecked(child_nibble); + let child_lower_bound = (*uncalculated_lower_bound_ref).max(child_path); + + // If the `hash_mask` bit is set for the next child it means the child's hash is cached + // in the `cached_branch`. We can use that instead of re-calculating the hash of the + // entire sub-trie. + // + // If the child needs to be retained for a proof then we should not use the cached + // hash, and instead continue on to calculate its node manually. + // + // If the child's path is in the prefix set then the cached hash is stale and must + // not be used. + if cached_branch.hash_mask.is_bit_set(child_nibble) && + child_lower_bound == child_path && + !self.prefix_set.contains(&child_path) + { + // Pull this child's hash out of the cached branch node. The hash index is the + // number of hash_mask bits set below this child's nibble. + let lower_bits = TrieMask::new((1u16 << child_nibble) - 1); + let hash_idx = (cached_branch.hash_mask & lower_bits).count_ones() as usize; + let hash = cached_branch.hashes[hash_idx]; + + // `take_cached_branch` replaces hashes with zero when their nodes must be + // revealed to support a possible branch collapse. + if !hash.is_zero() { + let mut probed_targets = targets.clone(); + if !self.should_retain(&mut probed_targets, &child_path, false) { + trace!( + target: TRACE_TARGET, + ?child_path, + ?hash_idx, + ?hash, + "Using cached hash for child", + ); + + // Keep this hint available while inserting the child so a branch which is + // naturally materialized at `cached_path` can inherit its masks. + let tree_mask_bit = cached_branch.tree_mask.is_bit_set(child_nibble); + self.cached_branch_stack.push((cached_path, cached_branch)); + self.push_child( + targets, + ProofTrieBranchChild::RlpNode { + node: RlpNode::word_rlp(&hash), + short_key: child_path, + hash_mask_bit: true, + tree_mask_bit, + }, + )?; + + if let (Some(targets), Some(probed_targets)) = + (targets.as_mut(), probed_targets) + { + targets.i = targets.i.max(probed_targets.i); + } + + // Update the `uncalculated_lower_bound` to indicate that the child whose + // bit was just set is completely processed. + uncalculated_lower_bound = child_path.next_without_prefix(); + + continue + } + } + } + + // We now want to check if there is a cached branch node at this child. The cached + // branch node may be the node at this child directly, or this child may be an + // extension and the cached branch is the child of that extension. + + // All trie nodes prior to `child_lower_bound` have been processed, so seek the trie + // cursor forward if necessary. + if trie_cursor_state.path().is_some_and(|path| path < &child_lower_bound) { + trace!(target: TRACE_TARGET, ?child_lower_bound, "Seeking trie cursor to child lower bound"); + *trie_cursor_state = TrieCursorState::seeked( + child_lower_bound, + self.trie_cursor.seek(child_lower_bound)?, + ); + } + + // If the next cached branch node is a child of `child_path` then we can assume it is + // the cached branch for this child. We push it onto the `cached_branch_stack` and loop + // back to the top. + if let TrieCursorState::Available(next_cached_path, next_cached_branch) = + &trie_cursor_state && + next_cached_path.starts_with(&child_path) + { + // Push the current cached branch back on before pushing its child and then looping + self.cached_branch_stack.push((cached_path, cached_branch)); + + trace!( + target: TRACE_TARGET, + ?child_path, + ?next_cached_path, + ?next_cached_branch, + "Pushing cached branch for child", + ); + let cached = self.take_cached_branch(trie_cursor_state); + self.cached_branch_stack.push(cached); + continue; + } + + // There is no cached data for the sub-trie at this child, we must recalculate the + // sub-trie root (this child) using the leaves. Return the range of keys based on the + // child path. + let child_upper_bound = child_path.next_without_prefix(); + trace!( + target: TRACE_TARGET, + lower=?child_lower_bound, + upper=?child_upper_bound, + "Returning sub-trie's key range to calculate", + ); + + // Push the current cached branch back onto the stack before returning. + self.cached_branch_stack.push((cached_path, cached_branch)); + + return Ok(Some((child_lower_bound, child_upper_bound))); + } + } + + /// Calculates trie nodes and retains proofs for targeted nodes within a sub-trie. The + /// sub-trie's bounds are denoted by the `lower_bound` and `upper_bound` arguments, + /// `upper_bound` is exclusive, None indicates unbounded. + #[instrument( + target = TRACE_TARGET, + level = "trace", + skip_all, + fields( + parent_prefix=?sub_trie_targets.parent_prefix, + lower_bound=?sub_trie_targets.lower_bound, + upper_bound=?sub_trie_targets.upper_bound, + ), + )] + fn proof_subtrie<'a>( + &mut self, + value_encoder: &mut VE, + trie_cursor_state: &mut TrieCursorState, + hashed_cursor_state: &mut HashedCursorState, + sub_trie_targets: SubTrieTargets<'a>, + ) -> Result<(), StateProofError> { + let traversal_lower_bound = sub_trie_targets.lower_bound; + let traversal_upper_bound = sub_trie_targets.upper_bound; + + // Wrap targets into a `TargetsCursor`. targets can be empty if we only want to calculate + // the root, in which case we don't need a cursor. + let mut targets = if sub_trie_targets.targets.is_empty() { + None + } else { + Some(TargetsCursor::new(sub_trie_targets.targets)) + }; + + // Ensure initial state is cleared. By the end of the method call these should be empty once + // again. + debug_assert!(self.cached_branch_stack.is_empty()); + debug_assert!(self.branch_stack.is_empty()); + debug_assert!(self.branch_path.is_empty()); + debug_assert!(self.child_stack.is_empty()); + + // `next_uncached_key_range`, which will be called in the loop below, expects the trie + // cursor to have already been positioned. Cursor resets for overlapping sub-tries are + // handled by `proof_inner`, so a buffered entry at-or-after this disjoint range remains the + // first unconsumed entry. Exhaustion is similarly stable across forward-only ranges. + if trie_cursor_state.needs_seek_to(&traversal_lower_bound) { + trace!(target: TRACE_TARGET, "Doing initial seek of trie cursor"); + *trie_cursor_state = TrieCursorState::seeked( + traversal_lower_bound, + self.trie_cursor.seek(traversal_lower_bound)?, + ); + } + + // `uncalculated_lower_bound` tracks the lower bound of node paths which have yet to be + // visited, either via the hashed key cursor (`calculate_key_range`) or trie cursor + // (`next_uncached_key_range`). If/when this becomes None then there are no further nodes + // which could exist. + let mut uncalculated_lower_bound = Some(traversal_lower_bound); + + trace!(target: TRACE_TARGET, "Starting loop"); + loop { + // Save the previous lower bound to detect forward progress. + let prev_uncalculated_lower_bound = uncalculated_lower_bound; + + // Determine the range of keys of the overall trie which need to be re-computed. + let Some((calc_lower_bound, calc_upper_bound)) = self.next_uncached_key_range( + &mut targets, + trie_cursor_state, + traversal_upper_bound.as_ref(), + prev_uncalculated_lower_bound, + )? + else { + // If `next_uncached_key_range` determines that there can be no more keys then + // complete the computation. + break; + }; + + // Forward-progress guard: detect trie inconsistencies that would cause infinite loops. + // If `next_uncached_key_range` returns a range that starts before the previous + // lower bound, we've gone backwards and would loop forever. + // + // This can specifically happen when there is a cached branch which shouldn't exist, or + // if state mask bit is set on a cached branch which shouldn't be. + if let Some(prev_lower) = prev_uncalculated_lower_bound.as_ref() && + calc_lower_bound < *prev_lower + { + let msg = format!( + "next_uncached_key_range went backwards: calc_lower={calc_lower_bound:?} < \ + prev_lower={prev_lower:?}, calc_upper={calc_upper_bound:?}, \ + lower_bound={traversal_lower_bound:?}, \ + upper_bound={traversal_upper_bound:?}", + ); + error!(target: TRACE_TARGET, "{msg}"); + return Err(StateProofError::TrieInconsistency(msg)); + } + + // Calculate the trie for that range of keys + self.calculate_key_range( + value_encoder, + &mut targets, + hashed_cursor_state, + calc_lower_bound, + calc_upper_bound, + )?; + + // Once outside `calculate_key_range`, `hashed_cursor_state` will be at the first key + // after the range, or exhausted. + // + // If the hashed cursor is exhausted, or has reached the end of the traversal range, + // then there are no more keys which can contribute to these target children. + if hashed_cursor_state.path().is_none_or(|key| { + traversal_upper_bound.is_some_and(|upper_bound| key >= &upper_bound) + }) { + break; + } + + // The upper bound of previous calculation becomes the lower bound of the uncalculated + // range, for which we'll once again check for cached data. + uncalculated_lower_bound = calc_upper_bound; + } + + // Once there's no more leaves we can pop the remaining branches, if any. + trace!(target: TRACE_TARGET, "Exited loop, popping remaining branches"); + while !self.branch_stack.is_empty() { + self.pop_branch(&mut targets)?; + } + + // At this point the branch stack should be empty. If the child stack is empty it means no + // keys were ever iterated from the hashed cursor in the first place. Otherwise there should + // only be a single node left: the root node. + debug_assert!(self.branch_stack.is_empty()); + debug_assert!(self.branch_path.is_empty()); + debug_assert!(self.child_stack.len() < 2); + + // The `cached_branch_stack` may still have cached branches on it, as it's not affected by + // `pop_branch`, but it is no longer needed and should be cleared. + self.cached_branch_stack.clear(); + + // We always pop the local root node off of the `child_stack` in order to empty it. If the + // parent branch is already known, compressed roots need to be rebased into a direct child + // of that parent before they can be attached to it. + trace!( + target: TRACE_TARGET, + parent_prefix = ?sub_trie_targets.parent_prefix, + child_stack_empty = self.child_stack.is_empty(), + "Maybe retaining local root", + ); + let root_node = self.child_stack.pop(); + + // A full-trie calculation always retains a root, using an empty root when traversal + // produced no root node. + let Some(parent_prefix) = sub_trie_targets.parent_prefix else { + let mut root_node = if let Some(root_node) = root_node { + self.rlp_encode_buf.clear(); + root_node.into_proof_trie_node(Nibbles::new(), &mut self.rlp_encode_buf)? + } else { + ProofTrieNodeV2::empty() + }; + + // Direct root branches do not have an entry in the branch node table. A root extension + // still carries the masks of the child branch embedded within it. + if matches!(&root_node.node, TrieNodeV2::Branch(branch) if branch.key.is_empty()) { + root_node.masks = None; + } + + self.retained_proofs.push(root_node); + return Ok(()) + }; + + // If there's no root node then the subtrie has no keys, return nothing. + let Some(mut root_node) = root_node else { return Ok(()) }; + + let root_full_path = *root_node.short_key(); + + // An exact match reconstructed the already-revealed parent; its targeted children were + // retained while that parent branch was popped. + if root_full_path == parent_prefix { + return Ok(()) + } + + // At this point we have a "root" node which the calculator has based at 0x (empty path), + // but the subtrie targets indicate that there is a known parent branch at parent_prefix + // which this root should be rebased onto. + + // The local root of a partial calculation must be at or below its known parent. + if !root_full_path.starts_with(&parent_prefix) { + return Err(StateProofError::TrieInconsistency(format!( + "local root path {root_full_path:?} does not start with parent prefix \ + {parent_prefix:?}", + ))) + } + + // Keep the parent branch's child nibble in the proof path so the local root attaches + // directly below that parent. + let child_path_len = parent_prefix.len() + 1; + let child_path = root_full_path.slice_unchecked(0, child_path_len); + + // It's possible that the local root lies on a child which is not targeted. + if !sub_trie_targets + .targets + .iter() + .any(|target| target.key_nibbles.starts_with(&child_path)) + { + return Ok(()) + } + + // Retain the requested child with only the path below its parent edge in the short key. + root_node.trim_short_key_prefix(child_path_len); + self.rlp_encode_buf.clear(); + let root_node = root_node.into_proof_trie_node(child_path, &mut self.rlp_encode_buf)?; + self.retained_proofs.push(root_node); + + Ok(()) + } + + /// Clears internal computation state. Called after errors to ensure the calculator is not + /// left in a partially-computed state when reused. + fn clear_computation_state(&mut self) { + self.branch_stack.clear(); + self.branch_path = Nibbles::new(); + self.child_stack.clear(); + self.cached_branch_stack.clear(); + self.retained_proofs.clear(); + } + + /// Internal implementation of proof calculation. Assumes both cursors have already been reset. + /// See docs on [`Self::proof`] for expected behavior. + fn proof_inner( + &mut self, + value_encoder: &mut VE, + targets: &mut [ProofV2Target], + ) -> Result, StateProofError> { + // If there are no targets then nothing could be returned, return early. + if targets.is_empty() { + trace!(target: TRACE_TARGET, "Empty targets, returning"); + return Ok(Vec::new()) + } + + // Initialize the variables which track the state of the two cursors. Both indicate the + // cursors are unseeked. + let mut trie_cursor_state = TrieCursorState::unseeked(); + let mut hashed_cursor_state = HashedCursorState::unseeked(); + let mut previous_traversal_bounds: Option<(Nibbles, Option)> = None; + + // Divide targets into bounded ranges, each corresponding to the direct children of one + // already-revealed parent, and handle all proofs within that range. + for sub_trie_targets in iter_sub_trie_targets(targets) { + let traversal_lower_bound = sub_trie_targets.lower_bound; + let traversal_upper_bound = sub_trie_targets.upper_bound; + if previous_traversal_bounds.is_some_and(|(_, previous_upper_bound)| { + previous_upper_bound.is_none_or(|upper_bound| upper_bound > traversal_lower_bound) + }) { + if trie_cursor_state.needs_reset_before_seek(&traversal_lower_bound) { + trace!( + target: TRACE_TARGET, + ?previous_traversal_bounds, + ?traversal_lower_bound, + ?traversal_upper_bound, + "Resetting trie cursor before overlapping or backward traversal range", + ); + self.trie_cursor.reset(); + trie_cursor_state = TrieCursorState::unseeked(); + } + if hashed_cursor_state.needs_reset_before_seek(&traversal_lower_bound) { + trace!( + target: TRACE_TARGET, + ?previous_traversal_bounds, + ?traversal_lower_bound, + ?traversal_upper_bound, + "Resetting hashed cursor before overlapping or backward traversal range", + ); + self.hashed_cursor.reset(); + hashed_cursor_state = HashedCursorState::unseeked(); + } + } + + if let Err(err) = self.proof_subtrie( + value_encoder, + &mut trie_cursor_state, + &mut hashed_cursor_state, + sub_trie_targets, + ) { + self.clear_computation_state(); + return Err(err); + } + + previous_traversal_bounds = Some((traversal_lower_bound, traversal_upper_bound)); + } + + trace!( + target: TRACE_TARGET, + retained_proofs_len = ?self.retained_proofs.len(), + "proof_inner: returning", + ); + self.retained_proofs.sort_unstable_by(|a, b| depth_first::cmp(&a.path, &b.path)); + self.retained_proofs.dedup_by(|a, b| a.path == b.path); + Ok(core::mem::take(&mut self.retained_proofs)) + } + + /// Generate a proof for the given targets. + /// + /// Given a set of [`ProofV2Target`]s, returns nodes whose paths are a prefix of any target. The + /// returned nodes are sorted in depth-first post-order (children before parents). + /// + /// Targets may be supplied in any order. Calculation sorts the slice in place by known parent + /// context, then by key within each group. + #[instrument(target = TRACE_TARGET, level = "trace", skip_all)] + pub fn proof( + &mut self, + value_encoder: &mut VE, + targets: &mut [ProofV2Target], + ) -> Result, StateProofError> { + self.trie_cursor.reset(); + self.hashed_cursor.reset(); + self.proof_inner(value_encoder, targets) + } + + /// Computes the root hash from a set of proof nodes. + /// + /// Returns `None` if there is no root node (partial proof), otherwise returns the hash of the + /// root node. + /// + /// This method reuses the internal RLP encode buffer for efficiency. + pub fn compute_root_hash( + &mut self, + proof_nodes: &[ProofTrieNodeV2], + ) -> Result, StateProofError> { + // Find the root node (node at empty path) + let root_node = proof_nodes.iter().find(|node| node.path.is_empty()); + + let Some(root) = root_node else { + return Ok(None); + }; + + // Compute the hash of the root node + self.rlp_encode_buf.clear(); + root.node.encode(&mut self.rlp_encode_buf); + let root_hash = keccak256(&self.rlp_encode_buf); + + Ok(Some(root_hash)) + } + + /// Calculates the root node of the trie. + /// + /// This method does not accept targets nor retain proofs. Returns the root node which can + /// be used to compute the root hash via [`Self::compute_root_hash`]. + #[instrument(target = TRACE_TARGET, level = "trace", skip(self, value_encoder))] + pub fn root_node( + &mut self, + value_encoder: &mut VE, + ) -> Result { + self.trie_cursor.reset(); + self.hashed_cursor.reset(); + + // Initialize the variables which track the state of the two cursors. Both indicate the + // cursors are unseeked. + let mut trie_cursor_state = TrieCursorState::unseeked(); + let mut hashed_cursor_state = HashedCursorState::unseeked(); + + static EMPTY_TARGETS: [ProofV2Target; 0] = []; + let sub_trie_targets = SubTrieTargets { + lower_bound: Nibbles::new(), + upper_bound: None, + parent_prefix: None, + targets: &EMPTY_TARGETS, + }; + + if let Err(err) = self.proof_subtrie( + value_encoder, + &mut trie_cursor_state, + &mut hashed_cursor_state, + sub_trie_targets, + ) { + self.clear_computation_state(); + return Err(err); + } + + // `proof_subtrie` retains the root node when there is no known parent, regardless of + // whether there are any targets. + let mut proofs = core::mem::take(&mut self.retained_proofs); + trace!( + target: TRACE_TARGET, + proofs_len = ?proofs.len(), + "root_node: extracting root", + ); + + // The root node is at the empty path. Since there is no parent and targets is empty, there + // should be no other retained proofs. + debug_assert_eq!( + proofs.len(), 1, + "prefix is empty, parent path is None, and targets is empty, so there must be only the root node" + ); + + // Find and remove the root node (node at empty path) + let root_node = proofs.pop().expect("prefix is empty, parent path is None, and targets is empty, so there must be only the root node"); + + Ok(root_node) + } +} + +/// A proof calculator for storage tries. +pub type StorageProofCalculator = ProofCalculator; + +impl StorageProofCalculator +where + TC: TrieStorageCursor, + HC: HashedStorageCursor, +{ + /// Create a new [`StorageProofCalculator`] instance. + pub fn new_storage(trie_cursor: TC, hashed_cursor: HC) -> Self { + Self::new(trie_cursor, hashed_cursor) + } + + /// Generate a proof for a storage trie at the given hashed address. + /// + /// Given a set of [`ProofV2Target`]s, returns nodes whose paths are a prefix of any target. The + /// returned nodes are sorted in depth-first post-order (children before parents). + /// + /// Targets may be supplied in any order. Calculation sorts the slice in place by known parent + /// context, then by key within each group. + #[instrument(target = TRACE_TARGET, level = "trace", skip(self, targets))] + pub fn storage_proof( + &mut self, + hashed_address: B256, + targets: &mut [ProofV2Target], + ) -> Result, StateProofError> { + self.hashed_cursor.set_hashed_address(hashed_address); + + // Shortcut: check if storage is empty + if self.hashed_cursor.is_storage_empty()? { + return Ok(if targets.iter().any(|target| !target.parent.is_known()) { + vec![ProofTrieNodeV2 { + path: Nibbles::default(), + node: TrieNodeV2::EmptyRoot, + masks: None, + }] + } else { + Vec::new() + }) + } + + // Don't call `set_hashed_address` on the trie cursor until after the previous shortcut has + // been checked. + self.trie_cursor.set_hashed_address(hashed_address); + + // Create a mutable storage value encoder + let mut storage_value_encoder = StorageValueEncoder; + self.proof_inner(&mut storage_value_encoder, targets) + } + + /// Calculates the root node of a storage trie. + /// + /// This method does not accept targets nor retain proofs. Returns the root node which can + /// be used to compute the root hash via [`Self::compute_root_hash`]. + #[instrument(target = TRACE_TARGET, level = "trace", skip(self))] + pub fn storage_root_node( + &mut self, + hashed_address: B256, + ) -> Result { + self.hashed_cursor.set_hashed_address(hashed_address); + + if self.hashed_cursor.is_storage_empty()? { + return Ok(ProofTrieNodeV2 { + path: Nibbles::default(), + node: TrieNodeV2::EmptyRoot, + masks: None, + }) + } + + // Don't call `set_hashed_address` on the trie cursor until after the previous shortcut has + // been checked. + self.trie_cursor.set_hashed_address(hashed_address); + + // Create a mutable storage value encoder + let mut storage_value_encoder = StorageValueEncoder; + self.root_node(&mut storage_value_encoder) + } + + /// Calculates the storage root hash of the account at `hashed_address`. + pub fn storage_root(&mut self, hashed_address: B256) -> Result { + let root_node = self.storage_root_node(hashed_address)?; + Ok(self + .compute_root_hash(core::slice::from_ref(&root_node))? + .expect("storage_root_node returns a node at empty path")) + } +} + +/// Helper type wrapping a slice of [`ProofV2Target`]s, primarily used to iterate through targets in +/// [`ProofCalculator::should_retain`]. +/// +/// It is assumed that the underlying slice is never empty, and that the iterator is never +/// exhausted. +#[derive(Clone)] +struct TargetsCursor<'a> { + targets: &'a [ProofV2Target], + i: usize, +} + +impl<'a> TargetsCursor<'a> { + /// Wraps a slice of [`ProofV2Target`]s with the `TargetsCursor`. + /// + /// # Panics + /// + /// Will panic in debug mode if called with an empty slice. + fn new(targets: &'a [ProofV2Target]) -> Self { + debug_assert!(!targets.is_empty()); + Self { targets, i: 0 } + } + + /// Returns the current and next [`ProofV2Target`] that the cursor is pointed at. + fn current(&self) -> (&'a ProofV2Target, Option<&'a ProofV2Target>) { + (&self.targets[self.i], self.targets.get(self.i + 1)) + } + + /// Iterates the cursor forward. + /// + /// # Panics + /// + /// Will panic if the cursor is exhausted. + fn next(&mut self) -> (&'a ProofV2Target, Option<&'a ProofV2Target>) { + self.i += 1; + debug_assert!(self.i < self.targets.len()); + self.current() + } + + // Iterate forwards over the slice, starting from the [`ProofV2Target`] after the current. + fn skip_iter(&self) -> impl Iterator { + self.targets[self.i + 1..].iter() + } + + /// Iterated backwards over the slice, starting from the [`ProofV2Target`] previous to the + /// current. + fn rev_iter(&self) -> impl Iterator { + self.targets[..self.i].iter().rev() + } +} + +/// Used to track the state of the trie cursor, allowing us to differentiate between a branch having +/// been taken (used as a cached branch) and the cursor having been exhausted. +#[derive(Debug)] +enum TrieCursorState { + /// The initial state of the cursor, indicating it's never been seeked. + Unseeked, + /// Cursor is seeked to this path and the node has not been used yet. + Available(Nibbles, BranchNodeCompact), + /// Cursor is seeked to this path, but the node has been used. + Taken(Nibbles), + /// Cursor has been exhausted after seeking from the given lower bound. + Exhausted(Nibbles), +} + +impl TrieCursorState { + /// Creates a [`Self::Unseeked`] based on an entry returned from the cursor itself. + const fn unseeked() -> Self { + Self::Unseeked + } + + /// Creates a [`Self`] based on an entry returned from the cursor itself. + fn seeked(key: Nibbles, entry: Option<(Nibbles, BranchNodeCompact)>) -> Self { + entry.map_or(Self::Exhausted(key), |(path, node)| Self::Available(path, node)) + } + + /// Returns the path the cursor is seeked to, or None if it's exhausted. + /// + /// # Panics + /// + /// Panics if the cursor is unseeked. + const fn path(&self) -> Option<&Nibbles> { + match self { + Self::Unseeked => panic!("cursor is unseeked"), + Self::Available(path, _) | Self::Taken(path) => Some(path), + Self::Exhausted(_) => None, + } + } + + /// Returns true if the cursor must seek to be usable for a range starting at `path`. + fn needs_seek_to(&self, path: &Nibbles) -> bool { + match self { + Self::Unseeked | Self::Taken(_) => true, + Self::Available(current_path, _) => current_path < path, + Self::Exhausted(_) => false, + } + } + + /// Returns true if seeking to `key` requires resetting the forward-only cursor. + fn needs_reset_before_seek(&self, key: &Nibbles) -> bool { + match self { + Self::Unseeked => false, + Self::Available(path, _) | Self::Taken(path) => path > key, + Self::Exhausted(exhausted_at) => exhausted_at > key, + } + } + + /// Takes the path and node from a [`Self::Available`]. Panics if not [`Self::Available`]. + fn take(&mut self) -> (Nibbles, BranchNodeCompact) { + let Self::Available(path, _) = self else { + panic!("take called on non-Available: {self:?}") + }; + + let path = *path; + let Self::Available(path, node) = core::mem::replace(self, Self::Taken(path)) else { + unreachable!("already checked that self is Self::Available"); + }; + + (path, node) + } +} + +/// Used to track the state of the hashed cursor, including the path that established exhaustion. +enum HashedCursorState { + /// The initial state of the cursor, indicating it's never been seeked. + Unseeked, + /// Cursor is seeked to this path and the value has not been used yet. + Available(Nibbles, V), + /// Cursor has been exhausted at or after the given path. + Exhausted(Nibbles), +} + +impl HashedCursorState { + /// Creates a [`Self::Unseeked`] state. + const fn unseeked() -> Self { + Self::Unseeked + } + + /// Creates a [`Self`] based on an entry returned from the cursor itself. + fn seeked(key: Nibbles, entry: Option<(Nibbles, V)>) -> Self { + entry.map_or(Self::Exhausted(key), |(path, value)| Self::Available(path, value)) + } + + /// Returns the path the cursor is seeked to, or None if it's unseeked or exhausted. + const fn path(&self) -> Option<&Nibbles> { + match self { + Self::Available(path, _) => Some(path), + Self::Unseeked | Self::Exhausted(_) => None, + } + } + + /// Returns true if the cursor must seek to be usable for a range starting at `key`. + fn needs_seek_to(&self, key: &Nibbles) -> bool { + match self { + Self::Unseeked => true, + Self::Available(path, _) => path < key, + Self::Exhausted(exhausted_at) => exhausted_at > key, + } + } + + /// Returns true if seeking to `key` requires resetting the forward-only cursor. + fn needs_reset_before_seek(&self, key: &Nibbles) -> bool { + match self { + Self::Unseeked => false, + Self::Available(path, _) => path > key, + Self::Exhausted(exhausted_at) => exhausted_at >= key, + } + } + + /// Takes the path and value from a [`Self::Available`]. Panics if not [`Self::Available`]. + fn take(&mut self) -> (Nibbles, V) { + match core::mem::replace(self, Self::Unseeked) { + Self::Available(path, value) => (path, value), + _ => panic!("take called on non-Available hashed cursor state"), + } + } +} + +/// Describes the state of the currently cached branch node (if any). +enum PopCachedBranchOutcome { + /// Cached branch has been popped from the `cached_branch_stack` and is ready to be used. + Popped((Nibbles, BranchNodeCompact)), + /// All cached branches have been exhausted. + Exhausted, + /// Need to calculate leaves from this range (exclusive upper) before the cached branch + /// (catch-up range). If None then + CalculateLeaves((Nibbles, Option)), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + hashed_cursor::{ + mock::MockHashedCursorFactory, noop::NoopHashedCursor, HashedCursorFactory, + HashedPostStateCursor, + }, + proof::StorageProof as LegacyStorageProof, + test_utils::TrieTestHarness, + trie_cursor::{ + depth_first, noop::NoopStorageTrieCursor, InMemoryTrieCursor, TrieCursorFactory, + }, + }; + use alloy_primitives::map::B256Set; + use alloy_rlp::Decodable; + use alloy_trie::proof::AddedRemovedKeys; + use itertools::Itertools; + use reth_trie_common::{ + prefix_set::{PrefixSet, PrefixSetMut}, + updates::TrieUpdatesSorted, + HashedPostState, HashedStorage, ProofTrieNode, ProofV2TargetParent, TrieNode, + EMPTY_ROOT_HASH, + }; + use std::collections::BTreeMap; + + /// Converts legacy proofs to V2 proofs by combining extension nodes with their child branch + /// nodes. + /// + /// In the legacy proof format, extension nodes and branch nodes are separate. In the V2 format, + /// they are combined into a single `BranchNodeV2` where the extension's key becomes the + /// branch's `key` field. + /// + /// Converts legacy proofs (sorted in depth-first order) to V2 format. + /// + /// In depth-first order, children come BEFORE parents. So when we encounter an extension node, + /// its child branch has already been processed and is in the result. We need to pop it and + /// combine it with the extension. + fn convert_legacy_proofs_to_v2(legacy_proofs: &[ProofTrieNode]) -> Vec { + ProofTrieNodeV2::from_sorted_trie_nodes( + legacy_proofs.iter().map(|p| (p.path, p.node.clone(), p.masks)), + ) + } + + /// Projects a legacy proof node into the representation requested by a V2 target. + fn project_legacy_proof_node( + node: &ProofTrieNodeV2, + target: &ProofV2Target, + ) -> Option { + let Some(parent_path_len) = target.parent.path_len() else { + return target.key_nibbles.starts_with(&node.path).then(|| node.clone()) + }; + + if node.path.len() > parent_path_len { + return target.key_nibbles.starts_with(&node.path).then(|| node.clone()) + } + + let logical_path = match &node.node { + TrieNodeV2::Leaf(leaf) => node.path.join(&leaf.key), + TrieNodeV2::Branch(branch) => node.path.join(&branch.key), + TrieNodeV2::EmptyRoot | TrieNodeV2::Extension(_) => return None, + }; + let child_path_len = parent_path_len + 1; + if logical_path.len() < child_path_len { + return None + } + + let child_path = logical_path.slice(0..child_path_len); + if !target.key_nibbles.starts_with(&child_path) { + return None + } + + let trim_len = child_path_len - node.path.len(); + let mut projected = node.clone(); + projected.path = child_path; + match &mut projected.node { + TrieNodeV2::Leaf(leaf) => leaf.key = leaf.key.slice(trim_len..), + TrieNodeV2::Branch(branch) => { + branch.key = branch.key.slice(trim_len..); + if branch.key.is_empty() { + branch.branch_rlp_node = None; + } + } + TrieNodeV2::EmptyRoot | TrieNodeV2::Extension(_) => unreachable!(), + } + Some(projected) + } + + /// Builds the exact V2 representation expected for a legacy proof and set of targets. + fn project_legacy_proof( + legacy_nodes: &[ProofTrieNodeV2], + targets: &[ProofV2Target], + ) -> Vec { + let mut projected = targets + .iter() + .flat_map(|target| { + legacy_nodes.iter().filter_map(move |node| project_legacy_proof_node(node, target)) + }) + .collect::>(); + projected.sort_unstable_by(|a, b| depth_first::cmp(&a.path, &b.path)); + projected.dedup_by(|a, b| { + if a.path != b.path { + return false + } + assert_eq!(a, b, "target projections disagree at path {:?}", a.path); + true + }); + projected + } + + /// A test harness for comparing `StorageProofCalculator` and legacy `StorageProof` + /// implementations. + /// + /// Wraps [`TrieTestHarness`] and adds a method to test that both proof implementations + /// produce equivalent results for storage proofs. + struct ProofTestHarness { + inner: TrieTestHarness, + } + + impl std::ops::Deref for ProofTestHarness { + type Target = TrieTestHarness; + fn deref(&self) -> &Self::Target { + &self.inner + } + } + + impl ProofTestHarness { + /// Creates a new test harness from a map of hashed storage slots to values. + fn new(storage: BTreeMap) -> Self { + Self { inner: TrieTestHarness::new(storage) } + } + + /// Computes the storage root while treating the supplied prefixes as dirty. + fn root_with_prefix_set(&self, prefix_set: PrefixSet) -> Option { + let trie_cursor = + self.trie_cursor_factory().storage_trie_cursor(self.hashed_address()).unwrap(); + let hashed_cursor = + self.hashed_cursor_factory().hashed_storage_cursor(self.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set); + + let mut targets = [ProofV2Target::new(B256::ZERO)]; + let proof = calculator.storage_proof(self.hashed_address(), &mut targets).unwrap(); + calculator.compute_root_hash(&proof).unwrap() + } + + /// Asserts that `StorageProofCalculator` and legacy `StorageProof` produce equivalent + /// results for storage proofs. + fn assert_proof( + &self, + targets: impl IntoIterator, + ) -> Result<(), StateProofError> { + let mut targets_vec = targets.into_iter().collect::>(); + + // Get v2 proof and root hash via harness + let (proof_v2_result, root_hash) = self.proof_v2(&mut targets_vec); + + // Verify the root hash matches the expected root (if the proof contains a root + // node) + if let Some(root_hash) = root_hash { + pretty_assertions::assert_eq!(self.original_root(), root_hash); + } + + // Fully materialize the legacy proof so compressed branches can be projected across + // arbitrary parent boundaries, including absence targets that diverge inside them. + let legacy_targets = targets_vec + .iter() + .map(|target| B256::from_slice(&target.key_nibbles.pack())) + .chain(self.storage().keys().copied()) + .collect::(); + + // Call legacy StorageProof::storage_multiproof + let proof_legacy_result = LegacyStorageProof::new_hashed( + self.trie_cursor_factory(), + self.hashed_cursor_factory(), + self.hashed_address(), + ) + .with_branch_node_masks(true) + .with_added_removed_keys(Some(AddedRemovedKeys::default().with_assume_added(true))) + .storage_multiproof(legacy_targets)?; + + // Decode and sort legacy proof nodes + let proof_legacy_nodes = proof_legacy_result + .subtree + .iter() + .map(|(path, node_enc)| { + let mut buf = node_enc.as_ref(); + let node = TrieNode::decode(&mut buf) + .expect("legacy implementation should not produce malformed proof nodes"); + + let masks = if path.is_empty() { + None + } else { + proof_legacy_result.branch_node_masks.get(path).copied() + }; + + ProofTrieNode { path: *path, node, masks } + }) + .sorted_by(|a, b| depth_first::cmp(&a.path, &b.path)) + .collect::>(); + + // Convert legacy proofs to V2 proofs by combining extensions with their child branches + let all_legacy_nodes_v2 = convert_legacy_proofs_to_v2(&proof_legacy_nodes); + + let expected_v2 = project_legacy_proof(&all_legacy_nodes_v2, &targets_vec); + pretty_assertions::assert_eq!(expected_v2, proof_v2_result); + + Ok(()) + } + } + + /// Tests that `clear_computation_state` properly resets internal stacks, allowing a + /// `StorageProofCalculator` to be reused after a mid-computation error left stale state. + /// Before the fix, stale data in `branch_stack`, `child_stack`, and `branch_path` + /// could cause a `usize` underflow panic in `pop_branch`. + #[test] + fn test_proof_calculator_reuse_after_error() { + reth_tracing::init_test_tracing(); + + let slots = [ + B256::right_padding_from(&[0x10]), + B256::right_padding_from(&[0x20]), + B256::right_padding_from(&[0x30]), + B256::right_padding_from(&[0x40]), + ]; + let storage: BTreeMap = + slots.iter().map(|&s| (s, U256::from(100u64))).collect(); + + let harness = ProofTestHarness::new(storage); + + let trie_cursor_factory = harness.trie_cursor_factory(); + let hashed_cursor_factory = harness.hashed_cursor_factory(); + + let hashed_address = harness.hashed_address(); + let trie_cursor = trie_cursor_factory.storage_trie_cursor(hashed_address).unwrap(); + let hashed_cursor = hashed_cursor_factory.hashed_storage_cursor(hashed_address).unwrap(); + let mut proof_calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); + + // Simulate stale state left by a mid-computation error: push fake entries onto internal + // stacks and set a non-empty branch_path. + proof_calculator + .branch_stack + .push(ProofTrieBranch { ext_len: 2, state_mask: TrieMask::new(0b1111) }); + proof_calculator + .branch_stack + .push(ProofTrieBranch { ext_len: 0, state_mask: TrieMask::new(0b11) }); + proof_calculator.child_stack.push(ProofTrieBranchChild::RlpNode { + node: RlpNode::word_rlp(&B256::ZERO), + short_key: Nibbles::new(), + hash_mask_bit: false, + tree_mask_bit: false, + }); + proof_calculator.branch_path = Nibbles::from_nibbles([0x1, 0x2, 0x3]); + + // clear_computation_state should reset everything so a subsequent call works. + proof_calculator.clear_computation_state(); + + let mut sorted_slots = slots.to_vec(); + sorted_slots.sort(); + let mut targets: Vec = + sorted_slots.iter().copied().map(ProofV2Target::new).collect(); + + let result = proof_calculator.storage_proof(hashed_address, &mut targets).unwrap(); + + // Compare against a fresh calculator to verify correctness. + let trie_cursor = trie_cursor_factory.storage_trie_cursor(hashed_address).unwrap(); + let hashed_cursor = hashed_cursor_factory.hashed_storage_cursor(hashed_address).unwrap(); + let mut fresh_calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); + let fresh_result = fresh_calculator.storage_proof(hashed_address, &mut targets).unwrap(); + + pretty_assertions::assert_eq!(fresh_result, result); + } + + #[test] + fn test_root_node_reuse_with_overlay() { + let storage = BTreeMap::from([ + (B256::right_padding_from(&[0x10]), U256::ONE), + (B256::right_padding_from(&[0x20]), U256::from(2)), + ]); + let harness = ProofTestHarness::new(storage.clone()); + let hashed_address = harness.hashed_address(); + let post_state = + HashedPostState::from_hashed_storage(hashed_address, HashedStorage::from_iter(storage)) + .into_sorted(); + let trie_updates = TrieUpdatesSorted::default(); + let trie_cursor = InMemoryTrieCursor::new_storage( + NoopStorageTrieCursor::default(), + &trie_updates, + hashed_address, + ); + let hashed_cursor = HashedPostStateCursor::new_storage( + NoopHashedCursor::default(), + &post_state, + hashed_address, + ); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); + + let first_root = calculator.root_node(&mut StorageValueEncoder).unwrap(); + assert!(matches!(first_root.node, TrieNodeV2::Branch(_))); + assert_eq!( + calculator.compute_root_hash(core::slice::from_ref(&first_root)).unwrap(), + Some(harness.original_root()) + ); + + let second_root = calculator.root_node(&mut StorageValueEncoder).unwrap(); + pretty_assertions::assert_eq!(first_root, second_root); + } + + #[test] + fn test_partial_storage_proof_after_root_calculation() { + let slot_a = B256::right_padding_from(&[0xae, 0xd4, 0x00]); + let slot_b = B256::right_padding_from(&[0xae, 0xd4, 0x10]); + let harness = + ProofTestHarness::new(BTreeMap::from([(slot_a, U256::ONE), (slot_b, U256::from(2))])); + let hashed_address = harness.hashed_address(); + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(hashed_address).unwrap(); + let hashed_cursor = + harness.hashed_cursor_factory().hashed_storage_cursor(hashed_address).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); + + let root_node = calculator.storage_root_node(hashed_address).unwrap(); + assert_eq!( + calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap(), + Some(harness.original_root()) + ); + + let target = ProofV2Target::new(slot_a).with_parent(ProofV2TargetParent::new(3)); + let mut actual_targets = [target]; + let actual = calculator.storage_proof(hashed_address, &mut actual_targets).unwrap(); + let mut expected_targets = [target]; + let (expected, root) = harness.proof_v2(&mut expected_targets); + + assert!(root.is_none()); + pretty_assertions::assert_eq!(expected, actual); + } + + mod proptest_tests { + use super::*; + use proptest::prelude::*; + + /// Generate a strategy for storage datasets (hashed slot → value). + fn storage_strategy() -> impl Strategy> { + prop::collection::vec((any::<[u8; 32]>(), any::()), 0..=100).prop_map(|slots| { + slots + .into_iter() + .map(|(slot_bytes, value)| (B256::from(slot_bytes), U256::from(value))) + .filter(|(_, v)| !v.is_zero()) + .collect() + }) + } + + /// Generate a strategy for proof targets that are 80% from existing storage slots + /// and 20% random keys. Each target has a random parent path length of `None` or 0..15. + fn proof_targets_strategy( + slot_keys: Vec, + ) -> impl Strategy> { + let num_slots = slot_keys.len(); + + let target_count = 0..=(num_slots + 5); + + target_count.prop_flat_map(move |count| { + let slot_keys = slot_keys.clone(); + prop::collection::vec( + ( + prop::bool::weighted(0.8).prop_flat_map(move |from_slots| { + if from_slots && !slot_keys.is_empty() { + prop::sample::select(slot_keys.clone()).boxed() + } else { + any::<[u8; 32]>().prop_map(B256::from).boxed() + } + }), + 0u8..16u8, + ) + .prop_map(|(key, encoded_parent_path_len)| { + let parent = encoded_parent_path_len.checked_sub(1).map_or( + ProofV2TargetParent::NONE, + |parent_path_len| { + ProofV2TargetParent::new(usize::from(parent_path_len)) + }, + ); + ProofV2Target::new(key).with_parent(parent) + }), + count, + ) + }) + } + + fn dirty_key_strategy() -> impl Strategy { + // Vary both nibbles of each byte to cover adjacent branches and extensions. + prop::array::uniform8(0u8..4).prop_map(|nibbles| { + B256::right_padding_from(&Nibbles::from_nibbles(nibbles).pack()) + }) + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(4000))] + #[test] + /// Tests that `StorageProofCalculator` produces valid proofs for randomly generated + /// storage datasets with proof targets. + fn proptest_proof_with_targets( + (storage, targets) in storage_strategy() + .prop_flat_map(|storage| { + let mut slot_keys: Vec = storage.keys().copied().collect(); + slot_keys.sort_unstable(); + let targets_strategy = proof_targets_strategy(slot_keys); + (Just(storage), targets_strategy) + }) + ) { + reth_tracing::init_test_tracing(); + let harness = ProofTestHarness::new(storage); + + harness.assert_proof(targets).expect("Proof generation failed"); + } + + #[test] + fn proptest_proof_with_dirty_prefixes( + before in prop::collection::btree_map(dirty_key_strategy(), 1u64..100, 0..80), + changes in prop::collection::btree_map(dirty_key_strategy(), 0u64..100, 0..8), + retain_unchanged in any::(), + target_indices in prop::collection::vec((0usize..100, 0usize..9), 0..10), + ) { + let before = before.into_iter() + .map(|(key, value)| (key, U256::from(value))) + .collect::>(); + let mut after = if retain_unchanged { before.clone() } else { BTreeMap::new() }; + for (key, value) in changes { + if value == 0 { + after.remove(&key); + } else { + after.insert(key, U256::from(value)); + } + } + let prefix_set = PrefixSetMut::from( + before.keys().chain(after.keys()) + .filter(|key| before.get(*key) != after.get(*key)) + .map(Nibbles::unpack), + ).freeze(); + let expected_root = crate::test_utils::storage_root_prehashed( + after.iter().map(|(key, value)| (*key, *value)), + ); + let mut targets = vec![ProofV2Target::new(B256::ZERO)]; + let keys = before.keys().chain(after.keys()).copied().collect::>(); + if !keys.is_empty() { + for (index, len) in target_indices { + targets.push(ProofV2Target::new(keys[index % keys.len()]) + .with_parent(ProofV2TargetParent::new(len))); + } + } + let harness = TrieTestHarness::new(before); + let hashed_factory = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), after)).collect(), + ); + let trie_cursor = harness.trie_cursor_factory() + .storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = hashed_factory + .hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set); + let proof = calculator.storage_proof(harness.hashed_address(), &mut targets).unwrap(); + + prop_assert_eq!(calculator.compute_root_hash(&proof).unwrap(), Some(expected_root)); + } + } + } + + #[test] + fn test_exact_subtrie_targets_with_root_target() { + reth_tracing::init_test_tracing(); + + let slot_80 = B256::right_padding_from(&[0x80]); + let slot_82 = B256::right_padding_from(&[0x82]); + let slot_f0 = B256::right_padding_from(&[0xf0]); + let storage = BTreeMap::from([ + (slot_80, U256::ONE), + (slot_82, U256::from(2)), + (slot_f0, U256::from(3)), + ]); + let targets = [ + ProofV2Target::new(B256::ZERO), + ProofV2Target::new(slot_80).with_parent(ProofV2TargetParent::new(1)), + ]; + + let harness = ProofTestHarness::new(storage); + harness.assert_proof(targets).expect("Proof generation failed"); + } + + #[test] + fn test_rebases_singleton_subtrie_root_below_known_parent() { + let slot = B256::right_padding_from(&[0xae, 0xd4, 0x09]); + let slot_nibbles = Nibbles::unpack(slot); + let harness = ProofTestHarness::new(BTreeMap::from([(slot, U256::ONE)])); + let mut targets = [ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(3))]; + + let (proof, root) = harness.proof_v2(&mut targets); + + assert!(root.is_none()); + assert_eq!(proof.len(), 1); + assert_eq!(proof[0].path, slot_nibbles.slice(0..4)); + let TrieNodeV2::Leaf(leaf) = &proof[0].node else { + panic!("singleton subtrie root should remain a leaf") + }; + assert_eq!(leaf.key, slot_nibbles.slice(4..)); + } + + #[test] + fn test_rebases_singleton_leaf_at_max_parent_depth() { + let slot = B256::repeat_byte(0xae); + let slot_nibbles = Nibbles::unpack(slot); + let harness = ProofTestHarness::new(BTreeMap::from([(slot, U256::ONE)])); + let mut targets = [ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(63))]; + + let (proof, root) = harness.proof_v2(&mut targets); + + assert!(root.is_none()); + assert_eq!(proof.len(), 1); + assert_eq!(proof[0].path, slot_nibbles); + let TrieNodeV2::Leaf(leaf) = &proof[0].node else { + panic!("singleton subtrie root should remain a leaf") + }; + assert!(leaf.key.is_empty()); + } + + #[test] + fn test_root_and_root_parent_targets_retain_both_singleton_representations() { + let slot = B256::right_padding_from(&[0x20]); + let slot_nibbles = Nibbles::unpack(slot); + let harness = ProofTestHarness::new(BTreeMap::from([(slot, U256::ONE)])); + let mut targets = [ + ProofV2Target::new(slot), + ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(0)), + ]; + + let (proof, root) = harness.proof_v2(&mut targets); + + assert_eq!(root, Some(harness.original_root())); + let root_node = proof.iter().find(|node| node.path.is_empty()).expect("root proof"); + let TrieNodeV2::Leaf(root_leaf) = &root_node.node else { panic!("root should be a leaf") }; + assert_eq!(root_leaf.key, slot_nibbles); + + let child_path = slot_nibbles.slice(0..1); + let child_node = + proof.iter().find(|node| node.path == child_path).expect("rebased root child proof"); + let TrieNodeV2::Leaf(child_leaf) = &child_node.node else { + panic!("root child should be a leaf") + }; + assert_eq!(child_leaf.key, slot_nibbles.slice(1..)); + } + + #[test] + fn test_exhausted_cursor_resets_at_equal_target_boundary() { + let first = B256::ZERO; + let last = B256::with_last_byte(1); + let last_nibbles = Nibbles::unpack(last); + let harness = + ProofTestHarness::new(BTreeMap::from([(first, U256::ONE), (last, U256::from(2))])); + let mut targets = [ + ProofV2Target::new(first), + ProofV2Target::new(last).with_parent(ProofV2TargetParent::new(63)), + ]; + + let (proof, root) = harness.proof_v2(&mut targets); + + assert_eq!(root, Some(harness.original_root())); + let child = proof + .iter() + .find(|node| node.path == last_nibbles) + .expect("depth-63 target child proof"); + let TrieNodeV2::Leaf(leaf) = &child.node else { panic!("target child should be a leaf") }; + assert!(leaf.key.is_empty()); + } + + #[test] + fn test_rebases_compressed_branch_subtrie_root() { + let slot_a = B256::right_padding_from(&[0xae, 0xd4, 0x00]); + let slot_b = B256::right_padding_from(&[0xae, 0xd4, 0x10]); + let slot_nibbles = Nibbles::unpack(slot_a); + let harness = + ProofTestHarness::new(BTreeMap::from([(slot_a, U256::ONE), (slot_b, U256::from(2))])); + let mut targets = [ProofV2Target::new(slot_a).with_parent(ProofV2TargetParent::new(3))]; + + let (proof, root) = harness.proof_v2(&mut targets); + + assert!(root.is_none()); + let branch_path = slot_nibbles.slice(0..4); + let branch_node = + proof.iter().find(|node| node.path == branch_path).expect("rebased compressed branch"); + let TrieNodeV2::Branch(branch) = &branch_node.node else { + panic!("rebased node should be a branch") + }; + assert!(branch.key.is_empty()); + assert!(branch.branch_rlp_node.is_none()); + } + + #[test] + fn test_discards_reconstructed_known_parent_branch() { + let slot_a = B256::right_padding_from(&[0xae, 0xd2]); + let slot_b = B256::right_padding_from(&[0xae, 0xd4]); + let slot_nibbles = Nibbles::unpack(slot_a); + let harness = + ProofTestHarness::new(BTreeMap::from([(slot_a, U256::ONE), (slot_b, U256::from(2))])); + let mut targets = [ProofV2Target::new(slot_a).with_parent(ProofV2TargetParent::new(3))]; + + let (proof, root) = harness.proof_v2(&mut targets); + + assert!(root.is_none()); + assert!(!proof.iter().any(|node| node.path == slot_nibbles.slice(0..3))); + assert!(proof.iter().any(|node| node.path == slot_nibbles.slice(0..4))); + } + + #[test] + fn test_rebased_root_matches_direct_child_not_full_short_key() { + let stored_slot = B256::right_padding_from(&[0xae, 0xd4, 0x09]); + let same_child_target = B256::right_padding_from(&[0xae, 0xd4, 0xff]); + let other_child_target = B256::right_padding_from(&[0xae, 0xd5]); + let harness = ProofTestHarness::new(BTreeMap::from([(stored_slot, U256::ONE)])); + + let mut same_child = + [ProofV2Target::new(same_child_target).with_parent(ProofV2TargetParent::new(3))]; + let (proof, _) = harness.proof_v2(&mut same_child); + assert_eq!(proof.len(), 1, "divergent leaf proves absence below the same child"); + + let mut other_child = + [ProofV2Target::new(other_child_target).with_parent(ProofV2TargetParent::new(3))]; + let (proof, _) = harness.proof_v2(&mut other_child); + assert!(proof.is_empty(), "a different direct child is unrelated to the target"); + } + + #[test] + fn test_known_parent_sibling_span_retains_only_target_children() { + let stored_slot_a = B256::right_padding_from(&[0xea, 0x53]); + let stored_slot_b = B256::right_padding_from(&[0xeb, 0x53]); + let stored_slot_c = B256::right_padding_from(&[0xec, 0x53]); + let target_a = B256::right_padding_from(&[0xea, 0x1f]); + let target_c = B256::right_padding_from(&[0xec, 0x1f]); + let harness = ProofTestHarness::new(BTreeMap::from([ + (stored_slot_a, U256::ONE), + (stored_slot_b, U256::from(2)), + (stored_slot_c, U256::from(3)), + ])); + let mut targets = [target_a, target_c] + .map(|target| ProofV2Target::new(target).with_parent(ProofV2TargetParent::new(1))); + + let (proof, root) = harness.proof_v2(&mut targets); + + assert!(root.is_none()); + assert_eq!( + proof.iter().map(|node| node.path).collect::>(), + [Nibbles::from_nibbles([0xe, 0xa]), Nibbles::from_nibbles([0xe, 0xc])] + ); + } + + #[test] + fn test_known_parent_does_not_use_stale_parent_mask() { + let stored_slot_a = B256::right_padding_from(&[0xea, 0x53]); + let stored_slot = B256::right_padding_from(&[0xeb, 0x53]); + let stored_slot_c = B256::right_padding_from(&[0xec, 0x53]); + let target = B256::right_padding_from(&[0xeb, 0x1f]); + let stored_slot_nibbles = Nibbles::unpack(stored_slot); + + // The known parent at `e` is supplied by the sparse trie and may be stale in the database + // when partial persistence masks that path. In particular, its state mask can omit the + // live `eb` child while hashed state already contains that child's leaf. + let stale_parent_mask = TrieMask::new((1 << 0xa) | (1 << 0xc)); + let stale_parent = BranchNodeCompact::new( + stale_parent_mask, + TrieMask::new(0), + TrieMask::new(0), + Vec::new(), + None, + ); + let storage_nodes = BTreeMap::from([(Nibbles::from_nibbles([0xe]), stale_parent)]); + + let mut harness = TrieTestHarness::new(BTreeMap::from([ + (stored_slot_a, U256::ONE), + (stored_slot, U256::from(2)), + (stored_slot_c, U256::from(3)), + ])); + harness.set_trie_nodes(storage_nodes); + + let mut targets = [ProofV2Target::new(target).with_parent(ProofV2TargetParent::new(1))]; + let (proof, root) = harness.proof_v2(&mut targets); + + assert!(root.is_none()); + assert_eq!(proof.len(), 1); + assert_eq!(proof[0].path, stored_slot_nibbles.slice(0..2)); + let TrieNodeV2::Leaf(leaf) = &proof[0].node else { + panic!("live direct child should be reconstructed as a leaf") + }; + assert_eq!(leaf.key, stored_slot_nibbles.slice(2..)); + } + + #[test] + fn test_empty_storage_respects_parent_context() { + let harness = ProofTestHarness::new(BTreeMap::new()); + let slot = B256::ZERO; + + let mut partial_target = + [ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(0))]; + let (partial_proof, partial_root) = harness.proof_v2(&mut partial_target); + assert!(partial_proof.is_empty()); + assert!(partial_root.is_none()); + + let mut root_target = [ProofV2Target::new(slot)]; + let (root_proof, root) = harness.proof_v2(&mut root_target); + assert_eq!(root_proof.len(), 1); + assert!(matches!(root_proof[0].node, TrieNodeV2::EmptyRoot)); + assert_eq!(root, Some(EMPTY_ROOT_HASH)); + } + + #[test] + fn test_big_trie() { + use rand::prelude::*; + + reth_tracing::init_test_tracing(); + let mut rng = rand::rngs::SmallRng::seed_from_u64(1); + + let mut rand_b256 = || { + let mut buf: [u8; 32] = [0; 32]; + rng.fill_bytes(&mut buf); + B256::from_slice(&buf) + }; + + // Generate random storage dataset. + let mut storage = BTreeMap::new(); + for _ in 0..10240 { + let hashed_slot = rand_b256(); + storage.insert(hashed_slot, U256::ONE); + } + + // Collect targets; partially from real keys, partially random keys which probably won't + // exist. + let mut targets = storage.keys().copied().collect::>(); + for _ in 0..storage.len() / 5 { + targets.push(rand_b256()); + } + targets.sort(); + + // Create test harness + let harness = ProofTestHarness::new(storage); + + harness + .assert_proof(targets.into_iter().map(ProofV2Target::new)) + .expect("Proof generation failed"); + } + + #[test] + fn test_node_with_masked_empty_child() { + reth_tracing::init_test_tracing(); + + let val = U256::from(42u64); + + // All storage keys share a common first nibble (0x6), so the branch is at path 0x6. The + // second nibble differentiates children: 0,1,3,5,7. + let slot_60 = B256::right_padding_from(&[0x60]); + let slot_61 = B256::right_padding_from(&[0x61]); + let slot_65 = B256::right_padding_from(&[0x65]); + let slot_67 = B256::right_padding_from(&[0x67]); + + // Construct a branch node at path 0x6 with state_mask bits 0,1,3,5,7. + // hash_mask has bits 0,1,5,7 (NOT 3) — nibble 3's hash is cleared because it's in the + // prefix set. Hashes are dummy values. + let state_mask = TrieMask::new(0b10101011); // bits 0,1,3,5,7 + let hash_mask = TrieMask::new(0b10100011); // bits 0,1,5,7 (NOT 3) + let hashes = vec![B256::repeat_byte(0xaa); hash_mask.count_ones() as usize]; + let branch = BranchNodeCompact::new(state_mask, TrieMask::new(0), hash_mask, hashes, None); + + let storage_nodes: BTreeMap = + std::iter::once((Nibbles::from_nibbles([0x6]), branch)).collect(); + + // Hashed cursor has slots at children 0, 1, 5, 7 — but NOT child 3 (0x63). + // This simulates the post-state overlay having deleted the slot at 0x63. + let mut harness = TrieTestHarness::new( + [slot_60, slot_61, slot_65, slot_67].iter().map(|s| (*s, val)).collect(), + ); + harness.set_trie_nodes(storage_nodes); + + let storage_trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_storage_cursor = harness + .hashed_cursor_factory() + .hashed_storage_cursor(harness.hashed_address()) + .unwrap(); + let mut calculator = + StorageProofCalculator::new_storage(storage_trie_cursor, hashed_storage_cursor); + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed with masked empty child"); + + let root_hash = calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap(); + assert!(root_hash.is_some(), "should produce a root hash"); + } + + /// Tests that `root_node` handles the case where `uncalculated_lower_bound` has advanced + /// entirely past a cached branch that still has unprocessed children in its `state_mask`. + /// + /// Branch at `0x6` has `state_mask` bits 0,1,5,f where nibble 5 has its `hash_mask` + /// cleared and no leaf data. The last child (nibble f) + /// causes `calculate_key_range` to be called with range `(0x6f, Some(0x7))`. After that range, + /// the hashed cursor still has keys (at `0x70...`), so `proof_subtrie` does not break and + /// re-enters `next_uncached_key_range` with `uncalculated_lower_bound = Some(0x7)`. + /// Since `0x7` is past `0x6`, all remaining children are skipped and the branch is popped. + #[test] + fn test_node_with_masked_empty_child_lower_bound_past_branch() { + reth_tracing::init_test_tracing(); + + let val = U256::from(42u64); + + // Leaf keys under 0x6 and one beyond (0x70) to keep the cursor alive after 0x6. + let slot_60 = B256::right_padding_from(&[0x60]); + let slot_61 = B256::right_padding_from(&[0x61]); + let slot_6f = B256::right_padding_from(&[0x6f]); + let slot_70 = B256::right_padding_from(&[0x70]); + + // Branch at 0x6: state_mask bits 0,1,5,f; hash_mask bits 0,1 (NOT 5, NOT f). + // Nibble 5 has state_mask set but no hash and no leaf data (masked empty child). + // Nibble f has state_mask set, no hash, but DOES have leaf data. + let state_mask = TrieMask::new(0b1000_0000_0010_0011); // bits 0,1,5,f + let hash_mask = TrieMask::new(0b0000_0000_0000_0011); // bits 0,1 + let hashes = vec![B256::repeat_byte(0xaa); hash_mask.count_ones() as usize]; + let branch = BranchNodeCompact::new(state_mask, TrieMask::new(0), hash_mask, hashes, None); + + let storage_nodes: BTreeMap = + std::iter::once((Nibbles::from_nibbles([0x6]), branch)).collect(); + + // Hashed cursor: slots at 0x60, 0x61, 0x6f, 0x70 — but NOT 0x65. + let mut harness = TrieTestHarness::new( + [slot_60, slot_61, slot_6f, slot_70].iter().map(|s| (*s, val)).collect(), + ); + harness.set_trie_nodes(storage_nodes); + + let storage_trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_storage_cursor = harness + .hashed_cursor_factory() + .hashed_storage_cursor(harness.hashed_address()) + .unwrap(); + let mut calculator = + StorageProofCalculator::new_storage(storage_trie_cursor, hashed_storage_cursor); + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed when lower bound advances past branch"); + + let root_hash = calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap(); + assert!(root_hash.is_some(), "should produce a root hash"); + } + + /// Tests that the prefix set causes `next_uncached_key_range` to add child nibbles that are + /// not present in the cached branch's `state_mask`. + /// + /// Setup: An original state with leaves at `0x60` and `0x61` produces a cached branch at + /// `0x6` with children at nibbles 0 and 1 (both with real cached hashes from `StorageRoot`). + /// A new leaf is then inserted at `0x63...`, which is NOT in the branch's `state_mask`. + /// The prefix set contains the new key. Without prefix set support, the calculator would + /// skip nibble 3 entirely and produce a stale root hash. With prefix set support, nibble 3 + /// is discovered and its subtrie is recalculated from leaves. + #[test] + fn test_prefix_set_adds_child_nibbles() { + reth_tracing::init_test_tracing(); + + let val = U256::from(42u64); + let slot_60 = B256::right_padding_from(&[0x60]); + let slot_61 = B256::right_padding_from(&[0x61]); + let slot_63 = B256::right_padding_from(&[0x63]); + + let harness = TrieTestHarness::new([(slot_60, val), (slot_61, val)].into_iter().collect()); + + let changeset: BTreeMap = std::iter::once((slot_63, val)).collect(); + let (expected_root, _) = harness.get_root_with_updates(&changeset); + + let mut updated_storage = harness.storage().clone(); + updated_storage.insert(slot_63, val); + + let updated_hashed = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), updated_storage)).collect(), + ); + + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(slot_63)); + + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed with prefix set adding child nibbles"); + let got_root = + calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap().unwrap(); + + pretty_assertions::assert_eq!( + expected_root, + got_root, + "Root hash with prefix set should match fresh computation" + ); + } + + /// Tests that `next_uncached_key_range` does not use a cached hash when the child's path + /// is in the prefix set, forcing recalculation from leaves. + /// + /// Setup: A cached branch at `0x6` with children at nibbles 0,1,5 — all with cached hashes. + /// The leaf at `0x65...` is changed (different value). The prefix set marks `0x65...` as + /// dirty. Without prefix set support, the calculator would use the stale cached hash for + /// nibble 5 and produce a wrong root. With prefix set support, the cached hash is skipped + /// and the subtrie is recalculated from the updated leaf data. + #[test] + fn test_prefix_set_invalidates_cached_hash() { + reth_tracing::init_test_tracing(); + + let original_val = U256::from(42u64); + let updated_val = U256::from(9999u64); + let slot_60 = B256::right_padding_from(&[0x60]); + let slot_61 = B256::right_padding_from(&[0x61]); + let slot_65 = B256::right_padding_from(&[0x65]); + + let harness = TrieTestHarness::new( + [(slot_60, original_val), (slot_61, original_val), (slot_65, original_val)] + .into_iter() + .collect(), + ); + + let changeset: BTreeMap = std::iter::once((slot_65, updated_val)).collect(); + let (expected_root, _) = harness.get_root_with_updates(&changeset); + + let mut updated_storage = harness.storage().clone(); + updated_storage.insert(slot_65, updated_val); + + let updated_hashed = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), updated_storage)).collect(), + ); + + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(slot_65)); + + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed with prefix set invalidating cached hash"); + let got_root = + calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap().unwrap(); + + pretty_assertions::assert_eq!( + expected_root, + got_root, + "Root hash with prefix set invalidation should match fresh computation" + ); + } + + fn b256(s: &str) -> B256 { + B256::from_slice(&alloy_primitives::hex::decode(s).expect("valid hex string")) + } + + #[test] + fn test_prefix_set_root_proof_processes_sibling_after_cached_descendant() { + reth_tracing::init_test_tracing(); + + let storage = [ + ("1022c69e9d900e40775cd387c134899f465f291dbc3c97899ff6bfb8dc972b37", 45u64), + ("1111ad8083c8a3a398b2b781217b989ff4d1ed182f46cc765eda49a7b316139d", 60), + ("12012d20943649899b2fc0f87b9840b70ef68e93613aac17c269bf8c5a78a712", 17), + ("12014b57b9a162c03d072eb6acd4e936f1c4bc23b803a054347c5ee9a9bcfb9a", 49), + ("1203f800840af3f898ab4572f2750106a7c4bd2b3e844b6e7fa72704673cc2c6", 76), + ("12208f18fbcd6971c92808721392acbf11d5af58e9143a374cc86e70bdd1f097", 10), + ] + .into_iter() + .map(|(key, value)| (b256(key), U256::from(value))) + .collect(); + + let dirty = b256("12208f18fbcd6971c92808721392acbf11d5af58e9143a374cc86e70bdd1f097"); + let harness = ProofTestHarness::new(storage); + let expected_root = harness.original_root(); + + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(dirty)); + + pretty_assertions::assert_eq!( + Some(expected_root), + harness.root_with_prefix_set(prefix_set.freeze()), + "root proof must process a prefix-set sibling after a cached descendant", + ); + } + + #[test] + fn test_prefix_set_root_proof_processes_trailing_dirty_sibling() { + reth_tracing::init_test_tracing(); + + let keys = [ + "0022001020000000000000000000000000000000000000000000000000000000", + "0110212112000000000000000000000000000000000000000000000000000000", + "0202210210000000000000000000000000000000000000000000000000000000", + "0211020211000000000000000000000000000000000000000000000000000000", + "0211211002000000000000000000000000000000000000000000000000000000", + "0212221010000000000000000000000000000000000000000000000000000000", + "0222011102000000000000000000000000000000000000000000000000000000", + ]; + let storage = + keys.iter().enumerate().map(|(i, key)| (b256(key), U256::from(i as u64 + 1))).collect(); + let harness = ProofTestHarness::new(storage); + let expected_root = harness.original_root(); + + // The dirty children straddle a clean cached descendant under branch 0x02. Traversal + // must resume at the trailing dirty sibling after using the cached descendant. + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(b256(keys[2]))); + prefix_set.insert(Nibbles::unpack(b256(keys[6]))); + + pretty_assertions::assert_eq!( + Some(expected_root), + harness.root_with_prefix_set(prefix_set.freeze()), + ); + } + + #[test] + fn test_prefix_set_preserves_cached_siblings() { + let storage = (0..4096u16) + .map(|key| (B256::right_padding_from(&key.to_be_bytes()), U256::from(1))) + .collect(); + let harness = TrieTestHarness::new(storage); + let changes = BTreeMap::from([ + (B256::right_padding_from(&[0x04, 0x00]), U256::from(2)), + (B256::right_padding_from(&[0x04, 0xf0]), U256::from(2)), + ]); + let (expected_root, _) = harness.get_root_with_updates(&changes); + let mut updated_storage = harness.storage().clone(); + updated_storage.extend(changes.clone()); + let hashed_factory = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), updated_storage)).collect(), + ); + let prefix_set = PrefixSetMut::from(changes.keys().map(Nibbles::unpack)).freeze(); + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = hashed_factory.hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set); + let mut targets = [ProofV2Target::new(B256::ZERO)]; + let proof = calculator.storage_proof(harness.hashed_address(), &mut targets).unwrap(); + + assert_eq!(calculator.compute_root_hash(&proof).unwrap(), Some(expected_root)); + + // Returning from 0x040 to cached parent 0x04 must not scan all leaves up to 0x05. + let clean_key = B256::right_padding_from(&[0x04, 0x88]); + assert!(!hashed_factory + .visited_storage_keys(harness.hashed_address()) + .iter() + .any(|visit| visit.visited_key == Some(clean_key))); + } + + #[test] + fn test_prefix_set_finishes_branch_before_returning_to_ancestor() { + let storage = + [[0x10, 0x00, 0x00], [0x12, 0x10, 0x00], [0x12, 0x10, 0x20], [0x12, 0x11, 0x00]] + .into_iter() + .map(|key| (B256::right_padding_from(&key), U256::from(1))) + .collect(); + let harness = TrieTestHarness::new(storage); + let changes = BTreeMap::from([ + (B256::right_padding_from(&[0x12, 0x00]), U256::from(1)), + (B256::right_padding_from(&[0x12, 0x20]), U256::from(1)), + ]); + let mut after = harness.storage().clone(); + after.extend(changes.clone()); + let expected_root = crate::test_utils::storage_root_prehashed( + after.iter().map(|(key, value)| (*key, *value)), + ); + let hashed_factory = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), after)).collect(), + ); + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = hashed_factory.hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(PrefixSetMut::from(changes.keys().map(Nibbles::unpack)).freeze()); + let mut targets = [ + ProofV2Target::new(B256::ZERO), + ProofV2Target::new(B256::right_padding_from(&[0x10])) + .with_parent(ProofV2TargetParent::new(0)), + ]; + + // Returning to cached ancestor 0x1 must finish dirty sibling 0x122 before encoding 0x12. + let proof = calculator.storage_proof(harness.hashed_address(), &mut targets).unwrap(); + assert_eq!(calculator.compute_root_hash(&proof).unwrap(), Some(expected_root)); + } + + /// Helper to compute the keccak256 hash of a storage leaf node. The `short_key` is the + /// leaf's key after trimming all branch/extension nibbles consumed by ancestor nodes. + fn storage_leaf_hash(short_key: &Nibbles, value: &U256) -> B256 { + let mut buf = Vec::new(); + alloy_trie::nodes::LeafNodeRef::new(short_key, &alloy_rlp::encode_fixed_size(value)) + .encode(&mut buf); + keccak256(&buf) + } + + /// Checks collapsing a cached branch after removing one of its two direct branch children. + fn assert_branch_collapse(remaining_nibble: u8, removed_nibble: u8) { + reth_tracing::init_test_tracing(); + + let val = U256::ONE; + let child_keys = |nibble| { + [ + B256::right_padding_from(&[0x20 | nibble, 0x00]), + B256::right_padding_from(&[0x20 | nibble, 0x10]), + ] + }; + let [remaining_a, remaining_b] = child_keys(remaining_nibble); + let [removed_a, removed_b] = child_keys(removed_nibble); + + // Build the cached trie through the production HashBuilder path. Each child at + // `remaining_nibble` and `removed_nibble` is a direct branch with two leaf children. + let initial_storage = BTreeMap::from([ + (remaining_a, val), + (remaining_b, val), + (removed_a, val), + (removed_b, val), + ]); + let harness = TrieTestHarness::new(initial_storage); + + let cached_branch = &harness + .storage_trie_updates() + .storage_nodes + .get(&Nibbles::from_nibbles([0x2])) + .expect("branch at 0x2"); + let child_mask = + TrieMask::from_nibble(remaining_nibble) | TrieMask::from_nibble(removed_nibble); + assert_eq!(cached_branch.state_mask, child_mask); + assert_eq!(cached_branch.hash_mask, child_mask); + assert!(cached_branch.tree_mask.is_empty()); + + let final_storage = BTreeMap::from([(remaining_a, val), (remaining_b, val)]); + let expected_root = TrieTestHarness::new(final_storage.clone()).original_root(); + let updated_hashed = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), final_storage)).collect(), + ); + + // The prefix set marks the entire removed child subtree dirty. + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(removed_a)); + prefix_set.insert(Nibbles::unpack(removed_b)); + + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed after branch collapse"); + let root = + calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap().unwrap(); + + pretty_assertions::assert_eq!(expected_root, root); + } + + #[test] + fn test_branch_collapse_removed_child_before_remaining() { + assert_branch_collapse(1, 0); + } + + #[test] + fn test_branch_collapse_removed_child_after_remaining() { + assert_branch_collapse(4, 9); + } + + #[test] + fn test_prefix_set_root_proof_preserves_clean_sibling_after_cached_branch_collapse() { + reth_tracing::init_test_tracing(); + + let dirty = B256::right_padding_from(&[0x10, 0x00, 0x10]); + let clean_sibling = B256::right_padding_from(&[0x10, 0x10]); + let storage = [ + B256::right_padding_from(&[0x10]), + dirty, + B256::right_padding_from(&[0x10, 0x01]), + B256::right_padding_from(&[0x10, 0x02]), + clean_sibling, + B256::right_padding_from(&[0x11]), + B256::right_padding_from(&[0x12]), + ] + .into_iter() + .map(|key| (key, U256::ONE)) + .collect(); + + let harness = ProofTestHarness::new(storage); + let expected_root = harness.original_root(); + + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(dirty)); + + let mut prefix_set_with_sibling = PrefixSetMut::default(); + prefix_set_with_sibling.insert(Nibbles::unpack(dirty)); + prefix_set_with_sibling.insert(Nibbles::unpack(clean_sibling)); + + pretty_assertions::assert_eq!( + Some(expected_root), + harness.root_with_prefix_set(prefix_set_with_sibling.freeze()), + ); + pretty_assertions::assert_eq!( + Some(expected_root), + harness.root_with_prefix_set(prefix_set.freeze()), + "a dirty prefix must not omit a clean sibling after collapsing a cached branch", + ); + } + + #[test] + fn test_prefix_set_range_skips_covered_cached_branch() { + reth_tracing::init_test_tracing(); + + let before = B256::right_padding_from(&[0x30]); + let cached_a = B256::right_padding_from(&[0x80, 0x10]); + let cached_b = B256::right_padding_from(&[0x80, 0x15]); + let dirty = B256::right_padding_from(&[0x80, 0xc0]); + let after = B256::right_padding_from(&[0x90]); + + // The cached branch at 0x80 remains parked while its children are rebuilt. Processing the + // dirty child advances the lower bound to 0x9, past the cached branch's entire range. + let storage = [before, cached_a, cached_b, dirty, after] + .into_iter() + .enumerate() + .map(|(i, key)| (key, U256::from(i + 1))) + .collect(); + + let harness = ProofTestHarness::new(storage); + let expected_root = harness.original_root(); + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(dirty)); + + pretty_assertions::assert_eq!( + Some(expected_root), + harness.root_with_prefix_set(prefix_set.freeze()), + ); + } + + #[test] + fn test_cached_branch_extension_skips_diverging_target() { + reth_tracing::init_test_tracing(); + + let val = U256::from(100u64); + + // Keys whose first bytes directly set the nibble paths we need. + let key_a0 = B256::right_padding_from(&[0x6a, 0x30]); // nibbles: 6,a,3,0,... + let key_a1 = B256::right_padding_from(&[0x6a, 0x31]); // nibbles: 6,a,3,1,... + let key_c = B256::right_padding_from(&[0x6a, 0x80]); // nibbles: 6,a,8,0,... + let key_d = B256::right_padding_from(&[0x6b, 0x00]); // nibbles: 6,b,0,0,... + let key_e = B256::right_padding_from(&[0x6c, 0x00]); // nibbles: 6,c,0,0,... + + // Build a correct trie from all five leaves to get the expected root and real hashes. + let all_storage: BTreeMap = + [(key_a0, val), (key_a1, val), (key_c, val), (key_d, val), (key_e, val)] + .into_iter() + .collect(); + let correct_harness = TrieTestHarness::new(all_storage.clone()); + let expected_root = correct_harness.original_root(); + + // Compute leaf hashes for constructing manual cached branch nodes. + let leaf_hash_a0 = storage_leaf_hash(&Nibbles::unpack(key_a0).slice(4..), &val); + let leaf_hash_a1 = storage_leaf_hash(&Nibbles::unpack(key_a1).slice(4..), &val); + let leaf_hash_d = storage_leaf_hash(&Nibbles::unpack(key_d).slice(2..), &val); + let leaf_hash_e = storage_leaf_hash(&Nibbles::unpack(key_e).slice(2..), &val); + + // ── Construct cached branch at [6] ───────────────────────────────────── + // state_mask: bits a, b, and c set. + // hash_mask: bits b and c — both have cached leaf hashes. Bit a has no hash, so the + // calculator will seek the trie cursor to find a deeper cached branch. + // + // Children b and c remain clean and can still use their cached hashes. + let branch_6_state_mask = TrieMask::new((1 << 0xa) | (1 << 0xb) | (1 << 0xc)); + let branch_6_hash_mask = TrieMask::new((1 << 0xb) | (1 << 0xc)); + let branch_6 = BranchNodeCompact::new( + branch_6_state_mask, + TrieMask::new(0), + branch_6_hash_mask, + vec![leaf_hash_d, leaf_hash_e], + None, + ); + + // ── Construct cached branch at [6,a,3] ──────────────────────────────── + // state_mask: bits 0 and 1 set (children key_a0 and key_a1). + // hash_mask: both bits set — both children have cached hashes. + let branch_6a3_state_mask = TrieMask::new((1 << 0) | (1 << 1)); + let branch_6a3 = BranchNodeCompact::new( + branch_6a3_state_mask, + TrieMask::new(0), + branch_6a3_state_mask, + vec![leaf_hash_a0, leaf_hash_a1], + None, + ); + + // Intentionally omit the branch at [6,a] — this is the inconsistency. + let inconsistent_nodes: BTreeMap = [ + (Nibbles::from_nibbles([0x6]), branch_6), + (Nibbles::from_nibbles([0x6, 0xa, 0x3]), branch_6a3), + ] + .into_iter() + .collect(); + + // Create harness with all five leaves but the inconsistent trie nodes. + let mut harness = TrieTestHarness::new(all_storage); + harness.set_trie_nodes(inconsistent_nodes); + + // Mark key_c as dirty — in the real scenario the leaf was touched by execution. The + // nested branch [6,a,3] remains clean because key_c diverges at [6,a,8]. + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(key_c)); + + // ── Verify root hash ─────────────────────────────────────────────────── + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = harness + .hashed_cursor_factory() + .hashed_storage_cursor(harness.hashed_address()) + .unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed"); + let got_root = calculator + .compute_root_hash(core::slice::from_ref(&root_node)) + .unwrap() + .expect("should produce a root hash"); + + // With the bug, the calculator skips key_c and produces a wrong root. + pretty_assertions::assert_eq!( + expected_root, + got_root, + "Root hash should match correct trie; cached extension must not skip diverging leaves" + ); + + // ── Verify proof for key_c contains nodes on its path ────────────────── + let mut targets = vec![ProofV2Target::new(key_c)]; + let proofs = calculator + .storage_proof(harness.hashed_address(), &mut targets) + .expect("storage_proof should succeed"); + + let key_c_nibbles = Nibbles::unpack(key_c); + let has_matching_node = proofs.iter().any(|node| key_c_nibbles.starts_with(&node.path)); + assert!( + has_matching_node, + "Proof for key_c should contain at least one node on key_c's path, got: {proofs:?}" + ); + } + + #[test] + fn test_cached_branch_extension_skips_diverging_target_before() { + reth_tracing::init_test_tracing(); + + let val = U256::from(100u64); + + // Keys whose first bytes directly set the nibble paths we need. + let key_a0 = B256::right_padding_from(&[0x6a, 0x80]); // nibbles: 6,a,8,0,... + let key_a1 = B256::right_padding_from(&[0x6a, 0x81]); // nibbles: 6,a,8,1,... + let key_c = B256::right_padding_from(&[0x6a, 0x30]); // nibbles: 6,a,3,0,... (BEFORE + // [6,a,8]) + let key_d = B256::right_padding_from(&[0x6b, 0x00]); // nibbles: 6,b,0,0,... + let key_e = B256::right_padding_from(&[0x6c, 0x00]); // nibbles: 6,c,0,0,... + + // Build a correct trie from all five leaves to get the expected root and real hashes. + let all_storage: BTreeMap = + [(key_a0, val), (key_a1, val), (key_c, val), (key_d, val), (key_e, val)] + .into_iter() + .collect(); + let correct_harness = TrieTestHarness::new(all_storage.clone()); + let expected_root = correct_harness.original_root(); + + // Compute leaf hashes for constructing manual cached branch nodes. + let leaf_hash_a0 = storage_leaf_hash(&Nibbles::unpack(key_a0).slice(4..), &val); + let leaf_hash_a1 = storage_leaf_hash(&Nibbles::unpack(key_a1).slice(4..), &val); + let leaf_hash_d = storage_leaf_hash(&Nibbles::unpack(key_d).slice(2..), &val); + let leaf_hash_e = storage_leaf_hash(&Nibbles::unpack(key_e).slice(2..), &val); + + // ── Construct cached branch at [6] ───────────────────────────────────── + // state_mask: bits a, b, and c set. + // hash_mask: bits b and c — both have cached leaf hashes. Bit a has no hash, so the + // calculator will seek the trie cursor to find a deeper cached branch. + // + // Children b and c remain clean and can still use their cached hashes. + let branch_6_state_mask = TrieMask::new((1 << 0xa) | (1 << 0xb) | (1 << 0xc)); + let branch_6_hash_mask = TrieMask::new((1 << 0xb) | (1 << 0xc)); + let branch_6 = BranchNodeCompact::new( + branch_6_state_mask, + TrieMask::new(0), + branch_6_hash_mask, + vec![leaf_hash_d, leaf_hash_e], + None, + ); + + // ── Construct cached branch at [6,a,8] ──────────────────────────────── + // state_mask: bits 0 and 1 set (children key_a0 and key_a1). + // hash_mask: both bits set — both children have cached hashes. + let branch_6a8_state_mask = TrieMask::new((1 << 0) | (1 << 1)); + let branch_6a8 = BranchNodeCompact::new( + branch_6a8_state_mask, + TrieMask::new(0), + branch_6a8_state_mask, + vec![leaf_hash_a0, leaf_hash_a1], + None, + ); + + // Intentionally omit the branch at [6,a] — this is the inconsistency. + let inconsistent_nodes: BTreeMap = [ + (Nibbles::from_nibbles([0x6]), branch_6), + (Nibbles::from_nibbles([0x6, 0xa, 0x8]), branch_6a8), + ] + .into_iter() + .collect(); + + // Create harness with all five leaves but the inconsistent trie nodes. + let mut harness = TrieTestHarness::new(all_storage); + harness.set_trie_nodes(inconsistent_nodes); + + // Mark key_c as dirty — it comes BEFORE the cached branch [6,a,8] in nibble order. + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(key_c)); + + // ── Verify root hash ─────────────────────────────────────────────────── + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = harness + .hashed_cursor_factory() + .hashed_storage_cursor(harness.hashed_address()) + .unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed"); + let got_root = calculator + .compute_root_hash(core::slice::from_ref(&root_node)) + .unwrap() + .expect("should produce a root hash"); + + // With the bug, the calculator skips key_c and produces a wrong root. + pretty_assertions::assert_eq!( + expected_root, + got_root, + "Root hash should match correct trie; cached extension must not skip diverging leaves before cached branch" + ); + + // ── Verify proof for key_c contains nodes on its path ────────────────── + let mut targets = vec![ProofV2Target::new(key_c)]; + let proofs = calculator + .storage_proof(harness.hashed_address(), &mut targets) + .expect("storage_proof should succeed"); + + let key_c_nibbles = Nibbles::unpack(key_c); + let has_matching_node = proofs.iter().any(|node| key_c_nibbles.starts_with(&node.path)); + assert!( + has_matching_node, + "Proof for key_c should contain at least one node on key_c's path, got: {proofs:?}" + ); + } + + #[test] + fn test_skipped_parent_branch_with_unskipped_child() { + reth_tracing::init_test_tracing(); + + let val = U256::ONE; + let updated_val = U256::from(2u64); + + // We need cached branches at [2], [2,f], and [3] in the trie DB. + let key_2 = B256::right_padding_from(&[0x20]); + let key_2f00 = B256::right_padding_from(&[0x2f, 0x00]); + let key_2f01 = B256::right_padding_from(&[0x2f, 0x01]); + let key_2f10 = B256::right_padding_from(&[0x2f, 0x10]); + let key_2f11 = B256::right_padding_from(&[0x2f, 0x11]); + let key_300 = B256::right_padding_from(&[0x30, 0x00]); + let key_301 = B256::right_padding_from(&[0x30, 0x10]); + let key_310 = B256::right_padding_from(&[0x31, 0x00]); + let key_311 = B256::right_padding_from(&[0x31, 0x10]); + let key_500 = B256::right_padding_from(&[0x50, 0x00]); + let key_501 = B256::right_padding_from(&[0x50, 0x10]); + let key_510 = B256::right_padding_from(&[0x51, 0x00]); + let key_511 = B256::right_padding_from(&[0x51, 0x10]); + + let all_keys = [ + key_2, key_2f00, key_2f01, key_2f10, key_2f11, key_300, key_301, key_310, key_311, + key_500, key_501, key_510, key_511, + ]; + + let original_storage: BTreeMap = all_keys.iter().map(|k| (*k, val)).collect(); + let harness = TrieTestHarness::new(original_storage); + + // Verify that the expected branches exist in the trie. + let trie_updates = harness.storage_trie_updates(); + assert!(trie_updates.storage_nodes.contains_key(&Nibbles::from_nibbles([0x2]))); + assert!(trie_updates.storage_nodes.contains_key(&Nibbles::from_nibbles([0x2, 0xf]))); + assert!(trie_updates.storage_nodes.contains_key(&Nibbles::from_nibbles([0x3]))); + + // Change only key_2 — triggers skip of parent branch [2] while child [2,f] is not + // skipped. + let changeset: BTreeMap = std::iter::once((key_2, updated_val)).collect(); + let (expected_root, _) = harness.get_root_with_updates(&changeset); + + let mut updated_storage = harness.storage().clone(); + updated_storage.insert(key_2, updated_val); + + let updated_hashed = MockHashedCursorFactory::new( + BTreeMap::new(), + std::iter::once((harness.hashed_address(), updated_storage)).collect(), + ); + + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(key_2)); + + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + let root_node = calculator + .storage_root_node(harness.hashed_address()) + .expect("storage_root_node should succeed"); + + let got_root = calculator + .compute_root_hash(&[root_node]) + .expect("root hash should succeed") + .expect("root should get hashed"); + pretty_assertions::assert_eq!(expected_root, got_root); + } + + #[test] + fn test_blinded_local_root_returns_trie_inconsistency() { + let key = B256::right_padding_from(&[0x63, 0xaa]); + let value = U256::ONE; + let hash = storage_leaf_hash(&Nibbles::unpack(key).slice(2..), &value); + let mask = TrieMask::from_nibble(3); + let cached_branch = + BranchNodeCompact::new(mask, TrieMask::default(), mask, vec![hash], None); + + let mut harness = TrieTestHarness::new(BTreeMap::from([(key, value)])); + harness.set_trie_nodes(BTreeMap::from([(Nibbles::from_nibbles([6]), cached_branch)])); + + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = harness + .hashed_cursor_factory() + .hashed_storage_cursor(harness.hashed_address()) + .unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); + + assert!(matches!( + calculator.storage_root_node(harness.hashed_address()), + Err(StateProofError::TrieInconsistency(_)) + )); + } + + #[test] + fn test_cached_hash_with_deleted_leaf() { + reth_tracing::init_test_tracing(); + + // Use different values to ensure distinct leaf hashes. + let val_3 = U256::from(111u64); + let val_5 = U256::from(222u64); + let val_8 = U256::from(333u64); + + // Keys under a common prefix `0x6_` to create a branch at path [6]. + // Use second byte to distinguish short keys (so they differ after position 2). + let key_63 = B256::right_padding_from(&[0x63, 0xaa]); // nibble path: 6,3,a,a,... + let key_65 = B256::right_padding_from(&[0x65, 0xbb]); // nibble path: 6,5,b,b,... + let key_68 = B256::right_padding_from(&[0x68, 0xcc]); // nibble path: 6,8,c,c,... + + // Compute leaf hashes. The branch at [6] consumes 2 nibbles (the branch path [6] + // plus the child nibble), so each leaf's short key starts at position 2. + let leaf_hash_3 = storage_leaf_hash(&Nibbles::unpack(key_63).slice(2..), &val_3); + let leaf_hash_5 = storage_leaf_hash(&Nibbles::unpack(key_65).slice(2..), &val_5); + let leaf_hash_8 = storage_leaf_hash(&Nibbles::unpack(key_68).slice(2..), &val_8); + + // Build cached branch at [6] with state_mask and hash_mask bits for nibbles 3, 5, 8. + let state_mask = TrieMask::new((1 << 3) | (1 << 5) | (1 << 8)); + let cached_branch = BranchNodeCompact::new( + state_mask, + TrieMask::new(0), + state_mask, // hash_mask = state_mask (all children have cached hashes) + vec![leaf_hash_3, leaf_hash_5, leaf_hash_8], + None, + ); + + let storage_nodes: BTreeMap = + std::iter::once((Nibbles::from_nibbles([0x6]), cached_branch)).collect(); + + // Compute the expected root from a fresh trie with just key_65 and key_68. + let mut harness = + TrieTestHarness::new([(key_65, val_5), (key_68, val_8)].into_iter().collect()); + let expected_root = harness.original_root(); + + // Update the harness with a cached trie node which will reference key_63 by hash. + harness.set_trie_nodes(storage_nodes); + + // Mark key_63 as dirty in the prefix set — in the real scenario the leaf was + // deleted and the HashedPostState overlay masks it out. + let mut prefix_set = PrefixSetMut::default(); + prefix_set.insert(Nibbles::unpack(key_63)); + + // Request a proof for key_63 (absence proof — no leaf exists). + // Because the prefix set marks nibble 3's child path as dirty, the cached hash for + // nibble 3 is skipped. + let mut targets = vec![ProofV2Target::new(key_63)]; + + let trie_cursor = + harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); + let hashed_cursor = harness + .hashed_cursor_factory() + .hashed_storage_cursor(harness.hashed_address()) + .unwrap(); + let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set.freeze()); + + let proofs = calculator + .storage_proof(harness.hashed_address(), &mut targets) + .expect("storage_proof should succeed"); + assert_eq!(1, proofs.len()); + let got_root = calculator + .compute_root_hash(&proofs) + .expect("compute_root_hash should succeed") + .expect("should produce a root hash (proof contains root node)"); + + // With the bug, nibble 5 gets hashes[0] (nibble 3's hash) and nibble 8 gets + // hashes[1] (nibble 5's hash), producing a wrong root. + pretty_assertions::assert_eq!( + expected_root, + got_root, + "Root hash should match trie without key_63; cached hash index is off when \ + an earlier hashed child has no leaves (absence proof target)" + ); + } +} diff --git a/crates/trie/trie/src/proof_v2/metrics.rs b/crates/trie/trie/src/proof_v2/metrics.rs new file mode 100644 index 00000000000..8e24a1a56f1 --- /dev/null +++ b/crates/trie/trie/src/proof_v2/metrics.rs @@ -0,0 +1,73 @@ +//! Cursor metrics of multiproof calculators. + +use crate::{ + hashed_cursor::InstrumentedHashedCursorFactory, trie_cursor::InstrumentedTrieCursorFactory, +}; + +#[cfg(feature = "metrics")] +use crate::{hashed_cursor::HashedCursorMetrics, trie_cursor::TrieCursorMetrics, TrieType}; + +/// Cursor metric handles shared by multiproof calculators. +#[cfg(feature = "metrics")] +#[derive(Clone, Debug)] +pub struct ProofCursorMetrics { + account_trie_cursor: TrieCursorMetrics, + storage_trie_cursor: TrieCursorMetrics, + account_hashed_cursor: HashedCursorMetrics, + storage_hashed_cursor: HashedCursorMetrics, +} + +#[cfg(feature = "metrics")] +impl Default for ProofCursorMetrics { + /// Registers cursor metric handles with the installed metrics recorder. + fn default() -> Self { + Self { + account_trie_cursor: TrieCursorMetrics::new(TrieType::State), + storage_trie_cursor: TrieCursorMetrics::new(TrieType::Storage), + account_hashed_cursor: HashedCursorMetrics::new(TrieType::State), + storage_hashed_cursor: HashedCursorMetrics::new(TrieType::Storage), + } + } +} + +#[cfg(feature = "metrics")] +impl ProofCursorMetrics { + /// Instruments all account and storage cursors opened by `factory`. + pub fn instrument_trie_factory(&self, factory: F) -> InstrumentedTrieCursorFactory { + InstrumentedTrieCursorFactory::new( + factory, + self.account_trie_cursor.clone(), + self.storage_trie_cursor.clone(), + ) + } + + /// Instruments all hashed account and storage cursors opened by `factory`. + pub fn instrument_hashed_factory(&self, factory: F) -> InstrumentedHashedCursorFactory { + InstrumentedHashedCursorFactory::new( + factory, + self.account_hashed_cursor.clone(), + self.storage_hashed_cursor.clone(), + ) + } +} + +/// No-op cursor metric handles used when the `metrics` feature is disabled. +#[cfg(not(feature = "metrics"))] +#[derive(Clone, Debug, Default)] +pub struct ProofCursorMetrics; + +#[cfg(not(feature = "metrics"))] +impl ProofCursorMetrics { + /// Returns the factory unchanged when cursor metrics are disabled. + pub const fn instrument_trie_factory(&self, factory: F) -> InstrumentedTrieCursorFactory { + factory + } + + /// Returns the factory unchanged when cursor metrics are disabled. + pub const fn instrument_hashed_factory( + &self, + factory: F, + ) -> InstrumentedHashedCursorFactory { + factory + } +} diff --git a/crates/trie/trie/src/proof_v2/mod.rs b/crates/trie/trie/src/proof_v2/mod.rs index f3dc4a0f61e..6116f721aab 100644 --- a/crates/trie/trie/src/proof_v2/mod.rs +++ b/crates/trie/trie/src/proof_v2/mod.rs @@ -1,3370 +1,21 @@ -//! Proof calculation version 2: Leaf-only implementation. -//! -//! This module provides a rewritten proof calculator that: -//! - Uses only leaf data (HashedAccounts/Storages) to generate proofs -//! - Returns proof nodes sorted lexicographically by path -//! - Automatically resets after each calculation -//! - Re-uses cursors across calculations -//! - Supports generic value types with lazy evaluation +//! Proof calculation version 2 using leaf data and cached branch hashes. -use crate::{ - hashed_cursor::{HashedCursor, HashedStorageCursor}, - trie_cursor::{depth_first, TrieCursor, TrieStorageCursor}, -}; -use alloy_primitives::{keccak256, B256, U256}; -use alloy_rlp::Encodable; -use alloy_trie::{BranchNodeCompact, TrieMask}; -use reth_execution_errors::trie::StateProofError; -use reth_trie_common::{ - prefix_set::PrefixSet, BranchNodeMasks, BranchNodeRef, BranchNodeV2, Nibbles, ProofTrieNodeV2, - ProofV2Target, RlpNode, TrieNodeV2, -}; -use std::{cmp::Ordering, sync::Arc}; -use tracing::{error, instrument, trace}; +mod calculator; +pub use calculator::*; + +mod metrics; +pub use metrics::ProofCursorMetrics; + +mod multiproof_calculator; +pub use multiproof_calculator::*; mod value; pub use value::*; mod node; -use node::*; mod target; pub(crate) use target::*; /// Target to use with the `tracing` crate. static TRACE_TARGET: &str = "trie::proof_v2"; - -/// Number of bytes to pre-allocate for [`ProofCalculator`]'s `rlp_encode_buf` field. -const RLP_ENCODE_BUF_SIZE: usize = 1024; - -/// A proof calculator that generates merkle proofs using only leaf data. -/// -/// The calculator: -/// - Accepts one or more B256 proof targets sorted lexicographically -/// - Returns proof nodes sorted lexicographically by path -/// - Automatically resets after each calculation -/// - Re-uses cursors from one calculation to the next -#[derive(Debug)] -pub struct ProofCalculator { - /// Trie cursor for traversing stored branch nodes. - trie_cursor: TC, - /// Hashed cursor for iterating over leaf data. - hashed_cursor: HC, - /// Branches which are currently in the process of being constructed, each being a child of - /// the previous one. - branch_stack: Vec, - /// The path of the last branch in `branch_stack`. - branch_path: Nibbles, - /// Children of branches in the `branch_stack`. - /// - /// Each branch in `branch_stack` tracks which children are in this stack using its - /// `state_mask`; the number of children the branch has in this stack is equal to the number of - /// bits set in its `state_mask`. - /// - /// The children for the bottom branch in `branch_stack` are found at the bottom of this stack, - /// and so on. When a branch is removed from `branch_stack` its children are removed from this - /// one, and the branch is pushed onto this stack in their place (see [`Self::pop_branch`]. - /// - /// Given that keys are consumed in lexicographical order, only the last child on the stack - /// can still require structural changes (e.g. splitting its short key when inserting a new - /// branch). Once a child is finalized, [`Self::commit_last_child`] converts it to a - /// [`ProofTrieBranchChild::RlpNode`] if retained for the proof. Unretained children can remain - /// unencoded anywhere on the stack until [`Self::pop_branch`], giving deferred encoders more - /// time for async work. - child_stack: Vec>, - /// Cached branch data pulled from the `trie_cursor`. The calculator will use the cached - /// [`BranchNodeCompact::hashes`] to skip over the calculation of sub-tries in the overall - /// trie. The cached hashes cannot be used for any paths which are prefixes of a proof target. - cached_branch_stack: Vec<(Nibbles, BranchNodeCompact)>, - /// The proofs which will be returned from the calculation. This gets taken at the end of every - /// proof call. - retained_proofs: Vec, - /// Free-list of re-usable buffers of [`RlpNode`]s, used for encoding branch nodes to RLP. - /// - /// We are generally able to re-use these buffers across different branch nodes for the - /// duration of a proof calculation, but occasionally we will lose one when a branch - /// node is returned as a `ProofTrieNode`. - rlp_nodes_bufs: Vec>, - /// Re-usable byte buffer, used for RLP encoding. - rlp_encode_buf: Vec, - /// Prefix set for tracking changed keys. - prefix_set: PrefixSet, -} - -impl ProofCalculator { - /// Create a new [`ProofCalculator`] instance for calculating account proofs. - pub fn new(trie_cursor: TC, hashed_cursor: HC) -> Self { - Self { - trie_cursor, - hashed_cursor, - branch_stack: Vec::<_>::with_capacity(64), - branch_path: Nibbles::new(), - child_stack: Vec::<_>::with_capacity(64), - cached_branch_stack: Vec::<_>::with_capacity(64), - retained_proofs: Vec::<_>::with_capacity(32), - rlp_nodes_bufs: Vec::<_>::with_capacity(8), - rlp_encode_buf: Vec::<_>::with_capacity(RLP_ENCODE_BUF_SIZE), - prefix_set: PrefixSet::default(), - } - } - - /// Sets the prefix set and returns `self`. - /// - /// When given, all cached hashes matching the [`PrefixSet`] will be invalidated and their - /// subtries recalculated. - pub fn with_prefix_set(mut self, prefix_set: PrefixSet) -> Self { - self.prefix_set = prefix_set; - self - } -} - -impl ProofCalculator -where - TC: TrieCursor, - HC: HashedCursor, - VE: LeafValueEncoder, -{ - /// Takes a re-usable `RlpNode` buffer from the internal free-list, or allocates a new one if - /// the free-list is empty. - /// - /// The returned Vec will have a length of zero. - fn take_rlp_nodes_buf(&mut self) -> Vec { - self.rlp_nodes_bufs - .pop() - .map(|mut buf| { - buf.clear(); - buf - }) - .unwrap_or_else(|| Vec::with_capacity(16)) - } - - // Returns zero if `branch_stack` is empty, one otherwise. - // - // This is used when working with the `ext_len` field of [`ProofTrieBranch`]. The `ext_len` is - // calculated by taking the difference of the current `branch_path` and the new branch's path; - // if the new branch has a parent branch (ie `branch_stack` is not empty) then 1 is subtracted - // from the `ext_len` to account for the child's nibble on the parent. - #[inline] - const fn maybe_parent_nibble(&self) -> usize { - !self.branch_stack.is_empty() as usize - } - - /// Returns true if the proof of a node at the given path should be retained. A node is retained - /// if its path is a prefix of any target. - /// - /// This may move the `targets` iterator forward if the given path comes after the current - /// target. - /// - /// This method takes advantage of the [`std::slice::Iter`] component of [`TargetsCursor`] to - /// check the minimum number of targets. In general it looks at a current target and the next - /// target simultaneously, forming an end-exclusive range. - /// - /// ```text - /// * Given targets: [ 0x012, 0x045, 0x678 ] - /// * targets.current() returns: - /// - (0x012, Some(0x045)): covers (0x012..0x045) - /// - (0x045, Some(0x678)): covers (0x045..0x678) - /// - (0x678, None): covers (0x678..) - /// ``` - /// - /// As long as the path which is passed in lies within that range we can continue to use the - /// current target. Once the path goes beyond that range (ie path >= next target) then we can be - /// sure that no further paths will be in the range, and we can iterate forward. - /// - /// ```text - /// * Given: - /// - path: 0x04 - /// - targets.current() returns (0x012, Some(0x045)) - /// - /// * 0x04 comes _after_ 0x045 in depth-first order, so (0x012..0x045) does not contain 0x04. - /// - /// * targets.next() is called. - /// - /// * targets.current() now returns (0x045, Some(0x678)). This does contain 0x04. - /// - /// * 0x04 is a prefix of 0x045, and so is retained. - /// ``` - #[instrument( - target = TRACE_TARGET, - level = "trace", - skip_all, - fields(?path, ?check_parent_path), - ret, - )] - fn should_retain<'a>( - &self, - targets: &mut Option>, - path: &Nibbles, - check_parent_path: bool, - ) -> bool { - // If no targets are given then we never retain anything - let Some(targets) = targets.as_mut() else { return false }; - - let (mut lower, mut upper) = targets.current(); - - loop { - // If the node in question is a prefix of the target then we do not iterate targets - // further. - // - // Even if the node is a prefix of the target's key, a target with a parent path only - // retains nodes strictly below that already-revealed parent. - // - // _However_ even if the node doesn't match one target due to its parent path, it may - // match other targets whose keys match this node. So we search forwards and backwards - // for all targets which might match this node. - // - // For example, given a branch 0xabc, with children at 0, 1, and 2, and targets: - // - key: 0xabc0, parent path length: 1 - // - key: 0xabc1, parent path length: 0 - // - key: 0xabc2, parent path length: 3 <-- current - // - key: 0xabc3, parent path length: 2 - // - // When the branch node at 0xabc is visited it will be after the targets has iterated - // forward to 0xabc2 (because all children will have been visited already). At this - // point the target for 0xabc2 will not match the branch due to its prefix, but any of - // the other targets would, so we need to check those as well. - if lower.key_nibbles.starts_with(path) { - let is_below_parent = |target: &ProofV2Target| { - target.parent.path_len().is_none_or(|len| path.len() > len) - }; - return !check_parent_path || - (is_below_parent(lower) || - targets - .skip_iter() - .take_while(|target| target.key_nibbles.starts_with(path)) - .any(is_below_parent) || - targets - .rev_iter() - .take_while(|target| target.key_nibbles.starts_with(path)) - .any(is_below_parent)) - } - - // If the path isn't in the current range then iterate forward until it is (or until - // there is no upper bound, indicating unbounded). - if upper - .is_some_and(|upper| depth_first::cmp(path, &upper.key_nibbles) != Ordering::Less) - { - (lower, upper) = targets.next(); - trace!(target: TRACE_TARGET, target = ?lower, "upper target <= path, next target"); - } else { - return false - } - } - } - - /// Takes a child which has been removed from the `child_stack` and converts it to an - /// [`RlpNode`], retaining it as a proof node. - /// - /// Calling this method indicates that the child will not undergo any further modifications. - /// - /// The caller has already determined via [`Self::should_retain`] that this child is retained. - /// That decision must not be re-evaluated here, because `should_retain` advances the `targets` - /// cursor. Children which are not retained are converted later, in [`Self::pop_branch`]. - fn commit_child( - &mut self, - child_path: Nibbles, - child: ProofTrieBranchChild, - ) -> Result { - // An already encoded child only needs an extension if it has been rebased upward. - if matches!(&child, ProofTrieBranchChild::RlpNode { .. }) { - self.rlp_encode_buf.clear(); - return child.into_rlp(&mut self.rlp_encode_buf).map(|(node, _)| node) - } - - trace!(target: TRACE_TARGET, ?child_path, "Retaining child"); - - // Convert to `ProofTrieNodeV2`, which will be what is retained. - // - // If this node is a branch then its `rlp_nodes_buf` will be taken and not returned to the - // `rlp_nodes_bufs` free-list. - self.rlp_encode_buf.clear(); - let proof_node = child.into_proof_trie_node(child_path, &mut self.rlp_encode_buf)?; - - // Use the `ProofTrieNodeV2` to encode the `RlpNode`, and then push it onto retained nodes - // before returning. - self.rlp_encode_buf.clear(); - proof_node.node.encode(&mut self.rlp_encode_buf); - - self.retained_proofs.push(proof_node); - Ok(RlpNode::from_rlp(&self.rlp_encode_buf)) - } - - /// Returns the path of the child of the currently under-construction branch at the given - /// nibble. - #[inline] - fn child_path_at(&self, nibble: u8) -> Nibbles { - let mut child_path = self.branch_path; - debug_assert!(child_path.len() < 64); - child_path.push_unchecked(nibble); - child_path - } - - /// Returns index of the highest nibble which is set in the mask. - /// - /// # Panics - /// - /// Will panic in debug mode if the mask is empty. - #[inline] - fn highest_set_nibble(mask: TrieMask) -> u8 { - debug_assert!(!mask.is_empty()); - (u16::BITS - mask.leading_zeros() - 1) as u8 - } - - /// Returns the path of the child on top of the `child_stack`, or the root path if the stack is - /// empty. Returns None if the current branch has not yet pushed a child (empty `state_mask`). - fn last_child_path(&self) -> Option { - // If there is no branch under construction then the top child must be the root child. - let Some(branch) = self.branch_stack.last() else { - return Some(Nibbles::new()); - }; - - (!branch.state_mask.is_empty()) - .then(|| self.child_path_at(Self::highest_set_nibble(branch.state_mask))) - } - - /// If the last child of `child_stack` is retained for the proof, calls [`Self::commit_child`] - /// to replace it with a [`ProofTrieBranchChild::RlpNode`]. Otherwise, leaves it unencoded until - /// [`Self::pop_branch`]. - /// - /// If `child_stack` is empty then this is a no-op. - /// - /// NOTE that this method call relies on the `state_mask` of the top branch of the - /// `branch_stack` to determine the last child's path. When committing the last child prior to - /// pushing a new child, it's important to set the new child's `state_mask` bit _after_ the call - /// to this method. - #[instrument( - target = TRACE_TARGET, - level = "trace", - skip_all, - fields(child_path = ?self.last_child_path()), - )] - fn commit_last_child<'a>( - &mut self, - targets: &mut Option>, - ) -> Result<(), StateProofError> { - if matches!(self.child_stack.last(), Some(ProofTrieBranchChild::RlpNode { .. })) { - trace!(target: TRACE_TARGET, "Last child already committed, leaving stack unchanged"); - return Ok(()) - } - - let Some(child_path) = self.last_child_path() else { return Ok(()) }; - let child = - self.child_stack.pop().expect("child_stack can't be empty if there's a child path"); - - // Only commit immediately if retained for the proof. Otherwise, defer conversion - // to pop_branch() to give DeferredEncoder time for async work. - if self.should_retain(targets, &child_path, true) { - let (hash_mask_bit, tree_mask_bit) = child.mask_bits(); - let child_rlp_node = self.commit_child(child_path, child)?; - trace!(target: TRACE_TARGET, ?child_rlp_node, "Pushing committed child RlpNode onto stack"); - self.child_stack.push(ProofTrieBranchChild::RlpNode { - node: child_rlp_node, - short_key: Nibbles::new(), - hash_mask_bit, - tree_mask_bit, - }); - } else { - trace!(target: TRACE_TARGET, "Pushing uncommitted child onto stack"); - self.child_stack.push(child); - } - - Ok(()) - } - - /// Adds a child at its full path, possibly collapsing an existing branch and/or creating a new - /// one depending on the path. - fn push_child<'a>( - &mut self, - targets: &mut Option>, - mut child: ProofTrieBranchChild, - ) -> Result<(), StateProofError> { - let path = *child.short_key(); - - loop { - trace!( - target: TRACE_TARGET, - ?path, - branch_stack_len = ?self.branch_stack.len(), - branch_path = ?self.branch_path, - child_stack_len = ?self.child_stack.len(), - "push_child: loop", - ); - - // Get the `state_mask` of the branch currently being built. If there are no branches - // on the stack then the trie is either empty or contains only a single child. - let (nibble, short_key) = match self.branch_stack.last().map(|branch| branch.state_mask) - { - None if self.child_stack.is_empty() => { - // The first child is the root and already has the correct short key. - self.child_stack.push(child); - return Ok(()) - } - None => { - // Split the existing root child from the new child by their common prefix. - debug_assert_eq!(self.child_stack.len(), 1); - debug_assert!(!self - .child_stack - .last() - .expect("already checked for emptiness") - .short_key() - .is_empty()); - self.push_new_branch(path) - } - Some(state_mask) => { - // Find the number of nibbles shared by the current branch and the new child. - let common_prefix_len = self.branch_path.common_prefix_length(&path); - - // A branch which is not a prefix of the child cannot receive any more - // children, so finish it and try again with its parent. - if common_prefix_len < self.branch_path.len() { - self.pop_branch(targets)?; - continue - } - - // If this nibble is already occupied, split the existing child from the new - // child. Otherwise the new child can be inserted directly into this branch. - let nibble = path.get_unchecked(common_prefix_len); - if state_mask.is_bit_set(nibble) { - self.push_new_branch(path) - } else { - (nibble, trim_nibbles_prefix(&path, common_prefix_len + 1)) - } - } - }; - - // Store the child key relative to its new parent branch. - child.trim_short_key_prefix(path.len() - short_key.len()); - - // The preceding child is structurally final. Commit it if retained for the proof; - // otherwise its encoding can wait until pop_branch. - self.commit_last_child(targets)?; - - let branch = self.branch_stack.last_mut().expect("branch_stack cannot be empty"); - debug_assert!(!branch.state_mask.is_bit_set(nibble)); - - // Set the new child's bit after commit_last_child, which uses the mask to find the - // preceding child's path. - branch.state_mask.set_bit(nibble); - - // The parent now contains this child at `nibble`. - self.child_stack.push(child); - return Ok(()) - } - } - - /// Pushes a new branch onto the `branch_stack` based on the path and short key of the last - /// child on the `child_stack` and the path of the next child which will be pushed on to the - /// stack after this call. - /// - /// Returns the nibble of the branch's `state_mask` which should be set for the new child, and - /// the short key that child should use. - fn push_new_branch(&mut self, new_child_path: Nibbles) -> (u8, Nibbles) { - // Get the full path to the root of the existing child. The trie root is used when there is - // no parent branch. - let first_child_path = self - .last_child_path() - .expect("push_new_branch requires the current branch to have a child"); - - // Put both children in the same coordinate system by trimming the existing child's root - // path from the new child's full path. - let new_child_short_key = trim_nibbles_prefix(&new_child_path, first_child_path.len()); - let first_child_short_key = *self - .child_stack - .last() - .expect("push_new_branch can't be called with empty child_stack") - .short_key(); - debug_assert!(!first_child_short_key.is_empty()); - - // Their shared prefix becomes the new branch's extension. The first differing nibble from - // each key becomes that child's position in the branch. - let common_prefix_len = first_child_short_key.common_prefix_length(&new_child_short_key); - let first_child_nibble = first_child_short_key.get_unchecked(common_prefix_len); - let new_child_nibble = new_child_short_key.get_unchecked(common_prefix_len); - - // Remove the extension and branch nibble from the new child's remaining short key. - let new_child_short_key = trim_nibbles_prefix(&new_child_short_key, common_prefix_len + 1); - - // The new branch starts after the existing child's parent path and their shared extension. - let branch_path_len = first_child_path.len() + common_prefix_len; - self.branch_path = new_child_path.slice_unchecked(0, branch_path_len); - - // Rebase the existing child beneath the new branch by removing the extension and its - // branch nibble from the front of its short key. - let first_child = self - .child_stack - .last_mut() - .expect("push_new_branch can't be called with empty child_stack"); - first_child.trim_short_key_prefix(common_prefix_len + 1); - - // Push the new branch onto the `branch_stack`. We do not yet set the `state_mask` bit of - // the new child; whatever actually pushes the child onto the `child_stack` is expected to - // do that. - self.branch_stack.push(ProofTrieBranch { - ext_len: common_prefix_len as u8, - state_mask: TrieMask::new(1 << first_child_nibble), - }); - - trace!( - target: TRACE_TARGET, - ?new_child_path, - ext_len = ?common_prefix_len, - ?first_child_nibble, - branch_path = ?self.branch_path, - "Pushed new branch", - ); - - (new_child_nibble, new_child_short_key) - } - - /// Pops the top branch off of the `branch_stack`, hashes its children on the `child_stack`, and - /// replaces those children on the `child_stack`. The `branch_path` field will be updated - /// accordingly. - /// - /// # Panics - /// - /// This method panics if `branch_stack` is empty. - #[instrument(target = TRACE_TARGET, level = "trace", skip_all)] - fn pop_branch<'a>( - &mut self, - targets: &mut Option>, - ) -> Result<(), StateProofError> { - trace!( - target: TRACE_TARGET, - branch = ?self.branch_stack.last(), - branch_path = ?self.branch_path, - child_stack_len = ?self.child_stack.len(), - "called", - ); - - // Retain the final child if needed before draining the branch's children. Unretained - // children are encoded below. - self.commit_last_child(targets)?; - - let mut rlp_nodes_buf = self.take_rlp_nodes_buf(); - let mut masks = BranchNodeMasks::default(); - let branch = self.branch_stack.pop().expect("branch_stack cannot be empty"); - - // Take the branch's children off the stack, using the state mask to determine how many - // there are. - let num_children = branch.state_mask.count_ones() as usize; - debug_assert!( - self.child_stack.len() >= num_children, - "Stack is missing necessary children ({num_children:?})" - ); - debug_assert!( - num_children >= 2, - "A branch must have at least two children, got {num_children}" - ); - - // Collect children into RlpNode Vec. Children are in lexicographic order. - rlp_nodes_buf.reserve(num_children); - for (nibble, child) in branch - .state_mask - .iter() - .zip(self.child_stack.drain(self.child_stack.len() - num_children..)) - { - let (hash_mask_bit, tree_mask_bit) = child.mask_bits(); - masks.set_child_bits(nibble, hash_mask_bit, tree_mask_bit); - - self.rlp_encode_buf.clear(); - let (child_rlp_node, freed_buf) = child.into_rlp(&mut self.rlp_encode_buf)?; - if let Some(buf) = freed_buf { - self.rlp_nodes_bufs.push(buf); - } - rlp_nodes_buf.push(child_rlp_node); - } - - debug_assert_eq!( - rlp_nodes_buf.len(), - num_children, - "children length must match number of bits set in state_mask" - ); - - // Calculate the short key of the parent extension (if the branch has a parent extension). - // It's important to calculate this short key prior to modifying the `branch_path`. - let short_key = trim_nibbles_prefix( - &self.branch_path, - self.branch_path.len() - branch.ext_len as usize, - ); - - // Compute hash for the branch node if it has a parent extension. - let rlp_node = if short_key.is_empty() { - None - } else { - self.rlp_encode_buf.clear(); - BranchNodeRef::new(&rlp_nodes_buf, branch.state_mask).encode(&mut self.rlp_encode_buf); - Some(RlpNode::from_rlp(&self.rlp_encode_buf)) - }; - - // Update the branch_path. If this branch is the only branch then only its extension needs - // to be trimmed, otherwise we also need to remove its nibble from its parent. - let new_path_len = - self.branch_path.len() - branch.ext_len as usize - self.maybe_parent_nibble(); - - // Wrap the `BranchNodeV2` so it can be pushed onto the child stack. - let branch_as_child = ProofTrieBranchChild::Branch { - node: BranchNodeV2::new(short_key, rlp_nodes_buf, branch.state_mask, rlp_node), - masks: (!masks.is_empty()).then_some(masks), - }; - - debug_assert!(self.branch_path.len() >= new_path_len); - self.branch_path = self.branch_path.slice_unchecked(0, new_path_len); - - self.child_stack.push(branch_as_child); - - Ok(()) - } - - /// Given the lower and upper bounds (exclusive) of a range of keys, iterates over the - /// `hashed_cursor` and calculates all trie nodes possible based on those keys. If the upper - /// bound is None then it is considered unbounded. - /// - /// It is expected that this method is "driven" by `next_uncached_key_range`, which decides - /// which ranges of keys need to be calculated based on what cached trie data is available. - #[instrument( - target = TRACE_TARGET, - level = "trace", - skip_all, - fields(?lower_bound, ?upper_bound), - )] - fn calculate_key_range<'a>( - &mut self, - value_encoder: &mut VE, - targets: &mut Option>, - hashed_cursor_state: &mut HashedCursorState, - lower_bound: Nibbles, - upper_bound: Option, - ) -> Result<(), StateProofError> { - // A helper closure for mapping entries returned from the `hashed_cursor`, converting the - // key to Nibbles and immediately creating the DeferredValueEncoder so that encoding of the - // leaf value can begin ASAP. - let mut map_hashed_cursor_entry = |(key_b256, val): (B256, _)| { - debug_assert_eq!(key_b256.len(), 32); - let key = Nibbles::unpack_array(key_b256.as_ref()); - let val = value_encoder.deferred_encoder(key_b256, val); - (key, val) - }; - - // If the cursor hasn't been used, or the last iterated key is prior to this range's key - // range, then seek forward to at least the first key. - if hashed_cursor_state.needs_seek_to(&lower_bound) { - trace!( - target: TRACE_TARGET, - current=?hashed_cursor_state.path(), - "Seeking hashed cursor to meet lower bound", - ); - - let lower_key = B256::right_padding_from(&lower_bound.pack()); - *hashed_cursor_state = HashedCursorState::seeked( - lower_bound, - self.hashed_cursor.seek(lower_key)?.map(&mut map_hashed_cursor_entry), - ); - } - - // Loop over all keys in the range, pushing each leaf onto the stack. - while hashed_cursor_state - .path() - .is_some_and(|key| upper_bound.is_none_or(|upper_bound| key < &upper_bound)) - { - let (key, val) = hashed_cursor_state.take(); - self.push_child(targets, ProofTrieBranchChild::Leaf { short_key: key, value: val })?; - *hashed_cursor_state = HashedCursorState::seeked( - key, - self.hashed_cursor.next()?.map(&mut map_hashed_cursor_entry), - ); - } - - trace!(target: TRACE_TARGET, "No further keys within range"); - Ok(()) - } - - /// Takes a cached branch from the trie cursor and invalidates its hashes if it may collapse. - fn take_cached_branch( - &mut self, - trie_cursor_state: &mut TrieCursorState, - ) -> (Nibbles, BranchNodeCompact) { - let (cached_path, mut cached_branch) = trie_cursor_state.take(); - - if self.prefix_set.contains(&cached_path) { - let mut unchanged_children = 0; - let mut child_path = cached_path; - for nibble in cached_branch.state_mask.iter() { - child_path.truncate(cached_path.len()); - child_path.push_unchecked(nibble); - if !self.prefix_set.contains(&child_path) { - unchanged_children += 1; - if unchanged_children > 1 { - break - } - } - } - - // If every dirty child is deleted, a lone unchanged child must be revealed so the - // branch can collapse into it. Keep the masks as structural hints, but prevent any of - // this branch's cached hashes from hiding that child. - if unchanged_children <= 1 { - Arc::make_mut(&mut cached_branch.hashes).fill(B256::ZERO); - trace!( - target: TRACE_TARGET, - ?cached_path, - ?unchanged_children, - "Invalidated cached hashes because branch may collapse", - ); - } - } - - (cached_path, cached_branch) - } - - /// Attempts to pop off the top branch of the `cached_branch_stack`, returning - /// [`PopCachedBranchOutcome::Popped`] on success. Returns other variants to indicate that the - /// stack is empty and what to do about it. - /// - /// This method only returns [`PopCachedBranchOutcome::CalculateLeaves`] if there is a cached - /// branch on top of the stack. - #[inline] - fn try_pop_cached_branch( - &mut self, - trie_cursor_state: &mut TrieCursorState, - traversal_upper_bound: Option<&Nibbles>, - uncalculated_lower_bound: &Option, - ) -> Result { - // If the `uncalculated_lower_bound` is None it indicates that there can be no more - // leaf data, so similarly there can be no more cached branch data. - let Some(uncalculated_lower_bound) = uncalculated_lower_bound else { - return Ok(PopCachedBranchOutcome::Exhausted) - }; - - // Use the branch on top of the stack unless a previously calculated range has already - // covered its entire subtrie. - while let Some(cached) = self.cached_branch_stack.pop() { - if cached - .0 - .next_without_prefix() - .is_some_and(|upper_bound| upper_bound <= *uncalculated_lower_bound) - { - trace!(target: TRACE_TARGET, cached_path=?cached.0, ?uncalculated_lower_bound, "Skipping covered cached branch"); - continue - } - return Ok(PopCachedBranchOutcome::Popped(cached)); - } - - // There is no cached branch on the stack. It's possible that another one exists - // farther on in the trie, but we perform some checks first to prevent unnecessary - // attempts to find it. - - // If [`TrieCursorState::path`] returns None it means that the cursor has been - // exhausted, so there can be no more cached data. - let Some(mut trie_cursor_path) = trie_cursor_state.path() else { - return Ok(PopCachedBranchOutcome::Exhausted) - }; - - // If the trie cursor is seeked to a branch whose leaves have already been processed - // then we can't use it, instead we seek forward and try again. - if trie_cursor_path < uncalculated_lower_bound { - *trie_cursor_state = TrieCursorState::seeked( - *uncalculated_lower_bound, - self.trie_cursor.seek(*uncalculated_lower_bound)?, - ); - - // Having just seeked forward we need to check if the cursor is now exhausted, - // extracting the new path at the same time. - if let Some(new_trie_cursor_path) = trie_cursor_state.path() { - trie_cursor_path = new_trie_cursor_path - } else { - return Ok(PopCachedBranchOutcome::Exhausted) - }; - } - - // If the trie cursor has reached the end of the traversal range then we consider cached - // data to be exhausted. The cursor itself remains positioned for reuse by a later range. - if traversal_upper_bound.is_some_and(|upper_bound| trie_cursor_path >= upper_bound) { - return Ok(PopCachedBranchOutcome::Exhausted) - } - - // At this point we can be sure that the cursor is in an `Available` state. We know for - // sure it's not `Exhausted` because of the calls to `path` above, and we know it's not - // `Taken` because we push all taken branches onto the `cached_branch_stack`, and the - // stack is empty. - // - // We will use this `Available` cached branch as our next branch. - let cached = self.take_cached_branch(trie_cursor_state); - trace!(target: TRACE_TARGET, cached=?cached, "Pushed next trie node onto cached_branch_stack"); - - // If the calculated range is not caught up to the next cached branch it means there - // are portions of the trie prior to that branch which may need to be calculated; - // return the uncalculated range up to that branch to make that happen. - // - // If the next cached branch's path is all zeros then we can skip this catch-up step, - // because there cannot be any keys prior to that range. - let cached_path = &cached.0; - if uncalculated_lower_bound < cached_path && !cached_path.is_zeroes() { - let range = (*uncalculated_lower_bound, Some(*cached_path)); - trace!(target: TRACE_TARGET, ?range, "Returning key range to calculate in order to catch up to cached branch"); - - // Push the cached branch onto the stack so it's available once the leaf range is done - // being calculated. - self.cached_branch_stack.push(cached); - - return Ok(PopCachedBranchOutcome::CalculateLeaves(range)); - } - - Ok(PopCachedBranchOutcome::Popped(cached)) - } - - /// Pop any under-construction branches that are now complete. Assumes that all trie data prior - /// to `next_path`, if any, has been computed. Any branches which were under-construction - /// previously, and which do not share a prefix with `next_path`, can be assumed to be - /// completed; they will not have any further keys added to them. - /// - /// Returns a range to calculate if a branch still has dirty keys to process, or popping it - /// exposes dirty keys which could split its extension. A missing lower bound disables these - /// checks when the caller has already scheduled the remaining range. - fn commit_branches<'a>( - &mut self, - targets: &mut Option>, - next_path: &Nibbles, - uncalculated_lower_bound: Option<&Nibbles>, - ) -> Result)>, StateProofError> { - let dirty_range = |prefix_set: &mut PrefixSet, upper_bound: Option| { - let uncalculated_lower_bound = uncalculated_lower_bound?; - - if upper_bound.as_ref().is_some_and(|upper| uncalculated_lower_bound >= upper) { - return None - } - - match upper_bound { - Some(upper_bound) => prefix_set - .contains_range(uncalculated_lower_bound..&upper_bound) - .then_some((*uncalculated_lower_bound, Some(upper_bound))), - None => prefix_set - .contains_from(uncalculated_lower_bound) - .then_some((*uncalculated_lower_bound, None)), - } - }; - - let mut popped_child_path_upper = None; - while !next_path.starts_with(&self.branch_path) { - // If the lower bound is still within this branch, process any remaining dirty keys - // before popping it so they can be added directly to the branch. - if uncalculated_lower_bound.is_some_and(|lower| lower.starts_with(&self.branch_path)) && - let Some(range) = - dirty_range(&mut self.prefix_set, self.branch_path.next_without_prefix()) - { - return Ok(Some(range)) - } - - let branch = self.branch_stack.last().expect("branch_stack cannot be empty"); - // Once popped, this branch becomes a child at this path. Its upper bound therefore - // covers any keys which could split the branch's extension on the right. - popped_child_path_upper = Some( - self.branch_path - .slice_unchecked(0, self.branch_path.len() - branch.ext_len as usize) - .next_without_prefix(), - ); - - self.pop_branch(targets)?; - } - - // An empty branch_stack is skipped because a popped local root does not need this check: - // any gap before `next_path` was already returned by `try_pop_cached_branch`, and forward - // traversal will split its extension and process later dirty keys as needed. - if !self.branch_stack.is_empty() && - let Some(upper_bound) = popped_child_path_upper && - let Some(range) = dirty_range(&mut self.prefix_set, upper_bound) - { - return Ok(Some(range)) - } - - Ok(None) - } - - // Returns the next child nibble on the current branch to process, or None if there are no - // further nibbles to process. - fn next_uncached_child_nibble( - prefix_set: &mut PrefixSet, - branch_path: &Nibbles, - uncalculated_lower_bound_ref: &Nibbles, - cached_state_mask: TrieMask, - ) -> Option { - let mut next_child_nibbles = cached_state_mask; - - // Also include child nibbles indicated by the prefix set. The prefix set can - // indicate children that need recalculation from leaves (e.g. new keys inserted - // under this branch). - if prefix_set.contains(branch_path) { - let branch_path_len = branch_path.len(); - let mut child_path = *branch_path; - for nibble in 0u8..16 { - child_path.truncate(branch_path_len); - child_path.push_unchecked(nibble); - if prefix_set.contains(&child_path) { - next_child_nibbles.set_bit(nibble); - } - } - } - - let _orig_next_child_nibbles = next_child_nibbles; - - // Mask out any child nibbles whose ranges have already been fully processed. - // This can happen when `calculate_key_range` finds no keys for a child's range, - // leaving the child's bit unset in `state_mask`. Without this, re-entering this - // function would select the same child again. - if uncalculated_lower_bound_ref.starts_with(branch_path) && - uncalculated_lower_bound_ref.len() > branch_path.len() - { - let lower_nibble = uncalculated_lower_bound_ref.get_unchecked(branch_path.len()); - // Clear all nibbles strictly below `lower_nibble`. If the lower bound is within the - // current child, the remainder of that child may still need to be processed. - let already_processed_mask = TrieMask::new((1u16 << lower_nibble) - 1); - next_child_nibbles &= !already_processed_mask; - trace!( - target: TRACE_TARGET, - ?branch_path, - ?_orig_next_child_nibbles, - ?already_processed_mask, - ?next_child_nibbles, - "Unset already processed key nibbles from next_child_nibbles", - ); - } else if !uncalculated_lower_bound_ref.starts_with(branch_path) && - uncalculated_lower_bound_ref > branch_path - { - // The lower bound has moved entirely past this branch (e.g. branch is 0x6 but - // lower is 0x7). All remaining children have been processed. - next_child_nibbles = TrieMask::default(); - trace!( - target: TRACE_TARGET, - ?branch_path, - ?_orig_next_child_nibbles, - ?next_child_nibbles, - "Unset all nibbles from next_child_nibbles due to branch_path being outside this subtrie", - ); - } - - next_child_nibbles.first_set_bit_index() - } - - /// Accepts the current state of both hashed and trie cursors, and determines the next range of - /// hashed keys which need to be processed using [`Self::calculate_key_range`]. - /// - /// This method will use cached branch node data from the trie cursor to skip over all possible - /// ranges of keys, to reduce computation as much as possible. - /// - /// # Returns - /// - /// - `None`: No more data to process, finish computation - /// - /// - `Some(lower, None)`: Indicates to process all keys starting at `lower`, with no upper - /// bound. This method won't be called again after this. - /// - /// - `Some(lower, Some(upper))`: Indicates to process all keys starting at `lower`, up to but - /// excluding `upper`, and then call this method once done. - /// - /// Once returned the `branch_stack` will be in the correct state to start calculating leaves - /// for the given range, if any. - #[instrument(target = TRACE_TARGET, level = "trace", skip_all)] - fn next_uncached_key_range<'a>( - &mut self, - targets: &mut Option>, - trie_cursor_state: &mut TrieCursorState, - traversal_upper_bound: Option<&Nibbles>, - mut uncalculated_lower_bound: Option, - ) -> Result)>, StateProofError> { - loop { - if let (Some(lower_bound), Some(upper_bound)) = - (uncalculated_lower_bound.as_ref(), traversal_upper_bound) && - lower_bound >= upper_bound - { - return Ok(None) - } - - // Pop the currently cached branch node. - // - // NOTE we pop off the `cached_branch_stack` because cloning the `BranchNodeCompact` - // means cloning an Arc, which incurs synchronization overhead. We have to be sure to - // push the cached branch back onto the stack once done. - let (cached_path, cached_branch) = match self.try_pop_cached_branch( - trie_cursor_state, - traversal_upper_bound, - &uncalculated_lower_bound, - )? { - PopCachedBranchOutcome::Popped(cached) => cached, - PopCachedBranchOutcome::Exhausted => { - // If cached branches are exhausted it's possible that there is still an - // unbounded range of leaves to be processed. `uncalculated_lower_bound` is - // used to return that range. - trace!(target: TRACE_TARGET, ?uncalculated_lower_bound, "Exhausted cached trie nodes"); - if let Some(lower) = uncalculated_lower_bound { - self.commit_branches(targets, &lower, None)?; - return Ok(Some((lower, traversal_upper_bound.copied()))); - } - return Ok(None) - } - PopCachedBranchOutcome::CalculateLeaves(range) => { - self.commit_branches(targets, &range.0, None)?; - return Ok(Some(range)); - } - }; - - let uncalculated_lower_bound_ref = uncalculated_lower_bound - .as_ref() - .expect("try_pop_cached_branch would return Exhausted if this were None"); - - trace!( - target: TRACE_TARGET, - branch_path = ?self.branch_path, - branch_state_mask = ?self.branch_stack.last().map(|b| b.state_mask), - ?cached_path, - cached_branch_state_mask = ?cached_branch.state_mask, - cached_branch_hash_mask = ?cached_branch.hash_mask, - "loop", - ); - - if let Some(range) = - self.commit_branches(targets, &cached_path, Some(uncalculated_lower_bound_ref))? - { - self.cached_branch_stack.push((cached_path, cached_branch)); - return Ok(Some(range)) - } - - // Since we've popped all constructed branches which don't contain `cached_path`, the - // remaining branch path must be its prefix. - debug_assert!( - self.branch_path.len() < cached_path.len() || self.branch_path == cached_path, - "branch_path {:?} is different-or-longer-than cached_path {cached_path:?}", - self.branch_path - ); - - // Dirty keys before this cached path may split the extension leading to it. Process - // them before using the cached branch as a hint. - if uncalculated_lower_bound_ref < &cached_path && - self.prefix_set.contains_range(uncalculated_lower_bound_ref..&cached_path) - { - self.cached_branch_stack.push((cached_path, cached_branch)); - return Ok(Some((*uncalculated_lower_bound_ref, Some(cached_path)))) - } - - let child_nibble = Self::next_uncached_child_nibble( - &mut self.prefix_set, - &cached_path, - uncalculated_lower_bound_ref, - cached_branch.state_mask, - ); - - let Some(child_nibble) = child_nibble else { - trace!( - target: TRACE_TARGET, - path=?cached_path, - ?cached_branch, - "No further cached children", - ); - - // no need to pop from `cached_branch_stack`, the current cached branch is already - // popped (see note at the top of the loop). - - // The completed branch has no more keys with its prefix. Set the lower bound which - // can be returned from this method to be the next possible prefix, if any. - uncalculated_lower_bound = cached_path.next_without_prefix(); - - continue - }; - - let mut child_path = cached_path; - child_path.push_unchecked(child_nibble); - let child_lower_bound = (*uncalculated_lower_bound_ref).max(child_path); - - // If the `hash_mask` bit is set for the next child it means the child's hash is cached - // in the `cached_branch`. We can use that instead of re-calculating the hash of the - // entire sub-trie. - // - // If the child needs to be retained for a proof then we should not use the cached - // hash, and instead continue on to calculate its node manually. - // - // If the child's path is in the prefix set then the cached hash is stale and must - // not be used. - if cached_branch.hash_mask.is_bit_set(child_nibble) && - child_lower_bound == child_path && - !self.prefix_set.contains(&child_path) - { - // Pull this child's hash out of the cached branch node. The hash index is the - // number of hash_mask bits set below this child's nibble. - let lower_bits = TrieMask::new((1u16 << child_nibble) - 1); - let hash_idx = (cached_branch.hash_mask & lower_bits).count_ones() as usize; - let hash = cached_branch.hashes[hash_idx]; - - // `take_cached_branch` replaces hashes with zero when their nodes must be - // revealed to support a possible branch collapse. - if hash != B256::ZERO { - let mut probed_targets = targets.clone(); - if !self.should_retain(&mut probed_targets, &child_path, false) { - trace!( - target: TRACE_TARGET, - ?child_path, - ?hash_idx, - ?hash, - "Using cached hash for child", - ); - - // Keep this hint available while inserting the child so a branch which is - // naturally materialized at `cached_path` can inherit its masks. - let tree_mask_bit = cached_branch.tree_mask.is_bit_set(child_nibble); - self.cached_branch_stack.push((cached_path, cached_branch)); - self.push_child( - targets, - ProofTrieBranchChild::RlpNode { - node: RlpNode::word_rlp(&hash), - short_key: child_path, - hash_mask_bit: true, - tree_mask_bit, - }, - )?; - - if let (Some(targets), Some(probed_targets)) = - (targets.as_mut(), probed_targets) - { - targets.i = targets.i.max(probed_targets.i); - } - - // Update the `uncalculated_lower_bound` to indicate that the child whose - // bit was just set is completely processed. - uncalculated_lower_bound = child_path.next_without_prefix(); - - continue - } - } - } - - // We now want to check if there is a cached branch node at this child. The cached - // branch node may be the node at this child directly, or this child may be an - // extension and the cached branch is the child of that extension. - - // All trie nodes prior to `child_lower_bound` have been processed, so seek the trie - // cursor forward if necessary. - if trie_cursor_state.path().is_some_and(|path| path < &child_lower_bound) { - trace!(target: TRACE_TARGET, ?child_lower_bound, "Seeking trie cursor to child lower bound"); - *trie_cursor_state = TrieCursorState::seeked( - child_lower_bound, - self.trie_cursor.seek(child_lower_bound)?, - ); - } - - // If the next cached branch node is a child of `child_path` then we can assume it is - // the cached branch for this child. We push it onto the `cached_branch_stack` and loop - // back to the top. - if let TrieCursorState::Available(next_cached_path, next_cached_branch) = - &trie_cursor_state && - next_cached_path.starts_with(&child_path) - { - // Push the current cached branch back on before pushing its child and then looping - self.cached_branch_stack.push((cached_path, cached_branch)); - - trace!( - target: TRACE_TARGET, - ?child_path, - ?next_cached_path, - ?next_cached_branch, - "Pushing cached branch for child", - ); - let cached = self.take_cached_branch(trie_cursor_state); - self.cached_branch_stack.push(cached); - continue; - } - - // There is no cached data for the sub-trie at this child, we must recalculate the - // sub-trie root (this child) using the leaves. Return the range of keys based on the - // child path. - let child_upper_bound = child_path.next_without_prefix(); - trace!( - target: TRACE_TARGET, - lower=?child_lower_bound, - upper=?child_upper_bound, - "Returning sub-trie's key range to calculate", - ); - - // Push the current cached branch back onto the stack before returning. - self.cached_branch_stack.push((cached_path, cached_branch)); - - return Ok(Some((child_lower_bound, child_upper_bound))); - } - } - - /// Calculates trie nodes and retains proofs for targeted nodes within a sub-trie. The - /// sub-trie's bounds are denoted by the `lower_bound` and `upper_bound` arguments, - /// `upper_bound` is exclusive, None indicates unbounded. - #[instrument( - target = TRACE_TARGET, - level = "trace", - skip_all, - fields( - parent_prefix=?sub_trie_targets.parent_prefix, - lower_bound=?sub_trie_targets.lower_bound, - upper_bound=?sub_trie_targets.upper_bound, - ), - )] - fn proof_subtrie<'a>( - &mut self, - value_encoder: &mut VE, - trie_cursor_state: &mut TrieCursorState, - hashed_cursor_state: &mut HashedCursorState, - sub_trie_targets: SubTrieTargets<'a>, - ) -> Result<(), StateProofError> { - let traversal_lower_bound = sub_trie_targets.lower_bound; - let traversal_upper_bound = sub_trie_targets.upper_bound; - - // Wrap targets into a `TargetsCursor`. targets can be empty if we only want to calculate - // the root, in which case we don't need a cursor. - let mut targets = if sub_trie_targets.targets.is_empty() { - None - } else { - Some(TargetsCursor::new(sub_trie_targets.targets)) - }; - - // Ensure initial state is cleared. By the end of the method call these should be empty once - // again. - debug_assert!(self.cached_branch_stack.is_empty()); - debug_assert!(self.branch_stack.is_empty()); - debug_assert!(self.branch_path.is_empty()); - debug_assert!(self.child_stack.is_empty()); - - // `next_uncached_key_range`, which will be called in the loop below, expects the trie - // cursor to have already been positioned. Cursor resets for overlapping sub-tries are - // handled by `proof_inner`, so a buffered entry at-or-after this disjoint range remains the - // first unconsumed entry. Exhaustion is similarly stable across forward-only ranges. - if trie_cursor_state.needs_seek_to(&traversal_lower_bound) { - trace!(target: TRACE_TARGET, "Doing initial seek of trie cursor"); - *trie_cursor_state = TrieCursorState::seeked( - traversal_lower_bound, - self.trie_cursor.seek(traversal_lower_bound)?, - ); - } - - // `uncalculated_lower_bound` tracks the lower bound of node paths which have yet to be - // visited, either via the hashed key cursor (`calculate_key_range`) or trie cursor - // (`next_uncached_key_range`). If/when this becomes None then there are no further nodes - // which could exist. - let mut uncalculated_lower_bound = Some(traversal_lower_bound); - - trace!(target: TRACE_TARGET, "Starting loop"); - loop { - // Save the previous lower bound to detect forward progress. - let prev_uncalculated_lower_bound = uncalculated_lower_bound; - - // Determine the range of keys of the overall trie which need to be re-computed. - let Some((calc_lower_bound, calc_upper_bound)) = self.next_uncached_key_range( - &mut targets, - trie_cursor_state, - traversal_upper_bound.as_ref(), - prev_uncalculated_lower_bound, - )? - else { - // If `next_uncached_key_range` determines that there can be no more keys then - // complete the computation. - break; - }; - - // Forward-progress guard: detect trie inconsistencies that would cause infinite loops. - // If `next_uncached_key_range` returns a range that starts before the previous - // lower bound, we've gone backwards and would loop forever. - // - // This can specifically happen when there is a cached branch which shouldn't exist, or - // if state mask bit is set on a cached branch which shouldn't be. - if let Some(prev_lower) = prev_uncalculated_lower_bound.as_ref() && - calc_lower_bound < *prev_lower - { - let msg = format!( - "next_uncached_key_range went backwards: calc_lower={calc_lower_bound:?} < \ - prev_lower={prev_lower:?}, calc_upper={calc_upper_bound:?}, \ - lower_bound={traversal_lower_bound:?}, \ - upper_bound={traversal_upper_bound:?}", - ); - error!(target: TRACE_TARGET, "{msg}"); - return Err(StateProofError::TrieInconsistency(msg)); - } - - // Calculate the trie for that range of keys - self.calculate_key_range( - value_encoder, - &mut targets, - hashed_cursor_state, - calc_lower_bound, - calc_upper_bound, - )?; - - // Once outside `calculate_key_range`, `hashed_cursor_state` will be at the first key - // after the range, or exhausted. - // - // If the hashed cursor is exhausted, or has reached the end of the traversal range, - // then there are no more keys which can contribute to these target children. - if hashed_cursor_state.path().is_none_or(|key| { - traversal_upper_bound.is_some_and(|upper_bound| key >= &upper_bound) - }) { - break; - } - - // The upper bound of previous calculation becomes the lower bound of the uncalculated - // range, for which we'll once again check for cached data. - uncalculated_lower_bound = calc_upper_bound; - } - - // Once there's no more leaves we can pop the remaining branches, if any. - trace!(target: TRACE_TARGET, "Exited loop, popping remaining branches"); - while !self.branch_stack.is_empty() { - self.pop_branch(&mut targets)?; - } - - // At this point the branch stack should be empty. If the child stack is empty it means no - // keys were ever iterated from the hashed cursor in the first place. Otherwise there should - // only be a single node left: the root node. - debug_assert!(self.branch_stack.is_empty()); - debug_assert!(self.branch_path.is_empty()); - debug_assert!(self.child_stack.len() < 2); - - // The `cached_branch_stack` may still have cached branches on it, as it's not affected by - // `pop_branch`, but it is no longer needed and should be cleared. - self.cached_branch_stack.clear(); - - // We always pop the local root node off of the `child_stack` in order to empty it. If the - // parent branch is already known, compressed roots need to be rebased into a direct child - // of that parent before they can be attached to it. - trace!( - target: TRACE_TARGET, - parent_prefix = ?sub_trie_targets.parent_prefix, - child_stack_empty = self.child_stack.is_empty(), - "Maybe retaining local root", - ); - let root_node = self.child_stack.pop(); - - // A full-trie calculation always retains a root, using an empty root when traversal - // produced no root node. - let Some(parent_prefix) = sub_trie_targets.parent_prefix else { - let mut root_node = if let Some(root_node) = root_node { - self.rlp_encode_buf.clear(); - root_node.into_proof_trie_node(Nibbles::new(), &mut self.rlp_encode_buf)? - } else { - ProofTrieNodeV2::empty() - }; - - // Direct root branches do not have an entry in the branch node table. A root extension - // still carries the masks of the child branch embedded within it. - if matches!(&root_node.node, TrieNodeV2::Branch(branch) if branch.key.is_empty()) { - root_node.masks = None; - } - - self.retained_proofs.push(root_node); - return Ok(()) - }; - - // If there's no root node then the subtrie has no keys, return nothing. - let Some(mut root_node) = root_node else { return Ok(()) }; - - let root_full_path = *root_node.short_key(); - - // An exact match reconstructed the already-revealed parent; its targeted children were - // retained while that parent branch was popped. - if root_full_path == parent_prefix { - return Ok(()) - } - - // At this point we have a "root" node which the calculator has based at 0x (empty path), - // but the subtrie targets indicate that there is a known parent branch at parent_prefix - // which this root should be rebased onto. - - // The local root of a partial calculation must be at or below its known parent. - if !root_full_path.starts_with(&parent_prefix) { - return Err(StateProofError::TrieInconsistency(format!( - "local root path {root_full_path:?} does not start with parent prefix \ - {parent_prefix:?}", - ))) - } - - // Keep the parent branch's child nibble in the proof path so the local root attaches - // directly below that parent. - let child_path_len = parent_prefix.len() + 1; - let child_path = root_full_path.slice_unchecked(0, child_path_len); - - // It's possible that the local root lies on a child which is not targeted. - if !sub_trie_targets - .targets - .iter() - .any(|target| target.key_nibbles.starts_with(&child_path)) - { - return Ok(()) - } - - // Retain the requested child with only the path below its parent edge in the short key. - root_node.trim_short_key_prefix(child_path_len); - self.rlp_encode_buf.clear(); - let root_node = root_node.into_proof_trie_node(child_path, &mut self.rlp_encode_buf)?; - self.retained_proofs.push(root_node); - - Ok(()) - } - - /// Clears internal computation state. Called after errors to ensure the calculator is not - /// left in a partially-computed state when reused. - fn clear_computation_state(&mut self) { - self.branch_stack.clear(); - self.branch_path = Nibbles::new(); - self.child_stack.clear(); - self.cached_branch_stack.clear(); - self.retained_proofs.clear(); - } - - /// Internal implementation of proof calculation. Assumes both cursors have already been reset. - /// See docs on [`Self::proof`] for expected behavior. - fn proof_inner( - &mut self, - value_encoder: &mut VE, - targets: &mut [ProofV2Target], - ) -> Result, StateProofError> { - // If there are no targets then nothing could be returned, return early. - if targets.is_empty() { - trace!(target: TRACE_TARGET, "Empty targets, returning"); - return Ok(Vec::new()) - } - - // Initialize the variables which track the state of the two cursors. Both indicate the - // cursors are unseeked. - let mut trie_cursor_state = TrieCursorState::unseeked(); - let mut hashed_cursor_state = HashedCursorState::unseeked(); - let mut previous_traversal_bounds: Option<(Nibbles, Option)> = None; - - // Divide targets into bounded ranges, each corresponding to the direct children of one - // already-revealed parent, and handle all proofs within that range. - for sub_trie_targets in iter_sub_trie_targets(targets) { - let traversal_lower_bound = sub_trie_targets.lower_bound; - let traversal_upper_bound = sub_trie_targets.upper_bound; - if previous_traversal_bounds.is_some_and(|(_, previous_upper_bound)| { - previous_upper_bound.is_none_or(|upper_bound| upper_bound > traversal_lower_bound) - }) { - if trie_cursor_state.needs_reset_before_seek(&traversal_lower_bound) { - trace!( - target: TRACE_TARGET, - ?previous_traversal_bounds, - ?traversal_lower_bound, - ?traversal_upper_bound, - "Resetting trie cursor before overlapping or backward traversal range", - ); - self.trie_cursor.reset(); - trie_cursor_state = TrieCursorState::unseeked(); - } - if hashed_cursor_state.needs_reset_before_seek(&traversal_lower_bound) { - trace!( - target: TRACE_TARGET, - ?previous_traversal_bounds, - ?traversal_lower_bound, - ?traversal_upper_bound, - "Resetting hashed cursor before overlapping or backward traversal range", - ); - self.hashed_cursor.reset(); - hashed_cursor_state = HashedCursorState::unseeked(); - } - } - - if let Err(err) = self.proof_subtrie( - value_encoder, - &mut trie_cursor_state, - &mut hashed_cursor_state, - sub_trie_targets, - ) { - self.clear_computation_state(); - return Err(err); - } - - previous_traversal_bounds = Some((traversal_lower_bound, traversal_upper_bound)); - } - - trace!( - target: TRACE_TARGET, - retained_proofs_len = ?self.retained_proofs.len(), - "proof_inner: returning", - ); - self.retained_proofs.sort_unstable_by(|a, b| depth_first::cmp(&a.path, &b.path)); - self.retained_proofs.dedup_by(|a, b| a.path == b.path); - Ok(core::mem::take(&mut self.retained_proofs)) - } - - /// Generate a proof for the given targets. - /// - /// Given a set of [`ProofV2Target`]s, returns nodes whose paths are a prefix of any target. The - /// returned nodes will be sorted depth-first by path. - /// - /// # Panics - /// - /// In debug builds, panics if the targets are not sorted lexicographically. - #[instrument(target = TRACE_TARGET, level = "trace", skip_all)] - pub fn proof( - &mut self, - value_encoder: &mut VE, - targets: &mut [ProofV2Target], - ) -> Result, StateProofError> { - self.trie_cursor.reset(); - self.hashed_cursor.reset(); - self.proof_inner(value_encoder, targets) - } - - /// Computes the root hash from a set of proof nodes. - /// - /// Returns `None` if there is no root node (partial proof), otherwise returns the hash of the - /// root node. - /// - /// This method reuses the internal RLP encode buffer for efficiency. - pub fn compute_root_hash( - &mut self, - proof_nodes: &[ProofTrieNodeV2], - ) -> Result, StateProofError> { - // Find the root node (node at empty path) - let root_node = proof_nodes.iter().find(|node| node.path.is_empty()); - - let Some(root) = root_node else { - return Ok(None); - }; - - // Compute the hash of the root node - self.rlp_encode_buf.clear(); - root.node.encode(&mut self.rlp_encode_buf); - let root_hash = keccak256(&self.rlp_encode_buf); - - Ok(Some(root_hash)) - } - - /// Calculates the root node of the trie. - /// - /// This method does not accept targets nor retain proofs. Returns the root node which can - /// be used to compute the root hash via [`Self::compute_root_hash`]. - #[instrument(target = TRACE_TARGET, level = "trace", skip(self, value_encoder))] - pub fn root_node( - &mut self, - value_encoder: &mut VE, - ) -> Result { - self.trie_cursor.reset(); - self.hashed_cursor.reset(); - - // Initialize the variables which track the state of the two cursors. Both indicate the - // cursors are unseeked. - let mut trie_cursor_state = TrieCursorState::unseeked(); - let mut hashed_cursor_state = HashedCursorState::unseeked(); - - static EMPTY_TARGETS: [ProofV2Target; 0] = []; - let sub_trie_targets = SubTrieTargets { - lower_bound: Nibbles::new(), - upper_bound: None, - parent_prefix: None, - targets: &EMPTY_TARGETS, - }; - - if let Err(err) = self.proof_subtrie( - value_encoder, - &mut trie_cursor_state, - &mut hashed_cursor_state, - sub_trie_targets, - ) { - self.clear_computation_state(); - return Err(err); - } - - // `proof_subtrie` retains the root node when there is no known parent, regardless of - // whether there are any targets. - let mut proofs = core::mem::take(&mut self.retained_proofs); - trace!( - target: TRACE_TARGET, - proofs_len = ?proofs.len(), - "root_node: extracting root", - ); - - // The root node is at the empty path. Since there is no parent and targets is empty, there - // should be no other retained proofs. - debug_assert_eq!( - proofs.len(), 1, - "prefix is empty, parent path is None, and targets is empty, so there must be only the root node" - ); - - // Find and remove the root node (node at empty path) - let root_node = proofs.pop().expect("prefix is empty, parent path is None, and targets is empty, so there must be only the root node"); - - Ok(root_node) - } -} - -/// A proof calculator for storage tries. -pub type StorageProofCalculator = ProofCalculator; - -impl StorageProofCalculator -where - TC: TrieStorageCursor, - HC: HashedStorageCursor, -{ - /// Create a new [`StorageProofCalculator`] instance. - pub fn new_storage(trie_cursor: TC, hashed_cursor: HC) -> Self { - Self::new(trie_cursor, hashed_cursor) - } - - /// Generate a proof for a storage trie at the given hashed address. - /// - /// Given a set of [`ProofV2Target`]s, returns nodes whose paths are a prefix of any target. The - /// returned nodes will be sorted depth-first by path. - /// - /// # Panics - /// - /// In debug builds, panics if the targets are not sorted lexicographically. - #[instrument(target = TRACE_TARGET, level = "trace", skip(self, targets))] - pub fn storage_proof( - &mut self, - hashed_address: B256, - targets: &mut [ProofV2Target], - ) -> Result, StateProofError> { - self.hashed_cursor.set_hashed_address(hashed_address); - - // Shortcut: check if storage is empty - if self.hashed_cursor.is_storage_empty()? { - return Ok(if targets.iter().any(|target| !target.parent.is_known()) { - vec![ProofTrieNodeV2 { - path: Nibbles::default(), - node: TrieNodeV2::EmptyRoot, - masks: None, - }] - } else { - Vec::new() - }) - } - - // Don't call `set_hashed_address` on the trie cursor until after the previous shortcut has - // been checked. - self.trie_cursor.set_hashed_address(hashed_address); - - // Create a mutable storage value encoder - let mut storage_value_encoder = StorageValueEncoder; - self.proof_inner(&mut storage_value_encoder, targets) - } - - /// Calculates the root node of a storage trie. - /// - /// This method does not accept targets nor retain proofs. Returns the root node which can - /// be used to compute the root hash via [`Self::compute_root_hash`]. - #[instrument(target = TRACE_TARGET, level = "trace", skip(self))] - pub fn storage_root_node( - &mut self, - hashed_address: B256, - ) -> Result { - self.hashed_cursor.set_hashed_address(hashed_address); - - if self.hashed_cursor.is_storage_empty()? { - return Ok(ProofTrieNodeV2 { - path: Nibbles::default(), - node: TrieNodeV2::EmptyRoot, - masks: None, - }) - } - - // Don't call `set_hashed_address` on the trie cursor until after the previous shortcut has - // been checked. - self.trie_cursor.set_hashed_address(hashed_address); - - // Create a mutable storage value encoder - let mut storage_value_encoder = StorageValueEncoder; - self.root_node(&mut storage_value_encoder) - } -} - -/// Helper type wrapping a slice of [`ProofV2Target`]s, primarily used to iterate through targets in -/// [`ProofCalculator::should_retain`]. -/// -/// It is assumed that the underlying slice is never empty, and that the iterator is never -/// exhausted. -#[derive(Clone)] -struct TargetsCursor<'a> { - targets: &'a [ProofV2Target], - i: usize, -} - -impl<'a> TargetsCursor<'a> { - /// Wraps a slice of [`ProofV2Target`]s with the `TargetsCursor`. - /// - /// # Panics - /// - /// Will panic in debug mode if called with an empty slice. - fn new(targets: &'a [ProofV2Target]) -> Self { - debug_assert!(!targets.is_empty()); - Self { targets, i: 0 } - } - - /// Returns the current and next [`ProofV2Target`] that the cursor is pointed at. - fn current(&self) -> (&'a ProofV2Target, Option<&'a ProofV2Target>) { - (&self.targets[self.i], self.targets.get(self.i + 1)) - } - - /// Iterates the cursor forward. - /// - /// # Panics - /// - /// Will panic if the cursor is exhausted. - fn next(&mut self) -> (&'a ProofV2Target, Option<&'a ProofV2Target>) { - self.i += 1; - debug_assert!(self.i < self.targets.len()); - self.current() - } - - // Iterate forwards over the slice, starting from the [`ProofV2Target`] after the current. - fn skip_iter(&self) -> impl Iterator { - self.targets[self.i + 1..].iter() - } - - /// Iterated backwards over the slice, starting from the [`ProofV2Target`] previous to the - /// current. - fn rev_iter(&self) -> impl Iterator { - self.targets[..self.i].iter().rev() - } -} - -/// Used to track the state of the trie cursor, allowing us to differentiate between a branch having -/// been taken (used as a cached branch) and the cursor having been exhausted. -#[derive(Debug)] -enum TrieCursorState { - /// The initial state of the cursor, indicating it's never been seeked. - Unseeked, - /// Cursor is seeked to this path and the node has not been used yet. - Available(Nibbles, BranchNodeCompact), - /// Cursor is seeked to this path, but the node has been used. - Taken(Nibbles), - /// Cursor has been exhausted after seeking from the given lower bound. - Exhausted(Nibbles), -} - -impl TrieCursorState { - /// Creates a [`Self::Unseeked`] based on an entry returned from the cursor itself. - const fn unseeked() -> Self { - Self::Unseeked - } - - /// Creates a [`Self`] based on an entry returned from the cursor itself. - fn seeked(key: Nibbles, entry: Option<(Nibbles, BranchNodeCompact)>) -> Self { - entry.map_or(Self::Exhausted(key), |(path, node)| Self::Available(path, node)) - } - - /// Returns the path the cursor is seeked to, or None if it's exhausted. - /// - /// # Panics - /// - /// Panics if the cursor is unseeked. - const fn path(&self) -> Option<&Nibbles> { - match self { - Self::Unseeked => panic!("cursor is unseeked"), - Self::Available(path, _) | Self::Taken(path) => Some(path), - Self::Exhausted(_) => None, - } - } - - /// Returns true if the cursor must seek to be usable for a range starting at `path`. - fn needs_seek_to(&self, path: &Nibbles) -> bool { - match self { - Self::Unseeked | Self::Taken(_) => true, - Self::Available(current_path, _) => current_path < path, - Self::Exhausted(_) => false, - } - } - - /// Returns true if seeking to `key` requires resetting the forward-only cursor. - fn needs_reset_before_seek(&self, key: &Nibbles) -> bool { - match self { - Self::Unseeked => false, - Self::Available(path, _) | Self::Taken(path) => path > key, - Self::Exhausted(exhausted_at) => exhausted_at > key, - } - } - - /// Takes the path and node from a [`Self::Available`]. Panics if not [`Self::Available`]. - fn take(&mut self) -> (Nibbles, BranchNodeCompact) { - let Self::Available(path, _) = self else { - panic!("take called on non-Available: {self:?}") - }; - - let path = *path; - let Self::Available(path, node) = core::mem::replace(self, Self::Taken(path)) else { - unreachable!("already checked that self is Self::Available"); - }; - - (path, node) - } -} - -/// Used to track the state of the hashed cursor, including the path that established exhaustion. -enum HashedCursorState { - /// The initial state of the cursor, indicating it's never been seeked. - Unseeked, - /// Cursor is seeked to this path and the value has not been used yet. - Available(Nibbles, V), - /// Cursor has been exhausted at or after the given path. - Exhausted(Nibbles), -} - -impl HashedCursorState { - /// Creates a [`Self::Unseeked`] state. - const fn unseeked() -> Self { - Self::Unseeked - } - - /// Creates a [`Self`] based on an entry returned from the cursor itself. - fn seeked(key: Nibbles, entry: Option<(Nibbles, V)>) -> Self { - entry.map_or(Self::Exhausted(key), |(path, value)| Self::Available(path, value)) - } - - /// Returns the path the cursor is seeked to, or None if it's unseeked or exhausted. - const fn path(&self) -> Option<&Nibbles> { - match self { - Self::Available(path, _) => Some(path), - Self::Unseeked | Self::Exhausted(_) => None, - } - } - - /// Returns true if the cursor must seek to be usable for a range starting at `key`. - fn needs_seek_to(&self, key: &Nibbles) -> bool { - match self { - Self::Unseeked => true, - Self::Available(path, _) => path < key, - Self::Exhausted(exhausted_at) => exhausted_at > key, - } - } - - /// Returns true if seeking to `key` requires resetting the forward-only cursor. - fn needs_reset_before_seek(&self, key: &Nibbles) -> bool { - match self { - Self::Unseeked => false, - Self::Available(path, _) => path > key, - Self::Exhausted(exhausted_at) => exhausted_at >= key, - } - } - - /// Takes the path and value from a [`Self::Available`]. Panics if not [`Self::Available`]. - fn take(&mut self) -> (Nibbles, V) { - match core::mem::replace(self, Self::Unseeked) { - Self::Available(path, value) => (path, value), - _ => panic!("take called on non-Available hashed cursor state"), - } - } -} - -/// Describes the state of the currently cached branch node (if any). -enum PopCachedBranchOutcome { - /// Cached branch has been popped from the `cached_branch_stack` and is ready to be used. - Popped((Nibbles, BranchNodeCompact)), - /// All cached branches have been exhausted. - Exhausted, - /// Need to calculate leaves from this range (exclusive upper) before the cached branch - /// (catch-up range). If None then - CalculateLeaves((Nibbles, Option)), -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - hashed_cursor::{ - mock::MockHashedCursorFactory, noop::NoopHashedCursor, HashedCursorFactory, - HashedPostStateCursor, - }, - proof::StorageProof as LegacyStorageProof, - test_utils::TrieTestHarness, - trie_cursor::{ - depth_first, noop::NoopStorageTrieCursor, InMemoryTrieCursor, TrieCursorFactory, - }, - }; - use alloy_primitives::map::B256Set; - use alloy_rlp::Decodable; - use alloy_trie::proof::AddedRemovedKeys; - use itertools::Itertools; - use reth_trie_common::{ - prefix_set::{PrefixSet, PrefixSetMut}, - updates::TrieUpdatesSorted, - HashedPostState, HashedStorage, ProofTrieNode, ProofV2TargetParent, TrieNode, - EMPTY_ROOT_HASH, - }; - use std::collections::BTreeMap; - - /// Converts legacy proofs to V2 proofs by combining extension nodes with their child branch - /// nodes. - /// - /// In the legacy proof format, extension nodes and branch nodes are separate. In the V2 format, - /// they are combined into a single `BranchNodeV2` where the extension's key becomes the - /// branch's `key` field. - /// - /// Converts legacy proofs (sorted in depth-first order) to V2 format. - /// - /// In depth-first order, children come BEFORE parents. So when we encounter an extension node, - /// its child branch has already been processed and is in the result. We need to pop it and - /// combine it with the extension. - fn convert_legacy_proofs_to_v2(legacy_proofs: &[ProofTrieNode]) -> Vec { - ProofTrieNodeV2::from_sorted_trie_nodes( - legacy_proofs.iter().map(|p| (p.path, p.node.clone(), p.masks)), - ) - } - - /// Projects a legacy proof node into the representation requested by a V2 target. - fn project_legacy_proof_node( - node: &ProofTrieNodeV2, - target: &ProofV2Target, - ) -> Option { - let Some(parent_path_len) = target.parent.path_len() else { - return target.key_nibbles.starts_with(&node.path).then(|| node.clone()) - }; - - if node.path.len() > parent_path_len { - return target.key_nibbles.starts_with(&node.path).then(|| node.clone()) - } - - let logical_path = match &node.node { - TrieNodeV2::Leaf(leaf) => node.path.join(&leaf.key), - TrieNodeV2::Branch(branch) => node.path.join(&branch.key), - TrieNodeV2::EmptyRoot | TrieNodeV2::Extension(_) => return None, - }; - let child_path_len = parent_path_len + 1; - if logical_path.len() < child_path_len { - return None - } - - let child_path = logical_path.slice(0..child_path_len); - if !target.key_nibbles.starts_with(&child_path) { - return None - } - - let trim_len = child_path_len - node.path.len(); - let mut projected = node.clone(); - projected.path = child_path; - match &mut projected.node { - TrieNodeV2::Leaf(leaf) => leaf.key = leaf.key.slice(trim_len..), - TrieNodeV2::Branch(branch) => { - branch.key = branch.key.slice(trim_len..); - if branch.key.is_empty() { - branch.branch_rlp_node = None; - } - } - TrieNodeV2::EmptyRoot | TrieNodeV2::Extension(_) => unreachable!(), - } - Some(projected) - } - - /// Builds the exact V2 representation expected for a legacy proof and set of targets. - fn project_legacy_proof( - legacy_nodes: &[ProofTrieNodeV2], - targets: &[ProofV2Target], - ) -> Vec { - let mut projected = targets - .iter() - .flat_map(|target| { - legacy_nodes.iter().filter_map(move |node| project_legacy_proof_node(node, target)) - }) - .collect::>(); - projected.sort_unstable_by(|a, b| depth_first::cmp(&a.path, &b.path)); - projected.dedup_by(|a, b| { - if a.path != b.path { - return false - } - assert_eq!(a, b, "target projections disagree at path {:?}", a.path); - true - }); - projected - } - - /// A test harness for comparing `StorageProofCalculator` and legacy `StorageProof` - /// implementations. - /// - /// Wraps [`TrieTestHarness`] and adds a method to test that both proof implementations - /// produce equivalent results for storage proofs. - struct ProofTestHarness { - inner: TrieTestHarness, - } - - impl std::ops::Deref for ProofTestHarness { - type Target = TrieTestHarness; - fn deref(&self) -> &Self::Target { - &self.inner - } - } - - impl ProofTestHarness { - /// Creates a new test harness from a map of hashed storage slots to values. - fn new(storage: BTreeMap) -> Self { - Self { inner: TrieTestHarness::new(storage) } - } - - /// Computes the storage root while treating the supplied prefixes as dirty. - fn root_with_prefix_set(&self, prefix_set: PrefixSet) -> Option { - let trie_cursor = - self.trie_cursor_factory().storage_trie_cursor(self.hashed_address()).unwrap(); - let hashed_cursor = - self.hashed_cursor_factory().hashed_storage_cursor(self.hashed_address()).unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set); - - let mut targets = [ProofV2Target::new(B256::ZERO)]; - let proof = calculator.storage_proof(self.hashed_address(), &mut targets).unwrap(); - calculator.compute_root_hash(&proof).unwrap() - } - - /// Asserts that `StorageProofCalculator` and legacy `StorageProof` produce equivalent - /// results for storage proofs. - fn assert_proof( - &self, - targets: impl IntoIterator, - ) -> Result<(), StateProofError> { - let mut targets_vec = targets.into_iter().collect::>(); - - // Get v2 proof and root hash via harness - let (proof_v2_result, root_hash) = self.proof_v2(&mut targets_vec); - - // Verify the root hash matches the expected root (if the proof contains a root - // node) - if let Some(root_hash) = root_hash { - pretty_assertions::assert_eq!(self.original_root(), root_hash); - } - - // Fully materialize the legacy proof so compressed branches can be projected across - // arbitrary parent boundaries, including absence targets that diverge inside them. - let legacy_targets = targets_vec - .iter() - .map(|target| B256::from_slice(&target.key_nibbles.pack())) - .chain(self.storage().keys().copied()) - .collect::(); - - // Call legacy StorageProof::storage_multiproof - let proof_legacy_result = LegacyStorageProof::new_hashed( - self.trie_cursor_factory(), - self.hashed_cursor_factory(), - self.hashed_address(), - ) - .with_branch_node_masks(true) - .with_added_removed_keys(Some(AddedRemovedKeys::default().with_assume_added(true))) - .storage_multiproof(legacy_targets)?; - - // Decode and sort legacy proof nodes - let proof_legacy_nodes = proof_legacy_result - .subtree - .iter() - .map(|(path, node_enc)| { - let mut buf = node_enc.as_ref(); - let node = TrieNode::decode(&mut buf) - .expect("legacy implementation should not produce malformed proof nodes"); - - let masks = if path.is_empty() { - None - } else { - proof_legacy_result.branch_node_masks.get(path).copied() - }; - - ProofTrieNode { path: *path, node, masks } - }) - .sorted_by(|a, b| depth_first::cmp(&a.path, &b.path)) - .collect::>(); - - // Convert legacy proofs to V2 proofs by combining extensions with their child branches - let all_legacy_nodes_v2 = convert_legacy_proofs_to_v2(&proof_legacy_nodes); - - let expected_v2 = project_legacy_proof(&all_legacy_nodes_v2, &targets_vec); - pretty_assertions::assert_eq!(expected_v2, proof_v2_result); - - Ok(()) - } - } - - /// Tests that `clear_computation_state` properly resets internal stacks, allowing a - /// `StorageProofCalculator` to be reused after a mid-computation error left stale state. - /// Before the fix, stale data in `branch_stack`, `child_stack`, and `branch_path` - /// could cause a `usize` underflow panic in `pop_branch`. - #[test] - fn test_proof_calculator_reuse_after_error() { - reth_tracing::init_test_tracing(); - - let slots = [ - B256::right_padding_from(&[0x10]), - B256::right_padding_from(&[0x20]), - B256::right_padding_from(&[0x30]), - B256::right_padding_from(&[0x40]), - ]; - let storage: BTreeMap = - slots.iter().map(|&s| (s, U256::from(100u64))).collect(); - - let harness = ProofTestHarness::new(storage); - - let trie_cursor_factory = harness.trie_cursor_factory(); - let hashed_cursor_factory = harness.hashed_cursor_factory(); - - let hashed_address = harness.hashed_address(); - let trie_cursor = trie_cursor_factory.storage_trie_cursor(hashed_address).unwrap(); - let hashed_cursor = hashed_cursor_factory.hashed_storage_cursor(hashed_address).unwrap(); - let mut proof_calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); - - // Simulate stale state left by a mid-computation error: push fake entries onto internal - // stacks and set a non-empty branch_path. - proof_calculator - .branch_stack - .push(ProofTrieBranch { ext_len: 2, state_mask: TrieMask::new(0b1111) }); - proof_calculator - .branch_stack - .push(ProofTrieBranch { ext_len: 0, state_mask: TrieMask::new(0b11) }); - proof_calculator.child_stack.push(ProofTrieBranchChild::RlpNode { - node: RlpNode::word_rlp(&B256::ZERO), - short_key: Nibbles::new(), - hash_mask_bit: false, - tree_mask_bit: false, - }); - proof_calculator.branch_path = Nibbles::from_nibbles([0x1, 0x2, 0x3]); - - // clear_computation_state should reset everything so a subsequent call works. - proof_calculator.clear_computation_state(); - - let mut sorted_slots = slots.to_vec(); - sorted_slots.sort(); - let mut targets: Vec = - sorted_slots.iter().copied().map(ProofV2Target::new).collect(); - - let result = proof_calculator.storage_proof(hashed_address, &mut targets).unwrap(); - - // Compare against a fresh calculator to verify correctness. - let trie_cursor = trie_cursor_factory.storage_trie_cursor(hashed_address).unwrap(); - let hashed_cursor = hashed_cursor_factory.hashed_storage_cursor(hashed_address).unwrap(); - let mut fresh_calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); - let fresh_result = fresh_calculator.storage_proof(hashed_address, &mut targets).unwrap(); - - pretty_assertions::assert_eq!(fresh_result, result); - } - - #[test] - fn test_root_node_reuse_with_overlay() { - let storage = BTreeMap::from([ - (B256::right_padding_from(&[0x10]), U256::from(1)), - (B256::right_padding_from(&[0x20]), U256::from(2)), - ]); - let harness = ProofTestHarness::new(storage.clone()); - let hashed_address = harness.hashed_address(); - let post_state = - HashedPostState::from_hashed_storage(hashed_address, HashedStorage::from_iter(storage)) - .into_sorted(); - let trie_updates = TrieUpdatesSorted::default(); - let trie_cursor = InMemoryTrieCursor::new_storage( - NoopStorageTrieCursor::default(), - &trie_updates, - hashed_address, - ); - let hashed_cursor = HashedPostStateCursor::new_storage( - NoopHashedCursor::default(), - &post_state, - hashed_address, - ); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); - - let first_root = calculator.root_node(&mut StorageValueEncoder).unwrap(); - assert!(matches!(first_root.node, TrieNodeV2::Branch(_))); - assert_eq!( - calculator.compute_root_hash(core::slice::from_ref(&first_root)).unwrap(), - Some(harness.original_root()) - ); - - let second_root = calculator.root_node(&mut StorageValueEncoder).unwrap(); - pretty_assertions::assert_eq!(first_root, second_root); - } - - #[test] - fn test_partial_storage_proof_after_root_calculation() { - let slot_a = B256::right_padding_from(&[0xae, 0xd4, 0x00]); - let slot_b = B256::right_padding_from(&[0xae, 0xd4, 0x10]); - let harness = ProofTestHarness::new(BTreeMap::from([ - (slot_a, U256::from(1)), - (slot_b, U256::from(2)), - ])); - let hashed_address = harness.hashed_address(); - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(hashed_address).unwrap(); - let hashed_cursor = - harness.hashed_cursor_factory().hashed_storage_cursor(hashed_address).unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); - - let root_node = calculator.storage_root_node(hashed_address).unwrap(); - assert_eq!( - calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap(), - Some(harness.original_root()) - ); - - let target = ProofV2Target::new(slot_a).with_parent(ProofV2TargetParent::new(3)); - let mut actual_targets = [target]; - let actual = calculator.storage_proof(hashed_address, &mut actual_targets).unwrap(); - let mut expected_targets = [target]; - let (expected, root) = harness.proof_v2(&mut expected_targets); - - assert!(root.is_none()); - pretty_assertions::assert_eq!(expected, actual); - } - - mod proptest_tests { - use super::*; - use proptest::prelude::*; - - /// Generate a strategy for storage datasets (hashed slot → value). - fn storage_strategy() -> impl Strategy> { - prop::collection::vec((any::<[u8; 32]>(), any::()), 0..=100).prop_map(|slots| { - slots - .into_iter() - .map(|(slot_bytes, value)| (B256::from(slot_bytes), U256::from(value))) - .filter(|(_, v)| *v != U256::ZERO) - .collect() - }) - } - - /// Generate a strategy for proof targets that are 80% from existing storage slots - /// and 20% random keys. Each target has a random parent path length of `None` or 0..15. - fn proof_targets_strategy( - slot_keys: Vec, - ) -> impl Strategy> { - let num_slots = slot_keys.len(); - - let target_count = 0..=(num_slots + 5); - - target_count.prop_flat_map(move |count| { - let slot_keys = slot_keys.clone(); - prop::collection::vec( - ( - prop::bool::weighted(0.8).prop_flat_map(move |from_slots| { - if from_slots && !slot_keys.is_empty() { - prop::sample::select(slot_keys.clone()).boxed() - } else { - any::<[u8; 32]>().prop_map(B256::from).boxed() - } - }), - 0u8..16u8, - ) - .prop_map(|(key, encoded_parent_path_len)| { - let parent = encoded_parent_path_len.checked_sub(1).map_or( - ProofV2TargetParent::NONE, - |parent_path_len| { - ProofV2TargetParent::new(usize::from(parent_path_len)) - }, - ); - ProofV2Target::new(key).with_parent(parent) - }), - count, - ) - }) - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(4000))] - #[test] - /// Tests that `StorageProofCalculator` produces valid proofs for randomly generated - /// storage datasets with proof targets. - fn proptest_proof_with_targets( - (storage, targets) in storage_strategy() - .prop_flat_map(|storage| { - let mut slot_keys: Vec = storage.keys().copied().collect(); - slot_keys.sort_unstable(); - let targets_strategy = proof_targets_strategy(slot_keys); - (Just(storage), targets_strategy) - }) - ) { - reth_tracing::init_test_tracing(); - let harness = ProofTestHarness::new(storage); - - harness.assert_proof(targets).expect("Proof generation failed"); - } - } - } - - #[test] - fn test_exact_subtrie_targets_with_root_target() { - reth_tracing::init_test_tracing(); - - let slot_80 = B256::right_padding_from(&[0x80]); - let slot_82 = B256::right_padding_from(&[0x82]); - let slot_f0 = B256::right_padding_from(&[0xf0]); - let storage = BTreeMap::from([ - (slot_80, U256::from(1)), - (slot_82, U256::from(2)), - (slot_f0, U256::from(3)), - ]); - let targets = [ - ProofV2Target::new(B256::ZERO), - ProofV2Target::new(slot_80).with_parent(ProofV2TargetParent::new(1)), - ]; - - let harness = ProofTestHarness::new(storage); - harness.assert_proof(targets).expect("Proof generation failed"); - } - - #[test] - fn test_rebases_singleton_subtrie_root_below_known_parent() { - let slot = B256::right_padding_from(&[0xae, 0xd4, 0x09]); - let slot_nibbles = Nibbles::unpack(slot); - let harness = ProofTestHarness::new(BTreeMap::from([(slot, U256::from(1))])); - let mut targets = [ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(3))]; - - let (proof, root) = harness.proof_v2(&mut targets); - - assert!(root.is_none()); - assert_eq!(proof.len(), 1); - assert_eq!(proof[0].path, slot_nibbles.slice(0..4)); - let TrieNodeV2::Leaf(leaf) = &proof[0].node else { - panic!("singleton subtrie root should remain a leaf") - }; - assert_eq!(leaf.key, slot_nibbles.slice(4..)); - } - - #[test] - fn test_rebases_singleton_leaf_at_max_parent_depth() { - let slot = B256::repeat_byte(0xae); - let slot_nibbles = Nibbles::unpack(slot); - let harness = ProofTestHarness::new(BTreeMap::from([(slot, U256::from(1))])); - let mut targets = [ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(63))]; - - let (proof, root) = harness.proof_v2(&mut targets); - - assert!(root.is_none()); - assert_eq!(proof.len(), 1); - assert_eq!(proof[0].path, slot_nibbles); - let TrieNodeV2::Leaf(leaf) = &proof[0].node else { - panic!("singleton subtrie root should remain a leaf") - }; - assert!(leaf.key.is_empty()); - } - - #[test] - fn test_root_and_root_parent_targets_retain_both_singleton_representations() { - let slot = B256::right_padding_from(&[0x20]); - let slot_nibbles = Nibbles::unpack(slot); - let harness = ProofTestHarness::new(BTreeMap::from([(slot, U256::from(1))])); - let mut targets = [ - ProofV2Target::new(slot), - ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(0)), - ]; - - let (proof, root) = harness.proof_v2(&mut targets); - - assert_eq!(root, Some(harness.original_root())); - let root_node = proof.iter().find(|node| node.path.is_empty()).expect("root proof"); - let TrieNodeV2::Leaf(root_leaf) = &root_node.node else { panic!("root should be a leaf") }; - assert_eq!(root_leaf.key, slot_nibbles); - - let child_path = slot_nibbles.slice(0..1); - let child_node = - proof.iter().find(|node| node.path == child_path).expect("rebased root child proof"); - let TrieNodeV2::Leaf(child_leaf) = &child_node.node else { - panic!("root child should be a leaf") - }; - assert_eq!(child_leaf.key, slot_nibbles.slice(1..)); - } - - #[test] - fn test_exhausted_cursor_resets_at_equal_target_boundary() { - let first = B256::ZERO; - let last = B256::with_last_byte(1); - let last_nibbles = Nibbles::unpack(last); - let harness = - ProofTestHarness::new(BTreeMap::from([(first, U256::from(1)), (last, U256::from(2))])); - let mut targets = [ - ProofV2Target::new(first), - ProofV2Target::new(last).with_parent(ProofV2TargetParent::new(63)), - ]; - - let (proof, root) = harness.proof_v2(&mut targets); - - assert_eq!(root, Some(harness.original_root())); - let child = proof - .iter() - .find(|node| node.path == last_nibbles) - .expect("depth-63 target child proof"); - let TrieNodeV2::Leaf(leaf) = &child.node else { panic!("target child should be a leaf") }; - assert!(leaf.key.is_empty()); - } - - #[test] - fn test_rebases_compressed_branch_subtrie_root() { - let slot_a = B256::right_padding_from(&[0xae, 0xd4, 0x00]); - let slot_b = B256::right_padding_from(&[0xae, 0xd4, 0x10]); - let slot_nibbles = Nibbles::unpack(slot_a); - let harness = ProofTestHarness::new(BTreeMap::from([ - (slot_a, U256::from(1)), - (slot_b, U256::from(2)), - ])); - let mut targets = [ProofV2Target::new(slot_a).with_parent(ProofV2TargetParent::new(3))]; - - let (proof, root) = harness.proof_v2(&mut targets); - - assert!(root.is_none()); - let branch_path = slot_nibbles.slice(0..4); - let branch_node = - proof.iter().find(|node| node.path == branch_path).expect("rebased compressed branch"); - let TrieNodeV2::Branch(branch) = &branch_node.node else { - panic!("rebased node should be a branch") - }; - assert!(branch.key.is_empty()); - assert!(branch.branch_rlp_node.is_none()); - } - - #[test] - fn test_discards_reconstructed_known_parent_branch() { - let slot_a = B256::right_padding_from(&[0xae, 0xd2]); - let slot_b = B256::right_padding_from(&[0xae, 0xd4]); - let slot_nibbles = Nibbles::unpack(slot_a); - let harness = ProofTestHarness::new(BTreeMap::from([ - (slot_a, U256::from(1)), - (slot_b, U256::from(2)), - ])); - let mut targets = [ProofV2Target::new(slot_a).with_parent(ProofV2TargetParent::new(3))]; - - let (proof, root) = harness.proof_v2(&mut targets); - - assert!(root.is_none()); - assert!(!proof.iter().any(|node| node.path == slot_nibbles.slice(0..3))); - assert!(proof.iter().any(|node| node.path == slot_nibbles.slice(0..4))); - } - - #[test] - fn test_rebased_root_matches_direct_child_not_full_short_key() { - let stored_slot = B256::right_padding_from(&[0xae, 0xd4, 0x09]); - let same_child_target = B256::right_padding_from(&[0xae, 0xd4, 0xff]); - let other_child_target = B256::right_padding_from(&[0xae, 0xd5]); - let harness = ProofTestHarness::new(BTreeMap::from([(stored_slot, U256::from(1))])); - - let mut same_child = - [ProofV2Target::new(same_child_target).with_parent(ProofV2TargetParent::new(3))]; - let (proof, _) = harness.proof_v2(&mut same_child); - assert_eq!(proof.len(), 1, "divergent leaf proves absence below the same child"); - - let mut other_child = - [ProofV2Target::new(other_child_target).with_parent(ProofV2TargetParent::new(3))]; - let (proof, _) = harness.proof_v2(&mut other_child); - assert!(proof.is_empty(), "a different direct child is unrelated to the target"); - } - - #[test] - fn test_known_parent_sibling_span_retains_only_target_children() { - let stored_slot_a = B256::right_padding_from(&[0xea, 0x53]); - let stored_slot_b = B256::right_padding_from(&[0xeb, 0x53]); - let stored_slot_c = B256::right_padding_from(&[0xec, 0x53]); - let target_a = B256::right_padding_from(&[0xea, 0x1f]); - let target_c = B256::right_padding_from(&[0xec, 0x1f]); - let harness = ProofTestHarness::new(BTreeMap::from([ - (stored_slot_a, U256::from(1)), - (stored_slot_b, U256::from(2)), - (stored_slot_c, U256::from(3)), - ])); - let mut targets = [target_a, target_c] - .map(|target| ProofV2Target::new(target).with_parent(ProofV2TargetParent::new(1))); - - let (proof, root) = harness.proof_v2(&mut targets); - - assert!(root.is_none()); - assert_eq!( - proof.iter().map(|node| node.path).collect::>(), - [Nibbles::from_nibbles([0xe, 0xa]), Nibbles::from_nibbles([0xe, 0xc])] - ); - } - - #[test] - fn test_known_parent_does_not_use_stale_parent_mask() { - let stored_slot_a = B256::right_padding_from(&[0xea, 0x53]); - let stored_slot = B256::right_padding_from(&[0xeb, 0x53]); - let stored_slot_c = B256::right_padding_from(&[0xec, 0x53]); - let target = B256::right_padding_from(&[0xeb, 0x1f]); - let stored_slot_nibbles = Nibbles::unpack(stored_slot); - - // The known parent at `e` is supplied by the sparse trie and may be stale in the database - // when partial persistence masks that path. In particular, its state mask can omit the - // live `eb` child while hashed state already contains that child's leaf. - let stale_parent_mask = TrieMask::new((1 << 0xa) | (1 << 0xc)); - let stale_parent = BranchNodeCompact::new( - stale_parent_mask, - TrieMask::new(0), - TrieMask::new(0), - Vec::new(), - None, - ); - let storage_nodes = BTreeMap::from([(Nibbles::from_nibbles([0xe]), stale_parent)]); - - let mut harness = TrieTestHarness::new(BTreeMap::from([ - (stored_slot_a, U256::from(1)), - (stored_slot, U256::from(2)), - (stored_slot_c, U256::from(3)), - ])); - harness.set_trie_nodes(storage_nodes); - - let mut targets = [ProofV2Target::new(target).with_parent(ProofV2TargetParent::new(1))]; - let (proof, root) = harness.proof_v2(&mut targets); - - assert!(root.is_none()); - assert_eq!(proof.len(), 1); - assert_eq!(proof[0].path, stored_slot_nibbles.slice(0..2)); - let TrieNodeV2::Leaf(leaf) = &proof[0].node else { - panic!("live direct child should be reconstructed as a leaf") - }; - assert_eq!(leaf.key, stored_slot_nibbles.slice(2..)); - } - - #[test] - fn test_empty_storage_respects_parent_context() { - let harness = ProofTestHarness::new(BTreeMap::new()); - let slot = B256::ZERO; - - let mut partial_target = - [ProofV2Target::new(slot).with_parent(ProofV2TargetParent::new(0))]; - let (partial_proof, partial_root) = harness.proof_v2(&mut partial_target); - assert!(partial_proof.is_empty()); - assert!(partial_root.is_none()); - - let mut root_target = [ProofV2Target::new(slot)]; - let (root_proof, root) = harness.proof_v2(&mut root_target); - assert_eq!(root_proof.len(), 1); - assert!(matches!(root_proof[0].node, TrieNodeV2::EmptyRoot)); - assert_eq!(root, Some(EMPTY_ROOT_HASH)); - } - - #[test] - fn test_big_trie() { - use rand::prelude::*; - - reth_tracing::init_test_tracing(); - let mut rng = rand::rngs::SmallRng::seed_from_u64(1); - - let mut rand_b256 = || { - let mut buf: [u8; 32] = [0; 32]; - rng.fill_bytes(&mut buf); - B256::from_slice(&buf) - }; - - // Generate random storage dataset. - let mut storage = BTreeMap::new(); - for _ in 0..10240 { - let hashed_slot = rand_b256(); - storage.insert(hashed_slot, U256::from(1u64)); - } - - // Collect targets; partially from real keys, partially random keys which probably won't - // exist. - let mut targets = storage.keys().copied().collect::>(); - for _ in 0..storage.len() / 5 { - targets.push(rand_b256()); - } - targets.sort(); - - // Create test harness - let harness = ProofTestHarness::new(storage); - - harness - .assert_proof(targets.into_iter().map(ProofV2Target::new)) - .expect("Proof generation failed"); - } - - #[test] - fn test_node_with_masked_empty_child() { - reth_tracing::init_test_tracing(); - - let val = U256::from(42u64); - - // All storage keys share a common first nibble (0x6), so the branch is at path 0x6. The - // second nibble differentiates children: 0,1,3,5,7. - let slot_60 = B256::right_padding_from(&[0x60]); - let slot_61 = B256::right_padding_from(&[0x61]); - let slot_65 = B256::right_padding_from(&[0x65]); - let slot_67 = B256::right_padding_from(&[0x67]); - - // Construct a branch node at path 0x6 with state_mask bits 0,1,3,5,7. - // hash_mask has bits 0,1,5,7 (NOT 3) — nibble 3's hash is cleared because it's in the - // prefix set. Hashes are dummy values. - let state_mask = TrieMask::new(0b10101011); // bits 0,1,3,5,7 - let hash_mask = TrieMask::new(0b10100011); // bits 0,1,5,7 (NOT 3) - let hashes = vec![B256::repeat_byte(0xaa); hash_mask.count_ones() as usize]; - let branch = BranchNodeCompact::new(state_mask, TrieMask::new(0), hash_mask, hashes, None); - - let storage_nodes: BTreeMap = - std::iter::once((Nibbles::from_nibbles([0x6]), branch)).collect(); - - // Hashed cursor has slots at children 0, 1, 5, 7 — but NOT child 3 (0x63). - // This simulates the post-state overlay having deleted the slot at 0x63. - let mut harness = TrieTestHarness::new( - [slot_60, slot_61, slot_65, slot_67].iter().map(|s| (*s, val)).collect(), - ); - harness.set_trie_nodes(storage_nodes); - - let storage_trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_storage_cursor = harness - .hashed_cursor_factory() - .hashed_storage_cursor(harness.hashed_address()) - .unwrap(); - let mut calculator = - StorageProofCalculator::new_storage(storage_trie_cursor, hashed_storage_cursor); - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed with masked empty child"); - - let root_hash = calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap(); - assert!(root_hash.is_some(), "should produce a root hash"); - } - - /// Tests that `root_node` handles the case where `uncalculated_lower_bound` has advanced - /// entirely past a cached branch that still has unprocessed children in its `state_mask`. - /// - /// Branch at `0x6` has `state_mask` bits 0,1,5,f where nibble 5 has its `hash_mask` - /// cleared and no leaf data. The last child (nibble f) - /// causes `calculate_key_range` to be called with range `(0x6f, Some(0x7))`. After that range, - /// the hashed cursor still has keys (at `0x70...`), so `proof_subtrie` does not break and - /// re-enters `next_uncached_key_range` with `uncalculated_lower_bound = Some(0x7)`. - /// Since `0x7` is past `0x6`, all remaining children are skipped and the branch is popped. - #[test] - fn test_node_with_masked_empty_child_lower_bound_past_branch() { - reth_tracing::init_test_tracing(); - - let val = U256::from(42u64); - - // Leaf keys under 0x6 and one beyond (0x70) to keep the cursor alive after 0x6. - let slot_60 = B256::right_padding_from(&[0x60]); - let slot_61 = B256::right_padding_from(&[0x61]); - let slot_6f = B256::right_padding_from(&[0x6f]); - let slot_70 = B256::right_padding_from(&[0x70]); - - // Branch at 0x6: state_mask bits 0,1,5,f; hash_mask bits 0,1 (NOT 5, NOT f). - // Nibble 5 has state_mask set but no hash and no leaf data (masked empty child). - // Nibble f has state_mask set, no hash, but DOES have leaf data. - let state_mask = TrieMask::new(0b1000_0000_0010_0011); // bits 0,1,5,f - let hash_mask = TrieMask::new(0b0000_0000_0000_0011); // bits 0,1 - let hashes = vec![B256::repeat_byte(0xaa); hash_mask.count_ones() as usize]; - let branch = BranchNodeCompact::new(state_mask, TrieMask::new(0), hash_mask, hashes, None); - - let storage_nodes: BTreeMap = - std::iter::once((Nibbles::from_nibbles([0x6]), branch)).collect(); - - // Hashed cursor: slots at 0x60, 0x61, 0x6f, 0x70 — but NOT 0x65. - let mut harness = TrieTestHarness::new( - [slot_60, slot_61, slot_6f, slot_70].iter().map(|s| (*s, val)).collect(), - ); - harness.set_trie_nodes(storage_nodes); - - let storage_trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_storage_cursor = harness - .hashed_cursor_factory() - .hashed_storage_cursor(harness.hashed_address()) - .unwrap(); - let mut calculator = - StorageProofCalculator::new_storage(storage_trie_cursor, hashed_storage_cursor); - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed when lower bound advances past branch"); - - let root_hash = calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap(); - assert!(root_hash.is_some(), "should produce a root hash"); - } - - /// Tests that the prefix set causes `next_uncached_key_range` to add child nibbles that are - /// not present in the cached branch's `state_mask`. - /// - /// Setup: An original state with leaves at `0x60` and `0x61` produces a cached branch at - /// `0x6` with children at nibbles 0 and 1 (both with real cached hashes from `StorageRoot`). - /// A new leaf is then inserted at `0x63...`, which is NOT in the branch's `state_mask`. - /// The prefix set contains the new key. Without prefix set support, the calculator would - /// skip nibble 3 entirely and produce a stale root hash. With prefix set support, nibble 3 - /// is discovered and its subtrie is recalculated from leaves. - #[test] - fn test_prefix_set_adds_child_nibbles() { - reth_tracing::init_test_tracing(); - - let val = U256::from(42u64); - let slot_60 = B256::right_padding_from(&[0x60]); - let slot_61 = B256::right_padding_from(&[0x61]); - let slot_63 = B256::right_padding_from(&[0x63]); - - let harness = TrieTestHarness::new([(slot_60, val), (slot_61, val)].into_iter().collect()); - - let changeset: BTreeMap = std::iter::once((slot_63, val)).collect(); - let (expected_root, _) = harness.get_root_with_updates(&changeset); - - let mut updated_storage = harness.storage().clone(); - updated_storage.insert(slot_63, val); - - let updated_hashed = MockHashedCursorFactory::new( - BTreeMap::new(), - std::iter::once((harness.hashed_address(), updated_storage)).collect(), - ); - - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(slot_63)); - - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed with prefix set adding child nibbles"); - let got_root = - calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap().unwrap(); - - pretty_assertions::assert_eq!( - expected_root, - got_root, - "Root hash with prefix set should match fresh computation" - ); - } - - /// Tests that `next_uncached_key_range` does not use a cached hash when the child's path - /// is in the prefix set, forcing recalculation from leaves. - /// - /// Setup: A cached branch at `0x6` with children at nibbles 0,1,5 — all with cached hashes. - /// The leaf at `0x65...` is changed (different value). The prefix set marks `0x65...` as - /// dirty. Without prefix set support, the calculator would use the stale cached hash for - /// nibble 5 and produce a wrong root. With prefix set support, the cached hash is skipped - /// and the subtrie is recalculated from the updated leaf data. - #[test] - fn test_prefix_set_invalidates_cached_hash() { - reth_tracing::init_test_tracing(); - - let original_val = U256::from(42u64); - let updated_val = U256::from(9999u64); - let slot_60 = B256::right_padding_from(&[0x60]); - let slot_61 = B256::right_padding_from(&[0x61]); - let slot_65 = B256::right_padding_from(&[0x65]); - - let harness = TrieTestHarness::new( - [(slot_60, original_val), (slot_61, original_val), (slot_65, original_val)] - .into_iter() - .collect(), - ); - - let changeset: BTreeMap = std::iter::once((slot_65, updated_val)).collect(); - let (expected_root, _) = harness.get_root_with_updates(&changeset); - - let mut updated_storage = harness.storage().clone(); - updated_storage.insert(slot_65, updated_val); - - let updated_hashed = MockHashedCursorFactory::new( - BTreeMap::new(), - std::iter::once((harness.hashed_address(), updated_storage)).collect(), - ); - - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(slot_65)); - - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed with prefix set invalidating cached hash"); - let got_root = - calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap().unwrap(); - - pretty_assertions::assert_eq!( - expected_root, - got_root, - "Root hash with prefix set invalidation should match fresh computation" - ); - } - - fn b256(s: &str) -> B256 { - B256::from_slice(&alloy_primitives::hex::decode(s).expect("valid hex string")) - } - - #[test] - fn test_prefix_set_root_proof_processes_sibling_after_cached_descendant() { - reth_tracing::init_test_tracing(); - - let storage = [ - ("1022c69e9d900e40775cd387c134899f465f291dbc3c97899ff6bfb8dc972b37", 45u64), - ("1111ad8083c8a3a398b2b781217b989ff4d1ed182f46cc765eda49a7b316139d", 60), - ("12012d20943649899b2fc0f87b9840b70ef68e93613aac17c269bf8c5a78a712", 17), - ("12014b57b9a162c03d072eb6acd4e936f1c4bc23b803a054347c5ee9a9bcfb9a", 49), - ("1203f800840af3f898ab4572f2750106a7c4bd2b3e844b6e7fa72704673cc2c6", 76), - ("12208f18fbcd6971c92808721392acbf11d5af58e9143a374cc86e70bdd1f097", 10), - ] - .into_iter() - .map(|(key, value)| (b256(key), U256::from(value))) - .collect(); - - let dirty = b256("12208f18fbcd6971c92808721392acbf11d5af58e9143a374cc86e70bdd1f097"); - let harness = ProofTestHarness::new(storage); - let expected_root = harness.original_root(); - - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(dirty)); - - pretty_assertions::assert_eq!( - Some(expected_root), - harness.root_with_prefix_set(prefix_set.freeze()), - "root proof must process a prefix-set sibling after a cached descendant", - ); - } - - #[test] - fn test_prefix_set_root_proof_processes_trailing_dirty_sibling() { - reth_tracing::init_test_tracing(); - - let keys = [ - "0022001020000000000000000000000000000000000000000000000000000000", - "0110212112000000000000000000000000000000000000000000000000000000", - "0202210210000000000000000000000000000000000000000000000000000000", - "0211020211000000000000000000000000000000000000000000000000000000", - "0211211002000000000000000000000000000000000000000000000000000000", - "0212221010000000000000000000000000000000000000000000000000000000", - "0222011102000000000000000000000000000000000000000000000000000000", - ]; - let storage = - keys.iter().enumerate().map(|(i, key)| (b256(key), U256::from(i as u64 + 1))).collect(); - let harness = ProofTestHarness::new(storage); - let expected_root = harness.original_root(); - - // The dirty children straddle a clean cached descendant under branch 0x02. Traversal - // must resume at the trailing dirty sibling after using the cached descendant. - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(b256(keys[2]))); - prefix_set.insert(Nibbles::unpack(b256(keys[6]))); - - pretty_assertions::assert_eq!( - Some(expected_root), - harness.root_with_prefix_set(prefix_set.freeze()), - ); - } - - /// Helper to compute the keccak256 hash of a storage leaf node. The `short_key` is the - /// leaf's key after trimming all branch/extension nibbles consumed by ancestor nodes. - fn storage_leaf_hash(short_key: &Nibbles, value: &U256) -> B256 { - let mut buf = Vec::new(); - alloy_trie::nodes::LeafNodeRef::new(short_key, &alloy_rlp::encode_fixed_size(value)) - .encode(&mut buf); - keccak256(&buf) - } - - /// Checks collapsing a cached branch after removing one of its two direct branch children. - fn assert_branch_collapse(remaining_nibble: u8, removed_nibble: u8) { - reth_tracing::init_test_tracing(); - - let val = U256::from(1u64); - let child_keys = |nibble| { - [ - B256::right_padding_from(&[0x20 | nibble, 0x00]), - B256::right_padding_from(&[0x20 | nibble, 0x10]), - ] - }; - let [remaining_a, remaining_b] = child_keys(remaining_nibble); - let [removed_a, removed_b] = child_keys(removed_nibble); - - // Build the cached trie through the production HashBuilder path. Each child at - // `remaining_nibble` and `removed_nibble` is a direct branch with two leaf children. - let initial_storage = BTreeMap::from([ - (remaining_a, val), - (remaining_b, val), - (removed_a, val), - (removed_b, val), - ]); - let harness = TrieTestHarness::new(initial_storage); - - let cached_branch = &harness - .storage_trie_updates() - .storage_nodes - .get(&Nibbles::from_nibbles([0x2])) - .expect("branch at 0x2"); - let child_mask = - TrieMask::from_nibble(remaining_nibble) | TrieMask::from_nibble(removed_nibble); - assert_eq!(cached_branch.state_mask, child_mask); - assert_eq!(cached_branch.hash_mask, child_mask); - assert!(cached_branch.tree_mask.is_empty()); - - let final_storage = BTreeMap::from([(remaining_a, val), (remaining_b, val)]); - let expected_root = TrieTestHarness::new(final_storage.clone()).original_root(); - let updated_hashed = MockHashedCursorFactory::new( - BTreeMap::new(), - std::iter::once((harness.hashed_address(), final_storage)).collect(), - ); - - // The prefix set marks the entire removed child subtree dirty. - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(removed_a)); - prefix_set.insert(Nibbles::unpack(removed_b)); - - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed after branch collapse"); - let root = - calculator.compute_root_hash(core::slice::from_ref(&root_node)).unwrap().unwrap(); - - pretty_assertions::assert_eq!(expected_root, root); - } - - #[test] - fn test_branch_collapse_removed_child_before_remaining() { - assert_branch_collapse(1, 0); - } - - #[test] - fn test_branch_collapse_removed_child_after_remaining() { - assert_branch_collapse(4, 9); - } - - #[test] - fn test_prefix_set_root_proof_preserves_clean_sibling_after_cached_branch_collapse() { - reth_tracing::init_test_tracing(); - - let dirty = B256::right_padding_from(&[0x10, 0x00, 0x10]); - let clean_sibling = B256::right_padding_from(&[0x10, 0x10]); - let storage = [ - B256::right_padding_from(&[0x10]), - dirty, - B256::right_padding_from(&[0x10, 0x01]), - B256::right_padding_from(&[0x10, 0x02]), - clean_sibling, - B256::right_padding_from(&[0x11]), - B256::right_padding_from(&[0x12]), - ] - .into_iter() - .map(|key| (key, U256::from(1u64))) - .collect(); - - let harness = ProofTestHarness::new(storage); - let expected_root = harness.original_root(); - - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(dirty)); - - let mut prefix_set_with_sibling = PrefixSetMut::default(); - prefix_set_with_sibling.insert(Nibbles::unpack(dirty)); - prefix_set_with_sibling.insert(Nibbles::unpack(clean_sibling)); - - pretty_assertions::assert_eq!( - Some(expected_root), - harness.root_with_prefix_set(prefix_set_with_sibling.freeze()), - ); - pretty_assertions::assert_eq!( - Some(expected_root), - harness.root_with_prefix_set(prefix_set.freeze()), - "a dirty prefix must not omit a clean sibling after collapsing a cached branch", - ); - } - - #[test] - fn test_prefix_set_range_skips_covered_cached_branch() { - reth_tracing::init_test_tracing(); - - let before = B256::right_padding_from(&[0x30]); - let cached_a = B256::right_padding_from(&[0x80, 0x10]); - let cached_b = B256::right_padding_from(&[0x80, 0x15]); - let dirty = B256::right_padding_from(&[0x80, 0xc0]); - let after = B256::right_padding_from(&[0x90]); - - // The cached branch at 0x80 remains parked while its children are rebuilt. Processing the - // dirty child advances the lower bound to 0x9, past the cached branch's entire range. - let storage = [before, cached_a, cached_b, dirty, after] - .into_iter() - .enumerate() - .map(|(i, key)| (key, U256::from(i + 1))) - .collect(); - - let harness = ProofTestHarness::new(storage); - let expected_root = harness.original_root(); - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(dirty)); - - pretty_assertions::assert_eq!( - Some(expected_root), - harness.root_with_prefix_set(prefix_set.freeze()), - ); - } - - #[test] - fn test_cached_branch_extension_skips_diverging_target() { - reth_tracing::init_test_tracing(); - - let val = U256::from(100u64); - - // Keys whose first bytes directly set the nibble paths we need. - let key_a0 = B256::right_padding_from(&[0x6a, 0x30]); // nibbles: 6,a,3,0,... - let key_a1 = B256::right_padding_from(&[0x6a, 0x31]); // nibbles: 6,a,3,1,... - let key_c = B256::right_padding_from(&[0x6a, 0x80]); // nibbles: 6,a,8,0,... - let key_d = B256::right_padding_from(&[0x6b, 0x00]); // nibbles: 6,b,0,0,... - let key_e = B256::right_padding_from(&[0x6c, 0x00]); // nibbles: 6,c,0,0,... - - // Build a correct trie from all five leaves to get the expected root and real hashes. - let all_storage: BTreeMap = - [(key_a0, val), (key_a1, val), (key_c, val), (key_d, val), (key_e, val)] - .into_iter() - .collect(); - let correct_harness = TrieTestHarness::new(all_storage.clone()); - let expected_root = correct_harness.original_root(); - - // Compute leaf hashes for constructing manual cached branch nodes. - let leaf_hash_a0 = storage_leaf_hash(&Nibbles::unpack(key_a0).slice(4..), &val); - let leaf_hash_a1 = storage_leaf_hash(&Nibbles::unpack(key_a1).slice(4..), &val); - let leaf_hash_d = storage_leaf_hash(&Nibbles::unpack(key_d).slice(2..), &val); - let leaf_hash_e = storage_leaf_hash(&Nibbles::unpack(key_e).slice(2..), &val); - - // ── Construct cached branch at [6] ───────────────────────────────────── - // state_mask: bits a, b, and c set. - // hash_mask: bits b and c — both have cached leaf hashes. Bit a has no hash, so the - // calculator will seek the trie cursor to find a deeper cached branch. - // - // Children b and c remain clean and can still use their cached hashes. - let branch_6_state_mask = TrieMask::new((1 << 0xa) | (1 << 0xb) | (1 << 0xc)); - let branch_6_hash_mask = TrieMask::new((1 << 0xb) | (1 << 0xc)); - let branch_6 = BranchNodeCompact::new( - branch_6_state_mask, - TrieMask::new(0), - branch_6_hash_mask, - vec![leaf_hash_d, leaf_hash_e], - None, - ); - - // ── Construct cached branch at [6,a,3] ──────────────────────────────── - // state_mask: bits 0 and 1 set (children key_a0 and key_a1). - // hash_mask: both bits set — both children have cached hashes. - let branch_6a3_state_mask = TrieMask::new((1 << 0) | (1 << 1)); - let branch_6a3 = BranchNodeCompact::new( - branch_6a3_state_mask, - TrieMask::new(0), - branch_6a3_state_mask, - vec![leaf_hash_a0, leaf_hash_a1], - None, - ); - - // Intentionally omit the branch at [6,a] — this is the inconsistency. - let inconsistent_nodes: BTreeMap = [ - (Nibbles::from_nibbles([0x6]), branch_6), - (Nibbles::from_nibbles([0x6, 0xa, 0x3]), branch_6a3), - ] - .into_iter() - .collect(); - - // Create harness with all five leaves but the inconsistent trie nodes. - let mut harness = TrieTestHarness::new(all_storage); - harness.set_trie_nodes(inconsistent_nodes); - - // Mark key_c as dirty — in the real scenario the leaf was touched by execution. The - // nested branch [6,a,3] remains clean because key_c diverges at [6,a,8]. - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(key_c)); - - // ── Verify root hash ─────────────────────────────────────────────────── - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = harness - .hashed_cursor_factory() - .hashed_storage_cursor(harness.hashed_address()) - .unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed"); - let got_root = calculator - .compute_root_hash(core::slice::from_ref(&root_node)) - .unwrap() - .expect("should produce a root hash"); - - // With the bug, the calculator skips key_c and produces a wrong root. - pretty_assertions::assert_eq!( - expected_root, - got_root, - "Root hash should match correct trie; cached extension must not skip diverging leaves" - ); - - // ── Verify proof for key_c contains nodes on its path ────────────────── - let mut targets = vec![ProofV2Target::new(key_c)]; - let proofs = calculator - .storage_proof(harness.hashed_address(), &mut targets) - .expect("storage_proof should succeed"); - - let key_c_nibbles = Nibbles::unpack(key_c); - let has_matching_node = proofs.iter().any(|node| key_c_nibbles.starts_with(&node.path)); - assert!( - has_matching_node, - "Proof for key_c should contain at least one node on key_c's path, got: {proofs:?}" - ); - } - - #[test] - fn test_cached_branch_extension_skips_diverging_target_before() { - reth_tracing::init_test_tracing(); - - let val = U256::from(100u64); - - // Keys whose first bytes directly set the nibble paths we need. - let key_a0 = B256::right_padding_from(&[0x6a, 0x80]); // nibbles: 6,a,8,0,... - let key_a1 = B256::right_padding_from(&[0x6a, 0x81]); // nibbles: 6,a,8,1,... - let key_c = B256::right_padding_from(&[0x6a, 0x30]); // nibbles: 6,a,3,0,... (BEFORE - // [6,a,8]) - let key_d = B256::right_padding_from(&[0x6b, 0x00]); // nibbles: 6,b,0,0,... - let key_e = B256::right_padding_from(&[0x6c, 0x00]); // nibbles: 6,c,0,0,... - - // Build a correct trie from all five leaves to get the expected root and real hashes. - let all_storage: BTreeMap = - [(key_a0, val), (key_a1, val), (key_c, val), (key_d, val), (key_e, val)] - .into_iter() - .collect(); - let correct_harness = TrieTestHarness::new(all_storage.clone()); - let expected_root = correct_harness.original_root(); - - // Compute leaf hashes for constructing manual cached branch nodes. - let leaf_hash_a0 = storage_leaf_hash(&Nibbles::unpack(key_a0).slice(4..), &val); - let leaf_hash_a1 = storage_leaf_hash(&Nibbles::unpack(key_a1).slice(4..), &val); - let leaf_hash_d = storage_leaf_hash(&Nibbles::unpack(key_d).slice(2..), &val); - let leaf_hash_e = storage_leaf_hash(&Nibbles::unpack(key_e).slice(2..), &val); - - // ── Construct cached branch at [6] ───────────────────────────────────── - // state_mask: bits a, b, and c set. - // hash_mask: bits b and c — both have cached leaf hashes. Bit a has no hash, so the - // calculator will seek the trie cursor to find a deeper cached branch. - // - // Children b and c remain clean and can still use their cached hashes. - let branch_6_state_mask = TrieMask::new((1 << 0xa) | (1 << 0xb) | (1 << 0xc)); - let branch_6_hash_mask = TrieMask::new((1 << 0xb) | (1 << 0xc)); - let branch_6 = BranchNodeCompact::new( - branch_6_state_mask, - TrieMask::new(0), - branch_6_hash_mask, - vec![leaf_hash_d, leaf_hash_e], - None, - ); - - // ── Construct cached branch at [6,a,8] ──────────────────────────────── - // state_mask: bits 0 and 1 set (children key_a0 and key_a1). - // hash_mask: both bits set — both children have cached hashes. - let branch_6a8_state_mask = TrieMask::new((1 << 0) | (1 << 1)); - let branch_6a8 = BranchNodeCompact::new( - branch_6a8_state_mask, - TrieMask::new(0), - branch_6a8_state_mask, - vec![leaf_hash_a0, leaf_hash_a1], - None, - ); - - // Intentionally omit the branch at [6,a] — this is the inconsistency. - let inconsistent_nodes: BTreeMap = [ - (Nibbles::from_nibbles([0x6]), branch_6), - (Nibbles::from_nibbles([0x6, 0xa, 0x8]), branch_6a8), - ] - .into_iter() - .collect(); - - // Create harness with all five leaves but the inconsistent trie nodes. - let mut harness = TrieTestHarness::new(all_storage); - harness.set_trie_nodes(inconsistent_nodes); - - // Mark key_c as dirty — it comes BEFORE the cached branch [6,a,8] in nibble order. - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(key_c)); - - // ── Verify root hash ─────────────────────────────────────────────────── - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = harness - .hashed_cursor_factory() - .hashed_storage_cursor(harness.hashed_address()) - .unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed"); - let got_root = calculator - .compute_root_hash(core::slice::from_ref(&root_node)) - .unwrap() - .expect("should produce a root hash"); - - // With the bug, the calculator skips key_c and produces a wrong root. - pretty_assertions::assert_eq!( - expected_root, - got_root, - "Root hash should match correct trie; cached extension must not skip diverging leaves before cached branch" - ); - - // ── Verify proof for key_c contains nodes on its path ────────────────── - let mut targets = vec![ProofV2Target::new(key_c)]; - let proofs = calculator - .storage_proof(harness.hashed_address(), &mut targets) - .expect("storage_proof should succeed"); - - let key_c_nibbles = Nibbles::unpack(key_c); - let has_matching_node = proofs.iter().any(|node| key_c_nibbles.starts_with(&node.path)); - assert!( - has_matching_node, - "Proof for key_c should contain at least one node on key_c's path, got: {proofs:?}" - ); - } - - #[test] - fn test_skipped_parent_branch_with_unskipped_child() { - reth_tracing::init_test_tracing(); - - let val = U256::from(1u64); - let updated_val = U256::from(2u64); - - // We need cached branches at [2], [2,f], and [3] in the trie DB. - let key_2 = B256::right_padding_from(&[0x20]); - let key_2f00 = B256::right_padding_from(&[0x2f, 0x00]); - let key_2f01 = B256::right_padding_from(&[0x2f, 0x01]); - let key_2f10 = B256::right_padding_from(&[0x2f, 0x10]); - let key_2f11 = B256::right_padding_from(&[0x2f, 0x11]); - let key_300 = B256::right_padding_from(&[0x30, 0x00]); - let key_301 = B256::right_padding_from(&[0x30, 0x10]); - let key_310 = B256::right_padding_from(&[0x31, 0x00]); - let key_311 = B256::right_padding_from(&[0x31, 0x10]); - let key_500 = B256::right_padding_from(&[0x50, 0x00]); - let key_501 = B256::right_padding_from(&[0x50, 0x10]); - let key_510 = B256::right_padding_from(&[0x51, 0x00]); - let key_511 = B256::right_padding_from(&[0x51, 0x10]); - - let all_keys = [ - key_2, key_2f00, key_2f01, key_2f10, key_2f11, key_300, key_301, key_310, key_311, - key_500, key_501, key_510, key_511, - ]; - - let original_storage: BTreeMap = all_keys.iter().map(|k| (*k, val)).collect(); - let harness = TrieTestHarness::new(original_storage); - - // Verify that the expected branches exist in the trie. - let trie_updates = harness.storage_trie_updates(); - assert!(trie_updates.storage_nodes.contains_key(&Nibbles::from_nibbles([0x2]))); - assert!(trie_updates.storage_nodes.contains_key(&Nibbles::from_nibbles([0x2, 0xf]))); - assert!(trie_updates.storage_nodes.contains_key(&Nibbles::from_nibbles([0x3]))); - - // Change only key_2 — triggers skip of parent branch [2] while child [2,f] is not - // skipped. - let changeset: BTreeMap = std::iter::once((key_2, updated_val)).collect(); - let (expected_root, _) = harness.get_root_with_updates(&changeset); - - let mut updated_storage = harness.storage().clone(); - updated_storage.insert(key_2, updated_val); - - let updated_hashed = MockHashedCursorFactory::new( - BTreeMap::new(), - std::iter::once((harness.hashed_address(), updated_storage)).collect(), - ); - - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(key_2)); - - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = updated_hashed.hashed_storage_cursor(harness.hashed_address()).unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - let root_node = calculator - .storage_root_node(harness.hashed_address()) - .expect("storage_root_node should succeed"); - - let got_root = calculator - .compute_root_hash(&[root_node]) - .expect("root hash should succeed") - .expect("root should get hashed"); - pretty_assertions::assert_eq!(expected_root, got_root); - } - - #[test] - fn test_blinded_local_root_returns_trie_inconsistency() { - let key = B256::right_padding_from(&[0x63, 0xaa]); - let value = U256::from(1); - let hash = storage_leaf_hash(&Nibbles::unpack(key).slice(2..), &value); - let mask = TrieMask::from_nibble(3); - let cached_branch = - BranchNodeCompact::new(mask, TrieMask::default(), mask, vec![hash], None); - - let mut harness = TrieTestHarness::new(BTreeMap::from([(key, value)])); - harness.set_trie_nodes(BTreeMap::from([(Nibbles::from_nibbles([6]), cached_branch)])); - - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = harness - .hashed_cursor_factory() - .hashed_storage_cursor(harness.hashed_address()) - .unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor); - - assert!(matches!( - calculator.storage_root_node(harness.hashed_address()), - Err(StateProofError::TrieInconsistency(_)) - )); - } - - #[test] - fn test_cached_hash_with_deleted_leaf() { - reth_tracing::init_test_tracing(); - - // Use different values to ensure distinct leaf hashes. - let val_3 = U256::from(111u64); - let val_5 = U256::from(222u64); - let val_8 = U256::from(333u64); - - // Keys under a common prefix `0x6_` to create a branch at path [6]. - // Use second byte to distinguish short keys (so they differ after position 2). - let key_63 = B256::right_padding_from(&[0x63, 0xaa]); // nibble path: 6,3,a,a,... - let key_65 = B256::right_padding_from(&[0x65, 0xbb]); // nibble path: 6,5,b,b,... - let key_68 = B256::right_padding_from(&[0x68, 0xcc]); // nibble path: 6,8,c,c,... - - // Compute leaf hashes. The branch at [6] consumes 2 nibbles (the branch path [6] - // plus the child nibble), so each leaf's short key starts at position 2. - let leaf_hash_3 = storage_leaf_hash(&Nibbles::unpack(key_63).slice(2..), &val_3); - let leaf_hash_5 = storage_leaf_hash(&Nibbles::unpack(key_65).slice(2..), &val_5); - let leaf_hash_8 = storage_leaf_hash(&Nibbles::unpack(key_68).slice(2..), &val_8); - - // Build cached branch at [6] with state_mask and hash_mask bits for nibbles 3, 5, 8. - let state_mask = TrieMask::new((1 << 3) | (1 << 5) | (1 << 8)); - let cached_branch = BranchNodeCompact::new( - state_mask, - TrieMask::new(0), - state_mask, // hash_mask = state_mask (all children have cached hashes) - vec![leaf_hash_3, leaf_hash_5, leaf_hash_8], - None, - ); - - let storage_nodes: BTreeMap = - std::iter::once((Nibbles::from_nibbles([0x6]), cached_branch)).collect(); - - // Compute the expected root from a fresh trie with just key_65 and key_68. - let mut harness = - TrieTestHarness::new([(key_65, val_5), (key_68, val_8)].into_iter().collect()); - let expected_root = harness.original_root(); - - // Update the harness with a cached trie node which will reference key_63 by hash. - harness.set_trie_nodes(storage_nodes); - - // Mark key_63 as dirty in the prefix set — in the real scenario the leaf was - // deleted and the HashedPostState overlay masks it out. - let mut prefix_set = PrefixSetMut::default(); - prefix_set.insert(Nibbles::unpack(key_63)); - - // Request a proof for key_63 (absence proof — no leaf exists). - // Because the prefix set marks nibble 3's child path as dirty, the cached hash for - // nibble 3 is skipped. - let mut targets = vec![ProofV2Target::new(key_63)]; - - let trie_cursor = - harness.trie_cursor_factory().storage_trie_cursor(harness.hashed_address()).unwrap(); - let hashed_cursor = harness - .hashed_cursor_factory() - .hashed_storage_cursor(harness.hashed_address()) - .unwrap(); - let mut calculator = StorageProofCalculator::new_storage(trie_cursor, hashed_cursor) - .with_prefix_set(prefix_set.freeze()); - - let proofs = calculator - .storage_proof(harness.hashed_address(), &mut targets) - .expect("storage_proof should succeed"); - assert_eq!(1, proofs.len()); - let got_root = calculator - .compute_root_hash(&proofs) - .expect("compute_root_hash should succeed") - .expect("should produce a root hash (proof contains root node)"); - - // With the bug, nibble 5 gets hashes[0] (nibble 3's hash) and nibble 8 gets - // hashes[1] (nibble 5's hash), producing a wrong root. - pretty_assertions::assert_eq!( - expected_root, - got_root, - "Root hash should match trie without key_63; cached hash index is off when \ - an earlier hashed child has no leaves (absence proof target)" - ); - } -} diff --git a/crates/trie/trie/src/proof_v2/multiproof_calculator.rs b/crates/trie/trie/src/proof_v2/multiproof_calculator.rs new file mode 100644 index 00000000000..57d252ca45c --- /dev/null +++ b/crates/trie/trie/src/proof_v2/multiproof_calculator.rs @@ -0,0 +1,280 @@ +//! Reusable multiproof generation over a fixed state snapshot. + +use super::{DeferredValueEncoder, LeafValueEncoder, ProofCalculator, StorageProofCalculator}; +use crate::{ + hashed_cursor::{HashedCursorFactory, HashedStorageCursor}, + prefix_set::{PrefixSet, TriePrefixSets}, + trie_cursor::{TrieCursorFactory, TrieStorageCursor}, + DecodedMultiProofV2, MultiProofTargetsV2, +}; +use alloy_primitives::{map::B256Map, B256, U256}; +use alloy_rlp::Encodable; +use parking_lot::Mutex; +use reth_execution_errors::StateProofError; +use reth_primitives_traits::Account; +use std::{fmt, marker::PhantomPinned, pin::Pin, sync::Arc}; + +/// Generates account and storage multiproofs for one immutable state snapshot. +/// +/// The calculator owns its cursor factories and may be reused for any number of requests. It stays +/// usable after an error, but must be dropped after a panic during a request. The factories must +/// keep describing the same state for the lifetime of the calculator. Cursor factories control +/// whether their cursors are instrumented. +/// +/// `'a` is the lifetime of the cursors opened from the owned factories. It is not tied to a +/// caller-supplied borrow; it only needs to be satisfied by `T` and `H`. +pub struct MultiProofCalculator<'a, T: TrieCursorFactory + 'a, H: HashedCursorFactory + 'a> { + account_calculator: Option>, + storage_calculator: Option>>>, + prefix_sets: TriePrefixSets, + known_storage_roots: Arc>, + // Cursors borrow these pinned factories. `Drop` releases them first. + factories: Pin>>, +} + +impl<'a, T: TrieCursorFactory + 'a, H: HashedCursorFactory + 'a> MultiProofCalculator<'a, T, H> { + /// Creates a calculator for the supplied factories and frozen changed-key prefixes. + /// + /// Prefixes invalidate cached branch hashes for keys whose leaf values have changed, and apply + /// to every request served by this calculator. + /// + /// `known_storage_roots` supplies roots keyed by hashed account address. Each root must be + /// valid for the account leaf being encoded. Accounts absent from the map calculate their + /// storage roots from the cursors. Explicit storage proof requests always use the cursors. + pub fn new( + trie_cursor_factory: T, + hashed_cursor_factory: H, + prefix_sets: TriePrefixSets, + known_storage_roots: Arc>, + ) -> Self { + Self { + account_calculator: None, + storage_calculator: None, + prefix_sets, + known_storage_roots, + factories: Box::pin(MultiProofCalculatorFactories { + trie_cursor_factory, + hashed_cursor_factory, + _pin: PhantomPinned, + }), + } + } + + /// Generates account and storage proofs for `targets`. + /// + /// An explicit storage address with no slot targets requests its root node. Empty targets + /// return an empty proof. + pub fn multiproof( + &mut self, + targets: MultiProofTargetsV2, + ) -> Result { + if targets.is_empty() { + return Ok(DecodedMultiProofV2::default()) + } + let MultiProofTargetsV2 { mut account_targets, storage_targets } = targets; + let mut proof = DecodedMultiProofV2::default(); + + // Open the storage calculator, which account proofs also use for storage roots. + let storage_calculator = match &mut self.storage_calculator { + Some(storage_calculator) => storage_calculator, + slot @ None => { + // SAFETY: Cursors are private to this calculator and released before factories. + let factories = unsafe { Self::borrow_factories(self.factories.as_ref()) }; + // Storage cursors are retargeted to each requested account before use. + let trie_cursor = factories.trie_cursor_factory.storage_trie_cursor(B256::ZERO)?; + let hashed_cursor = + factories.hashed_cursor_factory.hashed_storage_cursor(B256::ZERO)?; + // Prefixes are taken only after the fallible cursor opens, so a failed + // initialization can be retried. + let prefix_sets = std::mem::take(&mut self.prefix_sets.storage_prefix_sets); + slot.insert(Arc::new(Mutex::new(PrefixSetStorageCalculator { + calculator: StorageProofCalculator::new_storage(trie_cursor, hashed_cursor), + prefix_sets, + hashed_address: None, + }))) + } + }; + + // Calculate account proofs, reusing known storage roots or computing missing roots inline. + if !account_targets.is_empty() { + let account_calculator = match &mut self.account_calculator { + Some(account_calculator) => account_calculator, + slot @ None => { + // SAFETY: As above, cursors are released before factories. + let factories = unsafe { Self::borrow_factories(self.factories.as_ref()) }; + let trie_cursor = factories.trie_cursor_factory.account_trie_cursor()?; + let hashed_cursor = factories.hashed_cursor_factory.hashed_account_cursor()?; + let prefix_set = std::mem::take(&mut self.prefix_sets.account_prefix_set); + slot.insert( + ProofCalculator::new(trie_cursor, hashed_cursor) + .with_prefix_set(prefix_set), + ) + } + }; + let mut encoder = AccountValueEncoder { + storage_calculator: storage_calculator.clone(), + known_storage_roots: self.known_storage_roots.clone(), + }; + proof.account_proofs = account_calculator.proof(&mut encoder, &mut account_targets)?; + } + + // Calculate storage proofs, or root nodes for accounts without slot targets. + if !storage_targets.is_empty() { + let mut storage_calculator = storage_calculator.lock(); + for (hashed_address, mut targets) in storage_targets { + let calculator = storage_calculator.calculator_for(hashed_address); + let nodes = if targets.is_empty() { + vec![calculator.storage_root_node(hashed_address)?] + } else { + calculator.storage_proof(hashed_address, &mut targets)? + }; + proof.storage_proofs.insert(hashed_address, nodes); + } + } + Ok(proof) + } + + /// Extends the borrow of the pinned factories to the cursor lifetime `'a`. + /// + /// # Safety + /// + /// Every reference, cursor and encoder derived from the result must be dropped before the + /// factories, and none may escape this calculator. + const unsafe fn borrow_factories( + factories: Pin<&MultiProofCalculatorFactories>, + ) -> &'a MultiProofCalculatorFactories { + // SAFETY: The factories are pinned and immutable, and the caller keeps every borrower + // inside this calculator, which releases them before the factories. + unsafe { &*(factories.get_ref() as *const MultiProofCalculatorFactories) } + } +} + +impl<'a, T: TrieCursorFactory + 'a, H: HashedCursorFactory + 'a> Drop + for MultiProofCalculator<'a, T, H> +{ + fn drop(&mut self) { + // Cursors borrow `factories`, so release them before the fields drop. + self.account_calculator = None; + self.storage_calculator = None; + } +} + +impl fmt::Debug for MultiProofCalculator<'_, T, H> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MultiProofCalculator") + .field("account_initialized", &self.account_calculator.is_some()) + .field("storage_initialized", &self.storage_calculator.is_some()) + .finish_non_exhaustive() + } +} + +/// Account calculator opened from the cursor factories of a [`MultiProofCalculator`]. +type AccountCalculatorOf<'a, T, H> = ProofCalculator< + ::AccountTrieCursor<'a>, + ::AccountCursor<'a>, + AccountValueEncoder< + ::StorageTrieCursor<'a>, + ::StorageCursor<'a>, + >, +>; + +/// Storage calculator opened from the cursor factories of a [`MultiProofCalculator`]. +type StorageCalculatorOf<'a, T, H> = PrefixSetStorageCalculator< + ::StorageTrieCursor<'a>, + ::StorageCursor<'a>, +>; + +/// Cursor factories owned by a [`MultiProofCalculator`] at a stable address. +struct MultiProofCalculatorFactories { + trie_cursor_factory: T, + hashed_cursor_factory: H, + _pin: PhantomPinned, +} + +/// Storage proof calculator that applies each account's changed-slot prefixes. +/// +/// All storage calculations go through this type so the prefixes always match the account being +/// calculated. +struct PrefixSetStorageCalculator { + calculator: StorageProofCalculator, + /// Changed-slot prefixes keyed by hashed address. + prefix_sets: B256Map, + /// Account whose prefixes are currently applied to `calculator`. + hashed_address: Option, +} + +impl> + PrefixSetStorageCalculator +{ + fn calculator_for(&mut self, hashed_address: B256) -> &mut StorageProofCalculator { + if self.hashed_address != Some(hashed_address) { + self.calculator + .set_prefix_set(self.prefix_sets.get(&hashed_address).cloned().unwrap_or_default()); + self.hashed_address = Some(hashed_address); + } + &mut self.calculator + } +} + +/// Account value encoder that reuses known storage roots or computes them on demand. +struct AccountValueEncoder { + // Arc> keeps the calculator Send whenever its cursors are. The mutex never poisons, + // which is why the calculator must not be reused after a panic. + storage_calculator: Arc>>, + known_storage_roots: Arc>, +} + +impl> LeafValueEncoder + for AccountValueEncoder +{ + type Value = Account; + type DeferredEncoder = AccountDeferredValueEncoder; + + fn deferred_encoder( + &mut self, + hashed_address: B256, + account: Account, + ) -> Self::DeferredEncoder { + if let Some(root) = self.known_storage_roots.get(&hashed_address) { + AccountDeferredValueEncoder::Known { storage_root: *root, account } + } else { + AccountDeferredValueEncoder::Calculate { + storage_calculator: self.storage_calculator.clone(), + hashed_address, + account, + } + } + } +} + +/// Deferred account encoder using a known storage root or synchronous root calculation. +enum AccountDeferredValueEncoder { + Known { + storage_root: B256, + account: Account, + }, + Calculate { + storage_calculator: Arc>>, + hashed_address: B256, + account: Account, + }, +} + +impl> DeferredValueEncoder + for AccountDeferredValueEncoder +{ + fn encode(self, buf: &mut Vec) -> Result<(), StateProofError> { + let (account, root) = match self { + Self::Known { storage_root, account } => (account, storage_root), + Self::Calculate { storage_calculator, hashed_address, account } => { + let root = storage_calculator + .lock() + .calculator_for(hashed_address) + .storage_root(hashed_address)?; + (account, root) + } + }; + account.into_trie_account(root).encode(buf); + Ok(()) + } +} diff --git a/crates/trie/trie/src/proof_v2/value.rs b/crates/trie/trie/src/proof_v2/value.rs index 33e919b6723..424850ca78e 100644 --- a/crates/trie/trie/src/proof_v2/value.rs +++ b/crates/trie/trie/src/proof_v2/value.rs @@ -1,14 +1,8 @@ //! Generic value encoder types for proof calculation with lazy evaluation. -use crate::{ - hashed_cursor::HashedCursorFactory, prefix_set::PrefixSet, proof_v2::ProofCalculator, - trie_cursor::TrieCursorFactory, -}; -use alloy_primitives::{map::B256Map, B256, U256}; +use alloy_primitives::{B256, U256}; use alloy_rlp::Encodable; use reth_execution_errors::trie::StateProofError; -use reth_primitives_traits::Account; -use std::rc::Rc; /// A trait for deferred RLP-encoding of leaf values. pub trait DeferredValueEncoder { @@ -73,97 +67,3 @@ impl LeafValueEncoder for StorageValueEncoder { StorageDeferredValueEncoder(value) } } - -/// An account value encoder that synchronously computes storage roots. -/// -/// This encoder contains factories for creating trie and hashed cursors. Storage roots are -/// computed synchronously within the deferred encoder using a `StorageProofCalculator`. -#[derive(Debug, Clone)] -pub struct SyncAccountValueEncoder { - /// Factory for creating trie cursors. - trie_cursor_factory: Rc, - /// Factory for creating hashed cursors. - hashed_cursor_factory: Rc, - /// Storage prefix sets keyed by hashed address. - storage_prefix_sets: Rc>, -} - -impl SyncAccountValueEncoder { - /// Create a new account value encoder with the given factories. - pub fn new(trie_cursor_factory: T, hashed_cursor_factory: H) -> Self { - Self { - trie_cursor_factory: Rc::new(trie_cursor_factory), - hashed_cursor_factory: Rc::new(hashed_cursor_factory), - storage_prefix_sets: Rc::new(B256Map::default()), - } - } - - /// Sets the storage prefix sets. When given, all cached storage trie hashes matching the - /// prefix sets will be invalidated during storage root calculation for the corresponding - /// accounts. - pub fn with_storage_prefix_sets(mut self, storage_prefix_sets: B256Map) -> Self { - self.storage_prefix_sets = Rc::new(storage_prefix_sets); - self - } -} - -/// The deferred encoder for an account value with synchronous storage root calculation. -#[derive(Debug, Clone)] -pub struct SyncAccountDeferredValueEncoder { - trie_cursor_factory: Rc, - hashed_cursor_factory: Rc, - storage_prefix_sets: Rc>, - hashed_address: B256, - account: Account, -} - -impl DeferredValueEncoder for SyncAccountDeferredValueEncoder -where - T: TrieCursorFactory, - H: HashedCursorFactory, -{ - fn encode(self, buf: &mut Vec) -> Result<(), StateProofError> { - let trie_cursor = self.trie_cursor_factory.storage_trie_cursor(self.hashed_address)?; - let hashed_cursor = - self.hashed_cursor_factory.hashed_storage_cursor(self.hashed_address)?; - - let mut storage_proof_calculator = ProofCalculator::new_storage(trie_cursor, hashed_cursor); - if let Some(prefix_set) = self.storage_prefix_sets.get(&self.hashed_address) { - storage_proof_calculator = storage_proof_calculator.with_prefix_set(prefix_set.clone()); - } - let root_node = storage_proof_calculator.storage_root_node(self.hashed_address)?; - let storage_root = storage_proof_calculator - .compute_root_hash(&[root_node])? - .expect("storage_root_node returns a node at empty path"); - - let trie_account = self.account.into_trie_account(storage_root); - trie_account.encode(buf); - - Ok(()) - } -} - -impl LeafValueEncoder for SyncAccountValueEncoder -where - T: TrieCursorFactory, - H: HashedCursorFactory, -{ - type Value = Account; - type DeferredEncoder = SyncAccountDeferredValueEncoder; - - fn deferred_encoder( - &mut self, - hashed_address: B256, - account: Self::Value, - ) -> Self::DeferredEncoder { - // Return a deferred encoder that will synchronously compute the storage root when encode() - // is called. - SyncAccountDeferredValueEncoder { - trie_cursor_factory: Rc::clone(&self.trie_cursor_factory), - hashed_cursor_factory: Rc::clone(&self.hashed_cursor_factory), - storage_prefix_sets: Rc::clone(&self.storage_prefix_sets), - hashed_address, - account, - } - } -} diff --git a/crates/trie/trie/src/test_utils.rs b/crates/trie/trie/src/test_utils.rs index 1b904b68f88..176f371627f 100644 --- a/crates/trie/trie/src/test_utils.rs +++ b/crates/trie/trie/src/test_utils.rs @@ -145,7 +145,7 @@ impl TrieTestHarness { /// rebuilds the harness from scratch with the resulting storage. pub fn apply_changeset(&mut self, changeset: BTreeMap) { for (k, v) in changeset { - if v == U256::ZERO { + if v.is_zero() { self.storage.remove(&k); } else { self.storage.insert(k, v); diff --git a/crates/trie/trie/src/trie.rs b/crates/trie/trie/src/trie.rs index bf3144046de..40512b90f5d 100644 --- a/crates/trie/trie/src/trie.rs +++ b/crates/trie/trie/src/trie.rs @@ -73,6 +73,9 @@ impl StateRoot { } /// Configures the state root walker to visit all children of changed branch paths. + /// + /// When updates are retained, also removes stored descendants hidden by a changed branch's + /// tree mask. Regenerated branch updates take precedence over these deletions. pub const fn with_walk_all_changed_branch_children(mut self, enabled: bool) -> Self { self.walk_all_changed_branch_children = enabled; self @@ -266,7 +269,7 @@ where // no intermediate state, create new hash builder and node iter for state root // calculation let hash_builder = HashBuilder::default().with_updates(retain_updates); - let walker = TrieWalker::state_trie(trie_cursor, self.prefix_sets.account_prefix_set) + let walker = TrieWalker::state_trie(trie_cursor, self.prefix_sets.account_prefix_set)? .with_walk_all_changed_branch_children(self.walk_all_changed_branch_children) .with_deletions_retained(retain_updates); let node_iter = TrieNodeIter::state_trie(walker, hashed_account_cursor); @@ -586,6 +589,9 @@ impl StorageRoot { } /// Configures the storage root walker to visit all children of changed branch paths. + /// + /// When updates are retained, also removes stored descendants hidden by a changed branch's + /// tree mask. Regenerated branch updates take precedence over these deletions. pub const fn with_walk_all_changed_branch_children(mut self, enabled: bool) -> Self { self.walk_all_changed_branch_children = enabled; self @@ -820,7 +826,7 @@ where } None => { let hash_builder = HashBuilder::default().with_updates(retain_updates); - let walker = TrieWalker::storage_trie(trie_cursor, prefix_set) + let walker = TrieWalker::storage_trie(trie_cursor, prefix_set)? .with_walk_all_changed_branch_children(walk_all_changed_branch_children) .with_deletions_retained(retain_updates); let node_iter = TrieNodeIter::storage_trie(walker, hashed_storage_cursor); diff --git a/crates/trie/trie/src/trie_cursor/in_memory.rs b/crates/trie/trie/src/trie_cursor/in_memory.rs index 1d704cc4abf..8db7853e804 100644 --- a/crates/trie/trie/src/trie_cursor/in_memory.rs +++ b/crates/trie/trie/src/trie_cursor/in_memory.rs @@ -238,6 +238,10 @@ impl TrieCursor for InMemoryTrieCursor<'_, C> { &mut self, key: Nibbles, ) -> Result, DatabaseError> { + // Forward scans can reuse cursor positions; backward seeks and exhausted cursors cannot. + if self.last_key.is_none_or(|last| key < last) { + self.reset(); + } let mem_entry = self.in_memory_cursor.seek(&key); if let Some((mem_key, entry_inner)) = mem_entry && @@ -281,6 +285,10 @@ impl TrieCursor for InMemoryTrieCursor<'_, C> { &mut self, key: Nibbles, ) -> Result, DatabaseError> { + // Forward scans can reuse cursor positions; backward seeks and exhausted cursors cannot. + if self.last_key.is_none_or(|last| key < last) { + self.reset(); + } let mem_entry = self.in_memory_cursor.seek(&key); if let Some((mem_key, Some(node))) = mem_entry && diff --git a/crates/trie/trie/src/trie_cursor/metrics.rs b/crates/trie/trie/src/trie_cursor/metrics.rs index 1c256f6124f..ec72b88870c 100644 --- a/crates/trie/trie/src/trie_cursor/metrics.rs +++ b/crates/trie/trie/src/trie_cursor/metrics.rs @@ -1,24 +1,18 @@ -use super::{TrieCursor, TrieStorageCursor}; -use crate::{BranchNodeCompact, Nibbles}; +use super::{TrieCursor, TrieCursorFactory, TrieStorageCursor}; +use crate::{BranchNodeCompact, Nibbles, TrieType}; use alloy_primitives::B256; +use reth_metrics::metrics::{self, Histogram}; use reth_primitives_traits::FastInstant as Instant; use reth_storage_errors::db::DatabaseError; use std::time::Duration; -use tracing::trace_span; - -#[cfg(feature = "metrics")] -use crate::TrieType; -#[cfg(feature = "metrics")] -use reth_metrics::metrics::{self, Histogram}; /// Prometheus metrics for trie cursor operations. /// /// Tracks the number of cursor operations for monitoring and performance analysis. -#[cfg(feature = "metrics")] #[derive(Clone, Debug)] pub struct TrieCursorMetrics { - /// Histogram tracking overall time spent in database operations - overall_duration: Histogram, + /// Histogram tracking overall time spent in database operations, in seconds. + overall_duration_seconds: Histogram, /// Histogram for `next()` operations next_histogram: Histogram, /// Histogram for `seek()` operations @@ -27,15 +21,14 @@ pub struct TrieCursorMetrics { seek_exact_histogram: Histogram, } -#[cfg(feature = "metrics")] impl TrieCursorMetrics { /// Create a new metrics instance with the specified trie type label. pub fn new(trie_type: TrieType) -> Self { let trie_type_str = trie_type.as_str(); Self { - overall_duration: metrics::histogram!( - "trie.cursor.overall_duration", + overall_duration_seconds: metrics::histogram!( + "trie.cursor.overall_duration_seconds", "type" => trie_type_str ), next_histogram: metrics::histogram!( @@ -56,102 +49,52 @@ impl TrieCursorMetrics { } } - /// Record the cached metrics from the provided cache and reset the cache counters. - /// - /// This method adds the current counter values from the cache to the Prometheus metrics - /// and then resets all cache counters to zero. - pub fn record(&mut self, cache: &mut TrieCursorMetricsCache) { - self.next_histogram.record(cache.next_count as f64); - self.seek_histogram.record(cache.seek_count as f64); - self.seek_exact_histogram.record(cache.seek_exact_count as f64); - self.overall_duration.record(cache.total_duration.as_secs_f64()); - cache.reset(); - } -} - -/// Cached metrics counters for trie cursor operations. -#[derive(Debug, Copy, Clone)] -pub struct TrieCursorMetricsCache { - /// Counter for `next()` calls - pub next_count: usize, - /// Counter for `seek()` calls - pub seek_count: usize, - /// Counter for `seek_exact()` calls - pub seek_exact_count: usize, - /// Total duration spent in database operations - pub total_duration: Duration, -} - -impl Default for TrieCursorMetricsCache { - fn default() -> Self { - Self { next_count: 0, seek_count: 0, seek_exact_count: 0, total_duration: Duration::ZERO } - } -} - -impl TrieCursorMetricsCache { - /// Reset all counters to zero. - pub const fn reset(&mut self) { - self.next_count = 0; - self.seek_count = 0; - self.seek_exact_count = 0; - self.total_duration = Duration::ZERO; - } - - /// Extend this cache by adding the counts from another cache. - /// - /// This accumulates the counter values from `other` into this cache. - pub fn extend(&mut self, other: &Self) { - self.next_count += other.next_count; - self.seek_count += other.seek_count; - self.seek_exact_count += other.seek_exact_count; - self.total_duration += other.total_duration; - } - - /// Record the span for metrics. - pub fn record_span(&self, name: &'static str) { - let _span = trace_span!( - target: "trie::trie_cursor", - "Trie cursor metrics", - name, - next_count = self.next_count, - seek_count = self.seek_count, - seek_exact_count = self.seek_exact_count, - total_duration = self.total_duration.as_secs_f64(), - ) - .entered(); + /// Wraps a cursor with owned counters that are recorded when the cursor is dropped. + pub fn instrument(&self, cursor: C) -> InstrumentedTrieCursor { + InstrumentedTrieCursor { + cursor, + metrics: self.clone(), + next_count: 0, + seek_count: 0, + seek_exact_count: 0, + total_duration: Duration::ZERO, + } } } /// A wrapper around a [`TrieCursor`] that tracks metrics for cursor operations. /// +/// Counters are recorded into the supplied metric handles when the cursor is dropped. +/// /// This implementation counts the number of times each cursor operation is called: /// - `next()` - Move to the next entry /// - `seek()` - Seek to a key or the next greater key /// - `seek_exact()` - Seek to an exact key match #[derive(Debug)] -pub struct InstrumentedTrieCursor<'metrics, C> { - /// The underlying cursor being wrapped +pub struct InstrumentedTrieCursor { + /// The underlying cursor being wrapped. cursor: C, - /// Cached metrics counters - metrics: &'metrics mut TrieCursorMetricsCache, + /// Metric handles used to record counters when the cursor is dropped. + metrics: TrieCursorMetrics, + /// Counter for `next()` calls. + next_count: usize, + /// Counter for `seek()` calls. + seek_count: usize, + /// Counter for `seek_exact` calls. + seek_exact_count: usize, + /// Total duration spent in cursor operations. + total_duration: Duration, } -impl<'metrics, C> InstrumentedTrieCursor<'metrics, C> { - /// Create a new metrics cursor wrapping the given cursor. - pub const fn new(cursor: C, metrics: &'metrics mut TrieCursorMetricsCache) -> Self { - Self { cursor, metrics } - } -} - -impl<'metrics, C: TrieCursor> TrieCursor for InstrumentedTrieCursor<'metrics, C> { +impl TrieCursor for InstrumentedTrieCursor { fn seek_exact( &mut self, key: Nibbles, ) -> Result, DatabaseError> { let start = Instant::now(); - self.metrics.seek_exact_count += 1; + self.seek_exact_count += 1; let result = self.cursor.seek_exact(key); - self.metrics.total_duration += start.elapsed(); + self.total_duration += start.elapsed(); result } @@ -160,17 +103,17 @@ impl<'metrics, C: TrieCursor> TrieCursor for InstrumentedTrieCursor<'metrics, C> key: Nibbles, ) -> Result, DatabaseError> { let start = Instant::now(); - self.metrics.seek_count += 1; + self.seek_count += 1; let result = self.cursor.seek(key); - self.metrics.total_duration += start.elapsed(); + self.total_duration += start.elapsed(); result } fn next(&mut self) -> Result, DatabaseError> { let start = Instant::now(); - self.metrics.next_count += 1; + self.next_count += 1; let result = self.cursor.next(); - self.metrics.total_duration += start.elapsed(); + self.total_duration += start.elapsed(); result } @@ -183,8 +126,61 @@ impl<'metrics, C: TrieCursor> TrieCursor for InstrumentedTrieCursor<'metrics, C> } } -impl<'metrics, C: TrieStorageCursor> TrieStorageCursor for InstrumentedTrieCursor<'metrics, C> { +impl TrieStorageCursor for InstrumentedTrieCursor { fn set_hashed_address(&mut self, hashed_address: B256) { self.cursor.set_hashed_address(hashed_address) } } + +impl Drop for InstrumentedTrieCursor { + fn drop(&mut self) { + self.metrics.next_histogram.record(self.next_count as f64); + self.metrics.seek_histogram.record(self.seek_count as f64); + self.metrics.seek_exact_histogram.record(self.seek_exact_count as f64); + self.metrics.overall_duration_seconds.record(self.total_duration.as_secs_f64()); + } +} + +/// Instruments every account and storage cursor opened by the wrapped factory. +/// +/// Counters are owned by each cursor and recorded into the supplied handles on drop. Wrap the +/// outermost overlay factory to include both overlay and database operations in these counters. +#[derive(Clone, Debug)] +pub struct InstrumentedTrieCursorFactory { + factory: F, + account_metrics: TrieCursorMetrics, + storage_metrics: TrieCursorMetrics, +} + +impl InstrumentedTrieCursorFactory { + /// Wraps a factory with account and storage metric handles shared by its cursors. + pub const fn new( + factory: F, + account_metrics: TrieCursorMetrics, + storage_metrics: TrieCursorMetrics, + ) -> Self { + Self { factory, account_metrics, storage_metrics } + } +} + +impl TrieCursorFactory for InstrumentedTrieCursorFactory { + type AccountTrieCursor<'a> + = InstrumentedTrieCursor> + where + Self: 'a; + type StorageTrieCursor<'a> + = InstrumentedTrieCursor> + where + Self: 'a; + + fn account_trie_cursor(&self) -> Result, DatabaseError> { + Ok(self.account_metrics.instrument(self.factory.account_trie_cursor()?)) + } + + fn storage_trie_cursor( + &self, + hashed_address: B256, + ) -> Result, DatabaseError> { + Ok(self.storage_metrics.instrument(self.factory.storage_trie_cursor(hashed_address)?)) + } +} diff --git a/crates/trie/trie/src/trie_cursor/mock.rs b/crates/trie/trie/src/trie_cursor/mock.rs index 5f29a6734bf..452bbafed6b 100644 --- a/crates/trie/trie/src/trie_cursor/mock.rs +++ b/crates/trie/trie/src/trie_cursor/mock.rs @@ -207,8 +207,7 @@ impl TrieCursor for MockTrieCursor { key: Nibbles, ) -> Result, DatabaseError> { // Find the first key that is greater than or equal to the given key. - let entry = - self.trie_nodes().iter().find_map(|(k, v)| (k >= &key).then(|| (*k, v.clone()))); + let entry = self.trie_nodes().range(key..).next().map(|(k, v)| (*k, v.clone())); if let Some((key, _)) = &entry { self.current_key = Some(*key); } @@ -221,10 +220,14 @@ impl TrieCursor for MockTrieCursor { #[instrument(skip(self), ret(level = "trace"))] fn next(&mut self) -> Result, DatabaseError> { - let mut iter = self.trie_nodes().iter(); + let mut iter = match self.current_key { + Some(current) => self.trie_nodes().range(current..), + None => self.trie_nodes().range::(..), + }; // Jump to the first key that has a prefix of the current key if it's set, or to the first // key otherwise. - iter.find(|(k, _)| self.current_key.as_ref().is_none_or(|current| k.starts_with(current))) + iter.next() + .filter(|(k, _)| self.current_key.as_ref().is_none_or(|current| k.starts_with(current))) .expect("current key should exist in trie nodes"); // Get the next key-value pair. let entry = iter.next().map(|(k, v)| (*k, v.clone())); diff --git a/crates/trie/trie/src/trie_cursor/mod.rs b/crates/trie/trie/src/trie_cursor/mod.rs index 4390bc0947a..a7911a60917 100644 --- a/crates/trie/trie/src/trie_cursor/mod.rs +++ b/crates/trie/trie/src/trie_cursor/mod.rs @@ -19,10 +19,14 @@ pub mod depth_first; pub mod mock; /// Metrics tracking trie cursor implementations. +#[cfg(feature = "metrics")] pub mod metrics; #[cfg(feature = "metrics")] -pub use metrics::TrieCursorMetrics; -pub use metrics::{InstrumentedTrieCursor, TrieCursorMetricsCache}; +pub use metrics::{InstrumentedTrieCursor, InstrumentedTrieCursorFactory, TrieCursorMetrics}; + +/// Uses the underlying factory directly when cursor metrics are disabled. +#[cfg(not(feature = "metrics"))] +pub type InstrumentedTrieCursorFactory = F; pub use self::{depth_first::DepthFirstTrieIterator, in_memory::*, subnode::CursorSubNode}; diff --git a/crates/trie/trie/src/verify.rs b/crates/trie/trie/src/verify.rs index fcf751bb5a3..5ab0c848625 100644 --- a/crates/trie/trie/src/verify.rs +++ b/crates/trie/trie/src/verify.rs @@ -404,7 +404,7 @@ impl<'a, T: TrieCursorFactory, H: HashedCursorFactory + Clone> Verifier<'a, T, H let prev_account = *prev_account; // Calculate the max possible account address (all bits set). - let max_account = B256::from([0xFFu8; 32]); + let max_account = B256::repeat_byte(0xFFu8); self.verify_empty_storages(prev_account, max_account, false, true)?; } @@ -469,12 +469,13 @@ impl<'a, T: TrieCursorFactory, H: HashedCursorFactory + Clone> Iterator for Veri #[cfg(test)] mod tests { + use super::*; use crate::{ hashed_cursor::mock::MockHashedCursorFactory, trie_cursor::mock::{MockTrieCursor, MockTrieCursorFactory}, }; - use alloy_primitives::{address, keccak256, map::B256Map, U256}; + use alloy_primitives::{keccak256, map::B256Map, Address, U256}; use alloy_trie::TrieMask; use assert_matches::assert_matches; use reth_primitives_traits::Account; @@ -492,7 +493,7 @@ mod tests { let mut final_hashes = hashes; let mut counter = 100u8; while final_hashes.len() < expected_hashes { - final_hashes.push(B256::from([counter; 32])); + final_hashes.push(B256::repeat_byte(counter)); counter += 1; } final_hashes.truncate(expected_hashes); @@ -538,7 +539,7 @@ mod tests { let mut storage_tries = B256Map::default(); // Create test accounts - let addr1 = keccak256(address!("0000000000000000000000000000000000000001")); + let addr1 = keccak256(Address::with_last_byte(1)); accounts.insert( addr1, Account { @@ -551,8 +552,8 @@ mod tests { // Add storage for the account let mut storage1 = BTreeMap::new(); - storage1.insert(keccak256(B256::from(U256::from(1))), U256::from(100)); - storage1.insert(keccak256(B256::from(U256::from(2))), U256::from(200)); + storage1.insert(keccak256(B256::with_last_byte(1)), U256::from(100)); + storage1.insert(keccak256(B256::with_last_byte(2)), U256::from(200)); storage_tries.insert(addr1, storage1); let factory = MockHashedCursorFactory::new(accounts, storage_tries); @@ -623,7 +624,7 @@ mod tests { // Add some storage for each account let mut storage = BTreeMap::new(); for j in 0..i { - storage.insert(keccak256(B256::from(U256::from(j))), U256::from(j as u64 * 10)); + storage.insert(keccak256(B256::with_last_byte(j)), U256::from(j as u64 * 10)); } if !storage.is_empty() { storage_tries.insert(addr, storage); @@ -683,8 +684,8 @@ mod tests { #[test] fn test_single_verifier_next_exact_match() { // Test when the expected node matches exactly - let node1 = test_branch_node(0b1111, 0, 0b1111, vec![B256::from([1u8; 32])]); - let node2 = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::from([2u8; 32])]); + let node1 = test_branch_node(0b1111, 0, 0b1111, vec![B256::repeat_byte(1u8)]); + let node2 = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::repeat_byte(2u8)]); let trie_nodes = BTreeMap::from([ (Nibbles::from_nibbles([0x1]), node1.clone()), @@ -705,8 +706,8 @@ mod tests { #[test] fn test_single_verifier_next_wrong_value() { // Test when the path matches but value is different - let node_in_trie = test_branch_node(0b1111, 0, 0b1111, vec![B256::from([1u8; 32])]); - let node_expected = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::from([2u8; 32])]); + let node_in_trie = test_branch_node(0b1111, 0, 0b1111, vec![B256::repeat_byte(1u8)]); + let node_expected = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::repeat_byte(2u8)]); let trie_nodes = BTreeMap::from([(Nibbles::from_nibbles([0x1]), node_in_trie.clone())]); @@ -729,8 +730,8 @@ mod tests { #[test] fn test_single_verifier_next_missing() { // Test when expected node doesn't exist in trie - let node1 = test_branch_node(0b1111, 0, 0b1111, vec![B256::from([1u8; 32])]); - let node_missing = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::from([2u8; 32])]); + let node1 = test_branch_node(0b1111, 0, 0b1111, vec![B256::repeat_byte(1u8)]); + let node_missing = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::repeat_byte(2u8)]); let trie_nodes = BTreeMap::from([(Nibbles::from_nibbles([0x3]), node1)]); @@ -841,8 +842,8 @@ mod tests { #[test] fn test_single_verifier_storage_trie() { // Test SingleVerifier for storage trie (with account set) - let account = B256::from([42u8; 32]); - let node = test_branch_node(0b1111, 0, 0b1111, vec![B256::from([1u8; 32])]); + let account = B256::repeat_byte(42u8); + let node = test_branch_node(0b1111, 0, 0b1111, vec![B256::repeat_byte(1u8)]); let trie_nodes = BTreeMap::from([(Nibbles::from_nibbles([0x1]), node)]); @@ -851,7 +852,7 @@ mod tests { let mut outputs = Vec::new(); // Call next with missing node - let missing_node = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::from([2u8; 32])]); + let missing_node = test_branch_node(0b0101, 0b0001, 0b0100, vec![B256::repeat_byte(2u8)]); verifier.next(&mut outputs, Nibbles::from_nibbles([0x0]), missing_node.clone()).unwrap(); // Should produce StorageMissing, not AccountMissing @@ -872,7 +873,7 @@ mod tests { let mut outputs = Vec::new(); // Any node should be marked as missing - let node = test_branch_node(0b1111, 0, 0b1111, vec![B256::from([1u8; 32])]); + let node = test_branch_node(0b1111, 0, 0b1111, vec![B256::repeat_byte(1u8)]); verifier.next(&mut outputs, Nibbles::from_nibbles([0x1]), node.clone()).unwrap(); assert_eq!(outputs.len(), 1); diff --git a/crates/trie/trie/src/walker.rs b/crates/trie/trie/src/walker.rs index a8442578575..7457d74b8d1 100644 --- a/crates/trie/trie/src/walker.rs +++ b/crates/trie/trie/src/walker.rs @@ -4,22 +4,10 @@ use crate::{ BranchNodeCompact, Nibbles, }; use alloy_primitives::{map::HashSet, B256}; -use alloy_trie::proof::AddedRemovedKeys; +use alloy_trie::{proof::AddedRemovedKeys, TrieMask}; use reth_storage_errors::db::DatabaseError; use tracing::{instrument, trace}; -#[cfg(test)] -use crate::trie_cursor::{mock::MockTrieCursorFactory, TrieCursorFactory}; - -#[cfg(test)] -use alloy_primitives::map::B256Map; - -#[cfg(test)] -use alloy_trie::TrieMask; - -#[cfg(test)] -use std::collections::BTreeMap; - #[cfg(feature = "metrics")] use crate::metrics::WalkerMetrics; @@ -126,6 +114,10 @@ impl> TrieWalker { } /// Configures the walker to treat every child of a matching branch path as unskippable. + /// + /// When deletions are retained, also collects stored descendants hidden by a matching + /// branch's tree mask. These keys appear in the deletion set even if traversal never visits + /// them. Root calculations can replace these deletions with regenerated branch updates. pub const fn with_walk_all_changed_branch_children(mut self, enabled: bool) -> Self { self.walk_all_changed_branch_children = enabled; self @@ -184,11 +176,7 @@ impl> TrieWalker { pub fn next_unprocessed_key(&self) -> Option<(B256, Nibbles)> { self.key() .and_then(|key| if self.can_skip_current_node { key.increment() } else { Some(*key) }) - .map(|key| { - let mut packed = key.pack(); - packed.resize(32, 0); - (B256::from_slice(packed.as_slice()), key) - }) + .map(|key| (B256::right_padding_from(&key.pack()), key)) } /// Updates the skip node flag based on the walker's current state. @@ -230,7 +218,7 @@ impl> TrieWalker { } /// Constructs a new [`TrieWalker`] for the state trie. - pub fn state_trie(cursor: C, changes: PrefixSet) -> Self { + pub fn state_trie(cursor: C, changes: PrefixSet) -> Result { Self::new( cursor, changes, @@ -240,7 +228,7 @@ impl> TrieWalker { } /// Constructs a new [`TrieWalker`] for the storage trie. - pub fn storage_trie(cursor: C, changes: PrefixSet) -> Self { + pub fn storage_trie(cursor: C, changes: PrefixSet) -> Result { Self::new( cursor, changes, @@ -254,7 +242,7 @@ impl> TrieWalker { cursor: C, changes: PrefixSet, #[cfg(feature = "metrics")] trie_type: crate::TrieType, - ) -> Self { + ) -> Result { // Initialize the walker with a single empty stack element. let mut this = Self { cursor, @@ -269,13 +257,13 @@ impl> TrieWalker { }; // Set up the root node of the trie in the stack, if it exists. - if let Some((key, value)) = this.node(true).unwrap() { + if let Some((key, value)) = this.node(true)? { this.stack[0] = CursorSubNode::new(key, Some(value)); } // Update the skip state for the root node. this.update_skip_node(); - this + Ok(this) } /// Advances the walker to the next trie node and updates the skip node flag. @@ -354,6 +342,8 @@ impl> TrieWalker { return Ok(()) } + self.remove_orphaned_descendants(key, node.tree_mask)?; + // Create a new CursorSubNode and push it to the stack. let subnode = CursorSubNode::new(key, Some(node)); let position = subnode.position(); @@ -415,13 +405,63 @@ impl> TrieWalker { Ok(()) } + + /// Removes cached descendants hidden by a changed branch's tree mask during recovery. + /// + /// For example, a change under `0x3a` can leave stored `0x3c` and `0x3c4...` entries while + /// parent `0x3` has tree-mask bit `c` clear. The walker skips those entries and therefore never + /// records their keys for deletion. + /// + /// Collect the hidden cache keys without using their hashes; regenerated branch updates take + /// precedence over these deletions. The cursor must be positioned on this parent; restore that + /// position afterwards so traversal continues unchanged. + fn remove_orphaned_descendants( + &mut self, + key: Nibbles, + tree_mask: TrieMask, + ) -> Result<(), DatabaseError> { + let missing_children = !tree_mask; + if self.walk_all_changed_branch_children && + !missing_children.is_empty() && + let Some(removed_keys) = &mut self.removed_keys && + self.changes.contains(&key) + { + for nibble in missing_children.iter() { + let mut prefix = key; + prefix.push(nibble); + let mut entry = self.cursor.seek(prefix)?; + #[cfg(feature = "metrics")] + self.metrics.inc_branch_nodes_seeked(); + while let Some((path, _)) = entry && + path.starts_with(&prefix) + { + removed_keys.insert(path); + entry = self.cursor.next()?; + } + } + self.cursor.seek_exact(key)?; + #[cfg(feature = "metrics")] + self.metrics.inc_branch_nodes_seeked(); + } + Ok(()) + } } #[cfg(test)] mod tests { use super::*; - use crate::prefix_set::PrefixSetMut; - use alloy_primitives::B256; + use crate::{ + mock::KeyVisitType, + prefix_set::PrefixSetMut, + progress::StorageRootProgress, + test_utils::{storage_root_prehashed, TrieTestHarness}, + trie_cursor::{mock::MockTrieCursorFactory, TrieCursorFactory}, + updates::StorageTrieUpdates, + StorageRoot, + }; + use alloy_primitives::{map::B256Map, B256, U256}; + use reth_storage_errors::db::DatabaseErrorInfo; + use std::collections::BTreeMap; fn branch_node(state_mask: u16, tree_mask: u16, hash_mask: u16) -> BranchNodeCompact { let hash_count = hash_mask.count_ones() as usize; @@ -461,6 +501,7 @@ mod tests { prefix_set.insert(Nibbles::from_nibbles([0x2, 0x3, 0x1])); TrieWalker::state_trie(factory.account_trie_cursor().unwrap(), prefix_set.freeze()) + .unwrap() .with_walk_all_changed_branch_children(walk_all_changed_branch_children) } @@ -495,4 +536,221 @@ mod tests { assert_eq!(walker.key().copied(), Some(Nibbles::from_nibbles([0x2, 0x4]))); assert!(!walker.can_skip_current_node); } + + #[test] + fn changed_branch_children_remove_orphans() { + for regenerate_branch in [false, true] { + let mut storage = BTreeMap::from([ + (B256::right_padding_from(&[0x3a]), U256::ONE), + (B256::right_padding_from(&[0x3a, 0x01]), U256::from(10)), + (B256::right_padding_from(&[0x3a, 0x10]), U256::from(7)), + (B256::right_padding_from(&[0x3b]), U256::from(2)), + (B256::right_padding_from(&[0x3c, 0x47]), U256::from(3)), + (B256::right_padding_from(&[0x80]), U256::from(4)), + (B256::right_padding_from(&[0x81]), U256::from(5)), + (B256::right_padding_from(&[0x80, 0x10]), U256::from(8)), + ]); + if regenerate_branch { + storage.insert(B256::right_padding_from(&[0x3c, 0x48]), U256::from(6)); + storage.insert(B256::right_padding_from(&[0x3c, 0x47, 0x10]), U256::from(9)); + } + let mut harness = TrieTestHarness::new(storage.clone()); + let expected_nodes = harness + .storage_trie_updates() + .storage_nodes + .iter() + .map(|(key, node)| (*key, node.clone())) + .collect::>(); + let mut stored_nodes = expected_nodes.clone(); + let parent = Nibbles::from_nibbles([0x3]); + stored_nodes.get_mut(&parent).unwrap().tree_mask &= + !TrieMask::new((1 << 0xa) | (1 << 0xc)); + + // The cached branch at 0x3c and its descendants are unreachable through 0x3's mask. + // Include a descendant which should be regenerated and one which should disappear. + let orphan_paths = [ + Nibbles::from_nibbles([0x3, 0xc]), + Nibbles::from_nibbles([0x3, 0xc, 0x4]), + Nibbles::from_nibbles([0x3, 0xc, 0x4, 0xf]), + Nibbles::from_nibbles([0x3, 0xf, 0x1]), + Nibbles::from_nibbles([0x3, 0x1, 0x4]), + ]; + // The valid branch under 0x3a is also hidden by the mask. Its regenerated update can + // be split off before the walker finishes 0x3, so resumption must not delete it again. + assert!(expected_nodes.contains_key(&Nibbles::from_nibbles([0x3, 0xa]))); + for path in orphan_paths { + let mut stale_node = branch_node(0b11, 0, 0b11); + stale_node.hashes = vec![B256::repeat_byte(0xee); 2].into(); + stored_nodes.insert(path, stale_node); + } + harness.set_trie_nodes(stored_nodes.clone()); + + // A change under sibling 0x3a must also clean up 0x3c and state-mask gaps. + let prefix_set = + PrefixSetMut::from([Nibbles::unpack(B256::right_padding_from(&[0x3a]))]).freeze(); + for (enabled, threshold) in [(false, u64::MAX), (true, u64::MAX), (true, 1)] { + // Also rebuild the neighboring branch in checkpointed runs. Resuming a skipped + // branch after a leaf checkpoint currently fails even without orphan cleanup. + let changes = if threshold == 1 { + PrefixSetMut::from([ + Nibbles::unpack(B256::right_padding_from(&[0x3a])), + Nibbles::unpack(B256::right_padding_from(&[0x80])), + ]) + .freeze() + } else { + prefix_set.clone() + }; + let (root, updates) = + storage_root_with_progress(&harness, changes, enabled, threshold); + + let mut resulting_nodes = stored_nodes.clone(); + for (path, node) in updates.into_sorted().storage_nodes { + if let Some(node) = node { + resulting_nodes.insert(path, node); + } else { + resulting_nodes.remove(&path); + } + } + assert_eq!(root, storage_root_prehashed(storage.clone())); + if enabled { + assert_eq!(resulting_nodes, expected_nodes); + } else { + // Normal traversal ignores hidden descendants, but leaves them in the cache. + assert_ne!(resulting_nodes, expected_nodes); + } + } + } + } + + fn storage_root_with_progress( + harness: &TrieTestHarness, + changes: PrefixSet, + enabled: bool, + threshold: u64, + ) -> (B256, StorageTrieUpdates) { + let mut previous_state = None; + let mut updates = StorageTrieUpdates::default(); + let mut checkpoints = 0; + loop { + let progress = StorageRoot::new_hashed( + harness.trie_cursor_factory(), + harness.hashed_cursor_factory(), + harness.hashed_address(), + changes.clone(), + #[cfg(feature = "metrics")] + crate::metrics::TrieRootMetrics::new(crate::TrieType::Storage), + ) + .with_walk_all_changed_branch_children(enabled) + .with_threshold(threshold) + .with_intermediate_state(previous_state) + .root_with_progress() + .unwrap(); + match progress { + StorageRootProgress::Progress(state, _, new_updates) => { + updates.extend(new_updates); + previous_state = Some(*state); + checkpoints += 1; + } + StorageRootProgress::Complete(root, _, new_updates) => { + updates.extend(new_updates); + if threshold == 1 { + assert!(checkpoints > 1); + } + return (root, updates) + } + } + } + } + + #[test] + fn orphan_cleanup_skips_live_subtrees() { + let parent = Nibbles::from_nibbles([0x3]); + let tree_mask = TrieMask::from_nibble(0xa); + let orphans = [ + Nibbles::from_nibbles([0x3, 0xb]), + Nibbles::from_nibbles([0x3, 0xb, 0x5]), + Nibbles::from_nibbles([0x3, 0xb, 0x5, 0xf]), + ]; + let mut nodes = BTreeMap::from([ + (parent, branch_node((1 << 0xa) | (1 << 0xb), tree_mask.get(), 0)), + (Nibbles::from_nibbles([0x3, 0xa]), branch_node(0b11, 0, 0)), + (Nibbles::from_nibbles([0x4]), branch_node(0b11, 0, 0)), + ]); + for index in 0..128 { + nodes.insert( + Nibbles::from_nibbles([0x3, 0xa, index / 16, index % 16]), + branch_node(0b11, 0, 0), + ); + } + nodes.extend(orphans.into_iter().map(|path| (path, branch_node(0b11, 0, 0)))); + let factory = MockTrieCursorFactory::new(nodes, B256Map::default()); + let changes = PrefixSetMut::from([Nibbles::from_nibbles([0x3, 0xa])]).freeze(); + let mut walker = + TrieWalker::<_>::state_trie(factory.account_trie_cursor().unwrap(), changes) + .unwrap() + .with_walk_all_changed_branch_children(true) + .with_deletions_retained(true); + walker.cursor.seek_exact(parent).unwrap(); + factory.visited_account_keys().clear(); + walker.remove_orphaned_descendants(parent, tree_mask).unwrap(); + + assert_eq!(walker.take_removed_keys(), HashSet::from_iter(orphans)); + assert_eq!(walker.cursor.current().unwrap(), Some(parent)); + let next_count = factory + .visited_account_keys() + .iter() + .filter(|visit| matches!(visit.visit_type, KeyVisitType::Next)) + .count(); + assert!(next_count <= 4, "cleanup walked {next_count} entries for 3 orphan nodes"); + } + + #[test] + fn state_trie_initial_read_timeout_returns_error() { + let error = + TrieWalker::<_>::state_trie(TimedOutTrieCursor, PrefixSet::default()).unwrap_err(); + assert_matches::assert_matches!(error, DatabaseError::Read(info) if info == timeout_error()); + } + + #[test] + fn storage_trie_initial_read_timeout_returns_error() { + let error = + TrieWalker::<_>::storage_trie(TimedOutTrieCursor, PrefixSet::default()).unwrap_err(); + assert_matches::assert_matches!(error, DatabaseError::Read(info) if info == timeout_error()); + } + + fn timeout_error() -> DatabaseErrorInfo { + DatabaseErrorInfo { message: "read transaction has been timed out".into(), code: -96000 } + } + + #[derive(Debug)] + struct TimedOutTrieCursor; + + impl TrieCursor for TimedOutTrieCursor { + fn seek_exact( + &mut self, + key: Nibbles, + ) -> Result, DatabaseError> { + assert_eq!(key, Nibbles::default()); + Err(DatabaseError::Read(timeout_error())) + } + + fn seek( + &mut self, + _key: Nibbles, + ) -> Result, DatabaseError> { + unreachable!("initialization only seeks the root exactly") + } + + fn next(&mut self) -> Result, DatabaseError> { + unreachable!("initialization only seeks the root exactly") + } + + fn current(&mut self) -> Result, DatabaseError> { + unreachable!("initialization only seeks the root exactly") + } + + fn reset(&mut self) { + unreachable!("initialization only seeks the root exactly") + } + } } diff --git a/crates/trie/trie/src/witness.rs b/crates/trie/trie/src/witness.rs index 851d3eb434a..c36741e5ec0 100644 --- a/crates/trie/trie/src/witness.rs +++ b/crates/trie/trie/src/witness.rs @@ -1,6 +1,9 @@ use crate::{ - hashed_cursor::HashedCursorFactory, prefix_set::TriePrefixSetsMut, proof::Proof, proof_v2, - trie_cursor::TrieCursorFactory, TRIE_ACCOUNT_RLP_MAX_SIZE, + hashed_cursor::HashedCursorFactory, + prefix_set::TriePrefixSetsMut, + proof_v2::{self, MultiProofCalculator}, + trie_cursor::TrieCursorFactory, + TRIE_ACCOUNT_RLP_MAX_SIZE, }; use alloy_primitives::{ keccak256, @@ -49,30 +52,6 @@ impl TrieWitness { } } - /// Set the trie cursor factory. - pub fn with_trie_cursor_factory(self, trie_cursor_factory: TF) -> TrieWitness { - TrieWitness { - trie_cursor_factory, - hashed_cursor_factory: self.hashed_cursor_factory, - prefix_sets: self.prefix_sets, - always_include_root_node: self.always_include_root_node, - mode: self.mode, - witness: self.witness, - } - } - - /// Set the hashed cursor factory. - pub fn with_hashed_cursor_factory(self, hashed_cursor_factory: HF) -> TrieWitness { - TrieWitness { - trie_cursor_factory: self.trie_cursor_factory, - hashed_cursor_factory, - prefix_sets: self.prefix_sets, - always_include_root_node: self.always_include_root_node, - mode: self.mode, - witness: self.witness, - } - } - /// Set the prefix sets. They have to be mutable in order to allow extension with proof target. pub fn with_prefix_sets_mut(mut self, prefix_sets: TriePrefixSetsMut) -> Self { self.prefix_sets = prefix_sets; @@ -120,10 +99,13 @@ where } else { Self::get_proof_targets(&state) }; - let multiproof = - Proof::new(self.trie_cursor_factory.clone(), self.hashed_cursor_factory.clone()) - .with_prefix_sets_mut(self.prefix_sets.clone()) - .multiproof_v2(proof_targets)?; + let mut calculator = MultiProofCalculator::new( + self.trie_cursor_factory.clone(), + self.hashed_cursor_factory.clone(), + self.prefix_sets.clone().freeze(), + Default::default(), + ); + let multiproof = calculator.multiproof(proof_targets)?; // No need to reconstruct the rest of the trie, we just need to include // the root node and return. @@ -131,9 +113,7 @@ where let (root_hash, root_node) = if let Some(root_node) = multiproof.account_proofs.into_iter().find(|n| n.path.is_empty()) { - let mut encoded = Vec::new(); - root_node.node.encode(&mut encoded); - let bytes = Bytes::from(encoded); + let bytes = Bytes::from(alloy_rlp::encode(&root_node.node)); (keccak256(&bytes), bytes) } else { (EMPTY_ROOT_HASH, Bytes::from([EMPTY_STRING_CODE])) @@ -209,12 +189,7 @@ where break; } - let multiproof = Proof::new( - self.trie_cursor_factory.clone(), - self.hashed_cursor_factory.clone(), - ) - .with_prefix_sets_mut(self.prefix_sets.clone()) - .multiproof_v2(targets)?; + let multiproof = calculator.multiproof(targets)?; self.record_multiproof_nodes(&multiproof); sparse_trie.reveal_decoded_multiproof_v2(multiproof)?; } @@ -283,12 +258,7 @@ where break; } - let multiproof = Proof::new( - self.trie_cursor_factory.clone(), - self.hashed_cursor_factory.clone(), - ) - .with_prefix_sets_mut(self.prefix_sets.clone()) - .multiproof_v2(targets)?; + let multiproof = calculator.multiproof(targets)?; self.record_multiproof_nodes(&multiproof); sparse_trie.reveal_decoded_multiproof_v2(multiproof)?; } @@ -378,8 +348,7 @@ where if !state.accounts.contains_key(&hashed_address) { targets.account_targets.push(ProofV2Target::new(hashed_address)); } - // Skip accounts with no storage slot changes — an empty target set would produce - // an empty proof vec which cannot be revealed (no root node). + // Accounts without storage slot changes need only an account proof. if storage.storage.is_empty() { continue; } diff --git a/deny.toml b/deny.toml index be45b22db44..2c8039e9ffe 100644 --- a/deny.toml +++ b/deny.toml @@ -84,20 +84,5 @@ license-files = [{ path = "LICENSE", hash = 0x001c7e6c }] unknown-registry = "warn" # Lint level for what to happen when a crate from a git repository that is not # in the allow list is encountered -unknown-git = "deny" +unknown-git = "allow" unused-allowed-source = "allow" -# Frequently patched dependencies -allow-git = [ - "https://github.com/alloy-rs/alloy", - "https://github.com/foundry-rs/block-explorers", - "https://github.com/bluealloy/revm", - "https://github.com/paradigmxyz/revm-inspectors", - "https://github.com/alloy-rs/evm", - "https://github.com/alloy-rs/hardforks", - "https://github.com/paradigmxyz/jsonrpsee", - "https://github.com/DaniPopes/slotmap.git", - "https://github.com/sigp/discv5", - "https://github.com/paradigmxyz/revmc", - "https://github.com/paradigmxyz/revmc?branch=main", - "https://github.com/TheDan64/inkwell", -] diff --git a/docs/design/database.md b/docs/design/database.md index c2dc12d2d9b..c064de20a20 100644 --- a/docs/design/database.md +++ b/docs/design/database.md @@ -9,17 +9,16 @@ - We want Reth's serialized format to be able to trade off read/write speed for size, depending on who the user is. - To achieve that, we created the [Encode/Decode/Compress/Decompress traits](https://github.com/paradigmxyz/reth/blob/main/crates/storage/db-api/src/table.rs) to make the (de)serialization of database `Table::Key` and `Table::Values` generic. - - This allows for [out-of-the-box benchmarking](https://github.com/paradigmxyz/reth/blob/main/crates/storage/db/benches/criterion.rs) (using [Criterion](https://github.com/bheisler/criterion.rs)) - It also enables [out-of-the-box fuzzing](https://github.com/paradigmxyz/reth/blob/main/crates/storage/db-api/src/tables/codecs/fuzz/mod.rs) using [trailofbits/test-fuzz](https://github.com/trailofbits/test-fuzz). - We implemented that trait for the following encoding formats: - - [Ethereum-specific Compact Encoding](https://github.com/paradigmxyz/reth/blob/main/crates/storage/codecs/derive/src/compact/mod.rs): A lot of Ethereum datatypes have unnecessary zeros when serialized, or optional (e.g. on empty hashes) which would be nice not to pay in storage costs. + - [Ethereum-specific Compact Encoding](https://github.com/paradigmxyz/reth-core/blob/main/crates/codecs-derive/src/compact/mod.rs): A lot of Ethereum datatypes have unnecessary zeros when serialized, or optional (e.g. on empty hashes) which would be nice not to pay in storage costs. - [Erigon](https://github.com/ledgerwatch/erigon/blob/12ee33a492f5d240458822d052820d9998653a63/docs/programmers_guide/db_walkthrough.MD) achieves that by having a `bitfield` set on Table "PlainState" which adds a bitfield to Accounts. - [Akula](https://github.com/akula-bft/akula/) expanded it for other tables and datatypes manually. It also saved some more space by storing the length of certain types (U256, u64) using the [`modular_bitfield`](https://docs.rs/modular-bitfield/latest/modular_bitfield/) crate, which compacts this information. - We generalized it for all types, by writing a derive macro that autogenerates code for implementing the trait. It also generates the interfaces required for fuzzing using ToB/test-fuzz: - [Scale Encoding](https://github.com/paritytech/parity-scale-codec) - [Postcard Encoding](https://github.com/jamesmunns/postcard) - Passthrough (called `no_codec` in the codebase) -- We made implementation of these traits easy via a derive macro called [`reth_codec`](https://github.com/paradigmxyz/reth/blob/main/crates/storage/codecs/derive/src/lib.rs) that delegates to one of Compact (default), Scale, Postcard or Passthrough encoding. This is [derived on every struct we need](https://github.com/search?q=repo%3Aparadigmxyz%2Freth%20%22%23%5Breth_codec%5D%22&type=code), and lets us experiment with different encoding formats without having to modify the entire codebase each time. +- We made implementation of these traits easy via a derive macro called [`reth_codec`](https://github.com/paradigmxyz/reth-core/blob/main/crates/codecs-derive/src/lib.rs) that delegates to one of Compact (default), Scale, Postcard or Passthrough encoding. This is [derived on every struct we need](https://github.com/search?q=repo%3Aparadigmxyz%2Freth%20%22%23%5Breth_codec%5D%22&type=code), and lets us experiment with different encoding formats without having to modify the entire codebase each time. ### Table layout diff --git a/docs/vocs/bun.lock b/docs/vocs/bun.lock index 9567e477fc0..c73bbadf071 100644 --- a/docs/vocs/bun.lock +++ b/docs/vocs/bun.lock @@ -1215,7 +1215,7 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], diff --git a/docs/vocs/docs/pages/cli/reth/db.mdx b/docs/vocs/docs/pages/cli/reth/db.mdx index 0f589c0af89..6e3515d9138 100644 --- a/docs/vocs/docs/pages/cli/reth/db.mdx +++ b/docs/vocs/docs/pages/cli/reth/db.mdx @@ -113,6 +113,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/db/diff.mdx b/docs/vocs/docs/pages/cli/reth/db/diff.mdx index 868ebac93cd..4086c9662fd 100644 --- a/docs/vocs/docs/pages/cli/reth/db/diff.mdx +++ b/docs/vocs/docs/pages/cli/reth/db/diff.mdx @@ -62,6 +62,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/download.mdx b/docs/vocs/docs/pages/cli/reth/download.mdx index 6817a3c413b..37f67fc2cb1 100644 --- a/docs/vocs/docs/pages/cli/reth/download.mdx +++ b/docs/vocs/docs/pages/cli/reth/download.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). @@ -200,7 +202,7 @@ Storage: --without-rocksdb Skip optional RocksDB indices even when archive components are selected. - This affects `--archive`/`--all` and TUI archive preset (`a`). + This affects `--archive`/`--all`, TUI archive preset (`a`), and the full preset when it includes RocksDB indices. -y, --non-interactive Skip interactive component selection. Downloads the minimal set (state + headers + transactions + changesets) unless explicit --with-* flags narrow it @@ -208,6 +210,13 @@ Storage: --force Overwrite existing snapshot data by removing db, rocksdb, static_files, and reth.toml + --prune-unlisted + Remove files from db, rocksdb, static_files, and reth.toml that the selected snapshot plan does not list, then reuse the listed files that still verify. + + Unlike `--force`, this keeps reusable snapshot files, so only missing or changed archives are downloaded. Unlike a plain re-run, the data dir ends up with exactly the snapshot's files, which is required when the node has written data past the snapshot block. + + Components that are not selected are removed entirely, so `--prune-unlisted --minimal` over an archive node deletes its history. With `--non-interactive`, components must be selected explicitly with a preset or `--with-*` flags. reth.toml is always regenerated, which discards local edits. Symlinks below these paths are removed, not followed. Pruning refuses to run while a node holds the database lock, and happens before downloading, so a failed download leaves the data dir pruned, as with `--force`. + --resumable [] Enable resumable two-phase downloads (download to disk first, then extract). diff --git a/docs/vocs/docs/pages/cli/reth/export-era.mdx b/docs/vocs/docs/pages/cli/reth/export-era.mdx index 54c2ebad030..87720700d20 100644 --- a/docs/vocs/docs/pages/cli/reth/export-era.mdx +++ b/docs/vocs/docs/pages/cli/reth/export-era.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/import-era.mdx b/docs/vocs/docs/pages/cli/reth/import-era.mdx index 09dcc54ee37..40b00731ba0 100644 --- a/docs/vocs/docs/pages/cli/reth/import-era.mdx +++ b/docs/vocs/docs/pages/cli/reth/import-era.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/import.mdx b/docs/vocs/docs/pages/cli/reth/import.mdx index c6b66c57cf1..1d857873b7e 100644 --- a/docs/vocs/docs/pages/cli/reth/import.mdx +++ b/docs/vocs/docs/pages/cli/reth/import.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/init-state.mdx b/docs/vocs/docs/pages/cli/reth/init-state.mdx index fb369df3f95..2134e760563 100644 --- a/docs/vocs/docs/pages/cli/reth/init-state.mdx +++ b/docs/vocs/docs/pages/cli/reth/init-state.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/init.mdx b/docs/vocs/docs/pages/cli/reth/init.mdx index 2803a1f5c24..4787cc7fbfe 100644 --- a/docs/vocs/docs/pages/cli/reth/init.mdx +++ b/docs/vocs/docs/pages/cli/reth/init.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/node.mdx b/docs/vocs/docs/pages/cli/reth/node.mdx index 29c42d9925f..ebdcf93ddf0 100644 --- a/docs/vocs/docs/pages/cli/reth/node.mdx +++ b/docs/vocs/docs/pages/cli/reth/node.mdx @@ -138,6 +138,9 @@ Networking: [default: 200] + --snap.v2 + Advertise and serve experimental snap/2. `reth node` also bootstraps a fresh database with it, and uses the staged pipeline while the chain head predates block access lists + --trusted-peers Comma separated enode URLs or ENRs of trusted peers for P2P connections. @@ -330,7 +333,9 @@ RPC: [default: 8545] --http.disable-compression - Disable compression for HTTP responses + Disable compression for HTTP responses. + + Servers listening on a loopback address never compress responses. --http.compression Comma-separated list of allowed compression algorithms for HTTP responses. @@ -498,7 +503,7 @@ RPC: --rpc.txfeecap Maximum eth transaction fee (in ether) that can be sent via the RPC APIs (0 = no cap) - [default: 1.0] + [default: 1] --rpc.max-simulate-blocks Maximum number of blocks for `eth_simulateV1` call @@ -596,7 +601,7 @@ Gas Price Oracle: --gpo.ignoreprice Gas Price below which gpo will ignore transactions - [default: 0] + [default: 2] --gpo.maxprice Maximum transaction priority fee(or gasprice before London Fork) to be recommended by gpo @@ -614,7 +619,7 @@ Gas Price Oracle: --rpc.send-raw-transaction-sync-timeout Timeout for `send_raw_transaction_sync` RPC method - [default: 30s] + [default: 30] --testing.skip-invalid-transactions Skip invalid transactions in `testing_buildBlockV1` instead of failing. @@ -689,7 +694,7 @@ TxPool: [default: 7] --txpool.minimum-priority-fee - Minimum priority fee required for transaction acceptance into the pool. Transactions with priority fee below this value will be rejected + Minimum priority fee required for transaction acceptance into the pool. Transactions with priority fee below this value will be rejected. For legacy and EIP-2930 transactions the gas price is used as the priority fee --txpool.gas-limit The default enforced gas limit for transactions entering the pool @@ -897,6 +902,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). @@ -1110,6 +1117,9 @@ Engine: [default: 1] + --cpu-cores + Override the available CPU count for node thread pools and parallel execution decisions. Does not change CPU affinity. Explicit thread counts take precedence + --engine.disable-precompile-cache Disable precompile cache @@ -1125,10 +1135,10 @@ Engine: Allow unwinding canonical header to ancestor during forkchoice updates. See `TreeConfig::unwind_canonical_header` for more details --engine.storage-worker-count - Configure the number of storage proof workers in the Tokio blocking pool. If not specified, defaults to 2x available parallelism + Configure the number of storage proof workers spawned for each block. If not specified or zero, the count is derived from the block: 2x available parallelism, halved for blocks with few transactions and doubled for blocks at or above 100M gas --engine.account-worker-count - Configure the number of account proof workers in the Tokio blocking pool. If not specified, defaults to the same count as storage workers + Configure the number of account proof workers spawned for each block. If not specified or zero, the count is derived from the block the same way as for storage workers --engine.prewarming-threads Configure the number of prewarming threads. If not specified, defaults to available parallelism @@ -1187,6 +1197,11 @@ Engine: --engine.disable-bal-batch-io Disable BAL (Block Access List) storage prefetch IO during prewarming. When set, BAL storage slots are not read into the execution cache + --engine.prewarm-reuse + Reuse transaction prewarming results during block execution instead of executing transactions again when their prewarm reads are still valid + + [env: RETH_ENGINE_PREWARM_REUSE=] + ERA: --era.enable Enable import from ERA1 files diff --git a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx index cac838b76cf..1758598d62e 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/body.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/body.mdx @@ -78,6 +78,9 @@ Networking: [default: 200] + --snap.v2 + Advertise and serve experimental snap/2. `reth node` also bootstraps a fresh database with it, and uses the staged pipeline while the chain head predates block access lists + --trusted-peers Comma separated enode URLs or ENRs of trusted peers for P2P connections. diff --git a/docs/vocs/docs/pages/cli/reth/p2p/bootnode.mdx b/docs/vocs/docs/pages/cli/reth/p2p/bootnode.mdx index b46512df099..efc85e3bd38 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/bootnode.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/bootnode.mdx @@ -22,12 +22,17 @@ Options: --nat NAT resolution method (any|none|upnp|publicip|extip:\). - Can be repeated with one IPv4 and one IPv6 `extip:` to advertise a dual-stack discv5 ENR (discv4 binds a single socket and always advertises only the `--addr` family). + Can be repeated with one IPv4 and one IPv6 `extip:` to advertise a dual-stack node. The enode advertises only the `--addr` family; the discv5 and EIP-868 ENRs carry both families, and with `--v5` discv4 also answers the opposite family on the shared port. [default: any] --v5 - Also run discv5, sharing the discv4 UDP port (`--addr`) + Also run discv5, sharing the discv4 UDP port (`--addr`). + + The opposite-family sibling socket is only bound when an `extip:` of that family is advertised via `--nat`; the default `--nat any` serves the `--addr` family only. + + --bootnodes + Comma-separated nodes to seed discovery: `enode://` URLs with an IP host and/or signed `enr:` records. Each entry seeds discv4 and (with `--v5`) discv5 -h, --help Print help (see a summary with '-h') diff --git a/docs/vocs/docs/pages/cli/reth/p2p/enode.mdx b/docs/vocs/docs/pages/cli/reth/p2p/enode.mdx index 0cc4a89a818..bdef5c6bd4e 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/enode.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/enode.mdx @@ -18,6 +18,11 @@ Options: If not provided, defaults to 0.0.0.0. + --port + Port to include in the enode URL + + [default: 30303] + -h, --help Print help (see a summary with '-h') diff --git a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx index 6519ff37604..94cd50070ed 100644 --- a/docs/vocs/docs/pages/cli/reth/p2p/header.mdx +++ b/docs/vocs/docs/pages/cli/reth/p2p/header.mdx @@ -78,6 +78,9 @@ Networking: [default: 200] + --snap.v2 + Advertise and serve experimental snap/2. `reth node` also bootstraps a fresh database with it, and uses the staged pipeline while the chain head predates block access lists + --trusted-peers Comma separated enode URLs or ENRs of trusted peers for P2P connections. diff --git a/docs/vocs/docs/pages/cli/reth/prune.mdx b/docs/vocs/docs/pages/cli/reth/prune.mdx index 75be037c504..49dbe38d423 100644 --- a/docs/vocs/docs/pages/cli/reth/prune.mdx +++ b/docs/vocs/docs/pages/cli/reth/prune.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/re-execute.mdx b/docs/vocs/docs/pages/cli/reth/re-execute.mdx index d58ee5a875c..93472a61c91 100644 --- a/docs/vocs/docs/pages/cli/reth/re-execute.mdx +++ b/docs/vocs/docs/pages/cli/reth/re-execute.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/stage/drop.mdx b/docs/vocs/docs/pages/cli/reth/stage/drop.mdx index 8ba0951fb90..efcdaf12946 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/drop.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/drop.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/stage/dump.mdx b/docs/vocs/docs/pages/cli/reth/stage/dump.mdx index 653d6d53ab2..e99abb8aa86 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/dump.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/dump.mdx @@ -99,6 +99,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/cli/reth/stage/run.mdx b/docs/vocs/docs/pages/cli/reth/stage/run.mdx index 7a0b858f4b9..3af2dc85b77 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/run.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/run.mdx @@ -92,6 +92,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). @@ -237,6 +239,9 @@ Networking: [default: 200] + --snap.v2 + Advertise and serve experimental snap/2. `reth node` also bootstraps a fresh database with it, and uses the staged pipeline while the chain head predates block access lists + --trusted-peers Comma separated enode URLs or ENRs of trusted peers for P2P connections. diff --git a/docs/vocs/docs/pages/cli/reth/stage/unwind.mdx b/docs/vocs/docs/pages/cli/reth/stage/unwind.mdx index 8b16062a733..db060092df7 100644 --- a/docs/vocs/docs/pages/cli/reth/stage/unwind.mdx +++ b/docs/vocs/docs/pages/cli/reth/stage/unwind.mdx @@ -97,6 +97,8 @@ Database: --db.sync-mode Controls how aggressively the database synchronizes data to disk + [env: RETH_DB_SYNC_MODE=] + --db.rocksdb-block-cache-size `RocksDB` block cache size (e.g., 512MB, 4GB). diff --git a/docs/vocs/docs/pages/run/configuration.mdx b/docs/vocs/docs/pages/run/configuration.mdx index d922544a77d..5be5e0db424 100644 --- a/docs/vocs/docs/pages/run/configuration.mdx +++ b/docs/vocs/docs/pages/run/configuration.mdx @@ -4,6 +4,8 @@ description: How to configure Reth using reth.toml and its options. # Configuring Reth +Use `reth node --cpu-cores 8` to override the available CPU count for node thread pool sizing and parallel execution decisions. This is useful in test environments that report fewer CPUs than the workload should simulate. The count must be nonzero and does not change CPU affinity or the operating system's reported topology. Explicit thread counts take precedence; fixed-size pools and RPC concurrency limits are unchanged. Without this flag, Reth detects the available CPUs as before. + Reth places a configuration file named `reth.toml` in the data directory specified when starting the node. It is written in the [TOML] format. The default data directory is platform dependent: diff --git a/docs/vocs/vocs.config.ts b/docs/vocs/vocs.config.ts index 5e138998783..ac9de1b96e1 100644 --- a/docs/vocs/vocs.config.ts +++ b/docs/vocs/vocs.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ { text: 'Rustdocs', link: '/docs' }, { text: 'GitHub', link: 'https://github.com/paradigmxyz/reth' }, { - text: 'v2.7.0', + text: 'v2.8.0', items: [ { text: 'Releases', diff --git a/examples/beacon-api-sidecar-fetcher/src/mined_sidecar.rs b/examples/beacon-api-sidecar-fetcher/src/mined_sidecar.rs index 5ebf273a404..e7f303e946b 100644 --- a/examples/beacon-api-sidecar-fetcher/src/mined_sidecar.rs +++ b/examples/beacon-api-sidecar-fetcher/src/mined_sidecar.rs @@ -1,5 +1,5 @@ use crate::BeaconSidecarConfig; -use alloy_consensus::{Signed, Transaction as _, TxEip4844WithSidecar, Typed2718}; +use alloy_consensus::{Signed, Transaction as _, TxEip4844WithSidecar}; use alloy_eips::eip7594::BlobTransactionSidecarVariant; use alloy_primitives::B256; use alloy_rpc_types_beacon::sidecar::{BeaconBlobBundle, SidecarIterator}; @@ -101,8 +101,7 @@ where fn process_block(&mut self, block: &RecoveredBlock) { let txs: Vec<_> = block .body() - .transactions() - .filter(|tx| tx.is_eip4844()) + .eip4844_transactions_iter() .map(|tx| (tx.clone(), tx.blob_count().unwrap_or(0) as usize)) .collect(); @@ -197,8 +196,7 @@ where for block in old.blocks().values() { let txs: Vec = block .body() - .transactions() - .filter(|tx| tx.is_eip4844()) + .eip4844_transactions_iter() .map(|tx| { let transaction_hash = *tx.tx_hash(); let block_metadata = BlockMetadata { diff --git a/examples/bsc-p2p/src/block_import/service.rs b/examples/bsc-p2p/src/block_import/service.rs index 2b37fcdce00..a412a31d7c7 100644 --- a/examples/bsc-p2p/src/block_import/service.rs +++ b/examples/bsc-p2p/src/block_import/service.rs @@ -148,11 +148,7 @@ where } }; - let state = ForkchoiceState { - head_block_hash, - safe_block_hash: head_block_hash, - finalized_block_hash: head_block_hash, - }; + let state = ForkchoiceState::same_hash(head_block_hash); match engine.fork_choice_updated(state, None).await { Ok(response) => match response.payload_status.status { @@ -196,7 +192,7 @@ where impl Future for ImportService where - Provider: BlockNumReader + BlockHashReader + Sync + Clone + 'static + Unpin, + Provider: BlockNumReader + Sync + Clone + 'static + Unpin, T: PayloadTypes, { type Output = Result<(), Box>; @@ -231,10 +227,7 @@ mod tests { use reth_ethereum_primitives::Block; use reth_node_ethereum::EthEngineTypes; use reth_provider::ProviderError; - use std::{ - sync::Arc, - task::{Context, Poll}, - }; + use std::{future::poll_fn, sync::Arc, time::Duration}; #[tokio::test] async fn can_handle_valid_block() { @@ -375,20 +368,18 @@ mod tests { let block_msg = create_test_block(); self.handle.send_block(block_msg, PeerId::random()).unwrap(); - let waker = futures::task::noop_waker(); - let mut cx = Context::from_waker(&waker); - let mut outcomes = Vec::new(); - - // Wait for both NewPayload and FCU outcomes - while outcomes.len() < 2 { - match self.handle.poll_outcome(&mut cx) { - Poll::Ready(Some(outcome)) => { - outcomes.push(outcome); - } - Poll::Ready(None) => break, - Poll::Pending => tokio::task::yield_now().await, + let outcomes = tokio::time::timeout(Duration::from_secs(1), async { + let mut outcomes = Vec::new(); + while outcomes.len() < 2 { + let outcome = poll_fn(|cx| self.handle.poll_outcome(cx)) + .await + .expect("block import service closed before returning all outcomes"); + outcomes.push(outcome); } - } + outcomes + }) + .await + .expect("block import outcomes must arrive"); // Assert that at least one outcome matches our criteria assert!( @@ -413,11 +404,11 @@ mod tests { tokio::spawn(async move { while let Some(message) = from_engine.recv().await { match message { - BeaconEngineMessage::NewPayload { payload: _, tx } => { + BeaconEngineMessage::NewPayload { tx, .. } => { tx.send(Ok(PayloadStatus::new(responses.new_payload.clone(), None))) .unwrap(); } - BeaconEngineMessage::ForkchoiceUpdated { state: _, payload_attrs: _, tx } => { + BeaconEngineMessage::ForkchoiceUpdated { tx, .. } => { tx.send(Ok(OnForkChoiceUpdated::valid(PayloadStatus::new( responses.fcu.clone(), None, diff --git a/examples/bsc-p2p/src/chainspec.rs b/examples/bsc-p2p/src/chainspec.rs index 1896593b039..fcf7de99d2a 100644 --- a/examples/bsc-p2p/src/chainspec.rs +++ b/examples/bsc-p2p/src/chainspec.rs @@ -1,13 +1,13 @@ //! Chain specification for BSC, credits to: -use alloy_primitives::{BlockHash, U256}; +use alloy_primitives::{b256, U256}; use reth_chainspec::{ hardfork, make_genesis_header, BaseFeeParams, BaseFeeParamsKind, Chain, ChainHardforks, - ChainSpec, EthereumHardfork, ForkCondition, Hardfork, Head, NamedChain, + ChainSpec, EthereumHardfork, ForkCondition, Hardfork, Head, }; use reth_network_peers::NodeRecord; use reth_primitives_traits::SealedHeader; -use std::{str::FromStr, sync::Arc}; +use std::sync::Arc; hardfork!( /// The name of a bsc hardfork. @@ -107,20 +107,17 @@ pub fn bsc_chain_spec() -> Arc { .expect("Can't deserialize BSC Mainnet genesis json"); let hardforks = BscHardfork::bsc_mainnet(); ChainSpec { - chain: Chain::from_named(NamedChain::BinanceSmartChain), + chain: Chain::bsc_mainnet(), genesis: serde_json::from_str(include_str!("genesis.json")) .expect("Can't deserialize BSC Mainnet genesis json"), - paris_block_and_final_difficulty: Some((0, U256::from(0))), + paris_block_and_final_difficulty: Some((0, U256::ZERO)), hardforks: BscHardfork::bsc_mainnet(), deposit_contract: None, base_fee_params: BaseFeeParamsKind::Constant(BaseFeeParams::new(1, 1)), prune_delete_limit: 3500, genesis_header: SealedHeader::new( make_genesis_header(&genesis, &hardforks), - BlockHash::from_str( - "0x0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b", - ) - .unwrap(), + b256!("0x0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b"), ), ..Default::default() } diff --git a/examples/custom-beacon-withdrawals/src/main.rs b/examples/custom-beacon-withdrawals/src/main.rs index 35fea2d39f2..13cc98ed8b0 100644 --- a/examples/custom-beacon-withdrawals/src/main.rs +++ b/examples/custom-beacon-withdrawals/src/main.rs @@ -3,7 +3,7 @@ #![warn(unused_crate_dependencies)] -use alloy_eips::eip4895::Withdrawal; +use alloy_eips::{eip4788::SYSTEM_ADDRESS, eip4895::Withdrawal}; use alloy_evm::{ block::{BlockExecutorFactory, ExecutableTx, GasOutput}, eth::{EthBlockExecutionCtx, EthBlockExecutor, EthTxResult}, @@ -41,7 +41,6 @@ use reth_ethereum::{ }; use std::{fmt::Display, sync::Arc}; -pub const SYSTEM_ADDRESS: Address = address!("0xfffffffffffffffffffffffffffffffffffffffe"); pub const WITHDRAWALS_ADDRESS: Address = address!("0x4200000000000000000000000000000000000000"); fn main() { diff --git a/examples/custom-inspector/src/main.rs b/examples/custom-inspector/src/main.rs index e4ec329bfef..757e41fccf1 100644 --- a/examples/custom-inspector/src/main.rs +++ b/examples/custom-inspector/src/main.rs @@ -10,7 +10,7 @@ #![warn(unused_crate_dependencies)] -use alloy_eips::BlockNumberOrTag; +use alloy_eips::BlockId; use alloy_evm::Evm; use alloy_primitives::Address; use alloy_rpc_types_eth::{state::EvmOverrides, TransactionRequest}; @@ -68,7 +68,7 @@ fn main() { let result = eth_api .spawn_with_call_at( call_request, - BlockNumberOrTag::Latest.into(), + BlockId::latest(), EvmOverrides::default(), move |db, evm_env, tx_env| { let mut dummy_inspector = DummyInspector::default(); diff --git a/examples/custom-node-components/src/main.rs b/examples/custom-node-components/src/main.rs index 47636ad6b35..9930501db09 100644 --- a/examples/custom-node-components/src/main.rs +++ b/examples/custom-node-components/src/main.rs @@ -53,7 +53,7 @@ pub struct CustomPoolBuilder { impl PoolBuilder for CustomPoolBuilder where Node: FullNodeTypes>, - Evm: ConfigureEvm + Clone + 'static, + Evm: ConfigureEvm + 'static, { type Pool = EthTransactionPool; diff --git a/examples/db-access/src/main.rs b/examples/db-access/src/main.rs index 0984273f014..881c63b0653 100644 --- a/examples/db-access/src/main.rs +++ b/examples/db-access/src/main.rs @@ -7,8 +7,8 @@ use reth_ethereum::{ primitives::{AlloyBlockHeader, SealedBlock, SealedHeader}, provider::{ providers::{BlockchainProvider, ReadOnlyConfig}, - AccountReader, BlockNumReader, BlockReader, BlockSource, HeaderProvider, ReceiptProvider, - StateProvider, StateProviderFactory, TransactionVariant, TransactionsProvider, + BlockNumReader, BlockReader, BlockSource, HeaderProvider, ReceiptProvider, StateProvider, + StateProviderFactory, TransactionVariant, TransactionsProvider, }, rpc::eth::primitives::Filter, TransactionSigned, @@ -217,7 +217,7 @@ fn receipts_provider_example< } /// The `StateProvider` allows querying the state tables. -fn state_provider_example( +fn state_provider_example( provider: T, headers: &H, number: u64, diff --git a/examples/manual-p2p/src/main.rs b/examples/manual-p2p/src/main.rs index e315d67107b..a3da30c7ec2 100644 --- a/examples/manual-p2p/src/main.rs +++ b/examples/manual-p2p/src/main.rs @@ -132,7 +132,12 @@ async fn snoop(peer: NodeRecord, mut eth_stream: AuthedEthStream) { println!("Got {} new tx hashes from peer {}", txs.len(), peer.address); } EthMessage::NewBlock(block) => { - println!("Got new block data {:?} from peer {}", block, peer.address); + println!( + "Got new block {:?} with {} txs from peer {}", + block.block.header.num_hash_slow(), + block.block.body.transactions.len(), + peer.address + ); } EthMessage::NewPooledTransactionHashes68(txs) => { println!("Got {} new tx hashes from peer {}", txs.hashes.len(), peer.address); diff --git a/examples/node-custom-rpc/src/main.rs b/examples/node-custom-rpc/src/main.rs index fa9eeedc5af..78e7055ceb8 100644 --- a/examples/node-custom-rpc/src/main.rs +++ b/examples/node-custom-rpc/src/main.rs @@ -99,7 +99,7 @@ pub struct TxpoolExt { #[cfg(not(test))] impl TxpoolExtApiServer for TxpoolExt where - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, { fn transaction_count(&self) -> RpcResult { Ok(self.pool.pool_size().total) @@ -158,7 +158,7 @@ mod tests { #[cfg(test)] impl TxpoolExtApiServer for TxpoolExt where - Pool: TransactionPool + Clone + 'static, + Pool: TransactionPool + 'static, { fn transaction_count(&self) -> RpcResult { Ok(self.pool.pool_size().total) diff --git a/testing/ef-tests/Cargo.toml b/testing/ef-tests/Cargo.toml index 424e95dcc37..de9a7e12d30 100644 --- a/testing/ef-tests/Cargo.toml +++ b/testing/ef-tests/Cargo.toml @@ -24,10 +24,12 @@ reth-db = { workspace = true, features = ["mdbx", "test-utils", "disable-lock"] reth-db-api.workspace = true reth-db-common.workspace = true reth-provider = { workspace = true, features = ["test-utils"] } +alloy-eip7928.workspace = true reth-evm.workspace = true reth-evm-ethereum.workspace = true reth-ethereum-consensus.workspace = true reth-revm.workspace = true +reth-tasks.workspace = true reth-tracing.workspace = true reth-trie.workspace = true reth-trie-db.workspace = true diff --git a/testing/ef-tests/src/cases/blockchain_test.rs b/testing/ef-tests/src/cases/blockchain_test.rs index 15a6ba7cc88..7adbbb4f3fb 100644 --- a/testing/ef-tests/src/cases/blockchain_test.rs +++ b/testing/ef-tests/src/cases/blockchain_test.rs @@ -4,6 +4,7 @@ use crate::{ models::{BlockchainTest, ForkSpec}, Case, Error, Suite, }; +use alloy_eip7928::bal::Bal; use alloy_rlp::Decodable; use rayon::iter::{IndexedParallelIterator, ParallelIterator}; use reth_chainspec::ChainSpec; @@ -11,23 +12,29 @@ use reth_consensus::{Consensus, HeaderValidator}; use reth_db_common::init::{insert_genesis_hashes, insert_genesis_history, insert_genesis_state}; use reth_ethereum_consensus::{validate_block_post_execution, EthBeaconConsensus}; use reth_ethereum_primitives::Block; -use reth_evm::{execute::Executor, ConfigureEvm}; +use reth_evm::{ + execute::{BlockExecutionOutput, Executor}, + ConfigureEvm, +}; use reth_evm_ethereum::EthEvmConfig; use reth_primitives_traits::{ParallelBridgeBuffered, RecoveredBlock, SealedBlock}; use reth_provider::{ - test_utils::create_test_provider_factory_with_chain_spec, BlockWriter, DatabaseProviderFactory, - ExecutionOutcome, HashedPostStateProvider, HistoryWriter, OriginalValuesKnown, StateProvider, - StateWriteConfig, StateWriter, StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, - StorageSettingsCache, TrieWriter, + providers::{RocksDBBuilder, RocksDBProvider, StaticFileProviderBuilder}, + test_utils::MockNodeTypesWithDB, + BlockWriter, DatabaseProviderFactory, ExecutionOutcome, HashedPostStateProvider, HistoryWriter, + OriginalValuesKnown, ProviderFactory, StateProvider, StateWriteConfig, StateWriter, + StaticFileProviderFactory, StaticFileSegment, StaticFileWriter, StorageSettingsCache, + TrieWriter, }; use reth_revm::database::StateProviderDatabase; +use reth_tasks::Runtime; use reth_trie::StateRoot; use reth_trie_db::DatabaseStateRoot; use std::{ collections::BTreeMap, fs, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, LazyLock}, }; /// A handler for the blockchain test suite. @@ -194,7 +201,7 @@ impl Case for BlockchainTestCase { fn run_case(case: &BlockchainTest) -> Result<(), Error> { // Create a new test database and initialize a provider for the test case. let chain_spec = case.network.to_chain_spec(); - let factory = create_test_provider_factory_with_chain_spec(chain_spec.clone()); + let factory = create_test_provider_factory(chain_spec.clone()); let provider = factory.database_provider_rw().unwrap(); // Insert initial test state into the provider. @@ -222,11 +229,20 @@ fn run_case(case: &BlockchainTest) -> Result<(), Error> { insert_genesis_history(&provider, genesis_state.iter()) .map_err(|err| Error::block_failed(0, err))?; + // Build the genesis trie, as `init_genesis` does, so block 1 reads stored nodes. + let (_, trie_updates) = reth_trie_db::with_adapter!(provider, |A| { + StateRoot::, _>::from_tx(provider.tx_ref()) + .root_with_updates() + }) + .map_err(|err| Error::block_failed(0, err))?; + provider.write_trie_updates(trie_updates).map_err(|err| Error::block_failed(0, err))?; + // Decode blocks let blocks = decode_blocks(&case.blocks)?; let executor_provider = EthEvmConfig::ethereum(chain_spec.clone()); let mut parent = genesis_block; + let mut bal_buf = Vec::new(); for (block_index, block) in blocks.iter().enumerate() { // Note: same as the comment on `decode_blocks` as to why we cannot use block.number @@ -246,14 +262,26 @@ fn run_case(case: &BlockchainTest) -> Result<(), Error> { // Execute the block let state_provider = provider.latest(); let state_db = StateProviderDatabase((&state_provider).into_evm_state_provider()); - let executor = executor_provider.batch_executor(state_db); + let mut executor = executor_provider.batch_executor(state_db); - let output = executor - .execute(&(*block).clone()) + let result = executor + .execute_one(&(*block).clone()) + .map_err(|err| Error::block_failed(block_number, err))?; + // Check the block access list size cap and compute its hash for post-Amsterdam blocks so + // the consensus check below validates it. + let block_access_list_hash = executor + .take_bal() + .map(|bal| { + let bal = Bal::from(bal); + bal.validate_gas_limit(block.gas_limit) + .map(|()| bal.compute_hash_with_buf(&mut bal_buf)) + }) + .transpose() .map_err(|err| Error::block_failed(block_number, err))?; + let output = BlockExecutionOutput { state: executor.into_state().take_bundle(), result }; // Consensus checks after block execution - validate_block_post_execution(block, &chain_spec, &output, None, None) + validate_block_post_execution(block, &chain_spec, &output, None, block_access_list_hash) .map_err(|err| Error::block_failed(block_number, err))?; // Compute and check the post state root @@ -431,3 +459,56 @@ fn path_contains(path_str: &str, rhs: &[&str]) -> bool { let rhs = rhs.join(std::path::MAIN_SEPARATOR_STR); path_str.contains(&rhs) } + +/// `RocksDB` instance shared by all test cases of the current process. +/// +/// Opening `RocksDB` is expensive and ef-tests never use it: the storage settings stay at v1, so +/// everything is stored in MDBX and static files. A fresh instance per case dominates the runtime. +/// +/// The backing directory is created with [`reth_db::test_utils::tempdir_path`], which leaks it: +/// statics are never dropped, so there is no point at which a temp dir guard could clean +/// up. This costs one small, mostly empty directory in the system temp dir per test process. +static SHARED_ROCKSDB: LazyLock = LazyLock::new(|| { + RocksDBBuilder::new(reth_db::test_utils::tempdir_path().join("rocksdb")) + .with_default_tables() + .build() + .expect("failed to create shared test RocksDB provider") +}); + +/// Creates a provider factory for a single test case. +/// +/// Mirrors [`reth_provider::test_utils::create_test_provider_factory_with_chain_spec`], except +/// that it reuses [`SHARED_ROCKSDB`] instead of opening a new `RocksDB` instance. The temp datadir, +/// MDBX, static files and runtime stay isolated per case. +fn create_test_provider_factory( + chain_spec: Arc, +) -> ProviderFactory { + let genesis_block_number = chain_spec.genesis.number.unwrap_or_default(); + + // A single temp directory holds the MDBX and static file dirs. `TempDatabase` removes the + // entire directory on drop. + let datadir_path = reth_db::test_utils::tempdir_path(); + let static_files_path = datadir_path.join("static_files"); + fs::create_dir_all(&static_files_path).expect("failed to create static_files dir"); + + let db = reth_db::test_utils::create_test_rw_db_with_datadir(&datadir_path); + + let factory = ProviderFactory::new( + db, + chain_spec, + StaticFileProviderBuilder::read_write(static_files_path) + .with_genesis_block_number(genesis_block_number) + .build() + .expect("static file provider"), + SHARED_ROCKSDB.clone(), + Runtime::test(), + ) + .expect("failed to create test provider factory"); + + assert!( + !factory.cached_storage_settings().any_in_rocksdb(), + "the shared RocksDB is only valid while ef-tests run with v1 storage settings" + ); + + factory +} diff --git a/testing/ef-tests/src/models.rs b/testing/ef-tests/src/models.rs index a2cb4f78679..c576462a3eb 100644 --- a/testing/ef-tests/src/models.rs +++ b/testing/ef-tests/src/models.rs @@ -2,7 +2,7 @@ use crate::{assert::assert_equal, Error}; use alloy_consensus::Header as RethHeader; -use alloy_eips::eip4895::Withdrawals; +use alloy_eips::{eip4895::Withdrawals, eip7840::BlobParams}; use alloy_genesis::GenesisAccount; use alloy_primitives::{keccak256, map::HashMap, Address, Bloom, Bytes, B256, B64, U256}; use reth_chainspec::{ChainSpec, ChainSpecBuilder, EthereumHardfork, ForkCondition}; @@ -89,6 +89,10 @@ pub struct Header { pub requests_hash: Option, /// Target blobs per block. pub target_blobs_per_block: Option, + /// Block access list hash. + pub block_access_list_hash: Option, + /// Slot number. + pub slot_number: Option, } impl From

for SealedHeader { @@ -115,8 +119,8 @@ impl From
for SealedHeader { excess_blob_gas: value.excess_blob_gas.map(|v| v.to::()), parent_beacon_block_root: value.parent_beacon_block_root, requests_hash: value.requests_hash, - block_access_list_hash: None, - slot_number: None, + block_access_list_hash: value.block_access_list_hash, + slot_number: value.slot_number.map(|v| v.to::()), }; Self::new(header, value.hash) } @@ -168,12 +172,7 @@ impl State { .storage .iter() .filter(|(_, v)| !v.is_zero()) - .map(|(k, v)| { - ( - B256::from_slice(&k.to_be_bytes::<32>()), - B256::from_slice(&v.to_be_bytes::<32>()), - ) - }) + .map(|(k, v)| (B256::from(*k), B256::from(*v))) .collect(); let account = GenesisAccount { balance: account.balance, @@ -237,9 +236,7 @@ impl Account { let mut storage_cursor = tx.cursor_dup_read::()?; for (slot, value) in &self.storage { - if let Some(entry) = - storage_cursor.seek_by_key_subkey(address, B256::new(slot.to_be_bytes()))? - { + if let Some(entry) = storage_cursor.seek_by_key_subkey(address, B256::from(*slot))? { if U256::from_be_bytes(entry.key.0) == *slot { assert_equal( *value, @@ -325,6 +322,8 @@ pub enum ForkSpec { Prague, /// Osaka Osaka, + /// Amsterdam + Amsterdam, } impl ForkSpec { @@ -345,7 +344,7 @@ impl ForkSpec { fn to_chain_spec_inner(self) -> ChainSpec { let spec_builder = ChainSpecBuilder::mainnet().reset(); - match self { + let mut spec = match self { Self::Frontier => spec_builder.frontier_activated(), Self::FrontierToHomesteadAt5 => spec_builder .frontier_activated() @@ -393,8 +392,15 @@ impl ForkSpec { .with_fork(EthereumHardfork::Prague, ForkCondition::Timestamp(15_000)), Self::Prague => spec_builder.prague_activated(), Self::Osaka => spec_builder.osaka_activated(), + Self::Amsterdam => spec_builder.amsterdam_activated(), + } + .build(); + + // Amsterdam follows BPO1 and BPO2, so its fixtures use BPO2's blob parameters. + if self == Self::Amsterdam { + spec.blob_params = spec.blob_params.with_scheduled([(0, BlobParams::bpo2())]); } - .build() + spec } } diff --git a/testing/testing-utils/src/generators.rs b/testing/testing-utils/src/generators.rs index 89cc4c943a6..152dcfb84e8 100644 --- a/testing/testing-utils/src/generators.rs +++ b/testing/testing-utils/src/generators.rs @@ -334,7 +334,7 @@ where let (prev_from, _) = state.get_mut(&from).unwrap(); changeset.push((from, prev_from.clone(), Vec::new())); - transfer = max(min(transfer, prev_from.balance), U256::from(1)); + transfer = max(min(transfer, prev_from.balance), U256::ONE); prev_from.balance = prev_from.balance.wrapping_sub(transfer); // deposit in receiving account and update storage @@ -487,7 +487,7 @@ mod tests { use super::*; use alloy_consensus::TxEip1559; use alloy_eips::eip2930::AccessList; - use alloy_primitives::{hex, Signature}; + use alloy_primitives::{address, b256, bytes, hex, Signature}; use reth_primitives_traits::{ crypto::secp256k1::{public_key_to_address, sign_message}, SignerRecoverable, @@ -502,9 +502,9 @@ mod tests { chain_id: 1, nonce: 0x42, gas_limit: 44386, - to: TxKind::Call(hex!("6069a6c32cf691f5982febae4faf8a6f3ab2f0f6").into()), - value: U256::from(0_u64), - input: hex!("a22cb4650000000000000000000000005eee75727d804a2b13038928d36f8b188945a57a0000000000000000000000000000000000000000000000000000000000000000").into(), + to: TxKind::Call(address!("6069a6c32cf691f5982febae4faf8a6f3ab2f0f6")), + value: U256::ZERO, + input: bytes!("a22cb4650000000000000000000000005eee75727d804a2b13038928d36f8b188945a57a0000000000000000000000000000000000000000000000000000000000000000"), max_fee_per_gas: 0x4a817c800, max_priority_fee_per_gas: 0x3b9aca00, access_list: AccessList::default(), @@ -534,7 +534,7 @@ mod tests { nonce: 9, gas_price: 20 * 10_u128.pow(9), gas_limit: 21000, - to: TxKind::Call(hex!("3535353535353535353535353535353535353535").into()), + to: TxKind::Call(Address::repeat_byte(0x35)), value: U256::from(10_u128.pow(18)), input: Bytes::default(), }; @@ -544,14 +544,10 @@ mod tests { assert_eq!(expected.as_slice(), &alloy_rlp::encode(tx)); let hash = transaction.signature_hash(); - let expected = - B256::from_str("daf5a779ae972f972197303d7b574746c7ef83eadac0f2791ad23db92e4c8e53") - .unwrap(); + let expected = b256!("daf5a779ae972f972197303d7b574746c7ef83eadac0f2791ad23db92e4c8e53"); assert_eq!(expected, hash); - let secret = - B256::from_str("4646464646464646464646464646464646464646464646464646464646464646") - .unwrap(); + let secret = B256::repeat_byte(0x46); let signature = sign_message(secret, hash).unwrap(); let expected = Signature::new(